如何持续突破性能表现? | DX研发模式
岚遥2022-06-21

DX全称DinamicX,目前是在淘宝乃至整个阿里集团内广泛使用的Native动态化方案,核心优势是性能和稳定性。过去几年一直有其他淘宝/集团的外部文章中有涉及到DX,但DX一直没有对外做过完整介绍,对外界来说这两个字母颇有些神秘色彩。本系列文章《DX研发模式》我们就将拉下它神秘的面纱,看看过去两年 DX 在做什么。

本文主要阐述 DX 在追求极致的性能体验过程中,所突破的性能瓶颈与实践经验。

前言

DX 作为一种逻辑和视图分离的跨平台动态化方案,是集团内高性能动态化框架的代表,其性能是最接近 Native 的。根据业务的侧重点不同,技术选型也不同,没有绝对的最优解。在追求高性能极致体验,高稳定性及能够提供部分逻辑动态化的场景下,DX 为当前的第一选择。

现状

DX 为了追求极致的性能体验:

  1. 在服务端编译期就将 XML 文件解析成二进制,将生成抽象语法树的过程前置到编译期执行,端上执行时,仅需要拿到二进制文件进行属性解析,对于静态值类型,在编译期就直接转换成了对应的数据类型,减少了端上的拆装箱开销;
  2. 代码均使用平台原生语言实现,避免了各种跨语言通信成本;
  3. DX 通过使用轻量、不可变的虚拟树节点进行测量、布局等前置操作,只在最后 render 阶段才会操作平台视图。在渲染 view 之前会再次进行视图 diff 操作,确保尽可能复用 view,只生成必要的 view;
  4. DX在过去的一年通过事件链能力扩展了部分逻辑动态化能力,但这些能力属于可插拔能力,并不参与 DX 视图渲染阶段,以上这些都是 DX 持续保持高性能的基础;

通过对 DX 的管线分析,我们发现 DX 的性能还有进一步提升空间,比如

  1. 单线程的管线设计,在较为复杂的模板场景下,DX的虚拟树操作阶段耗时不可忽略;
  2. 依托平台本身的绘制能力限制,在大量文本、图片等场景下,系统实现均在主线程进行绘制,在手淘这种大量图文的场景下,平台本身的绘制性能限制也不可忽略;
  3. 由于部分业务高频组件缺乏内置通用实现,在庞大的业务方自定义组件生态中,性能、稳定性、通用性均无法得到保证。

基于上述分析流程,当前 DX 面临的性能瓶颈主要分为两个部分:虚拟节点树和视图渲染。

  1. 针对渲染管线的虚拟节点操作阶段,我们提供了管线异步化能力。
  2. 针对渲染阶段,我们提供了异步绘制框架、通用富文本组件及部分属性能力优化。
  3. 针对整体 CPU 计算资源占用,我们提供了离屏资源管控框架

管线优化

由于 DX 的服务端预编译及虚拟节点轻量等特征,一般场景下,虚拟节点的主线程耗时占比并不高,但在业务模板较为复杂时,比如含有大量表达式和复杂嵌套层级场景下,虚拟节点操作耗时占比可能超过 40%,在由 DX 卡片搭建的全页面场景下,虚拟节点的主线程耗时占比甚至超过 10%,所以虚拟节点耗时亟需优化。

管线简介

在 DX 中,开发者在 XML 中写的组件到端上都会先解析为“三棵树”后再交由真正的平台 view 进行渲染。分别为:原型树、展开树、拍平树。渲染管线会在虚拟节点树上分别将二进制解析、表达式解析、测量、布局、拍平等步骤进行操作,步骤之间可拆分重组,这也就为管线异步化打下了基础。

  1. 原型树:从二进制下发后到端上的原始树结构,主要包含静态树节点赋值及单位换算;
  2. 展开树:根据初始的原型树进行数据进一步解析,包含动态属性的解析、layout 子节点的转换、节点的测量及布局等操作;
  3. 拍平树:根据布局好的展开树进行再进一步的优化,主要包含无用布局节点的拍平以及整体层级的拍平,拍平树是真正需要交由平台 view 进行渲染的最终状态。

管线异步化

而从前面的流程分析我们可以了解到,DX 的虚拟节点并不操作实际平台 view,那么是否可以充分利用 CPU 的多核能力进行多线程管线调度,将非必要占用主线程的工作都异步化执行?基于上述分析,我们设计了 DX 的管线异步化能力。

管线异步化整体思路简化流程如图:将虚拟节点上的加载、表达式解析、测量、布局等操作均借助多核 CPU 的能力异步执行,仅有最后的拍平和渲染操作在主线程进行。

