淘宝小部件 Canvas 渲染流程与原理全解析
作者:史健平(楚奕)
上篇回顾:《 淘宝小部件:全新的开放卡片技术!》、《淘宝小部件在 2021 双十一中的规模化应用》
本文主要从技术视角阐述 Canvas 在小部件下的渲染原理。
在进入正文之前需要先解释下什么是【小部件】,小部件是淘宝模块/卡片级的开放解决方案,其主要面向私域提供类小程序的标准 &一致化的生产、开放、运营等能力,它有着多种业务形态,如商品卡片、权益卡片以及互动卡片等等,ISV 开发的小部件可以以极低成本部署到店铺、详情、订阅等业务场景,极大提高了运营 &分发效率。
从端上技术视角看,小部件首先是一个业务容器,它的特点是 DSL 标准化、跨平台渲染、跨场景流通:
DSL 标准化是指小部件完全兼容小程序的 DSL(不仅仅是 DSL,还包括原子 API 能力、生产链路等等),开发者不需要额外学习即可快速上手;
跨平台渲染顾名思义,小部件内核(基于 weex2.0)通过类似 flutter 自绘的方案可以在 Android、iOS 等不同操作系统上渲染出完全一致的效果,开发者不需要关心兼容性问题;
最后跨场景流通是指小部件容器可以『嵌入』到多种技术栈的其他业务容器中,比如 Native、WebView、小程序等等,以此做到对开发者屏蔽底层容器差异并达到一次开发,多处运行的效果。
无独有偶,Canvas 在小部件下的技术方案与小部件容器嵌入其他业务容器的技术方案居然有不少相似之处,那么下边笔者就从 Canvas 渲染方面展开来讲一讲。
原理揭秘
端侧整体技术架构
小部件技术侧的整体架构如下图所示,宏观看可分为 "壳" 与 "核" 两层。
"壳"即小部件容器,主要包括 DSL、小部件 JSFramework、原子 API 以及扩展模块比如 Canvas。
"核"为小部件的内核,基于全新的 weex2.0。在 weex1.0 中我们使用类 RN 的原生渲染方案,而到了 weex2.0 与时俱进升级到了类 Flutter 的自绘渲染方案,因此 weex2.0 承担了小部件 JS 执行、渲染、事件等核心职责,并细分为 JS 脚本引擎、Framework 与渲染引擎三模块。JS 引擎在 Android 侧使用轻量的 QuickJS,iOS 侧使用 JavaScriptCore,并且支持通过 JSI 编写与脚本引擎无关的 Bindings;Framework 层提供了与浏览器一致的 CSSOM 和 DOM 能力,此外还有 C++ MVVM 框架以及一些 WebAPI 等等(Console、setTimeout、...);最后是内部称之为 Unicorn 的渲染引擎,主要提供布局、绘制、合成、光栅化等渲染相关能力,Framework 与渲染引擎层均使用 C++开发,并对平台进行了相关抽象,以便更好的支持跨平台。
值得一提的是,unicorn 渲染引擎内置了 PlatformView 能力,它允许在 weex 渲染的 Surface 上嵌入另一 Surface,该 Surface 的内容完全由 PlatformView 开发者提供,通过这种扩展能力,Camera、Video 等组件得以低成本接入,Canvas 也正是基于此能力将小程序下的 Native Canvas(内部称之为 FCanvas)快速迁移到小部件容器。
多视角看渲染流程
更多细节还可以参考笔者先前的文章《跨平台Web Canvas渲染引擎架构的设计与思考(内含实现方案)》
到了本文的重点,首先依然从宏观角度看下 Canvas 大体的渲染流程,请看下面图示,我们从右到左看。
对开发者而言,直接接触到的是 Canvas API,包括 w3c 制定 Canvas2D API 以及 khronos group 制定的 WebGL API,它们分别通过 canvas.getContext('2d')和 canvas.getContext('webgl') 获得,这些 JS API 会通过 JSBinding 的方式绑定到 Native C++的实现,2D 基于 Skia 实现而 WebGL 则直接调用 OpenGLES 接口。图形 API 需要绑定平台窗体环境即 Surface,在 Android 侧可以是 SurfaceView 或是 TextureView。
再往左是小部件容器层。对 weex 而言,渲染合成的基本单位是 LayerTree,它描述了页面层级结构并记录了每个节点绘制命令,Canvas 就是这颗 LayerTree 中的一个 Layer -- PlatformViewLayer(此 Layer 定义了 Canvas 的位置及大小信息),LayerTree 通过 unicorn 光栅化模块合成到 weex 的 Surface 上,最终 weex 和 Canvas 的 Surface 均参与 Android 渲染管线渲染并由 SurfaceFlinger 合成器光栅化到 Display 上显示。
以上是宏观的渲染链路,下边笔者试着从 Canvas/Weex/Android 平台等不同视角分别描绘下整个渲染流程。
Canvas 自身视角
从 Canvas 自身视角看,可以暂时忽略平台与容器部分,关键之处有两点,一是 Rendering Surface 的创建,二是 Rendering Pipeline 流程。以下通过时序图的方式展示了这一过程,其中共涉及四个线程,Platform 线程(即平台 UI 线程)、JS 线程、光栅化线程、IO 线程。
Rendering Surface Setup: 当收到上游创建 PlatformView 的消息时,会先异步在 JS 线程绑定 Canvas API,随后在 Platform 线程创建 TextureView/SurfaceView。当收到 SurfaceCreated 信号时,会在 Raster 线程提前初始化 EGL 环境并与 Surface 绑定,此时 Rendering Surafce 创建完成,通知 JS 线程环境 Ready,可以进行渲染了。与 2D 不同的是,如果是 WebGL Context,Rendering Surace 默认会在 JS 线程创建(未开启 Command Buffer 情况下);
Rendering Pipeline Overview: 开发者收到该 Ready 事件后,可以拿到 Canvas 句柄进而通过 getContextAPI 选择 2d 或者 WebGL Rendering Context。对于 2d 来说,开发者在 JS 线程调用渲染 API 时,仅仅是记录了渲染指令,并未进行渲染,真正的渲染发生在光栅化线程,而对于 WebGL 来说,默认会直接在 JS 线程调用 GL 图形 API。不过无论是 2d 还是 WebGL 渲染均是由平台 VSYNC 信号驱动的,收到 VSYNC 信号后,会发送 RequestAnimationFrame 消息到 JS 线程,随后真正开始一帧的渲染。对于 2D 来说会在光栅化线程回放先前的渲染指令,提交真实渲染命令到 GPU,并 swapbuffer 送显,而 WebGL 则直接在 JS 线程 swapbuffer 送显。如果需要渲染图片,则会在 IO 线程下载并进行图片解码最终在 JS 或者光栅化线程被使用。
Weex 引擎视角
从 Weex 引擎视角看,Canvas 属于扩展组件,Weex 甚至都感知不到 Canvas 的存在,它只知道当前页面有一块区域是通过 PlatformView 方式嵌入的,具体是什么内容它并不关心,所有的 PlatformView 组件的渲染流程都是一致的。
下面这张图左半部分描述了 Weex2.0 渲染链路的核心流程: 小部件 JS 代码通过脚本引擎执行,通过 weex CallNative 万能 Binding 接口将小部件 DOM 结构转为一系列 Weex 渲染指令(如 AddElement 创建节点、UpdateAttrs 更新节点属性等等),随后 Unicorn 基于渲染指令还原为一颗静态的节点树(Node Tree),该树记录了父子关系、节点自身样式 &属性等信息。静态节点树会在 Unicorn UI 线程进一步生成 RenderObject 渲染树,渲染树经过布局、绘制等流程生成多张 Layer 组合成为 LayerTree 图层结构,经过引擎的 BuildScene 接口将 LayerTree 发送给光栅化模块进行合成,最终渲染到 Surface 上并经过 SwapBuffer 送显。
右半部分是 Canvas 的渲染流程,大体流程上边 Canvas 视角已介绍过,不再赘述,这里关注 Canvas 的嵌入方案,Canvas 是通过 PlatformView 机制嵌入的,其在 Unicorn 中会生成对应的 Layer,参与后续合成,不过 PlatformView 有多种实现方案,每种方案的流程大相径庭,下边展开讲一下。
Weex2.0 在 Android 平台提供了多种 PlatformView 嵌入的技术方案,这里介绍下其中两种:VirtualDisplay 与 Hybrid Composing,除此之外还有自研的挖洞方案。
VirtualDisplay
此模式下 PlatformView 内容最终会转为一张外部纹理参与 Unicorn 的合成流程,具体过程:首先创建 SurfaceTexture,并存储到 Unicorn 引擎侧,随后创建 android.app.Presentation,将 PlatformView(比如 Canvas TextureView)作为 Presentation 的子节点,并渲染到 VirtualDisplay 上。众所周知 VirtualDisplay 需要提供一个 Surface 作为 Backend,那么这里的 Surface 就是基于 SurfaceTexture 创建。当 SurfaceTexture 被填充内容后,引擎侧收到通知并将 SurfaceTexture 转 OES 纹理,参与到 Unicorn 光栅化流程,最终与其他 Layer 一起合成到 Unicorn 对应的 SurfaceView or TextureView 上。
此模式性能尚可,但是主要弊端是无法响应 Touch 事件、丢失 a11y 特性以及无法获得 TextInput 焦点,正是由于这些兼容性问题导致此方案应用场景比较受限。
Hybrid Composing
在此模式下小部件不再渲染到 SurfaceView or TextureView 上,而是被渲染到一张或者多张由 android.media.ImageReader 关联的 Surface 上。Unicorn 基于 ImageReader 封装了一个 Android 自定义 View,并使用 ImageReader 生产的 Image 对象作为数据源,不断将其转为 Bitmap 参与到 Android 原生渲染流程。那么,为啥有可能是多个 ImageReader?因为有布局层叠的可能性,PlatformView 上边和下边均有可能有 DOM 节点。与之对应的是,PlatformView 自身(比如 Canvas)也不再转为纹理而是作为普通 View 同样参与 Android 平台的渲染流程。
Hybrid Composing 模式解决了 VirtualDisplay 模式的大部分兼容性问题,然而也带来了新的问题,此模式主要弊端有两点,一是需要合并线程,启用 PlatformView 后,Raster 线程的任务会抛至 Android 主线程执行,增大了主线程压力;二是基于 ImageReader 封装的 Android 原生 View(即下文提到的 UnicornImageView)需要不断创建 Bitmap 并绘制,特别是在 Android 10 以前需要通过软件拷贝的方式生成 Bitmap,对性能有一定影响。
综合来看 Hyrbid Composing 兼容性更好,因此目前引擎默认使用该模式实现 PlatformView。
Android 平台视角
下面笔者试着进一步以 Android 平台视角重新审视下这一过程(以 Weex + Hybrid Composing PlatformView 模式为例)。
上边提到,Hybrid Composing 模式下小部件被渲染到一张或多张 Unicorn ImageView,按照 Z-index 从上到下排列是 UnicornImageView(Overlay) -> FCanvasTextureView -> UnicornImageView(Background) -> DecorView,那么从 Android 平台视角看,视图结构如上图所示。Android 根视图 DecorView 下嵌套 weex 根视图(UnicornView),其中又包含多个 UnicornImageView 和一个 FCanvasPlatformView (TextureView)。
从平台视角看,我们甚至不需要关心 UnicornImageView 和 FCanvas 的内容,只需要知道它们都是继承自 android.view.View 并且都遵循 Android 原生的渲染流程。原生渲染是由 VSYNC 信号进行驱动,通过 ViewRootImpl#PerformTraversal 顶级函数触发测量(Measure)、布局(Layout)、绘制(Draw)流程,以绘制为例,消息首先分发到根视图 DecorView,并自顶向下分发(dispatchDraw)依次回调每个 View 的 onDraw 函数。
对于 FCanvas PlatformView 来说,它是一个 TextureView,其本质上是一个 SurfaceTexture,当 SurfaceTexture 发现新的内容填充其内部缓冲区后,会触发 frameAvailable 回调,通知视图 invalidate,随后在 Android 渲染线程通过 updateTexImage 将 SurfaceTexture 转为纹理并交由系统合成;
对于 UnicornImageView 来说,它是一个自定义 View,其本质上是对 ImageReader 的封装,当 ImageReader 关联的 Surface 内部缓冲区被填充内容后,可以通过 acquireLatestImage 获得最新帧数据,在 UnicornImageView#onDraw 中,正是将最新的帧数据转为 Bitmap 并交给 android.graphics.Canvas 渲染。
而 Android 自身的 View Hierarchy 也关联着一块 Surface,通常称之为 Window Surface。上述 View Hierarchy 经由绘制流程之后,会生成 DisplayList,并在 Android 渲染线程经由 HWUI 模块解析 DisplayList 生成实际图形渲染指令交由 GPU 进行硬件渲染,最终内容均绘制到上述 Window Surface,然后与其他 Surface 一起(比如状态栏、SurfaceView 等)通过系统 SurfaceFlinger 合成到 FrameBuffer 并最终显示到设备上,以上就是 Android 平台视角下的渲染流程。
总结与展望
经过上边多个视角的分析,相信读者对渲染流程已有初步的了解,这里稍稍总结一下,Canvas 作为小部件核心能力,通过 weex 内核 PlatformView 扩展机制支持,这种松耦合、可插拔的架构模式一方面使得项目可以敏捷迭代,让 Canvas 可以在新场景快速落地赋能业务,而另一方面也让系统更加灵活和可扩展。
但与此同时,读者也可以看到 PlatformView 自身其实也存在一些性能缺陷,而性能优化正是我们后续演进的目标之一,下一步我们会尝试将 Canvas 与 Weex 内核渲染管线深度融合,让 Canvas 与 Weex 内核共享 Surface,不再通过 PlatformView 扩展的方式嵌入,此外对于互动小部件来说未来我们会提供更精简的渲染链路,敬请期待。
关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践 &干货给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/98752bab167be59e88eb33319】。文章转载请联系作者。
评论