跟我一步一步实现 Flutter 视频播放插件 (一)

当团队准备着手做 APP 时,我们把目标对准了 Flutter,尤其近期 Flutter 的使用热度一直不断攀升。由于第一次使用 Flutter,就想通过自己的实践去提升自己的能力。

在做 APP 时,我们用到了视频播放器,当前使用官方提供的插件「video_player」https://github.com/flutter/plugins/tree/master/packages/video_player,可能该插件在国外没什么问题,但国内很多视频播放器做的很精良,自定义功能很齐全。

举一个例子:国内的 APP 全屏播放视频时,几乎都是横向全屏的,但官方提供的插件在 iOS 端是竖向直播的,效果很不好。

image

因此萌生了自己想做一个视频播放插件:

要求

  1. Android 和 iOS 端都是使用原生开发,体验效果好;
  2. 尽可能使用 GitHub Star 靠前的第三方开源插件,减轻自己的开发工作量;

根据以上的「2」要求,我主要找到了 lipangit/JiaoZiVideoPlayernewyjp/JPVideoPlayer

image
image

好了,所有铺垫都做好了,我们开始一步步实现插件开发吧~

1. 创建插件

flutter create --org com.***.test --template=plugin bms_video_player

2. 创建关联类

lib/bms_video_player.dart 文件中创建 BmsVideoPlayerController 类,用于和原生代码关联:

class BmsVideoPlayerController {

  MethodChannel _channel;
  
  BmsVideoPlayerController.init(int id) {
    _channel =  new MethodChannel('bms_video_player_$id');
  }

  Future<void> loadUrl(String url) async {
    assert(url != null);
    return _channel.invokeMethod('loadUrl', url);
  }
}

这里存在的 MethodChannel 有待于下一次好好研究研究。

3. 创建 Callback

typedef void BmsVideoPlayerCreatedCallback(BmsVideoPlayerController controller);

4. 创建 Widget 布局

创建 Widget,用于添加原生布局:

class BmsVideoPlayer extends StatefulWidget {

  final BmsVideoPlayerCreatedCallback onCreated;
  final x;
  final y;
  final width;
  final height;

  BmsVideoPlayer({
    Key key,
    @required this.onCreated,
    @required this.x,
    @required this.y,
    @required this.width,
    @required this.height,
  });

  @override
  State<StatefulWidget> createState() => _VideoPlayerState();

}
  
class _VideoPlayerState extends State<BmsVideoPlayer> {

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      child: nativeView(),
      onHorizontalDragStart: (DragStartDetails details) {
        print("onHorizontalDragStart: ${details.globalPosition}");
        // if (!controller.value.initialized) {
        //   return;
        // }
        // _controllerWasPlaying = controller.value.isPlaying;
        // if (_controllerWasPlaying) {
        //   controller.pause();
        // }
      },
      onHorizontalDragUpdate: (DragUpdateDetails details) {
        print("onHorizontalDragUpdate: ${details.globalPosition}");
        print(details.globalPosition);
        // if (!controller.value.initialized) {
        //   return;
        // }
        // seekToRelativePosition(details.globalPosition);
      },
      onHorizontalDragEnd: (DragEndDetails details) {
        print("onHorizontalDragEnd");
        // if (_controllerWasPlaying) {
        //   controller.play();
        // }
      },
      onTapDown: (TapDownDetails details) {
        print("onTapDown: ${details.globalPosition}");
      },
    );
  }

  nativeView() {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(
        viewType: 'plugins.bms_video_player/view',
        onPlatformViewCreated: onPlatformViewCreated,
        creationParams: <String,dynamic>{
          "x": widget.x,
          "y": widget.y,
          "width": widget.width,
          "height": widget.height,
        },
        creationParamsCodec: const StandardMessageCodec(),
      );
    } else {
      return UiKitView(
        viewType: 'plugins.bms_video_player/view',
        onPlatformViewCreated: onPlatformViewCreated,
        creationParams: <String,dynamic>{
          "x": widget.x,
          "y": widget.y,
          "width": widget.width,
          "height": widget.height,
        },
        creationParamsCodec: const StandardMessageCodec(),
      );
    }
  }

  Future<void> onPlatformViewCreated(id) async {
    if (widget.onCreated == null) {
      return;
      }
    
    widget.onCreated(new BmsVideoPlayerController.init(id));
  }
}

这里的 AndroidViewUiKitView 字如其意,不同的系统使用不同的 widget。

其中,AndroidViewUiKitView 都自带几个参数,如:

  1. viewType:用于区分不同的插件名称和来源;
  2. onPlatformViewCreated:用于在 widget 创建后,调用其函数 (onPlatformViewCreated);
  3. creationParams:用于将参数传递给原生控件。

