LoginSignup
24
10

More than 1 year has passed since last update.

[Flutter] 動画プレイヤーを再実装する形でTexture Widgetについて調べてみた

Last updated at Posted at 2021-12-17

この記事は「Flutter Advent Calendar 2021」の17日目の記事です。

FlutterにはiOS/Androidと言った「プラットフォーム側で用意されているView」や「ネイティブ側で再生した映像」などをWidgetとして表示する仕組みとして、「Texture Widget」や「Platform Views」と言った機能が存在します。

今回、前者のTexture Widgetについて調査してみたので備忘録序に纏めてみようと思います。
もし記事中で間違いや説明不足の箇所などあれば、コメントや編集リクエストなどで教えて頂けると幸いです。

--

なお、調査する上では以下の記事を参考にさせて頂きました。
(特にTexture Widgetに関しては情報が少なかった?印象があったので感謝... :bow: )

こちらの資料などを参考にさせて頂きつつ、自分なりに纏めていければと思います。

▼ 対応プラットフォーム

  • iOS
  • Android

▼ 環境

  • [✓] Flutter (Channel stable, 2.5.3, on macOS 12.0.1 21A559 darwin-x64, locale ja-JP)
    • Dart version 2.14.4
  • [✓] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
  • [✓] Xcode - develop for iOS and macOS (version 13.0)
  • [✓] Android Studio (version 2020.3)

この記事で解説する内容について

この記事では公式のvideo_playerを参考に簡単な「動画プレイヤー」を再実装する形でTexture Widgetに関する実装ポイントを解説していきます。

最後には持論ベースではありますが、似たような性質を持つPlatform Viewsとの使い分けについても簡単に触れていければと思います思います。

※プラットフォーム固有の実装コードについて

iOS及びAndroidのプラットフォーム固有の実装コード(以降、ネイティブコードと記載)は以下の言語で実装していきます。

  • iOS: Swift 5.0 1
  • Android: Kotlin 1.6.0 1

あとは解説しやすいようにpigeonと言ったパッケージは利用せずに、Method Channelなどは全て手動で定義していきます。

記事のターゲット

基本的には 「iOS/Androidアプリの開発知識 (両方 or どちらか一方)」及び「Plugin Packages2に関する実装知識3」が多少あることを前提に解説していきます。

その上で以下の方にはリーチする内容になるかなと思います。

  • 上に挙げてる知識は多少あるが、Texture Widgetへのキャッチアップまでは行えていない
  • Texture Widgetを利用した機能の設計の一例を見てみたい

この記事の目的

上記に該当する方がTexture Widget及び設計の一例までのキャッチアップを完了するところまでを目的としております。
その上で深く調べていく上での足がかりとしたり、技術選定時の一助となれば幸いです。

解説しない内容

「記事のターゲット」でも記載した通り、以下の前知識はある程度前提としてます。

  • Flutterのアプリ開発及びPlugin Packageに関する開発の基礎知識
    • e.g. Method Channelとは何か? どう作り始めればよいか?など
  • iOS/Androidアプリ開発の基礎知識
    • Swift/Kotlinの構文など

動画プレイヤーを再実装してTexture Widgetの実装ポイントを理解する

この章ではTexture Widgetを利用した簡易的な動画プレイヤーの実装解説を行っていきます。

20211216_134623.GIF

ちなみに動画プレイヤーと言っても公式のvideo_playerと全く同じものを写経して作る形ではなく、大凡の設計思想などは参考にしつつもメソッド名や機能などは独自に定義してます。

そのために全体を通してみると「Texture Widgetの機能解説」と言うよりかは「それを応用した設計周りの解説」も含まれた内容となりますが、それも踏まえて実装の一例として見て頂けると幸いです。

▼ 仕様

  • 任意の数のインスタンスを生成して配置し、それぞれ個別に操作することが可能 (↑のGIF上では3つ生成している)
  • 機能としては以下を実装
    • インスタンスの生成・破棄
    • 再生するHLS形式の動画URLの設定
    • 再生
    • 一時停止
  • バックグラウンド再生やエラーハンドリングは未考慮

▼ プロジェクトについて

解説するプロジェクトはGitHubで公開してます。

そもそもTexture Widgetとは何か?