我们通过改造 DX 内部的渲染管线,提供了多种可扩展点,组件可将平台渲染无关的耗时操作在 onPrefetch 异步接口中提前执行,待到 render 阶段时仅需执行 UI 相关流程即可。整体流程图如下:

在 DX 内部,借助管线异步化能力,将图片组件中与 UI 无关的操作 URLParser 步骤提前到 onPrefetch 回调中提前进行,整体减少图片库 30% 以上时间占比,在订阅等图片较多的业务中提升显著。

由于 DX 丰富的生态中含有庞大的业务方自定义组件,所以将该能力作为 DX 基础扩展接口,可由业务方在自定义组件中进行预加载时机的自定义操作处理,为自定义组件提供了一种优化策略。

上述主要介绍了管线异步化的核心思路,针对外部容器和 DX 自建容器也提供了不同的接入策略:

  1. DX 的外部业务接入方,通常都是将 DX 作为一张卡片,嵌入各种不同的自建容器中,针对这种场景,DX 提供了诸多不同的异步化接入方式:单个/批量预加载能力,同步/异步预加载能力,异步渲染接口等,可以方便业务方根据自己的业务需求和业务场景进行选择性接入。而在 DX 内部,为了避免和解决多线程滥用问题导致的线程频繁切换开销和线程爆炸等问题,我们设计了小型多线程管控队列,管线异步化也借助该队列,得以根据当前 CPU 核数等动态调整最大并发数;
  2. 针对外部容器虽然 DX 已提供了多种可选的异步化接入接口,但对于业务方来说还是有一定接入成本的。而针对 DX 内部自建的功能强大的 RecycleLayout 容器,DX 提供了内置的管线异步化能力,得以使用 RecycleLayout 的业务方可以通过 prefetch 属性设置,无成本的接入管线异步化。

渲染优化

利用管线异步化方案,将虚拟节点上的操作异步执行,大大减少了主线程压力。但在不同业务场景下,虚拟节点操作和视图渲染操作在主线程所占比例并不尽相同,在大多数场景下更为耗时的为真正的视图渲染阶段。那么是否可以充分利用系统多线程能力,将耗时的绘制操作放到异步线程执行?基于此,我们设计并实现了异步绘制框架以解决这种必须要在 CPU 上进行绘制时的主线程消耗,而自测自绘的富文本组件也成为了异步绘制框架的第一个实际应用场景。

异步绘制框架

系统 CALayer 通常有两种绘制方式,直接使用纹理或手动绘制。DX 内部的异步绘制框架简易原理时序图如下,在接收到系统 layer 的 display 消息时,将纹理生成步骤在异步线程执行完成后再回到主线程统一提交。

DX 基于系统的绘制流程,模仿系统实现,业务方可选择直接实现位图或是基于 DX 提供的画布进行加工绘制。在该流程中间提供了有较多向外扩展点,满足业务方不同的定制化和时机监控需求。并且由于系统绘制框架 CoreGraphics 为线程安全,天然为我们提供了异步绘制的基础,所以可以将绘制的步骤放到异步线程进行。在提交任务时,通过监听系统提交事务时间,将每个 runloop 中的绘制任务暂存,再统一在系统 commit 时机之后从主线程提交到 renderServer。

在 DX 的丰富生态中,不仅有大量的内置组件,还有极为庞大的开发者自定义组件。在 DX 体系下的技术改造如何不影响现有流程并且易于开发者自定义扩展是一个必须要考虑的问题。所以在设计异步绘制框架时,采用面向接口编程的思想,对 DX 原有渲染逻辑无入侵性,也减轻了类之间的依赖耦合关系,可以实现仅对部分实现该协议的组件进行异步绘制,扩展性较强,针对于每个步骤都有对应的扩展点用于开发者自定义操作。

内置 DXDisplayLayer 和 DXBaseView 作为异步绘制基础类,DXBaseView 作为 displayLayer 的视图代理,用于视图展示和手势处理。DXWidgetNode 节点的 AsyncDisplay 分类作为 DisplayLayer 的 displayLayerDelegate,实现真正的同步/异步自定义绘制能力调度。开发者在自定义组件中使用时,可以通过实现 DXWidgetNodeAsyncDisplayProtocol 接口即可接入异步绘制,实现自己节点的异步绘制能力。

对外暴露 DXDisplayLayer 和 DXBaseView 两个基类,业务方可直接重写自定义节点 view 的 layerClass ,或是直接继承自 DXBaseView 来实现异步绘制相关能力。

通用富文本能力

DX 之前并未提供统一的通用富文本能力,由各个业务方封装自定义富文本组件。其中大部分都是为了完成业务方自身需求,具有较强的定制化属性,无法做到通用性,并且性能也无法保证。而通用富文本能力,天然可作为异步绘制框架在 DX 中的试验场。

