探索Flutter浪潮下的音视频研发之路

发表时间: 2019-09-24 11:10

作者|陈炉军

出品|阿里巴巴新零售淘系技术部

导读:本文来自 LiveVideoStack 线上分享第三季,第十期阿里巴巴闲鱼事业部无线开发专家陈炉军带来的分享内容,针对闲鱼APP在当下流行的跨平台框架Flutter的大规模实践,介绍其在音视频领域碰到的一些困难以及解决方案。

大家好,我是阿里巴巴闲鱼事业部的陈炉军,本次分享的主题是 Flutter 浪潮下的音视频研发探索,主要内容是针对闲鱼APP在当下流行的跨平台框架 Flutter 的大规模实践,介绍其在音视频领域碰到的一些困难以及解决方案。

分享内容主要分为四个方面,首先会对 Flutter 有一个简单介绍以及选择 Flutter 作为跨平台框架的原因,其次会介绍 Flutter 中与音视频关系非常大的外接纹理概念,以及对它做出的一些优化。之后会对闲鱼在音视频实践过程中碰到的一些 Flutter 问题提出了一些解决方案—— TPM 音视频框架。最后是闲鱼 Flutter 多媒体开源组件的介绍。

Flutter

Flutter 是一个跨平台框架,以往的做法是将音频、视频和网络这些模块都下沉到 C++ 层或者 ARM 层,在其上封装成一个音视频的 SDK ,供 UI 层的 PC 、 iOS 和 Android 调用。

而 Flutter 做为一个 UI 层的跨平台框架,顾名思义就是在 UI 层也实现了一个跨平台开发。可以预想的是未Flutter发展的好的话,会逐渐变为一个从底层到 UI 层的一个全链路的跨平台开发,技术人员分别负责 SDK 和 UI 层的开发。

在 Flutter 之前已经有很多跨平台 UI 解决方案,那为什么选择 Flutter 呢?我们主要考虑性能和跨平台的能力。

以往的跨平台方案比如 Weex,ReactNative,Cordova 等等因为架构的原因无法满足性能要求,尤其是在音视频这种性能要求几乎苛刻的场景。

而诸如 Xamarin 等,虽然性能可以和原生 App 一致,但是大部分逻辑还是需要分平台实现。

我们可以看一下,为什么 Flutter 可以实现高性能:

原生的native组件渲染以 IOS 为例,苹果的UIKit通过调用平台自己的绘制框架 QuaztCore 来实现 UI 的绘制,图形绘制也是调用底层的 API ,比如 OpenGL 、 Metal 等。

而 Flutter 也是和原生 API 逻辑一致,也是通过调用底层的绘制框架层 SKIA 实现 UI 层。这样相当于 Flutter 他自己实现了一套 UI 框架,提供了一种性能超越原生 API 的跨平台可能性。

但是我们说一个框架最终性能怎样,其实取决于设计者和开发者。至于现在到底是一个什么状况:

在闲鱼的实践中,我们发现在正常的开发没有特意的去优化 UI 代码的情况下,在一些低端机上,Flutter界面的流畅性是比Native界面要好的。

虽然现在闲鱼某些场景下会有卡顿闪退等情况,但是这是一个新事物发展过程中的必然问题,我们相信未来性能肯定不会成为限制 Flutter 发展的瓶颈的。

在闲鱼实践 Flutter 的过程中,混合栈和音视频是其中比较难解决的两个问题,混合栈是指一个 APP 在 Flutter 过程中不可能一口气将所有业务全部重写为 Flutter ,所以这是一个逐步迭代的过程,这期间原生 native 界面与 Flutter 界面共存的状态就称之为混合栈。闲鱼在混合栈上也有一些比较好的输出,例如 FlutterBoost 。

外接纹理

在讲音视频之前需要简要介绍一下外接纹理的概念,我们将它称之为是Flutter和Frame之间的桥梁。

Flutter 渲染一帧屏幕数据首先要做的是, GPU 发出的 VC 信号在 Flutter 的UI 线程,通过 AOT 编译的机器码结合当前 Dart Runtime ,生成 Layer Tree UI树, Layer Tree 上每一个叶子节点都代表了当前屏幕上所需要渲染的每一个元素,包含了这些元素渲染所需要的内容。将 Layer Tree 抛给 GPU 线程,在 GPU 线程内调用 Skia 去完成整个UI的渲染过程。

