声网Agora跨平台开发工程师卢旭辉带来了《Flutter2 渲染原理和如何实现视频渲染》的主题分享,本文是对演讲内容的整理。
本次分享主要包括 3 个部分:
- Flutter2 概览。
- Flutter2 视频渲染插件的实践。
- Flutter2 渲染原理(源码)。
其实 Flutter1 在国内的占有率并不算高,很多开发者可能知道 Flutter 的上层语言是基于 Google 的 Dart (一个曾经企图取代 JavaScript 的语言,但最后以失败告终),而Dart语言也是很多开发者不太能接受Flutter的点。国内很多公司可能还是选用 ReactNative 或者坚持原生开发,不过伴随着 Flutter2 的问世(全平台支持),以及阿里的北海框架(基于 Flutter Engine 的渲染能力实现的上层使用 JavaScript 的跨平台框架),我相信 Flutter2 未来可期。考虑到很多读者可能是前端开发者,所以在第三部分我会以 Web 的视角切入,大家会看到很多熟悉又陌生的内容,是不是 Flutter 开发者或者是否了解 Flutter 都不重要,重要的是 Flutter 的设计思想,希望对大家有所帮助。
Flutter2 是 Google 在 2021 年 3 月份发布的 Flutter 最新版本,它基于 Dart1.12 支持了 Null-Safety (空安全检查),大家可以类比TypeScript的"?",编译器会要求你对可能为空的数据进行校验,这样可以在开发过程中避免一些空指针的问题。而更为重要的就是对 Web 端提供了稳定版的支持,对桌面端的支持也已经合入。
下面我们一起看下 Flutter2 的整体架构:
Flutter2 的 Web 部分包括 Framework 层和 Browser 层,其中 Framework 层涵盖渲染、绘制、手势处理等,Browser 层涵盖 CSS、HTML、Canvas、WebGL 等(毕竟还是在浏览器上运行),而最后的 WebAssembly 是为了使用 C 和 C 从而调度 Skia 渲染引擎,这个我们在第三部分也会详细介绍。
Native 部分除了通用的 Framework 层,还包括 Engine 层 和Embedder 层,其中 Engine 层主要包括 Dart 虚拟机、Isolate 的初始化,还有图层合成、GPU 渲染、平台通道、文本布局等,而 Embedder 层主要用于不同平台的特性适配。
乍一看,Web 和 Native 的差异还是挺大的,但其实 Web 这边也有一个基于 Dart 开发的 Engine 层,叫作 web_ui,主要用来处理 Web 上的 Composition 和 Rendering 等。
接下来简单看一下 Flutter2 的平台差异,如上图所示。目前 Flutter2 支持 6 个主流平台,分别是 Web、Android、iOS、Windows、macOS 和 Linux。对比其他的跨平台框架,比如 ReactNative 和 Electron (分别是移动端和桌面端的代表),Flutter2 有着更为丰富的平台支持,虽然 ReactNative 也有微软贡献的桌面端支持,以及 expo 对 Web 的支持,但还不够统一。
对于一些构建工具或包管理工具, Flutter2 使用了各个平台比较标准的方式,比如 Web 还是基于 JavaScript,这得利于 dart2js 将 Dart 编译为 JavaScript;在 Android 中还是基于 Gradle 体系;在 iOS 和 macOS 中是基于 CocoaPods 把 Flutter 引入工程中;在 Windows 和 Linux 中则主要是基于 CMake。
关于 Flutter 的一些特性,比如 PlatformView,它提供了桥接原生控件的能力,比如在 Web 上显示一个 Element 或者在 Android、iOS 上显示自定义的 View。不过目前桌面端暂不支持 PlatformView,这并不是说技术上无法实现,而是目前还未开发。ExternalTexture 是外接纹理,用户可以对自己的图形数据进行渲染。dart::ffi 使 Flutter 拥有直接调用 C 和 C 的能力,这两点除了 Web 都是支持的。
接下来将分享下声网在视频渲染插件方面的实践,这里主要针对 Web 和桌面端。
就像在前面平台差异中所描述的那样,Web 不支持 ExternalTexture,Desktop 不支持 PlatformView。所以在 Web 上我们通过 PlatformView 的方式去实现视频渲染,基本的流程是使用 ui.platformViewRegistry 注册 PlatformView 并返回 DivElement,在 DivElement 创建完成之后,需要使用 package:js 实现 Dart 和 JavaScript 的互相调用。
声网有专门的 Web 音视频 SDK,所以我们并没有在 Dart 层做过多的操作,而是做了 JS 层的包装,由这个包装库来调度 SDK 操作 WebRTC 以创建 VideoElement,最后 append 到先前创建的 DivElement 中实现视频渲染。
接下来看一下桌面端的方案,因为它不支持 PlatformView,所以想实现自定义的视频渲染,我们只能使用 ExternalTexture 方案,通过 MethodChannel 调用 Native 层中自定义的 createTextureRender 函数,由它调度 FlutterTextureRegistry 创建 FlutterTexture,同时 将textureId 抛回 Dart 层与 Texture Widget 绑定。Native SDK 的视频数据会在 AgoraRtcWrapper 层进行图像格式转化,然后我们可以通过 FlutterTextureRegistry 的 MarkTextureFrameAvailable 函数通知 FlutterTexture 从回调中获取图像数据。
在插件开发过程中我们也会遇到一些问题,这里给大家简单分享一下:
就桌面端而言,macOS 是 OC 头文件,Windows 是 C 的头文件。Linux 则是 C 的头文件,这部分并没有完全统一,甚至有些 API 都不一样,所以在桌面开发过程中会遇到很多麻烦,毕竟它目前也没有完全稳定。
具体举一些案例,如上图所示,前面 3 个都是在 Web上遇到的问题。
1. ui.platformViewRegistry在Web上会报错,是因为它并没有在Framework层的ui.dart中定义,而是定义在web_ui/ui.dart中,不过它并不影响运行,所以可以选择使用ignore注释忽略它。
2. 我们使用 dart::js,比如构建一个 JavaScript 对象,这时候会使用 @JS 的注解进行声明,如果没有加上external构造函数,虽然在 Debug 模式下能够正常运行,但在 Profile 和 Release 模式下会报错。
3. dart::io 主要用来做一些具体平台的调用,比如平台判断在 Web 上是无法使用的。我们可以使用 if(dart.library.html) 在 import 的时候指向自定义的 Dart 文件,并对相关 API 定义空实现,也可以使用 kIsWeb 在 Web 上不去执行相关 API。
4. 在 Windows 上,是使用 EncodableValue 来进行 Dart和C 的通信(基于 C 17 的 std::variant,可以理解成 TypeScript 中的 type1|type2|type3)。在处理 int32 和 int64 的时候,Framework 层直接判断是不是超过 int32最大值,如果超过则直接标注成 int64,有用过声网 SDK 的开发者可能会知道,我们的 用户ID的类型是 uint32,uint32 取值范围有部分区间大于 int32 并小于 int64,因此如果单纯使用 std::get 来获取,则不论指定 int32_t 还是 int64_t 都有可能报错,好在它提供了 LongValue 函数,在内部做好了判断并统一使用 int64 返回。
接下来是本次主题的重点 Flutter2 渲染原理,Flutter 引擎这部分有很多原理是通用的,只不过在 Web 上用 Dart 实现,在 Native 上则主要使用 C 和 C 实现。
在正式开始前,我们先简单回顾一下,之前提到 Flutter 框架分为 Framework 部分和 Engine 部分,而渲染流程也是这两个部分互相配合完成的,但是区别于其他框架由上层处理完后直接交给下层的特点,Flutter Engine 会提供一些 Builder 供 Framework 使用,所以很多流程都由这两个部分来回调度完成的。
先看一下Flutter的整个渲染流程,UserInput 是处理用户输入,Animation 是动画,不过这两个部分不是今天要探讨的重点,Build 主要用于使 Widget 生成 Flutter 框架能识别的 RenderObject,Layout 主要用于确定组件位置和尺寸等,Paint 主要用于转化渲染对象为 Layer,再由 Composition 进行合并,最后 Rasterize 光栅化进行 GPU 渲染。
Flutter 在处理 UI 时都是基于树形结构,从下图中我们可以看到 3 个树形结构,分别是 Widget Tree、Element Tree 和 Render Tree。
我们从 Widget 开始,创建一个 Container,其中包含 Row ( Flex 布局容器),而 Row 又包含 Image 和 Text。Container 内部包含 ColoredBox,它可以作为背景或者边框。Image 内部包含 RawImage,Text内部则包含了 RichText,只有 ColoredBox、Row、RawImage 和 RichTexth 才会被转为 RenderObjectElement,它们最终会分别生成对应的 RerderObject。
那么我们看一下 RenderObject 是什么,它是真正需要被渲染的对象,其中的 attach 函数会把渲染的流程交给 PipelineOwner 管理,下图中 3 个函数主要用于判断是否需要 Layout、是否需要被合成,以及是否需要绘制。
现在看一下PipelineOwner的主要功能,它用于管理渲染流程,首先 Flutter 初始化时会注册一个帧回调,Flutter 的帧是由其自身管理的,随即会在回调中触发 flushLayout、flushCompositingBits 和 flushPaint这 3 个函数,它们和之前提到的 RenderObject 的 3 个 mark 函数相对应。
PipelineOwner 中有 3 个数组,之前被 mark 的 RenderObject 会分别存放在这个 3 个数组中,最后 flush 的时候可以快速遍历这些 RenderObject。经过 PipelineOwner 处理之后,它会调用 RenderView 的 compositeFrame 函数,这部分我们会在后文做讲解。
我们先来重点看下 flushPaint 函数,flushPaint 会调用 RenderObject 的 paint 函数,这是一个抽象函数,它本身是没有实现的,而是由继承它的子类去实现。
可以看到 paint 函数的第一个参数是 PaintingContext,我们来看一下它的部分 API,它们的返回值都是 Layer,包括后面的 pushClipRect 等函数会分别返回 Layer 的不同子类。所以 paint 函数的一个职责就是将 RenderObject 转成 Layer,并将其添加到其成员的 ContainerLayer 中,顺带一提,这里的 LayerHandle 是一个引用计数,用来处理自动释放。
而 paint 函数的另一个职责就是对于需要绘制的 RenderObject,通过 PictureRecorder 将 Canvas 的绘制指令保存起来。
Canvas 主要用于绘制需要绘制的对象,比如之前提到的 RichText、RawImage 等,除此之外,还可以进行 transform、clipPath 等操作。
这里的 Canvas 工厂构造中,会判断 useCanvasKit 并构造不同的 Canvas,为什么会有这个逻辑,这里先按下不表,后面会介绍。我们先按照 Render Pipeline 往下看。
之前提到的 PipeLineOwner 流程结束后,会调用 RenderView 的 compositeFrame 函数进行 Layer 合成。而在 compositeFrame 函数中,我们可以看到几个非常重要的 Class,那就是 Scene 和 SceneBuilder,Scene 是 Layer 合成完毕后的产物,由 SceneBuiler 构建得到。
如图所示,最后它会调用 _window.render 函数,这里的 _window 是 SingletonFlutterWindow,它是一个单例的 RenderView,后面会详细介绍,我们先看一下 Build Scene 的流程。
这里我们可以看到 Layer 的部分源码,之前提到 RenderObject 中有一个 ContainerLayer,buildScene 就是调用 ContainerLayer 的 buildScene 函数(如上图的右半部分),随后会调用 Layer 的 addToScene 函数,它和 RenderObject 的 paint 函数类似,也是一个抽象函数,需要 Layer 的子类自己去实现,比如 ContainerLayer 的 addToScene 函数就是遍历 Child Tree 来分别调用子 Layer 的 addToScene。
那 addToScene 做了什么呢,它实际上是调用 SceneBuilder 提供的 pushXXX 函数,这些函数的返回值也是 Layer,只不过是 EngineLayer,Layer 是 Framework 中图层的抽象,而EngineLayer 是 Engine 中图层的抽象,随后在 Engine 层将这些 EngineLayer 组合到 Scene 中。
Framework 层已经介绍得差不多了,接下来我们来看一下 Engine 层。
简单回顾一下,我们的 Widget 会经由这样的转换流程:Widget->RenderObject->Layer->EngineLayer->Scene,那么这个 Scene 如何渲染出来呢?
这里我们看到了之前提到的 SingletonFlutterWindow,它的 render 函数会调用 EnginePlatformDispatcher 的 render 函数,这里我们又看到了熟悉的 useCanvasKit,根据判断将 Scene 强转成了不同的 Scene,那么这个 useCanvasKit 到底表示什么呢,我们接着往下看。
这个时候我们必须得引入一个概念,就是 Web Renderer,在 Flutter Web 中有两种渲染模式:一种是基于 HTML 标签的渲染模式,它会将 Flutter 的 Widget 都映射成不同的标签,无法单纯用标签表示的就会使用 Canvas 进行绘制,有点类似于 ReactNative 的表现形式。
另一种则是基于 CanvasKit 的渲染模式,它会下载 2MB 的 wasm 文件以调用 Skia 渲染引擎,Widget 渲染都是通过该引擎来绘制的。
我们可以通过命令行参数在 flutter build 或者 run 的时候指定渲染模式,值得一提的是,默认的渲染模式是 auto,在桌面端浏览器上默认是 CanvasKit,而在移动端 WebView 上默认是 HTML。
首先,我们来看一下 HTML 渲染模式,以 我们 Flutter SDK 的 API Example 为例,通过 Elements Tree 可以看到,它的标签层级还是比较多的,图片中的 <canvas> 标签指向了 "Basic" 的文本,这说明该模式下文本的渲染使用的是 Canvas,那为什么要使用 Canvas 绘制文本而不使用浏览器默认的文字渲染能力呢?那是因为要抹除平台渲染表现的差异,尤其是文字的换行处理等,Flutter 内置了文字排版的引擎,会基于该引擎进行渲染。此处延伸一下,比如输入框组件,在没有获取焦点的状态下,它其实和 Text 是类似的,如果获取了焦点 Flutter 则会添加一个 <input> 标签,然后接收输入的文字信息,当焦点失去的时候再隐藏,这是一个非常巧妙的方案。
接下我们看一下在 HTML 渲染模式下的一些细节。之前按下不表的 Canvas 在这里就要显示它的真身了,在HTML渲染模式下会构建 SurfaceCanvas,可以从右图中看到List,这就是存放绘图指令的集合。
而对于 SceneBuilder,这里的是其子类 SurfaceSceneBuilder,我们可以先看一下下图中右侧的PersistedSurface。
它是 EngineLayer 的子类,并且拥有一个 rootElement 属性,还有一个 visitChildren 函数,这也是一个抽象函数。PersistedLeafSurface 是一个没有 child 的EngineLayer,所以它的 visitChildren 是空实现,由它派生出 PersistedPicture 和 PersistedPlatformView,分别对应图片文字(我们之前提到文字是使用 Canvas 绘制的)和平台 View。PersistedContainerSurface 就是一个容器的 EngineLayer,它也有非常多的子类,比如 PersistedClipPath、PersistedTransform等,这些 EngineLayer 对应到之前 API Example 复杂的 Elements Tree 中的各个自定义标签。
在 SurfaceSceneBuilder 的 build 函数执行后,生成的 SurfaceScene 中的 webOnlyRootElement 就已经包含了我们的整个 Html Element 了。
最后我们可以看到 SurfaceScene 会调用 DomRenderer 的 renderScene 函数,将这些 Element 添加到 _sceneHostElement 中。
到这里 HTML 渲染模式就完结了。
下面我们看一下 CanvasKit 的渲染模式,从 Elements Tree 中我们可以看到该模式下的层级非常简单,所有的渲染都是在一个 canvas 中进行的,这里用到的 #shadow-root 是 HTML 的一个特性,可以做到样式隔离。
同样,我们先从 Canvas 入手,这里的是 CanvasKitCanvas,而绘图指令则保存在 CkPictureSnapshot 的 _commands 属性中。
对于 SceneBuilder,CanvasKit 渲染模式下的子类是 LayerSceneBuilder,这里的 Layer 类似于 HTML 渲染模式下的 PersistedSurface,都是派生自 EngineLayer,并且有用一个 ContainerLayer 包含所有的 child,也有对应的 PictureLayer 和 PlatformViewLayer。不过不同的是,它有一个 paint 函数,这里的 paint 函数才是真正的操作 GPU 进行绘制的函数。
而 LayerSceneBuilder 的 build 函数生成的 LayerScene 中包含一个叫作 LayerTree 的根节点,和 HTML 渲染模式下的 webOnlyRootElement 相对应。
既然这里提到 paint 函数才是真正的绘制,那么我们来看一下它是什么时候被调用的。
之前提到 How To Render Scene 的时候,LayerScene 通过调用 rasterizer 的 draw 函数进行绘制。Rasterizer 是负责光栅化进行 GPU 渲染的类,这里会先调用 acquireFrame 从 LayerTree 中获取 frameSize 以构建 SurfaceFrame,同时也会在其内部构建 SkSurface,绑定 WebGLContext 等一系列对 Skia 的调度操作。
context.acquireFrame 生成的 Frame 只是一个简单的聚合类,不用太在意,随后调用 Frame 的 raster 函数进行光栅化处理。最后的 addToScene 则是将 baseSurface 中的 canvas 的 HTML 标签添加到 skiaSceneHost 中。
光栅化阶段由 preroll 和 paint 组成,分别计算绘制边界,以及遍历 LayerTree 并调用所有 Layer 的 paint 函数,这里的 PaintContext 区别于 Framework 的 PaintingContext,它持有所有的 canvas,以便于不同的 Layer 对其进行 paint 操作。
至此,CanvasKit 渲染模式下的流程也差不多走完了,我们最后看一下最终是如何显示在HTML 中的。其实,CanvasKit 渲染模式下最终也使用了 DomRenderer,在 Flutter 的初始化流程中,我们可以看到,initializeCanvasKit 函数的前半部分是我们之前提到的引入 Skia 的 wasm 资源和对应的 JavaScript 文件;后半部分则是创建了一个 skiaSceneHost 根节点,这个 Element 就是之前 baseSurface.addToScene 中引用的。
整个渲染原理到这里就介绍完了,当然,整个渲染中还有很多的细节,比如 SurfaceFactory 中除了 baseSurface 还有 backupSurface 可以对绘制进行缓存等,这些点每个展开都能作为一个单独的议题进行讨论。最后贴上一个总结的流程图,大家可以结合前文回顾一下整个流程。
在分享的最后,给大家附上 Flutter RTC SDK 的 GitHub 链接,目前我们已经在 dev/flutter 分支上做了 Flutter2 的适配。在 Web 和桌面端上也支持了屏幕共享。大家可以自行体验,如果有任何问题或者建议,欢迎大家反馈,如果使用体验还不错,也欢迎大家给我们的仓库点上 Star。