Flutter 桌面端实践之识别外接媒体设备

7,289 阅读5分钟

最近我们希望Flutter技术在桌面端的应用能有所突破,所以笔者跨进了本不熟悉的桌面端应用领域。今天给大家分享下我们是如何让Flutter如何识别外接媒体设备,并且实现视频流渲染和拍照;从官方插件外界纹理platformView实践,都尝试了一遍,最后选择了webRtc,整个预研过程一波三折,学到了很多知识!

需求背景

需求是在win10和Android9的设备上支持外接摄像头,能够进行实时拍摄,做一个类似相机的应用。
从技术流程上来分析,我们需要识别出相机设备,拿到媒体流信息然后做渲染(渲染机制一般通过外接纹理Texture去实现),最后捕获帧进行拍照/录制。Flutter中,任何对象渲染后自然能拿到RanderObject,只要有RanderObject这个真实的渲染对象,我们就能进行照片的存储。
以上流程,理论上库已经帮我们做好,但是桌面端的生态,往往没那么简单~~~

一、官方Plugin

Android端使用camera,windows使用camera_windows。官方的库对于内置相机的支持做的很不错,直接引用后在手机和普通电脑上效果都很好;但是两个库都是明确不支持外接设备,见issus-1issus-2,优先级分别是P4、P5,显然官方认为这些问题优先级不高。
而纵观整个Flutter生态对USB外设的支持,并没有一个官方的库,pub上的基本也是参差不齐,大多只支持单一平台。

实现原理

  • Android端的camera插件,使用原生Camera2 Api,通过TextureRegistry创建纹理,然后Flutter用Texture进行绘制。
  1. 创建相机实例,返回textureId
// camera_android-0.9.8+3\lib\src\android_camera.dart
@override
Future<int> createCamera(
  CameraDescription cameraDescription,
  ResolutionPreset? resolutionPreset, {
  bool enableAudio = false,
}) async {
  try {
    final Map<String, dynamic>? reply = await _channel
        .invokeMapMethod<String, dynamic>('create', <String, dynamic>{
      'cameraName': cameraDescription.name,
      'resolutionPreset': resolutionPreset != null
          ? _serializeResolutionPreset(resolutionPreset)
          : null,
      'enableAudio': enableAudio,
    });

    return reply!['cameraId']! as int;
  } on PlatformException catch (e) {
    throw CameraException(e.code, e.message);
  }
}
  1. 预览控件返回Flutter Texture Widget,与原生返回的纹理id形成绑定,从而接收纹理信息然后绘制
// camera_android-0.9.8+3\lib\src\android_camera.dart
@override
Widget buildPreview(int cameraId) {
  return Texture(textureId: cameraId);
}
  1. Android端通过TextureRegistry创建createSurfaceTexture,把textureId返回到Dart层。
// camera_android-0.9.8+3\android\src\main\java\io\flutter\plugins\camera\MethodCallHandlerImpl.java
private void instantiateCamera(MethodCall call, Result result) throws CameraAccessException {
  String cameraName = call.argument("cameraName");
  String preset = call.argument("resolutionPreset");
  boolean enableAudio = call.argument("enableAudio");

  TextureRegistry.SurfaceTextureEntry flutterSurfaceTexture =
      textureRegistry.createSurfaceTexture();
  DartMessenger dartMessenger =
      new DartMessenger(
          messenger, flutterSurfaceTexture.id(), new Handler(Looper.getMainLooper()));
  CameraProperties cameraProperties =
      new CameraPropertiesImpl(cameraName, CameraUtils.getCameraManager(activity));
  ResolutionPreset resolutionPreset = ResolutionPreset.valueOf(preset);

  camera =
      new Camera(
          activity,
          flutterSurfaceTexture,
          new CameraFeatureFactoryImpl(),
          dartMessenger,
          cameraProperties,
          resolutionPreset,
          enableAudio);

  Map<String, Object> reply = new HashMap<>();
  reply.put("cameraId", flutterSurfaceTexture.id());
  result.success(reply);
}

值得一提的是Flutter3.0后,官方的原生绘制方式已经抛弃了VirtualDisplay,拥抱TextureLayer,性能上已经优化了不少,让Flutter的音视频渲染能力提升了不少。 但问题就是在instantiateCamera之前,官方在Camera2的实现上,没有对外界设备进行处理,从而搜索不到对应的外接相机。

  • Windows端的实现完全一样,都是通过Texture做渲染,原因也是获取相机列表的时候没有做外接设备的实现,这里不在赘述。