具体的な解説に入る前に今回の主役であるTexture Widgetそのものについて簡単に解説していきます。
API Referenceとしては以下のTexture classであり、この記事中では基本的にTexture Widgetと言う名称で記載していきます。

機能自体をザックリと説明すると「配置したTexture Widgetの表示領域に対し、ネイティブコード側から好きな映像や描画結果などを書き込んで表示できる」と言えそうな特殊なWidgetになります。

主な用途

こちらは公式のvideo_playercameraと言ったパッケージで利用されており、主にネイティブ側の映像をFlutter上で表示する際に利用されています。

例えば上記に挙げたパッケージだとvideo_playerではネイティブ側で再生した映像の表示」に利用しており、cameraの方はカメラ映像のプレビュー表示」で利用されてます。

他にも以下の記事にあるように、OpenGLと言ったGraphicsApiを用いて自前でレンダリングした結果を表示するのにも使えそうです。

Texture Widgetの描画の仕組みについて

Texture Widgetの描画を行うには、ネイティブコード側から自前で描画処理を呼び出して更新を行う必要があります。

手順についてはTexture classのドキュメントにそれっぽい記載があり、引用すると以下のように記載されてます。

A rectangle upon which a backend texture is mapped.

Backend textures are images that can be applied (mapped) to an area of the Flutter view. They > are created, managed, and updated using a platform-specific texture registry. This is typically done by a plugin that integrates with host platform video player, camera, or OpenGL APIs, or similar image sources.

A texture widget refers to its backend texture using an integer ID. Texture IDs are obtained from the texture registry and are scoped to the Flutter view. Texture IDs may be reused after deregistration, at the discretion of the registry. The use of texture IDs currently unknown to the registry will silently result in a blank rectangle.

Texture widgets are repainted autonomously as dictated by the backend (e.g. on arrival of a video frame). Such repainting generally does not involve executing Dart code.

The size of the rectangle is determined by its parent widget, and the texture is automatically scaled to fit.

また、それとは別にTextureLayer classと言うクラスの説明には以下のように記載されてます。

A composited layer that maps a backend texture to a rectangle.

Backend textures are images that can be applied (mapped) to an area of the Flutter view. They > are created, managed, and updated using a platform-specific texture registry. This is typically done by a plugin that integrates with host platform video player, camera, or OpenGL APIs, or similar image sources.

A texture layer refers to its backend texture using an integer ID. Texture IDs are obtained from the texture registry and are scoped to the Flutter view. Texture IDs may be reused after deregistration, at the discretion of the registry. The use of texture IDs currently unknown to the registry will silently result in a blank rectangle.

Once inserted into the layer tree, texture layers are repainted autonomously as dictated by the backend (e.g. on arrival of a video frame). Such repainting generally does not involve executing Dart code.

Texture layers are always leaves in the layer tree.

これらを見るにFlutterがプラットフォームごとにTexture Registryと言う実装を持っているので、こちらを利用して以下の手順で描画及び表示を行う必要があると取れそうです。4

  • ネイティブコード
    • 1. TextureRegistryに対して登録処理を呼び出してtextureIdの取得を行う
    • 2. 取得したtextureIdに紐付いている描画領域に対して任意の描画処理を呼び出す
      • e.g. 動画プレーヤーから得られた映像の反映など
    • 3. 不要になったらTextureRegistryに対し登録解除処理を呼び出すことでtexureIdを返却可能
      • ※ その上で返却(登録解除)されたtextureIdはエンジン側の裁量で使い回される可能性がある
  • Flutter (Dart)
    • 上記の1の手順で取得したtextureIdTexture Widgetに渡す形で任意の箇所に配置 5
      • 上記の2の手順で更新した描画結果が実際に配置したWidget内に表示される
        • e.g. 動画プレーヤーの場合には映像がWidget内に表示される

これらを踏まえつつ今回実装した動画プレイヤーの具体的な解説に入っていきます。

全体の構成について

先ずはイメージを掴みやすいように全体構成から解説していきます。

ポイントとしてはDart側で動画プレイヤー(図で言うSimpleVideoPlayerクラス)を生成したら、それに合わせてネイティブ側でも「動画プレイヤーの生成」とtextureIdの登録」を行い、Dart側にtextureIdを返します。

