Amplitude Session Replay を Flutter アプリに導入してみた話

に公開

⚠️ **本記事で紹介する方法は非公式・非推奨の手法です。Amplitude 公式の Flutter SDK は Session Replay をサポートしていないため、あくまでチャレンジ的な実装であり、導入は自己責任でお願いいたします。

Amplitude Session Replay とは

Amplitude Session Replayとは、Amplitudeプラットフォームの一部として提供されている機能です。Web やモバイルアプリ上でのユーザーの操作を動画のように再生可能にし、行動を視覚的に把握・分析できるようにします。

技術的には、アプリケーションのビューツリーに対する変更を逐次キャプチャすることで、ユーザーの操作履歴を再現します。セッション開始時には UI 全体のスナップショットを取得し、以降のユーザーインタラクションによる変更を 差分(diff)形式で記録します。

なぜ導入しようと思ったのか

弊社では 2023 年から Amplitude を全社横断で導入しており、ユーザー行動データの収集・分析を強化してきました。

ただし、Amplitude の Flutter 公式 SDK での Session Replay 対応はまだ先で、最悪の場合対応されない可能性もあるため、今回一足早く iOS ネイティブ SDK を経由して Flutter アプリに導入することに挑戦しました。

実装の概要

  • Dart ⇔ Swift 間通信に MethodChannel を利用
  • Amplitude iOS SDK + Session Replay Plugin を CocoaPods で導入
  • Flutter 側から initialize, enable, disable, trackEvent を呼び出せるようにブリッジ定義

導入手順

Podfile の設定(iOS ネイティブ SDK の導入)

// 以下をPodfileに追加
pod 'AmplitudeSwift', '~> 1.13.2'
pod 'AmplitudeSessionReplay', :git => 'https://github.com/amplitude/AmplitudeSessionReplay-iOS.git'
pod 'AmplitudeSwiftSessionReplayPlugin', :git => 'https://github.com/amplitude/AmplitudeSessionReplay-iOS.git'

Flutter 側のブリッジコード(Dart)

AmplitudeSessionReplay.dart
class AmplitudeSessionReplay {
  final MethodChannel _channel = MethodChannel('amplitude_session_replay');

  Future<void> initialize(String apiKey) async {
    try {
      await _channel.invokeMethod('initializeSessionReplay', {
        'apiKey': apiKey,
      });
    } on PlatformException catch (e) {
      throw Exception('Failed to initialize session replay: ${e.message}');
    }
  }

  Future<void> enable() async {
    try {
      await _channel.invokeMethod('enableSessionReplay');
    } on PlatformException catch (e) {
      throw Exception('Failed to enable session replay: ${e.message}');
    }
  }
  Future<void> disable() async {
    try {
      await _channel.invokeMethod('disableSessionReplay');
    } on PlatformException catch (e) {
      throw Exception('Failed to disable session replay: ${e.message}');
    }
  }

  Future<void> trackEvent(String eventName, {Map<String, dynamic>? properties}) async {
    try {
      await _channel.invokeMethod('trackEvent', {
        'eventName': eventName,
        'properties': properties ?? {},
      });
    } on PlatformException catch (e) {
      throw Exception('Failed to track event: ${e.message}');
    }
  }
}

Swift プラグイン(AmplitudeSessionReplayPlugin.swift)

  • Session Replay 初期化・開始・停止のメソッドを提供
  • Amplitude SDK と Flutter MethodChannel の連携を定義
AmplitudeSessionReplayPlugin.swift
public class AmplitudeSessionReplayPlugin: NSObject, FlutterPlugin {
    private let channelName = "amplitude_session_replay"
    private var channel: FlutterMethodChannel!
    private var amplitude: Amplitude?
    private var sessionReplayPlugin: AmplitudeSwiftSessionReplayPlugin?

    public init(with registrar: FlutterPluginRegistrar) {
        super.init()
        let messenger = registrar.messenger()
        channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
        registrar.addMethodCallDelegate(self, channel: channel)
    }

    public static func register(with registrar: FlutterPluginRegistrar) {
        registrar.addApplicationDelegate(AmplitudeSessionReplayPlugin(with: registrar))
    }

