从富文本窥探苹果的代码秘密
背景
在我们的业务场景下,为突出诸如 “利益点”和“利率” 等特性以推动订单成交,引入了 “富文本” 这一概念。富文本具备丰富格式的文本展示与编辑功能。然而,恰是由于富文本具有 “多样式”“复杂排版” 等特质,致使其在复杂元素渲染过程中会耗费更多系统资源。相较于简洁的纯文本,富文本在加载与显示时或许会产生延迟现象,尤其是处理大量富文本内容或在较老旧的 iOS 设备上,延迟表现得更为显著。我们项目内长期存在这一问题,对用户的使用体验及交互效率造成了一定影响。
现状:
直接上视频:
注意看文案:“注意看我 我会闪烁...”这句话,可以非常直观的看到,伴随着每次的刷新,中间侧的富文本都会有一个闪动。
为什么会产生这种情况?
富文本包含多种格式信息,如字体、字号、颜色、段落样式、对齐方式、图片、表格等。简单的纯文本可能只存储字符编码序列,而富文本要记录每个字符或段落对应的格式属性。以 HTML 为例,一段带有加粗、斜体和不同颜色的文本,会有大量的标签(如<b>、<i>、<span style="color:red">等)来描述这些格式。在解析富文本时,软件或系统需要花费更多的时间和计算资源来解读这些格式标记。需要识别每个标签的含义,按照标签要求正确地显示文本内容。
仅将标签式的 HTML 格式转化为能够被 iOS 系统直接加载的 UI 控件,就已经是一种对系统资源消耗极大的情况。但在我们的项目中,为了推动订单达成交易转化,非常频繁的使用到了“删除线”“下划线”等元素。在iOS视图的叠加逻辑下,这会非常平常频繁的触发一个iOSer的噩梦 ——离屏渲染。
离屏渲染:
在大部分计算机视图的叠加中,都遵循下图,油画算法。
油画算法(Painter's Algorithm)也被称为画家算法,是一种在计算机图形学中用于解决可见性问题的图形渲染算法。其基本思想源于传统绘画过程,就像画家在作画时,先画远处的背景,再画近处的物体,这样近处的物体自然会覆盖远处的部分,从而确定最终画面的可见部分。
图2.油画算法
上面这段话是GPT写的,说人话就是:先画山(最底层/最远处),再画草地(第二层),最后画树(最顶层)。
这样的好处是:当渲染较近的树木时,其像素会覆盖掉之前渲染的山川在相同位置上的像素,从而模拟出近物遮挡远物的视觉效果。并且它主要依靠物体的深度排序来确定渲染顺序,在物体数量较少、深度关系简单的场景中,计算资源的消耗相对较少。
但如果在完成树的绘制之后,我们又想要改变山的形状,颜色,这个时候视图“山”,已经被草地和树遮盖住了,无法直接修改。而iOS对此的改进措施既是:离屏渲染。
正常渲染的流程是:APP中的数据经过CPU计算和GPU渲染后,将结果存放在帧缓冲区,利用视频控制器从帧缓冲区中取出,并显示到屏幕上。
离屏渲染(offscreen-rendering)顾名思义为屏幕外的渲染,即渲染的结果不会直接呈现到当前屏幕上,而是等待合适的时机才会被显示。譬如上述“完成树的绘制后,又要改变山的背景”,计算机是无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。
在先前对 iOS 官方文档的研读过程中,得知仅有某些特定场景,例如圆角、遮罩、透明度等会触发离屏渲染,而对于富文本是否会触发离屏渲染,并没有得到苹果官方的验证。但在借用OffScreen 等工具的帮助下,确认了这一点(标绿的即为首页卡片中触发了离屏渲染的场景)
(有背景色的即为触发离屏渲染的场景)
GPU 操作高度流水线化,正常时向帧缓冲区(frame buffer)有序地输出计算工作,当突然接收到"输出到另一块内存"的指令时,流水线中正在进行的所有操作被迫丢弃,转而服务于当前的这一操作指令。完成后,再将计算好的所有内容copy回帧缓冲区(frame buffer)并清空临时内存。在这个操作中,系统会做以下几件事
- 开辟一个临时的空间。
- 上下文切换
- 内存拷贝
- ...
以上每一条对CPU & GPU来说都是极其承重的包袱。分析这正是导致项目中的富文本出现闪烁的原因之一。
层级嵌套过深
在其它需求开发的过程中,针对首页卡片的视图层进行了剖析与梳理。结果令人震惊,仅仅一个首页卡片,其层级嵌套竟然高达 9 层。如果将 iOS 本身的 window 等系统层级也计算进来,那么就会有十几层的嵌套。如此高的层级嵌套很难不引发性能问题。
(虽然马赛克,但是不影响我们理解视图层级之深对吧)
其次,我们项目中为了解决"多设备"”多分辨率视图“的UI问题,引入了三方库”Masonry“。
Masonry 是一个轻量级的布局框架,它使用简洁的链式编程语法来创建和更新视图布局。提供了强大的自动布局功能,能够很好地适应不同屏幕尺寸和设备方向。但是在诸多的优点下有一个非常致命的缺陷:在一些非常复杂的布局场景中,大量使用 Masonry 会导致性能下降。因为每次更新布局时,Masonry 都需要重新计算和调整视图的约束,会消耗较多的计算资源。
分析是导致项目中的富文本出现闪烁的原因之一。
改进措施
改进UI层级?
理论上这是最好的解决办法了,但是贸然去改动UI层级是非常有风险的一件事,并且冗长的工期怕也是产品不能接收的,测试同学也得执行一遍所有的用例。为了一行富文本的展示,去站到代码质量,产品,测试的对立面,确实得不偿失。
预排版,提前计算?
Masonry 在多层嵌套的UI层级下有性能问题的核心原因是:
为了正确地应用约束和渲染视图,Masonry 需要遍历整个 UI 层级结构。在多层嵌套的情况下,遍历的路径变长,深度增加。就像在一个有很多分支的树形结构中寻找叶子节点一样,需要花费更多的时间来遍历每个节点。那么我们把这个计算过程置前,或者说,这个计算过程由我们自己来计算,不再交给Masonry 处理。
图7.预计算
业务场景下,富文本最多会由两个”子富文本“ & 一个”分割线“的image拼接而成。这里根据不同的情况, 提前对子view的位置进行了计算。直接赋值给Masonry 去布局。
采用更为轻量级的富文本对象
渲染过慢的原因之一,富文本对象是一个非常重的对象,通常包含了大量的属性和信息。例如,除了基本的文本内容外,它还可能有字体、字号、颜色、段落格式、对齐方式、链接、图片、表格等诸多属性。这些丰富的属性使得富文本对象在存储和处理时占用大量的资源,就像一个装满各种复杂工具和材料的大箱子。若要减少系统处理的信息量,只使用需要用到的属性即可,就像是只从大箱子中挑选当前任务所需的工具和材料。譬如只是简单地显示一段富文本的标题部分,只提取文本内容和字号、字体等基本属性进行渲染,就可以避免处理那些与当前任务无关的链接属性、复杂的段落缩进等属性,从而加快渲染速度。
但是他的维护成本真的是太太太高了。首先,确定哪些属性是真正需要用到的这个过程本身就需要耗费大量的精力。其次,iOS的系统更新对开发者来说就是纯黑盒,我们无法猜测apple官方会在什么时间点针对富文本新增or删除什么样的属性。最重要的,我们并不知道富文本内部属性的关系和依赖。例如,如果只选择了字体和字号属性进行渲染,但是在某些情况下,字体颜色也可能会影响到显示效果,这就需要额外的代码来判断是否需要添加字体颜色属性。这种复杂的逻辑关系会使代码变得难以理解和维护,bug率必定飞升。
离屏渲染的避免 or 减少?
得益于iOS对系统安全的绝对保护,iOS代码是不开源的。我们并不能直观的看到iOS离屏渲染的执行情况。所以要想直接第一角度为离屏渲染减负是非常宽泛,复杂且不现实的事儿。既然减少不了单次离屏渲染的耗时,那就珍惜GPU成果,将其缓存下来,以减少离屏渲染的频次。
这里简单阐述一下项目中富文本的逻辑,服务端给到的富文本有两种情况
- 一条富文本文案。
- 两条富文本文案拼接起来的,中间用 && 进行分割。
针对这种情况,添加了两层缓存。第一层缓存仅记录最近一次富文本的结果,不对&&的情况进行区分。缓存中记录了普通文本对象、计算后产生的富文本对象、富文本布局位置等信息。第二层缓存是一个key-value的字典数据结构,同样保存上述所有信息。不一样的是,二级缓存所有已经计算过的富文本。防止出现:当富文本是由A,B两条富文本拼接而成的,A有改动,B没有改动的情况,A从缓存中取值,B重新计算。最小化CPU的操作频次。
核心代码:
图8.缓存
成果
一套组合拳下来,效果显著,直接上图
当刷新时富文本文案没有变动:
当刷新时富文本文案有变动:
左半部分富文本文案不变动,右半部分富文本文案新增
易用性封装
为了便于日后的富文本场景的简易开发,封装了PPDHTMLLabel,可以直接通过一个方法实现一个不闪动,高性能的富文本视图。调用方法如下:
无心插柳柳成荫:
上述一套操作下来,帮CPU + GPU减负很多,将视图的性能消耗降低在了触发离屏渲染的阈值以下。
猜测小case
在写这篇文章的同时,发现了一个非常有意思的case
为了更清晰的展示富文本的闪动,我将首页的刷新动画慢放了16倍。发现在富文本没有被计算出来之前,苹果为了不出现白屏的情况,会先把渲染的文本直接赋值上页面上。
最重要的几帧见下图:
一个更大胆的猜测是,这种富文本的耗时计算,甚至并不是,在子线程中计算然后callback回来主线程刷新UI。而是直接在主线程计算并刷新的!!!因为在展示普通文本的那几帧画面时,这个页面是完全卡死的,没有任何动画效果,这非常符合主线程做耗时操作卡死UI的特征。看来苹果都有垃圾代码,那我写点bug也是情有可原的吧。(手动狗头) 玩笑归玩笑,其实这种表现倒也是符合Apple近年来的策略方针,Apple一直在致力于推进SwiftUI,这种webView + H5方案或者标签语言转富文本的操作与基于原生的SwiftUI是互为对立面的。那苹果对这方面不上心也就情有可原了。
欧盟努力了十多年,致力于推动苹果将 Lightning 充电线改为 Type-C 。终于在23年的9月份。在iPhone 15系列机型上成功落地。希望反垄断组织继续努力,早日督促苹果开源,在我”有生之年“可以验证下自己的猜测。
作者简介
nuc_zb,移动研发高级工程师
招聘信息
拍码场