このtextureIdは配置するTexture Widgetの引数として渡す他にも、Dart側の動画プレイヤーのインスタンスから「対応するネイティブ側の動画プレイヤー」を参照するためのキーとしても利用してます。

これによりSimpleVideoPlayerを任意の数だけ生成しつつ、それぞれを独立して管理を行えるような構成にしてます。

スクリーンショット 2021-12-17 14.04.40.png

textureIdの本来の使い方としては「Texture Widgetへの引数としての利用」だが、今回の実装では序に「ネイティブ側の動画プレイヤーと紐付けるためのキー」としても利用する形となっている。 (参考にしたvideo_playerの実装がそうなっていた)

Flutter (Dart)側の実装

先にDart側の実装解説から入っていきます。
ネイティブコードからtextureIdを登録する手順などは次の章で解説します。

ここでは以下のトピックに分けて解説していきます。

  • 動画プレイヤー本体側の実装 (Plugin Packageとして実装される箇所)
    • ./lib以下のコード
  • 動画プレイヤーを利用したサンプルの実装
    • ./example/lib以下のコード

動画プレイヤー本体側の実装

こちらは実際にPlugin Packageとして実装される箇所です。
要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。

■ インスタンスの初期化時にtextureIdを返しつつ、ネイティブ側で動画プレイヤーの生成を行う

SampleVideoPlayerを生成したら利用する側はinitialize()で初期化を行うようにします。

initialize()ではMethod Channelでネイティブコード側の「動画プレイヤー生成」及び「textureIdの登録」を呼び出しつつ、同時にネイティブコード側で登録したtextureIdを取得して保持するようにします。

ここでtextureIdを保持することによって「SimpleVideoPlayerとネイティブ側の動画プレイヤー」の紐付けが成り立ちます。

./lib/sample_video_player.dart
class SampleVideoPlayer {
  int _textureId = uninitializedTextureId;

  /// textureId (再生するTextureWidgetに渡すこと)
  int get textureId => _textureId;

  /// 初期化
  Future initialize() async {
    if (_textureId != uninitializedTextureId) {
      debugPrint('textureId登録済み');
      return;
    }

    // ネイティブ側で「動画プレイヤーの生成」と「textureIdの登録」を行う
    // → `createPlayer`の戻り値には登録した`textureId`が返ってくる
    _initializeCompleter = Completer<void>();
    _textureId = await _platformInterface.createPlayer();
    _initializeCompleter!.complete(null);
  }

呼び出しているMethod Channelの処理は以下のような感じです。

./lib/src/sample_video_player_method_channel.dart
class SampleVideoPlayerMethodChannel implements SampleVideoPlayerPlatforminterface {

  /// ネイティブ側でプレイヤーの生成を行う
  ///
  /// 戻り値として返す[int]は「生成したプレイヤーの管理ID」及び、「TextureWidgetに渡すtextureId」として機能する
  ///
  /// NOTE: 内部的にプレイヤーの生成と共に、TextureWidgetで利用するtextureIdの登録を行う
  @override
  Future<int> createPlayer() async {
    final textureId = await _methodChannel.invokeMethod<int>(
      'createPlayer',
    );
    assert(textureId != null);
    return textureId!;
  }

textureIdはインスタンスメソッドの呼び出しにも利用

保持したtextureIdネイティブ側の動画プレイヤーのインスタンスメソッドを呼び出すためのキーとしても利用します。
(詳しくは後述しますが、ネイティブコード側では渡されたtextureIdを元に対応する動画プレイヤーのインスタンスを探し出してインスタンスメソッドを呼び出します)

例として「再生」と「破棄」のコードを切り出すと以下のようになります。

./lib/sample_video_player.dart
class SampleVideoPlayer {

  /// 破棄
  Future dispose() async {
    if (_textureId == uninitializedTextureId) {
      debugPrint('textureIdが未登録');
      return;
    }

    await _initializeCompleter!.future;
    await pause();
    await _platformInterface.disposePlayer(_textureId);
    _textureId = uninitializedTextureId;
    _initializeCompleter = null;
  }