    public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "initializeSessionReplay":
            initializeSessionReplay(call: call, result: result)
        case "enableSessionReplay":
            enableSessionReplay(result: result)
        case "disableSessionReplay":
            disableSessionReplay(result: result)
        case "trackEvent":
            trackEvent(call: call, result: result)
        default:
            result(FlutterMethodNotImplemented)
        }
    }

    private func initializeSessionReplay(call: FlutterMethodCall, result: @escaping FlutterResult) {
        guard let args = call.arguments as? [String: Any],
              let apiKey = args["apiKey"] as? String else {
            result(FlutterError(code: "NO_API_KEY", message: "API Key is null", details: nil))
            return
        }

        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let root = windowScene.windows.first?.rootViewController?.view {
            root.amp_isBlocked = false 
        }
        
        amplitude = Amplitude(configuration: Configuration(apiKey: apiKey))
        sessionReplayPlugin = AmplitudeSwiftSessionReplayPlugin(
            sampleRate: 1.0 //0.1 ~ 1.0 リプレイ収集のためにランダムに選択されるセッションの割合を表します。
        )
        sessionReplayPlugin!.start()
        amplitude?.add(plugin: sessionReplayPlugin! as UniversalPlugin)
        result(nil)
    }

    private func enableSessionReplay(result: @escaping FlutterResult) {
        if let plugin = sessionReplayPlugin {
            plugin.start()
            result(nil)
        } else {
            result(FlutterError(code: "PLUGIN_NOT_INITIALIZED", message: "SessionReplayPlugin not initialized", details: nil))
        }
    }

    private func disableSessionReplay(result: @escaping FlutterResult) {
        if let plugin = sessionReplayPlugin {
            plugin.stop()
            result(nil)
        } else {
            result(FlutterError(code: "PLUGIN_NOT_INITIALIZED", message: "SessionReplayPlugin not initialized", details: nil))
        }
    }

    private func trackEvent(call: FlutterMethodCall, result: @escaping FlutterResult) {
        guard let args = call.arguments as? [String: Any],
              let eventName = args["eventName"] as? String else {
            result(FlutterError(code: "MISSING_EVENT_NAME", message: "eventName is null", details: nil))
            return
        }

        let properties = args["properties"] as? [String: Any] ?? [:]
        amplitude?.track(eventType: eventName, eventProperties: properties)
        result(nil)
    }
}

AppDelegate への登録

AppDelegate.swift
@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    AmplitudeSessionReplayPlugin.register(with: registrar(forPlugin: "amplitude-session-replay-plugin")!)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
} 

実装時の注意点(iOS)

1. マスクされたビューの録画解除が必須

デフォルト状態では、amp_isBlocked が true のビューはすべて録画から除外され、動画が真っ白になる問題があります。そのため、amp_isBlockedはfalseを指定しないと適切に録画されません。

if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
         let root = windowScene.windows.first?.rootViewController?.view {
          root.amp_isBlocked = false 
      }

2. 個人情報を含む画面の録画制御

全ての画面が録画対象となるため、AmplitudeSessionReplay の設定でユーザーデータのマスキング(秘匿化)を有効にしても、入力欄や画像などがそのまま録画されてしまう可能性があります。
そのため、プライバシー保護の観点からは plugin.stop() を使って録画を一時停止する制御が必須となります。

3. パフォーマンスへの影響

Amplitude Session Replay は UI の変更を逐次キャプチャし続ける設計であり、特に sampleRate: 1.0(= すべてのセッションを録画)に設定した場合、次のようなオーバーヘッドが発生します:

  • 毎フレームごとに UIView 階層の差分を解析・記録
  • タップやスクロールなどの操作情報もイベントとして補足
  • 大量のメモリおよび CPU を継続的に消費

⚠️これにより、スクロールやアニメーション中にカクツキや描画遅延が発生することがあります

対処法(軽減策)

  • ユーザー操作が少ない画面のみ plugin.start() を呼び出す
  • 更新頻度の高い画面では plugin.stop() を用いて録画を一時停止

Android の落とし穴

Flutter 側の実装では、iOS のように Session Replay を正常に動作させることができません。理由は以下のとおりです。

Flutter の描画方法と SDK の相性問題

Kotlin で実装した画面では動画を録画できますが、Flutter で構築された画面では録画結果が真っ黒になる問題があります。
Flutter の描画は SurfaceView を使用して GPU ベースで直接レンダリングしているため、Amplitude SDK が想定する Android 標準 View 階層をトラバースできず、画面が真っ黒に記録されてしまうという問題があります。

View 階層図

┌──────────────────────────────────────────┐
│ FlutterView (FrameLayout)                │
│  ├─ FlutterSurfaceView (SurfaceView)     │ ← Flutter の描画
│  └─ PlatformViewsController container    │
│     └─ [View created by AndroidView]     │
│         └─ SurfaceView / TextureView     │ ← ここに AndroidView が描画される、
│                            │   sessionReplayの録画対象
└──────────────────────────────────────────┘

まとめ

現時点では、Amplitude Session Replay を Flutter アプリで完全に利用するのは困難です。

  • iOS ではネイティブ SDK の組み込みで対応可能
  • Android では FlutterView の制約により録画不可

iOS のみであっても、ユーザーの重要な行動パターンを可視化する手段として導入価値があります。
ただし、非公式・非推奨の手法であるため、導入は自己責任でお願いいたします。
Flutter 向けの公式 SDK が登場するまでの間、この記事が参考になれば幸いです。

Linc'well, inc.

Discussion