本文主要从技术视角阐述 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句柄进而通过
getContext
API选择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扩展的方式嵌入,此外对于互动小部件来说未来我们会提供更精简的渲染链路,敬请期待。