  /// 再生
  Future play() async {
    if (_textureId == uninitializedTextureId) {
      debugPrint('textureIdが未登録');
      return;
    }

    await _initializeCompleter!.future;
    await _platformInterface.play(_textureId);
  }
}

Method Channelの実装クラスはシングルトンとなるので、ネイティブコードのインスタンスメソッドを呼び出すにはこの様に「インスタンスを参照するキー」と言ったものを渡して間接的に呼び出す必要があります。

./lib/src/sample_video_player_method_channel.dart
class SampleVideoPlayerMethodChannel implements SampleVideoPlayerPlatforminterface {

  /// ネイティブ側で生成したプレイヤーの破棄
  ///
  /// [textureId]に紐付かれたプレイヤーの破棄を行う
  ///
  /// NOTE: 内部的にプレイヤーの破棄と共に、TextureWidgetで利用するtextureIdの登録解除を行う
  @override
  Future disposePlayer(int textureId) async {
    return _methodChannel.invokeMethod(
      'disposePlayer',
      <String, dynamic>{
        'textureId': textureId,
      },
    );
  }

  /// プレイヤーの再生
  ///
  /// [textureId]に紐付かれたプレイヤーを再生する
  @override
  Future play(int textureId) {
    return _methodChannel.invokeMethod(
      'play',
      <String, dynamic>{
        'textureId': textureId,
      },
    );
  }
}

動画プレイヤーを利用したサンプルの実装

上記で実装したSimpleVideoPlayerを実際に利用しているサンプルについても解説します。
こちらも要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。

SimpleVideoPlayerの生成と破棄

ネイティブに関わる一通りの処理はSimpleVideoPlayerにカプセル化される形で実装されているので、ぶっちゃけ使う側はそこまで複雑ではありません。

今回の例だと動画プレイヤーを任意の数だけ生成して持てるようにしつつ、ViewModel側に「追加」と「削除」のメソッドを用意しているだけです。

その上でそれぞれのプレイヤーは独立したインスタンスとなっているので、個別に「再生」「一時停止」と言った操作を行うことができます。

./example/lib/page/view_model.dart
class ExampleViewModel extends ChangeNotifier {
  // サンプルで再生するHLSの動画URL(固定)
  final _hlsUrl =
      'http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8';

  // 生成したプレイヤー
  final createdPlayers = <SampleVideoPlayer>[];

  /// プレイヤーの追加
  Future addPlayer() async {
    // `initialize()`は必ず呼び出すこと。
    // でないと`textureId`が登録されない上に動画プレイヤーも生成されない
    final player = SampleVideoPlayer();
    await player.initialize();
    await player.setUrl(_hlsUrl);
    createdPlayers.add(player);
    notifyListeners();
  }

  /// プレイヤーの削除
  Future removePlayer() async {
    final index = createdPlayers.length - 1;
    if (index < 0) return;

    final player = createdPlayers[index];
    await player.dispose();
    createdPlayers.removeAt(index);
    notifyListeners();
  }
}

Texture Widgetでの表示

実際にFlutterアプリ上にTexture Widgetとして表示している箇所は以下です。

とは言え、こちらもそこまで複雑ではありません。
Texture Widgetの引数textureId:SimpleVideoPlayerが持つtextureIdを渡しているだけです。

この例ではtextureIdが未登録(SimpleVideoPlayer.initialize()が呼び出されていない)時には表示範囲を黒塗りするように実装してます。
(利便性を考慮するなら自明なエラー表示とかに切り替えても良いかも?)

./example/lib/page/view.dart
  Widget _buildVideoPlayerWidget(BuildContext context) {
    final viewModel = context.watch<ExampleViewModel>();

    // `index`には現在操作しているプレイヤーのindexが入るよ
    final textureId = viewModel.createdPlayers[index].textureId;

    // [16:9]のサイズで表示
    return SizedBox(
      width: 320 * 0.8,
      height: 180 * 0.8,
      // NOTE: textureIdが未登録状態の場合には黒塗りにしておく
      child: textureId != uninitializedTextureId
          ? Texture(textureId: textureId)    // ← SimpleVideoPlayerが持つ`textureId`
          : Container(color: Colors.black),
    );
  }

ネイティブコードの実装

次にネイティブコード側に於ける以下のトピックについて解説していきます。