众所周知,iOS 系统上的 UILabel 是在 CPU 上绘制成为一张 bitmap 后再交由 GPU 进行混合、合成等操作。而在大量图文场景,例如手淘信息流场景,含有大量价格标签和各种角标,富文本需求强烈。在这种情况下,文本绘制整体占比主线程 10% 以上。1、由于 UIKit 默认非线程安全,所以默认文本绘制均在主线程进行;2、由于 DX 渲染管线机制,需要先测量、布局、再渲染,在测量的过程中需要借助系统函数对文本测量,这也就导致了文本需要测量两次,对主线程占用较高,对帧率产生了较大负面影响。

iOS

在 iOS 上自定义绘制文本可选择 TextKit / CoreText,从 iOS7 开始,苹果提供了封装性更加好的 TextKit 供开发者使用,并且把 UITextView、UILabel 等内置控件的布局方式全部替换为 TextKit。

  1. CoreText 的特点是可定制性强,灵活程度高、使用 C 语言,直接与 CoreGraphics 交互,线程安全。但其测量计算均需要自己实现,计算出来的宽高可能与系统控件有差别,代码维护困难;
  2. 而 TextKit 的特点是面向对象封装性更强,API 均可在子线程方案,对上层开发者更加友好,在去年的 WWDC2021 中也进一步升级了 TextKit 能力。

基于以上特点以及 DX 富文本组件需求,并不需要特别复杂的布局,需要满足基础的图文混排及裁剪,我们选择使用 TextKit 实现富文本组件。

通用富文本能力整体分层结构设计如下:

  1. Basic层主要封装异步绘制的相关类,DisplayLayer及DXBaseView,其中主要由 DXWidgetNode 的 DXAsyncDisplay 分类提供异步绘制的能力;
  2. DXTextKit 层用于封装系统TextKit中的NSLayoutManager、NSTextContainer及 NSTextStorage,用于实现图文混排、自测自绘、点击长按等手势、文本截断等基础能力;
  3. WidgetNode层主要用于信息解析和节点装配;
  4. 最后真正的渲染层 RichTextView 作为绘制画布进行 bitmap 绘制,并根据需要实现一些自定义属性和能力。

Android

而在安卓上,Android 原生 TextView 其实际上底层都是基于 Layout 进行测量和绘制的。当传入SpannableString 时,Layout(TextLine)会解析 SpannableString 中配置的各 Style,绘制前会使用特定的 Span 修改 Paint 属性。DX 的富文本组件也是基于 SpannableString 方案,但并不直接使用 TextView,而是用底层的 Layout,View层上自己实现点击事件响应。一方面减少 TextView 中一些不必要功能的耗时,另一方面方便实现自定义截断等特殊功能,整体架构层级与 iOS 差别不大,此处不再赘述。

优化效果

借助通用富文本能力+异步绘制框架,iPhone6 上的复杂富文本场景下,平均帧率可保持在 55+,提升明显。

离屏资源管控框架

当前动图、视频均作为高频使用能力,在信息流场景中有较大占比,而同时,这类能力对设备 CPU 占用也是极为明显的,若不加以管控,较容易能够复现发热、降频、卡顿现象。在当前 DX 的服务范围涵盖集团内大部分 App 的背景下,了解到视频、动图等能力均为较高频使用能力,所以 DX 团队在框架层面抽象了一套通用播控框架,致力于解决当前这种多视频、多动图等场景下的播控能力缺失及 CPU 资源浪费问题。

设计原理

离屏资源管控框架整体采用分层设计:

  1. 底层核心曝光逻辑层主要实现核心消息转发流程,对业务原有逻辑无侵入性,基于该实现,监听 cell 生命周期进行自动曝光,针对曝光的 cell 还会进行时间校验和面积校验,在符合时间和面积条件后将节点信息传递至上层框架消费该消息。核心曝光逻辑层可单独作为通用曝光能力使用,并不依赖上层播控逻辑;
  2. 中间播控逻辑适配层主要重写代理类以支持与曝光不同的生命周期;
  3. 播控逻辑层实现了播控框架本身的主要能力:作为底层曝光逻辑层的代理,处理曝光逻辑层上报的 index 等信息,根据对应信息寻找上层对应消息接口的实现对象,建立消息通道连接;根据查找到的对应消息代理处理播控队列的建立和更新;播放和停止消息传递,播放完毕回调接收,控制播放队列进行下一元素播放;适配多容器场景,容器之间互相隔离,相同容器的不同场景之间互相隔离;

