Flutter視訊播放封裝歷程

左手木亽發表於2020-02-24

本文基於官方視訊播放plugin進行封裝 github.com/flutter/plu…

在日常的開發中,難免會遇到視訊開發需求;隨著Flutter技術日漸活躍所以在所難逃會有視訊功能的需求,如果完完整整把官方提供的video_player功能直接搬進來使用會發現在很多地方需要進一步封裝。

一、整合視訊播放功能

首先,由於公司的Android採用的是ijkplayer來實現視訊播放功能而官方的這個外掛採用的是exoplayer,所以在整合的時候我們把VideoPlayerPlugin對應的類進行改造把相關的exoplayer換成自家App中的ijkplayer,這樣子做的好處不僅僅可以減少因為引入了exoplayer給App帶來了更大的包大小,而且也可以複用原生端的程式碼。

其次,需要大概瞭解一些官方的視訊播放外掛原理,而對於原理可以用一句話來概括就是:外接紋理 Texture; 在Flutter端中的Textture類的定義如下:

class Texture extends LeafRenderObjectWidget {
  const Texture({
    Key key,
    @required this.textureId,
  }) : assert(textureId != null),
       super(key: key);
}
複製程式碼

所以也就說每一個紋理Textture對應著一個必須的textureId,這就是實現視訊播放供的關鍵點;對於原生如何生成textureId,可以大概看下官方的外掛的原始碼:

TextureRegistry textures = registrar.textures();
TextureRegistry.SurfaceTextureEntry textureEntry = textures.createSurfaceTexture();
textureId = textureEntry.id()
複製程式碼

問題1:什麼時候生成這個textureId呢? 當我們在Flutter端呼叫視訊初始化的時候,會呼叫plugin的create方法:

final Map<dynamic, dynamic> response = await _channel.invokeMethod(
  'create',
  dataSourceDescription,
);
_textureId = response['textureId'];
複製程式碼

問題2:獲取了textureId是如何進行傳遞資料呢? 當Flutter端呼叫create的時候,原生端會生成一個textureId並註冊了一個新的EventChannel;至此視訊播放的相關資料如:initialized(初始化)、completed(播放完成)、bufferingUpdate(進度更新)、bufferingStart(緩衝開始)、bufferingEnd(緩衝結束)就會回撥給Flutter端具體的每一個視訊Widget,從而實現下一步邏輯。

原生端如何生成一個新的EventChannel

TextureRegistry.SurfaceTextureEntry textureEntry = textures.createSurfaceTexture();
String eventChannelName = "flutter.io/videoPlayer/videoEvents" + textureEntry.id();
EventChannel eventChannel =
        new EventChannel(
                registrar.messenger(), eventChannelName);
複製程式碼

Flutter端如何監聽:

void eventListener(dynamic event) {
   final Map<dynamic, dynamic> map = event;
   switch (map['event']) {
     case 'initialized':
       value = value.copyWith(
         duration: Duration(milliseconds: map['duration']),
         size: Size(map['width']?.toDouble() ?? 0.0,
             map['height']?.toDouble() ?? 0.0),
       );
       initializingCompleter.complete(null);
       _applyLooping();
       _applyVolume();
       _applyPlayPause();
       break;
       ......
   }
 }
 void errorListener(Object obj) {
   final PlatformException e = obj;
   LogUtil.d("----------- ErrorListener Code = ${e.code}");
   value = VideoPlayerValue.erroneous(e.code);
   _timer?.cancel();
 }

 _eventSubscription = _eventChannelFor(_textureId)
     .receiveBroadcastStream()
     .listen(eventListener, onError: errorListener);
 return initializingCompleter.future;
}

EventChannel _eventChannelFor(int textureId) {
 return EventChannel('flutter.io/videoPlayer/videoEvents$textureId');
}
複製程式碼

當然對於類似暫停/播放/快進...等一些需要觸發操作的走的邏輯有點不同;走的是跟呼叫create方法的同一個plugin(即"flutter.io/videoPlayer"對應的plugin):

// 如播放/暫停呼叫方式
final MethodChannel _channel = const MethodChannel('flutter.io/videoPlayer')
_channel.invokeMethod( 'play', <String, dynamic>{'textureId': _textureId});
複製程式碼

至此;就可以實現了Flutter的視訊播放功能了。