  • textureIdの登録
  • 描画処理の呼び出し
  • textureIdの登録解除

iOSの実装

要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。

クラスとしてはSwiftSampleVideoPlayerPluginでMethod Channelの紐付けや処理の呼び出しを行い、VideoPlayerで動画再生を行ってます。

textureIdの登録

先ず最初にTextureRegistryを取得する必要がありますが、iOSの場合にはFlutterPluginRegistrarから手に入ります。

今回はFlutterPlugin.registerが呼ばれた際にSwiftSampleVideoPlayerPluginのイニシャライザを呼び出し、その中で取得して保持するようにしてます。

SwiftSampleVideoPlayerPlugin+FlutterPlugin.swif
extension SwiftSampleVideoPlayerPlugin: FlutterPlugin {

    // ※ Flutterが自動で呼び出してくれるメソッド
    public static func register(with registrar: FlutterPluginRegistrar) {

        // このタイミングで自身をインスタンス化
        let instance = SwiftSampleVideoPlayerPlugin(with: registrar)
    }
}
SwiftSampleVideoPlayerPlugin.swif
public final class SwiftSampleVideoPlayerPlugin: NSObject {

    /// FlutterPluginより取得可能なテクスチャの管理クラス
    ///
    /// NOTE: iOSはこのクラスに対して「登録」「登録解除」「更新(再描画)」などの呼び出しを行う
    private let textureRegistry: FlutterTextureRegistry

    init(with registrar: FlutterPluginRegistrar) {
        // イニシャライザで`TextureRegistry`を取得する (このメソッドから手に入る)
        textureRegistry = registrar.textures()
        super.init()
    }

あとはMethod Channel経由でcreatePlayerが呼び出されたら「動画プレイヤーの生成」及び「textureIdの登録」を呼び出して、createdPlayersと言うDictionaryに登録します。

ここでDictionaryにインスタンスを登録しておくことで、Dart側のSimpleVideoPlayerがtextureIdをキーに「対応する動画プレイヤー」のインスタンスメソッドを間接的に呼び出せるという仕組みになってます。

SwiftSampleVideoPlayerPlugin.swif
    /// 登録したtextureId, 生成済みのプレイヤー
    private var createdPlayers = [Int64: VideoPlayer]()

    /// プレイヤーの生成
    func createPlayer() -> Int64 {
        // NOTE:
        // プレイヤー内部でテクスチャの「登録、登録解除、更新(再描画)」を呼び出せるように`textureRegistry`のインスタンスを渡しとく
        let player = VideoPlayer(with: textureRegistry)
        let textureId = player.textureId

        // 動画プレイヤーはDictionaryに保持。キーは`textureId`
        createdPlayers[textureId] = player
        return textureId
    }

VideoPlayerのイニシャライザでは「textureRegistry.register」を呼び出してtextureIdの登録を行います。
(この処理については続きがあり、次で解説します)

ちなみにiOSの場合にはFlutterTextureRegistryは「描画の更新処理」を呼び出す際にも利用するので、動画プレイヤークラスの内部でも保持しておきます。 (Androidではこの手順は不要)

VideoPlayer.swift
final class VideoPlayer: NSObject {

    /// テクスチャ未登録時の値
    private static let uninitializedTextureId: Int64 = -1

    private let textureRegistry: FlutterTextureRegistry
    private var _textureId: Int64 = uninitializedTextureId

    // AVPlayer関連
    private let avPlayer: AVPlayer
    private let playerItemVideoOutput: AVPlayerItemVideoOutput
    private var currentItem: AVPlayerItem? = nil

    /// 登録されたtextureId
    var textureId: Int64 {
        _textureId
    }

    // MARK:- Public Methods

    init(with registrar: FlutterTextureRegistry) {
        textureRegistry = registrar

        // プレイヤーの生成
        avPlayer = AVPlayer()
        playerItemVideoOutput = AVPlayerItemVideoOutput(
            pixelBufferAttributes: [
                kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
            ]
        )

        super.init()

        // textureIdの登録
        //
        // NOTE:
        // `register`に渡すクラスは`FlutterTexture`と言うprotocolを実装している必要がある。
        // → 今回の実装例ではVideoPlayer自身が`FlutterTexture`を実装している
        _textureId = textureRegistry.register(self)
    }

■ 描画処理の呼び出し

描画処理の呼び出しはVideoPlayerにて行ってます。

具体的に言うとFlutterTextureと言うプロトコルの「copyPixelBuffer」がその処理に当たり、ここで返したCVPixelBufferTexture Widget上に表示されます。
→ ここではplayerItemVideoOutputから再生中の動画を切り出して返している

ちなみにtextureRegistry.registertextureIdの登録を行うのですが、このメソッドに渡すクラスはFlutterTextureプロトコルを実装している必要があり、ここで返されるtexuteIdの表示結果がcopyPixelBufferで返した値となります。8

VideoPlayer.swift
final class VideoPlayer: NSObject {