解决方案

基于多端的camera接口做处理,把外设设备的逻辑加上,应该就可以了。 在Texture纹理这块官方的实现是没有问题的。
当然这个思路我目前只停留在理论层面,并未真正去实现,原因如下:

  1. 两个库都是设计原生知识,我们维护成本会很大;
  2. 官方的库维护的很频繁,后面更多优化还得看官方,很有可能哪个版本就得全部推翻重新来一遍。

二、PlatformView

明确一个观点,这个方案不可落地。预研这个方案的原因是我们本身已经有原生的代码封装,基于CameraX的Android实现,我只需要在Plugin上注册下视图即可,具体实现代码如下:

  1. 注册视图
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    val key: String = "camera"

    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "camera_plugin")
    channel.setMethodCallHandler(this)
    CameraInfoManager.getCameraInfoList().forEach {
        Log.d(TAG, "onAttachedToEngine: $it")
    }

    // 注册视图
    flutterPluginBinding.platformViewRegistry.registerViewFactory(
        key,
        CameraFactory(flutterPluginBinding.binaryMessenger)
    )
}
  1. 视图工厂
class CameraFactory(private val messenger: BinaryMessenger) :
    PlatformViewFactory(StandardMessageCodec.INSTANCE) {

    override fun create(context: Context?, id: Int, args: Any?): PlatformView {
        return CameraPlatformView(context!!)
    }

}
  1. 引入CameraX视图
class CameraPreView(context: Context, attrs: AttributeSet?) :
    LinearLayout(context, attrs), LifecycleOwner {

    private var camera: PreviewView

    private val mLifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)

    init {
        val view: View = LayoutInflater.from(context).inflate(R.layout.layout_camera_preview, this)
        camera = view.findViewById(R.id.camera_preview_view)
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)

        // 这里是封装好的CameraX预览视图
        CameraXPreview
            .bindLifecycle(this)
            .setPreviewView(camera)
            .setCameraId(0)
            .startPreview(context)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    }

    override fun getLifecycle(): Lifecycle = mLifecycleRegistry
}

问题显而易见,Flutter引擎启动白屏300ms,视图同步产生延时,内存平均新增20M+,而且视图生命周期没法同步,都是致命问题。
基于上面的实践,Windows上我们没有再做尝试了,Fail。

三、webRtc

上面两种方案都以失败告终后,大佬提到了webRtc,从基础协议出发,往往能解决核心问题。于是flutter_webrtc上场,WebRTC提供音视频的采集、编解码、网络传输、显示等功能,并且还支持跨平台:windows,linux,mac,android,已经被纳入被纳入W3C推荐标准。webRtc开发文档

  • 引用flutter_webrtc这个库,其渲染原理依旧是外接纹理,使用方法查看官方的example实例即可;
  • 重点在实现拍照功能,拍照无非就是进行帧捕获,Android已经实现:
  final videoTrack = localStream!
      .getVideoTracks()
      .firstWhere((track) => track.kind == 'video');
  final frame = await videoTrack.captureFrame();

  // 使用image.memory即可渲染
  frameList = frame.asUint8List();
  • 而windows端很遗憾,还没有实现拍照功能,见issus;于是我想到了曲线救国,通过截取屏幕来保存图像由于是使用Texture渲染,通常的RenderRepaintBoundary+GlobalKey是没办法拿到RanderObject的!
    幸好插件提供了截取屏幕的方式,也算完成曲线救国了。
try {
  var sources = await desktopCapturer.getSources(types: [SourceType.Window]);
  DesktopCapturerSource capture =
      sources.firstWhere((element) => element.name == 'my_camera');
      
  // 使用image.memory即可渲染
  frameList = capture.thumbnail;
  return;
} catch (e) {
  print(e.toString());
}

写在最后

到此,坎坷的外接相机预研之路告一段落。但是性能比起原生,真的差了一截,这让我们意识到,在官方不支持外接设备之前,针对此类需求,还是少用Flutter来实现。
Flutter桌面应用虽然发布了Stable版本,但说句实话生态确实比移动端差了不少,这意味着我们需要共同建设这个生态,但是趋势起来了,我们也愿意社区共建!
另外插个题外话,关于上面windows截取屏幕的需求,其实是有issus未关闭的,7月1号下午刚参与了issue的讨论,傍晚作者就拉了pull request,并且更了一版,解了燃眉之急啊!!!
怎么说呢,开源万岁,Respect!

image.png

image.png

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