Cube 技术解读 | Cube 渲染设计的前世今生
作者:何瑾(潇珺)
本文为《Cube 技术解读》系列第四篇文章,往期文章欢迎大家回顾。
阿里是个重运营的公司,前端开发者居多,2016-2017 年,在 Weex 还是 1.0 时代,React Native 开源还没多久,Flutter 还没诞生的时候,如何在贴合前端开发环境的前提下,快速铺到 android/iOS 双平台是个大热点,支付宝内部孵化一个动态化跨平台方案顺势而生。
前面三篇文章分别介绍了 Cube 当前架构,Cube 卡片和 Cube 小程序技术产品形态。这篇文章主要讨论 Cube 的渲染设计,帮助大家了解 Cube 卡片渲染技术的前世今生。
Native 原生渲染的问题
我们都知道一个原生 view 渲染上屏需要几个步骤,以 android 举例:create、measure、layout、draw,这些需要在主线程完成,当实现原生列表时,即使完美复用 item,对不同数据渲染时,也需要 measure、layout、draw 几步缺一不可,而且随着 view 嵌套层级越深,对主线程资源消耗越大,当列表 fly 起来以后,帧率快速下降,造成页面卡顿,基于这个问题,cube 在调研期间,如何解决渲染效率是重要的一 part。
通常来说优化列表滚动帧率,也就是 view 层级、布局复杂度、去掉不必要背景色,解决过度绘制,图片懒加载、item 复用等方面下手,但根本还是绕不过 measure、layout、draw。彼时的 weex 和 RN,也都还是将 html 中的标签映射到平台层 view,在某些场景下,开发者又不能像原生开发一样自行优化,在渲染性能上饱受诟病。因此 cube 调研期间渲染目标是:优化渲染效率+跨平台。
跨平台异步渲染方案
异步渲染
基于上面提到的背景和需求,那么我们就想,能否有一种方式,把关键步骤移除出线程呢,即异步渲染。在列表滚动时基本只有系统手势和列表本身滚动算法、动画需要占用主线程,将大大提高帧率。视图内元素绘制的产物是一个像素缓存(Cube 采用的设计是 Bitmap),回到主线程给视图进行刷新显示。
跨平台架构
另一个目标跨平台,是要做到可以快速扩展其他平台,cube 将涉及平台的部分分离出来,形成 platform 层。
platform
这里提供了各平台通用的标准 c++原子接口,在不同平台用平台语言实现,初步只实现了 android、iOS 两个平台,android 通过 jni 调用 java 方法,iOS 在实现文件中 c++、OC 混编。如果未来需要扩展其他平台例如 macOS,只需实现 platform 层定义的接口即可,可以达到快速扩展其他平台的目标。
core
library 是基于 platform 原子接口用 c++实现的是基础库,例如文件 IO、UI 控件、图片下载、消息通讯等,供上层引擎使用。library 之上,就是 cube 渲染的核心实现,渲染部分包括数据模型和渲染逻辑,组件库指 cube 内部支持的一些系统实体控件,或者开发者可外接的实体组件。
下图是第一版 cube 渲染架构图。
cube 渲染架构图
异步渲染技术选型
前面提到了,异步渲染方案里异步绘制的“产物”是一张 bitmap 交给“容器”View,为什么是 bitmap 呢,看起来对内存很不友好,View 又是个什么 View,有没有特殊性,下面聊聊 cube 调研时期都研究过哪些方案,最终为什么选型 bitmap。
Android 平台技术选型
android 的选型之路坎坷崎岖,最先能想到的支持独立渲染线程的 textureView、GLSurfaceView 做为容器,但有明显缺陷,是不能用于常见业务的列表场景的,只能应用于特定场景。
SurfaceView、GLSurfaceView
SurfaceView 从 android1.0 开始就有,主要特点是它的渲染可以在子线程中实现,因此存在的问题是,虽然它继承 View,但是它拥有独立的 Surface,不在 View hierachy 中,它的显示也不受 View 的属性控制,因此不能像普通 view 一样缩放平移,更不能作为 item 放在 listView/RecycleView 中当作普通 view 使用,滚动起来会有不同步的问题。
GLSurfaceView 继承 SurfaceView,它自带 GLThread,有和 GLSurfaceView 相同的问题,总之,这两个 view 更适合单个视频渲染或者像地图类渲染场景。
有人可能要问,整个页面都用 SurfaceView/GLSurfaceView 不就行了,连列表也在 render 线程实现?这里两个问题:
1、如果列表容器也在 render 线程实现,正如现在的 flutter 一样,那么列表滑动手势处理需要自己实现,比如 drag,fling,各种列表滚动个动画,以及滚动加速度计算等,成本很高。并且,touch 事件捕获仍然依赖平台层,而处理事件需要切换到 render 线程,这中间一定有线程切换成本造成的不跟手的体验问题。现在很多基于 flutter 引擎改造的渲染引擎,正面临着这些问题;
2、在当时 cube 团队的主要目标是快速验证 ,列表的实现这种成本过高,不是主要矛盾所在。
TextureVIew
textureView 是 google 从 android4.0 开始提供的,它的出现很大程度上是为了弥补 SurfaceView、GLSurfaceView 与原生 View 融合的不足,基于上面一节描述的这两个 view 与原生 view 一起动画的问题,textureView 似乎更适合我们的场景,既能支持独立 render 线程,又能保证与原生 view 完美融合。
但是,在实际的调研过程中发现,textureView 的渲染机制,不适用于长列表,如果每个列表的 item 是一个 textureView,那么就涉及到出屏回收,进屏创建,否则会带来内存问题。而回收和创建 SurfaceTexture 是异步过程,出现了闪黑屏问题。除此之外,进一步发现 textureView 的数量和容量(每个 view 的尺寸累计)存在某个上限,而且不同手机上限也差异很大。简单说,这是一个看起来很美好,但是兼容性坑无数的技术路线。
Bitmap+普通 View
最终选择了 bitmap 看起来并不完美的方案,虽然这被大多数 android 开发认为 bitmap 带来大量内存消耗,视为不可接受,但随着 cube 的应用范围越来越广,这逐渐被证明是在当时,最普适的一个方案。
每一个 layer 对应一个系统 view,每个 view 的绘制内容在子线程通过 CanvasAPI 异步绘制在 bitmap 上,当 view 上屏时,系统 onDraw 绘制这个 bitmap“产物”。
BitmapCache
虽然用了 Bitmap 绘制方案,但必须要考虑内存过载的问题,这里我们采用了 BitmapCache,主要针对列表类型场景,依赖系统的 item 回收回调通知,将 bitmap 画布放入 Cache,item 上屏渲染时,优先从 cache 取 bitmap 画布使用,优先取相同大小的,如果不存在,则取 width、height 大于目标 width、height,让 view 只绘制 bitmap 局部,达到正确渲染的目的
iOS 平台技术选型
iOS 的实现原理与 android 大致相同,区别是,iOS 异步线程绘制完成的“产物”,不会在 UIView 的 drawRect 里利用 CoreGraphics 进行渲染,这种方式效率很低,页面卡顿明显,最终采用的是将画布赋值给 UIView 的 layer,托管给系统渲染 layer。
渲染技术的演进
上面讲了 cube 异步渲染大体方案和关键技术选型,事实上,从 19 年初上线答答星球,到现在,cube 在支付宝内应用越来越广泛,这中间也伴随着 cube 团队根据实际业务场景不断摸索、优化的过程,渲染链路经历了两次重构。需要强调的,这个演进过程是在严格的内存/性能下完成的,而且要对 Android 兼容性做出妥协。一些看起来不那么优雅或者先进的设计,事实上是不得不这么做,比如选择 Bitmap 作为像素缓冲,比如接入三方组件的设计等。从某种意义上,抛开约束谈论技术优劣也意义不大。我们曾经借鉴 flutter 的部分,但 Cube 最终还是沿着适合自身场景的技术路线往前走。
常见术语
LayoutTree:DomApi 通过 add、update、remove 构建的经过 yoga 布局的,用来描述节点父子关系,包含布局信息的原始树型结构;
RenderTree:用来描述绘制节点父子关系,包含绘制信息的树型结构,与 layoutTree 的区别举例:一个 layoutNode visible 为 gone,则该节点不会在 RenderTree 中出现;
Layer:一般情况下,根节点及其子节点绘制在同一个画布上,定义为一个 layer,对应平台层一个 view,当子节点有动画属性,或者超出父节点范围,则需要独立出一个 layer;
LayerTree:上面提到的 layer 节点,构建的树型结构,一个 layer 对应平台层一个 view,我们叫 ContainerView;
实体节点:需要独立 layer 的节点为实体节点;
虚拟节点:除了实体节点以外,其他节点均会被绘制在父容器的画布上,这些是虚拟节点。
演进过程
调研初期——1.0 验证方案的可行性
调研时期验证方案可行性,场景比较简单,以支付宝内朋友动态页面为验证场景,每条状态(一个 item/cell)作为一个渲染单元,这里只考虑了 layerTree 只有一个 layer 的情况,头像、昵称、时间、配图、“赞”、“赏”,“评”等元素均绘制在 root 节点对应的 layer 上,“赞”、“赏”,“评”文本旁边的小图标则作为外接实体组件,通过 addSubView 添加在 rootLayer 的 View 上。
数据模型
如下图所示,根据 layoutTree 构建 RenderTree,但非渲染节点不在 renderTree 上,layerTree 只有一个自绘制 layer(rootLayer),和其他自定义组件 X,最终除自定义组件外,其他所有节点都绘制在 rootLayer 上。
渲染流程
bridge 线程通过 DomApi 构建 layoutTree,当主线程触发渲染时,主线程根据 layoutTree 构建 RenderTree,构建过程中遇到外接实体组件,创建实例并 addSubView,之后切换子线程绘制 RenderTree,即 rootLayer 上的所有虚拟节点,绘制完成后切换主线程贴图(bitmap“产物”)。
缺点
不能支持多 layer 结构
实体 view 没有复用,也就是朋友动态列表中有多少 item/cell,就会有多少“赞”、“赏”,“评”实体组件
但这个调研验证了异步渲染的可行性,在列表滚动时帧率大幅提升。
产品化时期——2.0 支持多 layer
前面验证了可行性,在进行产品化设计时,就必须要满足多 layer 结构了,即实际的一张卡片中,会有一个或几个不同的节点被设置为 layer,这些节点及其子节点,分别绘制在不同画布上,供不同的 layer 渲染。
数据模型
改进之处时 layerTree 里有个多 layer 节点,layer 节点下面的子虚拟节点,将绘制在该 layer 的 bitmap“产物”上。
渲染流程
brige 线程构建 layoutTree 的过程中,每个指令(addNode、removeNode……)都会相应分发到 render 模块的主线程,render 根据指令构建 RenderTree,并用指令信息生成 task 入队,当 VSync 信号来时,触发任务出队并去重,构建 layerTree,不同 layer 分发到不同 draw 线程绘制,绘制完成后切主线程贴图(bitmap“产物”)。
缺点
主线程计算量大,可能造成卡顿
render 节点既包含绘制信息,是绘制对象,还包含逻辑,例如 display:"none"节点忽略不显示,职责不清晰。
优化时期——3.0 取长补短
上面可以看到 renderTree 的构建以及 layerTree 的构建,都是在 UI 线程,在节点数比较多活复杂的情况下会造成 UI 的卡顿,为了追求极致滚动帧率,尽可能减少主线程计算内容,优化 3.0 版本将 renderObject 构建 layer、以及计算节点变更导致的绘制影响范围,的部分改在子线程完成,形成了现在线上运行的版本。
数据模型
新增了 PaintTree 这个结构,它挂载在 Layer 节点上,样式和属性值从 RenderTree 拷贝而来,但不涉及任何逻辑处理,单纯的是一个绘制对象,每个绘制任务只绘制 paintTree 上的 paint 节点,与 layerTree 和 renderTree 没有并发问题。
渲染流程
layout 线程构建 layoutTree,切换到 render 线程构建 renderTree,当平台层触发渲染,切换到 renderTree 构建 layerTree,并计算影响范围等,切换到主线程将 layer 对应的实体化 View 添加在容器 View 上,生成绘制任务在 paint 线程执行,绘制结束后切换主线程贴图(bitmap 产物)。
缺点
render 线程繁忙时造成的闪白率升高
以上就是 cube 渲染从诞生到现在线上方案的演进,目前在支付宝端内卡片形态接入业务超过 20+,线上运行的卡片模版个数达到 500 多个,显示 PV 过百亿,经受住了各业务方的考验。
但在技术支持中也发现了一些问题,例如渲染任务过多时,render 线程阻塞排队,不能及时消费导致白屏概率变大,最近 cube 也在继续研究优化方案。
存在的问题
两端一致性问题
cube 目前的绘制 api,采用的系统平台层提供的 CanvasApi(iOS 是 CoreGraphics),这就导致了两个平台在绘制点线面的细节上必须两端人工代码对齐,否则就会产生效果差异,当新增一些 feature,例如支持点划线,需要两个平台各自实现 DrawDottedLine 接口,但这个问题,cube 团队正调研自绘制,即使用 skia api 将绘制接口下沉到 c++,实现跨平台自绘制;
文本也是容易产生差异的一个点,利用平台层 api 对文本进行布局,在绘制时调用布局的 api 进行绘制,因此可能会产品平台差异,但 cube 团队目前已经在 Cube 小程序上把文本布局,布局算法下沉在 c++层,不依赖平台 api,实现双平台一致;限于内存/性能的约束尚未在 Cube 卡片上应用。
闪白问题
因为滚动采用的异步渲染,所以必然会产生主线程卡片已经上屏,异步绘制还未完成造成的闪白问题,线程切换有成本,这个闪白理论上一定存在,只是时间长短问题,cube 团队致力于提高渲染效率,将线程切换带来的损耗降到最低,使用户在列表滚动中体验提升。
未来规划
针对目前已知的问题,cube 团队致力于持续优化,主要优化点包括但不限于以下:
渲染快照,提高冷启的渲染效率,减少闪白时间;
渲染策略,例如预渲染、同异步绘制自适应、线程模型优化、组件缓存和预加载等,减少闪白率,提升渲染效率;
用于 Cube 卡片的 yoga 布局引擎优化,提升 layout 布局效率;
skia 自绘制实现,实现双端一致性;
cube 的渲染技术的应用包含卡片和小程序两种技术形态,场景包括支付宝端内、端外、IOT 等多样化场景,团队成员将持续在渲染性能、用户体验、以及工具链等方向持续发力,努力把产品打磨好,把开发者服务好,成长为具有竞争力的跨平台动态化渲染方案。
关注【阿里巴巴移动技术】,阿里前沿移动干货 &实践给你思考!
版权声明: 本文为 InfoQ 作者【阿里巴巴移动技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/7e97d4efa1141d305ecf39295】。文章转载请联系作者。
评论