この記事は「Flutter Advent Calendar 2021」の17日目の記事です。
FlutterにはiOS/Androidと言った「プラットフォーム側で用意されているView」や「ネイティブ側で再生した映像」などをWidgetとして表示する仕組みとして、「Texture Widget」や「Platform Views」と言った機能が存在します。
今回、前者のTexture Widget
について調査してみたので備忘録序に纏めてみようと思います。
もし記事中で間違いや説明不足の箇所などあれば、コメントや編集リクエストなどで教えて頂けると幸いです。
--
なお、調査する上では以下の記事を参考にさせて頂きました。
(特にTexture Widget
に関しては情報が少なかった?印象があったので感謝... )
- Flutterで動画を再生するためには
- Flutterで動画配信するプラグインを作った話
- OpenGL with Texture widget
- Flutter 实时视频渲染:Texture与PlatformView
こちらの資料などを参考にさせて頂きつつ、自分なりに纏めていければと思います。
▼ 対応プラットフォーム
- 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のプラットフォーム固有の実装コード(以降、ネイティブコード
と記載)は以下の言語で実装していきます。
あとは解説しやすいようにpigeonと言ったパッケージは利用せずに、Method Channel
などは全て手動で定義していきます。
記事のターゲット
基本的には 「iOS/Androidアプリの開発知識 (両方 or どちらか一方)」及び「Plugin Packages
2に関する実装知識3」が多少あることを前提に解説していきます。
その上で以下の方にはリーチする内容になるかなと思います。
- 上に挙げてる知識は多少あるが、
Texture Widget
へのキャッチアップまでは行えていない -
Texture Widget
を利用した機能の設計の一例を見てみたい
この記事の目的
上記に該当する方がTexture Widget
及び設計の一例までのキャッチアップを完了するところまでを目的としております。
その上で深く調べていく上での足がかりとしたり、技術選定時の一助となれば幸いです。
解説しない内容
「記事のターゲット」でも記載した通り、以下の前知識はある程度前提としてます。
- Flutterのアプリ開発及び
Plugin Package
に関する開発の基礎知識- e.g. Method Channelとは何か? どう作り始めればよいか?など
- iOS/Androidアプリ開発の基礎知識
- Swift/Kotlinの構文など
動画プレイヤーを再実装してTexture Widgetの実装ポイントを理解する
この章ではTexture Widget
を利用した簡易的な動画プレイヤーの実装解説を行っていきます。
ちなみに動画プレイヤーと言っても公式のvideo_player
と全く同じものを写経して作る形ではなく、大凡の設計思想などは参考にしつつもメソッド名や機能などは独自に定義してます。
そのために全体を通してみると「Texture Widget
の機能解説」と言うよりかは**「それを応用した設計周りの解説」**も含まれた内容となりますが、それも踏まえて実装の一例として見て頂けると幸いです。
▼ 仕様
- 任意の数のインスタンスを生成して配置し、それぞれ個別に操作することが可能 (↑のGIF上では3つ生成している)
- 機能としては以下を実装
- インスタンスの生成・破棄
- 再生するHLS形式の動画URLの設定
- 再生
- 一時停止
- バックグラウンド再生やエラーハンドリングは未考慮
▼ プロジェクトについて
解説するプロジェクトはGitHubで公開してます。
そもそもTexture Widgetとは何か?
具体的な解説に入る前に今回の主役であるTexture Widget
そのものについて簡単に解説していきます。
API Referenceとしては以下のTexture class
であり、この記事中では基本的にTexture Widget
と言う名称で記載していきます。
機能自体をザックリと説明すると**「配置したTexture Widget
の表示領域に対し、ネイティブコード側から好きな映像や描画結果などを書き込んで表示できる」**と言えそうな特殊なWidgetになります。
主な用途
こちらは公式のvideo_playerやcameraと言ったパッケージで利用されており、主にネイティブ側の映像を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
-
ネイティブコード
-
-
TextureRegistry
に対して登録処理を呼び出してtextureId
の取得を行う
-
-
- 取得した
textureId
に紐付いている描画領域に対して任意の描画処理を呼び出す
- e.g. 動画プレーヤーから得られた映像の反映など
- 取得した
-
- 不要になったら
TextureRegistry
に対し登録解除処理を呼び出すことでtexureId
を返却可能
- ※ その上で返却(登録解除)された
textureId
はエンジン側の裁量で使い回される可能性がある
- 不要になったら
-
-
Flutter (Dart)
- 上記の1の手順で取得した
textureId
をTexture Widget
に渡す形で任意の箇所に配置 5- → 上記の2の手順で更新した描画結果が実際に配置したWidget内に表示される
- e.g. 動画プレーヤーの場合には映像がWidget内に表示される
- → 上記の2の手順で更新した描画結果が実際に配置したWidget内に表示される
- 上記の1の手順で取得した
これらを踏まえつつ今回実装した動画プレイヤーの具体的な解説に入っていきます。
全体の構成について
先ずはイメージを掴みやすいように全体構成から解説していきます。
ポイントとしてはDart側で動画プレイヤー(図で言うSimpleVideoPlayer
クラス)を生成したら、それに合わせてネイティブ側でも「動画プレイヤーの生成」と**「textureId
の登録」**を行い、Dart側にtextureId
を返します。
このtextureId
は配置するTexture Widget
の引数として渡す他にも、Dart側の動画プレイヤーのインスタンスから「対応するネイティブ側の動画プレイヤー」を参照するためのキーとしても利用してます。
これによりSimpleVideoPlayer
を任意の数だけ生成しつつ、それぞれを独立して管理を行えるような構成にしてます。
textureIdの本来の使い方としては「Texture Widgetへの引数としての利用」だが、今回の実装では序に「ネイティブ側の動画プレイヤーと紐付けるためのキー」としても利用する形となっている。
(参考にしたvideo_playerの実装がそうなっていた)
Flutter (Dart)側の実装
先にDart側の実装解説から入っていきます。
ネイティブコードからtextureId
を登録する手順などは次の章で解説します。
ここでは以下のトピックに分けて解説していきます。
-
動画プレイヤー本体側の実装 (Plugin Packageとして実装される箇所)
- →
./lib
以下のコード
- →
-
動画プレイヤーを利用したサンプルの実装
- →
./example/lib
以下のコード
- →
動画プレイヤー本体側の実装
こちらは実際にPlugin Packageとして実装される箇所です。
要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。
-
動画プレイヤー本体 (
SimpleVideoPlayer
) - Method Channel定義部分 6
- Platform Interfaceの定義クラス 6, 7
■ インスタンスの初期化時にtextureId
を返しつつ、ネイティブ側で動画プレイヤーの生成を行う
SampleVideoPlayer
を生成したら利用する側はinitialize()
で初期化を行うようにします。
initialize()
ではMethod Channelでネイティブコード側の「動画プレイヤー生成」及び「textureId
の登録」を呼び出しつつ、同時にネイティブコード側で登録したtextureId
を取得して保持するようにします。
ここでtextureId
を保持することによって「SimpleVideoPlayer
とネイティブ側の動画プレイヤー」の紐付けが成り立ちます。
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の処理は以下のような感じです。
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
を元に対応する動画プレイヤーのインスタンスを探し出してインスタンスメソッドを呼び出します)
例として「再生」と「破棄」のコードを切り出すと以下のようになります。
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の実装クラスはシングルトンとなるので、ネイティブコードのインスタンスメソッドを呼び出すにはこの様に「インスタンスを参照するキー」と言ったものを渡して間接的に呼び出す必要があります。
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
を実際に利用しているサンプルについても解説します。
こちらも要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。
- ExampleView
- ExampleViewModel
■ SimpleVideoPlayer
の生成と破棄
ネイティブに関わる一通りの処理はSimpleVideoPlayer
にカプセル化される形で実装されているので、ぶっちゃけ使う側はそこまで複雑ではありません。
今回の例だと動画プレイヤーを任意の数だけ生成して持てるようにしつつ、ViewModel側に「追加」と「削除」のメソッドを用意しているだけです。
その上でそれぞれのプレイヤーは独立したインスタンスとなっているので、個別に「再生」「一時停止」と言った操作を行うことができます。
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()
が呼び出されていない)時には表示範囲を黒塗りするように実装してます。
(利便性を考慮するなら自明なエラー表示とかに切り替えても良いかも?)
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.swift
- SwiftSampleVideoPlayerPlugin+FlutterPlugin.swift
- VideoPlayer.swift
クラスとしてはSwiftSampleVideoPlayerPlugin
でMethod Channelの紐付けや処理の呼び出しを行い、VideoPlayer
で動画再生を行ってます。
■ textureId
の登録
先ず最初にTextureRegistry
を取得する必要がありますが、iOSの場合にはFlutterPluginRegistrar
から手に入ります。
今回はFlutterPlugin.register
が呼ばれた際にSwiftSampleVideoPlayerPlugin
のイニシャライザを呼び出し、その中で取得して保持するようにしてます。
extension SwiftSampleVideoPlayerPlugin: FlutterPlugin {
// ※ Flutterが自動で呼び出してくれるメソッド
public static func register(with registrar: FlutterPluginRegistrar) {
// このタイミングで自身をインスタンス化
let instance = SwiftSampleVideoPlayerPlugin(with: registrar)
}
}
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をキーに「対応する動画プレイヤー」のインスタンスメソッドを間接的に呼び出せるという仕組みになってます。
/// 登録した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ではこの手順は不要)
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」がその処理に当たり、ここで返したCVPixelBufferがTexture Widget
上に表示されます。
→ ここではplayerItemVideoOutput
から再生中の動画を切り出して返している
ちなみにtextureRegistry.register
でtextureId
の登録を行うのですが、このメソッドに渡すクラスはFlutterTexture
プロトコルを実装している必要があり、ここで返されるtexuteId
の表示結果がcopyPixelBuffer
で返した値となります。8
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も同様のことを行っている)
// MARK:- CADisplayLink Events
/// `CADisplayLink`よりリフレッシュレートに合わせて定期呼び出しされる処理
@objc private func onDisplayLinkUpdate(_ displayLink: CADisplayLink) {
// これを呼び出すと`FlutterTexture.copyPixelBuffer`が実行される
textureRegistry.textureFrameAvailable(_textureId)
}
■ textureId
の登録解除
textureId
の登録解除はTextureRegistry
が持つ「unregisterTexture」と言うメソッドで行います。
この例ではVideoPlayer.dispose()
内で呼び出しを行ってます。
func dispose() {
currentItem = nil
displayLink.invalidate()
displayLink = nil
kvoObservers.forEach({ $0.invalidate() })
kvoObservers.removeAll()
// textureIdの登録解除
textureRegistry.unregisterTexture(_textureId)
}
↑を呼び出しているのはSwiftSampleVideoPlayerPlugin
であり、Method ChannelからdisposePlayer
が呼び出されたら該当するプレイヤーのdispose
を呼び出します。(その上でcreatedPlayers
からも消しておく)
/// プレイヤーの破棄
///
/// - 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の実装
要点だけ取り上げて解説していくので、ソース全体については以下を参照して下さい。
- SampleVideoPlayerPlugin.ket
- VideoPlayer.kt
クラスの名称や役割はiOS版と同様です。(なるべく合わせる形で実装してます)
■ textureId
の登録
こちらも最初にTextureRegistry
を取得する必要がありますが、Androidの場合にはFlutterPlugin.FlutterPluginBinding
から手に入ります。
今回はFlutterPlugin.onAttachedToEngine
が呼ばれた際に保持するようにしてます。
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」が戻り値として返ります。
(textureId
はid()
と言うメソッドから取得可能)
// 登録した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
に渡して動画プレイヤーのセットアップを行います。
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を作ったら、それをExoPlayer
のsetVideoSurface
に渡すだけで後は裏で勝手に更新してくれます。
init {
// ExoPlayerの初期化 (省略)
// ExoPlayerに対し、テクスチャの紐付けを行う
// NOTE: Androidの場合は簡単でPlayerにSurfaceを渡すだけで内部的によしなに更新してくれる
val createSurface = Surface(surfaceTextureEntry.surfaceTexture())
// ExoPlayerの`setVideoSurface`に渡すだけで終わり (後は裏で勝手に更新してくれる)
player.setVideoSurface(createSurface)
}
(もしネイティブコード側でフィルター処理を掛けたいとなった場合、どこをいじれば出来そうか?については勉強不足のためにイメージできず...)
■ textureId
の登録解除
Androidの場合は登録解除も簡単です。
SurfaceTextureEntry
が持つ「release」を呼び出すだけで終わりです。
iOSみたいにTextureRegistry
から呼び出す必要はありません。
/**
* プレイヤーの破棄
*/
fun dispose() {
// テクスチャの登録解除
surfaceTextureEntry.release()
surface.release()
exoPlayer.release()
}
あとはSampleVideoPlayerPlugin
の方もcreatePlayers
から消しておきます。
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. 例えば
WebView
9など) - 表示対象のネイティブ側のインスタンス(Viewなど)とWidgetの寿命が結びついても問題が無い場合
- 画面全体に表示を行い、操作する必要があるもの (e.g. 例えば
-
Texture Widget
-
表示対象のネイティブ側のインスタンスとWidgetの寿命を分けて管理したい場合
- → 例えば表示しているWidgetが破棄されてもネイティブ側はインスタンスを残しておきたい時とか
- 再描画のタイミングを制御したい
-
表示対象のネイティブ側のインスタンスとWidgetの寿命を分けて管理したい場合
特にPlatform Views
は**「ネイティブ側の表示対象のインスタンス」と「Widgetの寿命」が結びついてしまいやすい印象を受けました。**
(実装側の工夫次第では回避できるかもだが...)
例えば動画プレイヤーの要件として「画面遷移しても裏で動画を再生し続けたい」と言った実装を行いたいなら、今回みたいなTexture Widget
ベースの実装がやりやすいのかな?とは思います。
→ 動画を再生しているTexture Widget
自体が破棄されても「ネイティブ側の動画プレイヤー」自体は破棄されていないので
もし「自分はこう考えている・使い分けている」と言った例があれば教えて頂けると幸いです!
参考/関連リンク
参考資料
- Texture Widget
- Platform Views
pub.dev
- video_player
-
camera
- ※ 上記2点とも内部的に
Texture Widget
を利用している
- ※ 上記2点とも内部的に
Documents
- Texture class
- Hosting native Android and iOS views in your Flutter app with Platform Views
- Developing packages & plugins
-
各バージョンとも取り敢えず「デフォルト指定のバージョン」や「新しいバージョン」を選んだだけであり、言語バージョンの選定理由にはそこまで強いこだわりはありません。 ↩ ↩2
-
プラットフォーム固有の機能を呼び出すパッケージの名称 (公式ドキュメントの「Package types」より) ↩
-
公式ドキュメントの「Developing packages & plugins」を大凡把握出来ていればOK ↩
-
これ以外の公式の詳細な資料が見つからなかったのもあり、ひょっとしたら説明に若干の語弊があるかもです...。(Flutter Engineのソースを追えば詳細について知ることが出来るかもしれないが...まだそこまで追えておらず...) ↩
-
ちなみにここで未登録の
textureId
をTexture Widgetに渡した際にはブランクが表示されるっぽい (≒ 例外とかは投げられない模様) ↩ -
ここらの設計思想や実装の詳細については「Method Channel」や「Federated plugins」の話となってしまうので、本記事では掘り下げて解説しません。 ↩ ↩2
-
ちなみに「sample_video_player_platform_interface.dart」のドキュメントコメントにも記載してますが、このサンプルでは便宜的に「Platform Interface」の定義を同じプロジェクトに含めています。(ちゃんとするなら別プロジェクトに分けたほうが良いかも) ↩
-
コメントにも記載しているが、今回の例では
VideoPlayer
自体がFlutterTexture
を実装しているのでself
を渡している ↩ -
実際に公式のWebViewパッケージではPlatform Viewsが使われている ↩