二、視訊列表介面實現

在Flutter跟普通的列表式介面一樣使用一個ListView這種可滑動的控制元件即可實現;但是對於視訊介面有點不同的是需要處理當當前播放的item不可見的時候需要暫停播放,當點選另一個視訊的時候前一個播放中的視訊需要暫停。

首先,針對點選另一個視訊的時候前一個播放中的視訊需要暫停的處理方式: 這種情況還是比較好處理,我這裡的處理方式是給每一個視訊的Widget都註冊一個點選回撥,當另一個點選播放的時候遍歷回撥時發現當前視訊處於播放中就執行暫停操作:

/// 控制當點選播放的時候上一個視訊需要暫停
playCallback = () {
  if (controller.value.isPlaying) {
    setState(() {
      controller.pause();
    });
  }
};
VideoPlayerController.playCallbacks.add(playCallback);
複製程式碼

其次,滑動的時候當item不可見的時候需要停止播放;相對這種情況主要的原理就是獲取可滑動檢視的Rect(區域),然後當視訊Rect的底部小於可滑動檢視的頂部或者當前的視訊的檢視的頂部小於可滑動檢視的底部就執行暫停操作。 問題1:如何獲取Widget的Rect呢?

  /// 返回對應的Rect區域...
  static Rect getRectFromKey(BuildContext currentContext) {
    var object = currentContext?.findRenderObject();
    var translation = object?.getTransformTo(null)?.getTranslation();
    var size = object?.semanticBounds?.size;

    if (translation != null && size != null) {
      return new Rect.fromLTWH(translation.x, translation.y, size.width, size.height);
    } else {
      return null;
    }
  }
複製程式碼

問題2:如何根據滑動來判斷滑出了螢幕呢? 給視訊Widget註冊一個監聽,當滑動的時候進行回撥判斷:

/// 滑動ListView的時候進行回撥給視訊Widget
scrollController = ScrollController();
scrollController.addListener(() {
  if (videoScrollController.scrollOffsetCallbacks.isNotEmpty) {
    for (ScrollOffsetCallback callback in videoScrollController.scrollOffsetCallbacks) {
      callback();
    }
  }
});
複製程式碼

當視訊Widget接收到滑動回撥的時候:

scrollOffsetCallback = () {
itemRect = VideoScrollController.getRectFromKey(videoBuildContext);
      /// 狀態列 + 標題欄的高度(存在一點偏差)
      int toolBarAndStatusBarHeight = 44 + 25;
      if (itemRect != null && videoScrollController.rect != null &&
          (itemRect.top > videoScrollController.rect.bottom || itemRect.bottom - toolBarAndStatusBarHeight < videoScrollController.rect.top)) {
        if (controller.value.isPlaying) {
          setState(() {
            LogUtil.d("=============== 正在播放中,被移出螢幕外需要暫停播放 ======");
            controller.pause();
          });
        }
      }
    };
videoScrollController?.scrollOffsetCallbacks?.add(scrollOffsetCallback);
複製程式碼

至此,真的列表式視訊介面就解決了相關滑動或者點選的時候執行暫停/播放的功能了。

三、全屏切換

對於視訊功能很常見的就是全屏的需求,所以在Flutter端自然也是少不了這種功能了;針對全屏的功能參考了開源庫github.com/brianegan/c…的思路。 而主要的原理還是利用每個紋理TextturetextureId唯一的原理,跟原生的扣View的方式有一些差別,按照開源庫的整體程式碼還是比較簡單沒有非常多的麻煩問題:

/// 退出全屏
  _popFullScreenWidget() {
    Navigator.of(context).pop();
  }

/// 切換至全屏狀態
  _pushFullScreenWidget() async {
    final TransitionRoute<Null> route = new PageRouteBuilder<Null>(
      settings: new RouteSettings(isInitialRoute: false),
      pageBuilder: _fullScreenRoutePageBuilder,
    );

    SystemChrome.setEnabledSystemUIOverlays([]);
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
    await Navigator.of(context).push(route);
    SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
  }
複製程式碼

那麼,以上大概就是在Flutter端開發時常見的需求功能了;當然在具體的或者更變態的視訊需求時需要基於該方案再進一步完善。 附上幾張demo的效果圖:

在這裡插入圖片描述

相關文章