下面开始,根据 iOS 和 Android 分别注册插件和实现功能,首先是 Android。

5.1 注册 ViewFactory

BmsVideoPlayerPlugin 类中注册 ViewFactorynew VideoViewFactory(registrar),并命名为 「plugins.bms_video_player/view」:

public static void registerWith(Registrar registrar) {
    registrar.platformViewRegistry()
             .registerViewFactory("plugins.bms_video_player/view", new VideoViewFactory(registrar));
  }

5.2 创建 VideoViewFactory

VideoViewFactory 类需要集成类 PlatformViewFactory,实现函数:create(Context context, int viewId, Object args)

public class VideoViewFactory extends PlatformViewFactory {
    private final Registrar registrar;

    public VideoViewFactory(Registrar registrar) {
        super(StandardMessageCodec.INSTANCE);
        this.registrar = registrar;
    }

    @Override
    public PlatformView create(Context context, int viewId, Object args) {
        return new VideoView(context, viewId, args, this.registrar);
    }
}

开始我们的正餐了,创建实现类 VideoView

5.3 VideoView

public class VideoView implements PlatformView, MethodCallHandler  {
    private final JzvdStd jzvdStd;
    private final MethodChannel methodChannel;
    private final Registrar registrar;

    VideoView(Context context, int viewId, Object args, Registrar registrar) {
        this.registrar = registrar;
        this.jzvdStd = getJzvStd(registrar, args);
        this.methodChannel = new MethodChannel(registrar.messenger(), "bms_video_player_" + viewId);
        this.methodChannel.setMethodCallHandler(this);
    }

    @Override
    public View getView() {
        return jzvdStd;
    }

    @Override
    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        switch (methodCall.method) {
            case "loadUrl":
                String url = methodCall.arguments.toString();
                jzvdStd.setUp(url, "", Jzvd.SCREEN_NORMAL);
                break;
            default:
                result.notImplemented();
        }

    }

    @Override
    public void dispose() {}

    private JzvdStd getJzvStd(Registrar registrar, Object args) {
        JzvdStd view = (JzvdStd) LayoutInflater.from(registrar.activity()).inflate(R.layout.jz_video, null);
        return view;
    }
}

直接分析代码:

  1. 实现接口:PlatformView 和 MethodCallHandler,第一个接口「PlatformView」,用于 return 原生 View,也就是我们使用的第三方插件:JzvdStd。第二个接口「MethodCallHandler」,用于处理从 Dart 发过来的请求函数,如本文创建的函数:loadUrl
  2. 这里 returnJzvdStd,使用 xml:
<?xml version="1.0" encoding="utf-8"?>
<cn.jzvd.JzvdStd
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/jz_video"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

5.4 引入第三方插件

当然,我们需要在 build.gradle 最后加入插件:

dependencies {
    implementation 'cn.jzvd:jiaozivideoplayer:7.0_preview'
}

至此,我们的 Android 端就算完成了,接下来看看 iOS 端。

6.1 注册 ViewFactory
同样的,在类 BmsVideoPlayerPlugin 中注册 VideoViewFactory

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
  VideoViewFactory* factory =
      [[VideoViewFactory alloc] initWithMessenger:registrar.messenger];
  [registrar registerViewFactory:factory withId:@"plugins.bms_video_player/view"];
}

6.2 创建 VideoViewFactory

#import "VideoViewFactory.h"
#import "BMSVideoPlayerViewController.h"

@implementation VideoViewFactory {
  NSObject<FlutterBinaryMessenger>* _messenger;
}

- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger>*)messenger {
  self = [super init];
  if (self) {
    _messenger = messenger;
  }
  return self;
}

- (NSObject<FlutterMessageCodec>*)createArgsCodec {
  return [FlutterStandardMessageCodec sharedInstance];
}

- (nonnull NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame
                                            viewIdentifier:(int64_t)viewId
                                                 arguments:(id _Nullable)args {
    BMSVideoPlayerViewController* viewController =
      [[BMSVideoPlayerViewController alloc] initWithWithFrame:frame
                                       viewIdentifier:viewId
                                            arguments:args
                                      binaryMessenger:_messenger];
    return viewController;
}

@end

代码还是很简单,重点往下看 BMSVideoPlayerViewController

6.3 BMSVideoPlayerViewController

#import "BMSVideoPlayerViewController.h"
#import <JPVideoPlayer/JPVideoPlayerKit.h>

@interface BMSVideoPlayerViewController ()<JPVideoPlayerDelegate>

@end

@implementation BMSVideoPlayerViewController {
    UIView * _videoView;
    int64_t _viewId;
    FlutterMethodChannel* _channel;
}

#pragma mark - life cycle

- (instancetype)initWithWithFrame:(CGRect)frame
                   viewIdentifier:(int64_t)viewId
                        arguments:(id _Nullable)args
                  binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger {
  if ([super init]) {
    _viewId = viewId;
    _videoView = [UIView new];
    _videoView.backgroundColor = [UIColor greenColor];
    NSDictionary *dic = args;
    CGFloat x = [dic[@"x"] floatValue];
    CGFloat y = [dic[@"y"] floatValue];
    CGFloat width = [dic[@"width"] floatValue];
    CGFloat height = [dic[@"height"] floatValue];
    _videoView.frame = CGRectMake(x, y, width, height);
    _videoView.jp_videoPlayerDelegate = self;
    NSString* channelName = [NSString stringWithFormat:@"bms_video_player_%lld", viewId];
    _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger];
    __weak __typeof__(self) weakSelf = self;
    [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
      [weakSelf onMethodCall:call result:result];
    }];

  }
  return self;
}