方案整体利用 OC 的 NSProxy 直接进行消息转发,采用 AOP 的形式,对原有逻辑流程无侵入性;对于同一个容器对象可注册不同场景,每个场景相互隔离,互不影响。

管控流程

播控框架提供两种播控方式供业务方自行选择,自动播控/手动播控。

当使用自动播控时,会监听卡片上屏/离屏等时机

当接收到上屏消息时

  1. 底层曝光逻辑会对卡片的面积和停留时间进行校验,对于停留时间和面积比符合条件的位置信息会进行记录并上报上层播控逻辑层;
  2. 上层播控逻辑层会根据该 index 寻找到当前卡片和其中符合条件的组件,可能为 1 个或多个,用户可根据配置调整顺序;
  3. 找到符合条件的播控节点后会判断当前可播控的个数与正在播放的数量比,如果符合则直接触发播放,若不符合将其放入队列后,等待播放;

当接收到离屏消息时

  1. 底层曝光逻辑层会找到当前容器中符合条件的位置信息并进行上报;
  2. 上层播控逻辑层会直接找到当前播控逻辑层记录的正在播放的对象和待播放的队列,将部分符合该位置信息的对象移除队列,其余对象仍旧正常进行播控。

手动播控会在用户触发时遍历当前屏上可见 cell,将其 index 主动进行上报。其余流程与自动流程一致。

优化效果

通过性能测试分析,视频资源比较多的时候对整体 CPU 影响极大,以 iPhoneX ,手淘首页信息流为例,如果不加播控能力在一屏有多视频场景下慢速滑动都可以容易复现出降频的场景。

加入资源管控后对视频 mediaServerd 进程在设备内整体占比有较大正向影响,影响也是跟播控时间停留筏值呈正相关。在视频较多的场景下,在加入播控后的 mediaServerd 在设备中的整体 CPU 占比可降为原来的 1/4 ~ 1/3。整体 CPU 资源优化 65% 以上。

优化效果总结

DX 的性能优化与业务方密不可分,借助于多个复杂业务方的性能问题分析,驱动 DX 的性能进一步优化,而 DX 的性能优化能力,也在第一时间输出业务方,为业务方解决问题。以订阅首页信息流为例,在 DX 的多种优化及业务方自己的部分优化结合后,在 iPhone6 低端机上,帧率从原来 30 帧左右提升到了平均 50 帧以上,性能提升不仅表现在数据上,用户体感也同样明显:

总结及展望

通过上述对 DX 最近一年来的性能优化总结,我们为 DX 解决了不少原有性能问题:圆角、渐变色、文本、图片组件等,也增加很多新的优化方式和可能性:管线异步化、异步绘制及富文本、离屏资源管控框架等。帮助手淘内外多个业务方排查和解决了不少性能问题,在多个复杂业务上真正帮助业务方实现了低端机平均帧率 50+ 的目标。但作为高性能动态化框架的代表,我们做的还远远不够,例如:

  1. 当前 DX 虽然是单向数据驱动,但对于富交互的动态卡片支持并不友好,数据流并未形成环,仅比较适合静态展示卡片,一旦涉及复杂交互,开发者就必须在客户端自定义处理事件。理想情况下,view 应该是状态的函数输出,而 view 也可以通过发出 action 来影响状态,而进一步自动改变 view 样式,在这种情况下,让单向数据流形成环,响应式就显得十分自然;
  2. 当前 DX 采用自定义 XML 并且使用逻辑与 UI 分离的形式描述 UI,这一方面是 DX 高性能的基础,避免了逻辑影响渲染流程。而另一方面,使用自定义非标准的 XML 描述 UI,形式过于陈旧,也阻碍了 DX 的通用性;逻辑与视图分离的描述形式,对模板开发者限制较多,也提升了模板开发者的成本。在响应式框架较为流行的当下,是否有一种方案可以让开发者比较自然的使用现代声明式 DSL 来描述视图和逻辑,并且能够持续保持 DX 的高性能水准;
  3. 当前 DX 的渲染完全依托于平台 NativeUI,基本无自绘能力。一方面,保障了使用系统组件时可以利用系统本身的优化特性及稳定性,例如列表容器的滚动优化,iOS 平台本身已经针对此做了大量优化和扩展点。但另一方面,完全依托于平台特性进行渲染也失去了自绘的诸多可能性,也难以完全保证双端一致性,跨平台的最终形态是否为自绘还未可知,后续是否需要在自绘和 Native 渲染结合进行深一步探索,支持在自绘渲染和 NativeUI 之间灵活切换以满足业务方的多种需求和跨平台支持。

性能优化方向没有止境,后续我们还会继续带着上述问题进行深一步探索和行动。