Layer Tree 中有 PictureLayer 和 TextureLayer 两个比较重要的节点。PictureLayer 主要负责屏幕图片的渲染, Flutter 内部实现了一套图片解码逻辑,在 IO 线程将图片读取或者从网络上拉取之后,通过解码能够在 IO 线程上加载出纹理,交给 GPU 线程将图片渲染到屏幕上。

但是由于音视频场景下系统 API 太过繁多,业务场景过于复杂。Flutter 没有一套逻辑去实现跨平台的音视频组件,所以说 Flutter 提出了一种让第三方开发者来实现音视频组件的方式,而这些音视频组件的视频渲染出口,就是 TextureLayer 。

在整个 Layer Tree 渲染的过程中, TextureLayer 的数据纹理需要由外部第三方开发者来指定,可以把视频数据和播放器数据送到 TextureLayer 里,由 Flutter 将这些数据渲染出来。

TextureLayer 渲染过程:首先判断 Layer 是否已经初始化,如果没有就创建一个 Texture ,然后将 Texture Attach 到一个 SufaceTexture 上。

这个 SufaceTexture 是音视频的 native 代码可以获取到的对象,通过这个对象创建的 Suface ,我们可以将视频数据、摄像头数据解码放到 Suface 中,然后 Flutter 端通过监听 SufaceTexture 的数据更新就可以顺利把刚才创建的数据更新到它的纹理中,然后再将纹理交给 SKIA 渲染到屏幕上。

然而我们如果需要用 Flutter 实现美颜,滤镜,人脸贴图等等功能,就需要将视频数据读取出来,更新到纹理中,再将 GPU 纹理经过美颜滤镜处理后生成一个处理后的纹理。按Flutter提供的现有能力,必须先将纹理中的数据从 GPU 读出到 CPU 中,生成 Bitmap 后再写入 Surface 中,这样在 Flutter 中才能顺利的更新到视频数据,这样做对系统性能的消耗很大。

通过对 Flutter 渲染过程分析,我们知道 Flutter 底层需要渲染的数据就是 GPU 纹理,而我们经过美颜滤镜处理完成以后的结果也是 GPU 纹理,如果可以将它直接交给 Flutter 渲染,那就可以避免 GPU->CPU->GPU 这样的无用循环。这样的方法是可行的,但是需要一个条件,就是 OpenGL 上下文共享。

OpenGL

在说上下文之前,得提到一个和上线文息息相关的概念:线程。

Flutter 引擎启动后会启动四个线程:

第一个线程是 UI 线程,这是 Flutter 自己定义的UI线程,主要负责 GPU 发出的 VSync 信号时候用当前 Dart 编译的机器码和当前运行环境创建出 Layer Tree。

还有就是 IO 线程和 GPU 线程。和大部分 OpenGL 处理解决方案中一样, Flutter 也采取一个线程责资源加载,一部分负责资源渲染这种思路。

两个线程之间纹理共享有两种方式。一种是 EGLImage ( IOS 是 CVOpenGLESTextureCache )。一种是 OpenGL Share Context 。Flutter 通过 Share Context 来实现纹理共享,将 IO 线程的 Context 和 GPU 线程的 Context 进行 Share ,放到同一个 Share Group 下面,这样两个线程下资源是互相可见可以共享的。

Platform 线程是主线程, Flutter 中有一个很奇怪的设定, GPU 线程和主线程共用一个 Context 。并且在主线程也有很多 OpenGL 操作。

这样的设计会给音视频开发带来很多问题,后面会详细说。

音视频端美颜处理完成的 OpenGL 纹理能够让Flutter直接使用的条件就是 Flutter 的上下文需要和平台音视频相关的 OpenGL 上下文处在一个 Share Group 下面。

由于 Flutter 主线程的 Context 就是 GPU 的 Context ,所以在音视频端主线程中有一些 OpenGL 操作的话,很有可能使 Flutter 整个 OpenGL 被破坏掉。所以需要将所有的 OpenGL 操作都限制在子线程中。

通过上述这两个条件的处理,我们就可以在没有增加 GPU 消耗的前提下实现美颜和滤镜等等功能。

TPM

在经过 demo 验证之后,我们将这个方案应用到闲鱼音视频组件中,但改造过程中发现了一些问题。