- (nonnull UIView *)view {
    return _videoView;
}

- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
  if ([[call method] isEqualToString:@"loadUrl"]) {
    [self onLoadUrl:call result:result];
  } else {
    result(FlutterMethodNotImplemented);
  }
}

- (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result {
  NSString* url = [call arguments];
  if (![self loadUrl:url]) {
    result([FlutterError errorWithCode:@"loadUrl_failed"
                               message:@"Failed parsing the URL"
                               details:[NSString stringWithFormat:@"URL was: '%@'", url]]);
  } else {
    result(nil);
  }
}

- (bool)loadUrl:(NSString*)url {
  NSURL* nsUrl = [NSURL URLWithString:url];
  if (!nsUrl) {
    return false;
  }
  
  [_videoView jp_playVideoWithURL:nsUrl
                           bufferingIndicator:nil
                                  controlView:nil
                                 progressView:nil
                                configuration:^(UIView *view, JPVideoPlayerModel *playerModel) {
                                    // self.muteSwitch.on = ![self.videoContainer jp_muted];
                                }];
  return true;
}

#pragma mark - JPVideoPlayerDelegate

- (BOOL)shouldAutoReplayForURL:(nonnull NSURL *)videoURL {
    return true;
}
@end

其实,代码实现都很简单,唯一和 Android 端不一样的就是控件的创建不一样,Android 的我直接用 xml,iOS 的主要是需要定义 Frame 大小,我尝试使用函数传递的 frame 值,貌似不管用。如果有人知道问题所在,欢迎告知我!

最后,和 Android 一样,引入我们使用的第三方插件:

6.4 引入 JPVideoPlayer

在文件 bms_video_player.podspec 引入:

s.dependency 'JPVideoPlayer'

7. 链接调用

看「4」的创建 widget 后的回调函数:

Future<void> onPlatformViewCreated(id) async {
    if (widget.onCreated == null) {
      return;
      }
    
    widget.onCreated(new BmsVideoPlayerController.init(id));
  }

直接 new BmsVideoPlayerController.init(id),即创建了 channel

MethodChannel _channel;
  
BmsVideoPlayerController.init(int id) {
_channel =  new MethodChannel('bms_video_player_$id');
}

Future<void> loadUrl(String url) async {
assert(url != null);
return _channel.invokeMethod('loadUrl', url);
}

有了 channel 自然和原生代码串联起来了,同时创建 loadUrl 函数供外界调用。

8. 测试使用

藉此,我们的插件实现了基本功能了,写个 demo,测试下效果:

import 'package:flutter/material.dart';

import 'package:bms_video_player/bms_video_player.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {

  var viewPlayerController;

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    var x = 0.0;
    var y = 0.0;
    var width = 400.0;
    var height = width * 9.0 / 16.0;

    BmsVideoPlayer videoPlayer = new BmsVideoPlayer(
      onCreated: onViewPlayerCreated,
      x: x,
      y: y,
      width: width,
      height: height
    );
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Container(
          child: videoPlayer,
          width: width,
          height: height
        )
      ),
    );
  }

  void onViewPlayerCreated(viewPlayerController) {
    this.viewPlayerController = viewPlayerController;
    this.viewPlayerController.loadUrl("https://www.****.com/****.mp4");
  }
}

相信这代码不用多解释了,引入我们的插件 widget,然后调用 loadUrl 函数,传入我们的视频链接,即可开始播放了。

iOS 效果

image

Android 效果

image

总结

第一次使用 Flutter,第一次实现基本的插件功能,写的比较粗糙,但相信基本的写法都在里面了。接下来就是实现播放视频的所有功能,如:暂停/播放,小窗口播放、全屏播放、缓存、静音等。

还有,就是如何实现 Dart 和原生代码进行通讯的。

image

未完待续,敬请期待

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354

推荐阅读更多精彩内容