    init(with registrar: FlutterTextureRegistry) {

        // textureIdの登録
        //
        // NOTE:
        // `register`に渡すクラスは`FlutterTexture`と言うprotocolを実装している必要がある。
        // → 今回の実装例ではVideoPlayer自身が`FlutterTexture`を実装している
        _textureId = textureRegistry.register(self)
    }
}

// `textureRegistry.register`で登録するのに必要なprotocol
extension VideoPlayer: FlutterTexture {

    // `textureRegistry.textureFrameAvailable`が呼び出されたらこちらが発火される
    // → 戻り値で返したCVPixelBufferが実際にTextureWidget上に表示される
    public func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {

        // 現在のタイミングからPixelBufferを取得
        let outputItemTime = playerItemVideoOutput.itemTime(forHostTime: CACurrentMediaTime())
        guard playerItemVideoOutput.hasNewPixelBuffer(forItemTime: outputItemTime),
              let buffer = playerItemVideoOutput.copyPixelBuffer(forItemTime: outputItemTime, itemTimeForDisplay: nil) else {
            return nil
        }

        // ここで返したPixelBufferが実際にTextureWidget上に表示される
        return Unmanaged<CVPixelBuffer>.passRetained(buffer)
    }
}

(下手にやると重くなりそうですが)ここでCVPixelBufferにフィルター処理を施すなんてことも出来そうな印象がありますね。

★ 【iOSのみ】 再描画のための「更新処理」は自前で呼び出す必要あり

上記で挙げたFlutterTexture.copyPixelBuffer自動では呼び出されません。
copyPixelBufferを呼び出して再描画を行うには、こちらから「更新されたタイミング」をFlutterに伝える必要があります。

そのためのメソッドがTextureRegistryが持つ「textureFrameAvailable」であり、こちらを呼び出すことでFlutterに更新された旨が通知されてFlutterTexture.copyPixelBufferが呼び出されます。

そしてこの動画プレイヤーではCADisplayLinkを用いて常に更新し続けることで動画再生を行うようにします。(ちなみにvideo_playerも同様のことを行っている)

VideoPlayer.swift
    // MARK:- CADisplayLink Events

    /// `CADisplayLink`よりリフレッシュレートに合わせて定期呼び出しされる処理
    @objc private func onDisplayLinkUpdate(_ displayLink: CADisplayLink) {
        // これを呼び出すと`FlutterTexture.copyPixelBuffer`が実行される
        textureRegistry.textureFrameAvailable(_textureId)
    }

textureIdの登録解除

textureIdの登録解除はTextureRegistryが持つ「unregisterTexture」と言うメソッドで行います。
この例ではVideoPlayer.dispose()内で呼び出しを行ってます。

VideoPlayer.swift
    func dispose() {
        currentItem = nil

        displayLink.invalidate()
        displayLink = nil

        kvoObservers.forEach({ $0.invalidate() })
        kvoObservers.removeAll()

        // textureIdの登録解除
        textureRegistry.unregisterTexture(_textureId)
    }

↑を呼び出しているのはSwiftSampleVideoPlayerPluginであり、Method ChannelからdisposePlayerが呼び出されたら該当するプレイヤーのdisposeを呼び出します。(その上でcreatedPlayersからも消しておく)

SwiftSampleVideoPlayerPlugin.swift
    /// プレイヤーの破棄
    ///
    /// - Parameter textureId: 紐付いているtextureId
    func disposePlayer(with textureId: Int64) {
        guard let player = createdPlayers[textureId] else {
            return
        }

        player.dispose()
        createdPlayers[textureId] = nil
    }

今回の実装では行っていませんが、登録解除時にはFlutterTextureプロトコルが持つ「onTextureUnregistered」が呼び出されるので、こちらから動画プレイヤー内のインスタンスの破棄などを行うことも可能です。 ただ、今回の実装では「onTextureUnregistered」を実装せずに「dispose」内で一括で破棄処理を行ってます。(それもあってか「onTextureUnregistered」自体はoptionalな定義だったりする)

Androidの実装

要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。

クラスの名称や役割はiOS版と同様です。(なるべく合わせる形で実装してます)

textureIdの登録

こちらも最初にTextureRegistryを取得する必要がありますが、Androidの場合にはFlutterPlugin.FlutterPluginBindingから手に入ります。

今回はFlutterPlugin.onAttachedToEngineが呼ばれた際に保持するようにしてます。

SampleVideoPlayerPlugin.kt
class SampleVideoPlayerPlugin : FlutterPlugin, MethodCallHandler {
    // FlutterPluginより取得可能なテクスチャの管理クラス
    private lateinit var textureRegistry: TextureRegistry