上图是摄像头采集数据转换为纹理的一段代码,其中有两个操作:首先是切进程,将后面的 OpenGL 操作都切到 cameraQueue 中。然后是设置一次上下文。然后这种限制条件或者说是潜规则往往在开发过程中容易被忽略的。而这个条件一旦忽略后果就是出现一些莫名其妙的诡异问题极难排查。因此我们就希望能抽象出一套框架,由框架本身实现线程的切换、上下文和模块生命周期等的管理,开发者接入框架以后只需要安心实现自己的算法,而不需要关心这些潜规则还有其他一些重复的逻辑操作。

在引入 Flutter 之前闲鱼的音视频架构与大部分音视频逻辑一样采用分层架构:

1:底层是一些独立模块

2:SDK 层是对底层模块的封装

3:最上层是 UI 层。

引入 Flutter 之后,通过分析各个模块的使用场景,我们可以得出一个假设或者说是抽象:音视频应用在终端上可以归纳为视频帧解码之后视频数据帧在各个模块之间流动的过程,基于这种假设去做 Flutter 音视频框架的抽象。

闲鱼 Flutter 多媒体开源组件

整个 Flutter 音视频框架抽象分为管线和数据的抽象、模块的抽象、线程统一管理和上下文同一管理四部分。

管线,其实就是视频帧流动的管道。数据,音视频中涉及到的数据包括纹理、 Bit Map 以及时间戳等。结合现有的应用场景我们定义了管线流通数据以 Texture 为主数据,同时可以选择性的添加 Bit Map 等作为辅助数据。这样的数据定义方式,避免重复的创建和销毁纹理带来的性能开销以及多线程访问纹理带来的一些问题。也满足一些特殊模块对特殊数据的需求。同时也设计了纹理池来管理管线中的纹理数据。

模块:如果把管线和数据比喻成血管和血液,那框架音视频的场景就可以比喻成器官,我们根据模块所在管线的位置抽象出采集、处理和输出三个基类。这三个基类里实现了刚才说的线程切换,上下文切换,格式转换等等共同逻辑,各个功能模块通过集成自这些基类,可以避免很多重复劳动。

线程:每一个模块初始化的时候,初始化函数就会去线程管理的模块去获取自己的线程,线程管理模块可以决定给初始化函数分配新的线程或者已经分配过其他模块的线程。

这样有三个好处:

  • 可以根据需要去决定一个线程可以挂载多少模块,做到线程间的负载均衡。
  • 多线程并发式能够保证模块内的 OpenGL 操作是在当前线程内而不会跑到主线程去,彻底避免 Flutter 的 OpenGL 环境被破坏。
  • 多线程并行可以充分利用 CPU 多核架构,提升处理速度。

从 Flutter 端修改 Flutter 引擎将 Context 取出后,根据 Context 创建上下文的统一管理模块,每一个模块在初始化的时候会获取它的线程,获取之后会调用上下文管理模块获取自己的上下文。这样可以保证每一个模块的上下文都是与 Flutter 的上下文进行Share的,每个模块之间资源都是共享可见的, Flutter 和音视频 native 之间也是互相共享可见的。

基于上述框架如果要实现一个简单的场景,比如画面实时预览和滤镜处理功能,

1、需要选择功能模块,功能模块包括摄像头模块、滤镜处理模块和 Flutter 画面渲染模块;

2、需要配置模块参数,比如采集分辨率、滤镜参数和前后摄像头设置等;

3、在创建视频管线后使用已配置的参数创建模块;

4、最后管线搭载模块,开启管线就可以实现这样简单的功能;

上图为整个功能实现的代码和结构图。

结合上述音视频框架,闲鱼实现了 Flutter 多媒体开源组件。

组要包含四个基本组件分别是:

1、视频图像拍摄组件

2、播放器组件

3、视频图像编辑组件

4、相册选择组件

现在这些组件正在走内部开源流程。预计 9 月份,相册和播放器会实现开源。

后续展望和规划

1、实现开头所说的从底层 SDK 到 UI 的全链路的跨端开发。目前底层框架层和模块层都是各个平台各自实现,反而是 Flutter 的 UI 端进行了跨平台的统一,所以后续会将底层也按照音视频常用做法把逻辑下沉到 C++ 层,尽可能的实现全链路跨平台。

2、第二部分内容为开源共建,闲鱼开源的内容不仅包括拍摄、编辑组件,还包括了很多底层模块,希望有开发者在基于 Flutter 开发音视频应用时可以充分利用闲鱼开源出的音视频模块能力,搭建 APP 框架,开发者只要去负责实现特殊需求模块就可以,尽可能的减少重复劳动。

作者:陈炉军