    // ※ Flutterが自動で呼び出してくれるメソッド
    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        // onAttachedToEngineで`TextureRegistry`を取得する
        textureRegistry = flutterPluginBinding.textureRegistry
    }

あとはMethod Channel経由でcreatePlayerが呼び出されたら「動画プレイヤーの生成」及び「テクスチャの登録」を呼び出して、createdPlayersと言うDictionaryに登録します。

Dictionaryにインスタンスを登録する理由はiOSと同じです。
※詳しくはiOS側の「textureIdの登録」にあるNOTE INFOを参照

その上でAndroidの場合には登録時には「Flutterによって管理されているSurfaceTexture及びその管理インターフェースであるTextureRegistry.SurfaceTextureEntry」が戻り値として返ります。
(textureIdid()と言うメソッドから取得可能)

kotlin
    // 登録したtextureId, 生成済みのプレイヤー
    private var createPlayers: MutableMap<Long, VideoPlayer> = mutableMapOf()

    private fun onCreatePlayer(result: Result) {
        // テクスチャの登録  (iOSと違って`textureId`が返るわけではない)
        val surfaceTextureEntry = textureRegistry.createSurfaceTexture()

        // Androidの場合は`SurfaceTextureEntry`クラスさえ渡しておけばプレイヤー側で完結可能
        // → iOSみたいに`textureRegistry`を丸ごと渡す必要はない
        val videoPlayer = VideoPlayer(applicationContext, surfaceTextureEntry)
        val textureId = surfaceTextureEntry.id()
        createPlayers[textureId] = videoPlayer
        result.success(textureId)
    }

これをVideoPlayerに渡して動画プレイヤーのセットアップを行います。

VideoPlayer.kt
class VideoPlayer(
    applicationContext: Context,
    private val surfaceTextureEntry: TextureRegistry.SurfaceTextureEntry,
) {
    private val exoPlayer: SimpleExoPlayer
    private val dataSourceFactory: DataSource.Factory
    private val surface: Surface

    init {
        // video_playerの実装を参考に初期化
        val player = SimpleExoPlayer.Builder(applicationContext).build()
        val factory = DefaultHttpDataSource.Factory()
            .setUserAgent("ExoPlayer")
            .setAllowCrossProtocolRedirects(true)

        // ExoPlayerに対し、テクスチャの紐付けを行う
        // NOTE: Androidの場合は簡単でPlayerにSurfaceを渡すだけで内部的によしなに更新してくれる
        val createSurface = Surface(surfaceTextureEntry.surfaceTexture())
        player.setVideoSurface(createSurface)
        player.setAudioAttributes(
            AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MOVIE).build(),
            false)

        exoPlayer = player
        dataSourceFactory = factory
        surface = createSurface
    }

■ 描画処理の呼び出し

Androidの場合は簡単で、SurfaceTextureEntryが持つ「surfaceTexture」からSurfaceを作ったら、それをExoPlayersetVideoSurfaceに渡すだけで後は裏で勝手に更新してくれます。

VideoPlayer.kt
    init {
        // ExoPlayerの初期化 (省略)

        // ExoPlayerに対し、テクスチャの紐付けを行う
        // NOTE: Androidの場合は簡単でPlayerにSurfaceを渡すだけで内部的によしなに更新してくれる
        val createSurface = Surface(surfaceTextureEntry.surfaceTexture())

        // ExoPlayerの`setVideoSurface`に渡すだけで終わり (後は裏で勝手に更新してくれる)
        player.setVideoSurface(createSurface)

    }

(もしネイティブコード側でフィルター処理を掛けたいとなった場合、どこをいじれば出来そうか?については勉強不足のためにイメージできず...)

textureIdの登録解除

Androidの場合は登録解除も簡単です。

SurfaceTextureEntryが持つ「release」を呼び出すだけで終わりです。
iOSみたいにTextureRegistryから呼び出す必要はありません。

VideoPlayer.kt
    /**
     * プレイヤーの破棄
     */
    fun dispose() {
        // テクスチャの登録解除
        surfaceTextureEntry.release()

        surface.release()
        exoPlayer.release()
    }

あとはSampleVideoPlayerPluginの方もcreatePlayersから消しておきます。

SampleVideoPlayerPlugin.kt
    private fun onDisposePlayer(textureId: Long, result: Result) {
        createPlayers[textureId]?.let {
            it.dispose()
            createPlayers.remove(textureId)
        }

        result.success(null)
    }

終わりに

解説としては以上となります。
結構マニュアル操作感のあるAPIではありますが、応用することでFlutterだけは完結できない描画周りの問題を解決することが出来るかもしれません。

Platform Viewsとどう使い分けていけば良いか?

Flutterには「ネイティブ側のViewをFlutter上に表示する」仕組みとしてPlatform Views言う機能が提供されてます。
こちらは公式ドキュメントがしっかりと纏められているので、詳細については以下を御覧ください。

表題の使い分けについてですが、自分が調査した上での持論にはなりますが...以下のような使い分けが有りかな?と思ってます。

  • Platform Views
    • 画面全体に表示を行い、操作する必要があるもの (e.g. 例えばWebView9など)
    • 表示対象のネイティブ側のインスタンス(Viewなど)とWidgetの寿命が結びついても問題が無い場合
  • Texture Widget
    • 表示対象のネイティブ側のインスタンスとWidgetの寿命を分けて管理したい場合
      • → 例えば表示しているWidgetが破棄されてもネイティブ側はインスタンスを残しておきたい時とか
    • 再描画のタイミングを制御したい

特にPlatform Views「ネイティブ側の表示対象のインスタンス」と「Widgetの寿命」が結びついてしまいやすい印象を受けました。
(実装側の工夫次第では回避できるかもだが...)

例えば動画プレイヤーの要件として「画面遷移しても裏で動画を再生し続けたい」と言った実装を行いたいなら、今回みたいなTexture Widgetベースの実装がやりやすいのかな?とは思います。
→ 動画を再生しているTexture Widget自体が破棄されても「ネイティブ側の動画プレイヤー」自体は破棄されていないので

もし「自分はこう考えている・使い分けている」と言った例があれば教えて頂けると幸いです! :bow:

参考/関連リンク

参考資料

pub.dev

Documents


  1. 各バージョンとも取り敢えず「デフォルト指定のバージョン」や「新しいバージョン」を選んだだけであり、言語バージョンの選定理由にはそこまで強いこだわりはありません。 

  2. プラットフォーム固有の機能を呼び出すパッケージの名称 (公式ドキュメントの「Package types」より) 

  3. 公式ドキュメントの「Developing packages & plugins」を大凡把握出来ていればOK 

  4. これ以外の公式の詳細な資料が見つからなかったのもあり、ひょっとしたら説明に若干の語弊があるかもです...。(Flutter Engineのソースを追えば詳細について知ることが出来るかもしれないが...まだそこまで追えておらず...) 

  5. ちなみにここで未登録のtextureIdをTexture Widgetに渡した際にはブランクが表示されるっぽい (≒ 例外とかは投げられない模様) 

  6. ここらの設計思想や実装の詳細については「Method Channel」や「Federated plugins」の話となってしまうので、本記事では掘り下げて解説しません。 

  7. ちなみに「sample_video_player_platform_interface.dart」のドキュメントコメントにも記載してますが、このサンプルでは便宜的に「Platform Interface」の定義を同じプロジェクトに含めています。(ちゃんとするなら別プロジェクトに分けたほうが良いかも) 

  8. コメントにも記載しているが、今回の例ではVideoPlayer自体がFlutterTextureを実装しているのでselfを渡している 

  9. 実際に公式のWebViewパッケージではPlatform Viewsが使われている 

24
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
10