鸿蒙跨端实践-长列表解决方案和性能优化

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化

文章图片

鸿蒙跨端实践-长列表解决方案和性能优化
作者:京东科技 徐超
这是我参加创作者计划的第一篇文章 。


前言长列表是前端和客户端应用中最常见的业务场景 , 比如商品瀑布流等 , 有成千上万条数据 , 因此长列表的渲染性能在iOS , Android , Harmony , Web等各大平台都非常重要 。 HarmonyOS和iOS类似也提供了自己的解决方案 。 Roma(罗码)作为跨端平台 , 在此基础上进行了具体的实践 。 在实践过程中 , 遇到了各种问题和挑战 , 经历了ArkTS+C++架构向纯C++架构的转变 , 本文将围绕实践中的各种问题和挑战 , 探讨Roma的具体解决方案和优化思路 。
一、鸿蒙长列表解决方案及原理鸿蒙系统为List , WaterFlow , Grid等容器组件的数据加载和渲染提供了一次性加载方案(ForEach)和按需加载方案(LazyForEach)两种方式 。
1. 一次性加载方案(ForEach)?ForEach:一次性加载全量数据并循环渲染 。 原理如下:



??


(图片来自鸿蒙官网)
缺点:
1) 因为要一次性加载所有的列表数据 , 创建所有组件节点并完成组件树的构建 , 在数据量大时会非常耗时 , 从而导致页面加载渲染时间过长
2) 屏幕可视区外的组件虽然不会显示在屏幕上 , 但是仍然会占用内存 。 在系统处于高负载的情况下 , 更容易出现性能问题 , 极限情况下甚至会导致应用异常退出 。
实际业务中数据条数非常多 , 该方案存在很严重的性能问题 。 为了解决这个性能问题 , HarmonyOS提供了性能更好的解决方案
2. 按需加载方案(LazyForEach)?LazyForEach: 实现延迟加载数据并按需渲染 。 原理如下:
1) 根据屏幕可视区能够容纳显示的组件数量按需加载数据 。
2) 根据加载的数据量创建组件 , 挂载在组件树上 , 屏幕可以展示多少列表项组件 , 就按需创建多少个ListItem组件节点挂载在List组件树根节点上 。
3) 当组件滑出可视区域外时 , 框架会进行组件销毁以降低内存占用;当组件滑入可视区域时 , 需要从头完成数据加载、组件创建、挂载组件树这一过程 , 直至渲染到屏幕上 。



??


(图片来自鸿蒙官网)
LazyForEach实现了按需加载 , 针对列表数据量大、列表组件复杂的场景 , 减少了页面首次启动时一次性加载数据的时间消耗 , 减少了内存峰值 。 可以显著提升页面的能效比和用户体验 。 提升性能 , HarmonyOS又给出了两种优化手段: 缓存列表项(CacheCount) + 组件复用(@Reusable) 。
2.1 缓存列表项CacheCount如果只有懒加载 , 滑动速度过快时 , 则会导致数据来不及加载而出现“白块现象” 。 为了解决这一问题 , LazyForEach懒加载可以通过设置cachedCount属性来指定缓存数量 。 在设置cachedCount后 , 除屏幕内显示的ListItem组件外 , 还会预先将屏幕可视区外指定数量的列表项数据缓存起来 。 这样当缓存列表项需要从屏幕可视区外进入可视区内时 , 只用创建、渲染组件即可 , 相比不设置cachedCount提升了显示效率 。 (cacheCount具体设置多少 , 这里依然不详细展开 , 详见后续文章 。 )
原理如下:



??


(图片来自鸿蒙官网)
2.2 组件复用@Reusable由上文可知LazyForEach+cacheCount方案中 , 当组件滑出可视区域外时 , 框架会进行组件销毁以降低内存占用;当组件滑入可视区域时 , 需要从头完成组件创建、挂载组件树这一过程 , 直至渲染到屏幕上 。 而且列表页面很多列表项的UI样式完全相同 , 只有数据上的差异 , 如果能组件复用 , 就能节省组件创建的时间 , 因此就可以进一步提高列表页面的加载速度和响应速度 。
框架为我们提供了组件复用的能力 , 机制如下:
1)标记为@Reusable的组件从组件树上被移除时 , 组件和其对应的JSView对象都会被放入复用缓存中 , 复用缓存可以通过reuseId标记为不同的缓存池 。
2)当列表滑动新的ListItem将要被显示 , List组件树上需要新建节点时 , 将会从相应的复用缓存池中查找可复用的组件节点 。
3)找到可复用节点并对其进行更新后添加到组件树中 。 从而节省了组件节点和JSView对象的创建时间 。



??


(图片来自鸿蒙官网)


二、动态化的长列表解决方案结合上文HarmonyOS提供的解决方案 , 开始考虑动态化的长列表方案 。 通过前面鸿蒙跨端方案介绍文章 , 我们知道 , 跨平台框架的核心原理是通过JavaScript在JS引擎上执行时 , 对虚拟DOM进行操作 , 通过桥接或JSI与原生端进行通信 , 同时通过组件抽象 , 这些组件在不同平台上映射到相应的原生组件 。 运行时我们会有相应的节点树:JS虚拟DOM节点树 -> 原生端组件节点树 -> 原生端渲染节点树 。 长列表的渲染同样会涉及这三棵树 , 并且过程比较复杂 。
1. 移植iOS、Android方案到鸿蒙1.1 其他两端的方案原理?缓存池大小设置为最大N页 , 每个方向N/2页(这里的N和摩擦系数等因素有关 , 这里暂时不详细展开 , 后面有机会专门写文章分享)
?当组件滑出缓存区域外时 , 操作虚拟DOM树删除列表项节点 , 同时通过bridge在原生端进行相应列表项组件的销毁以降低内存占用;当组件滑入缓存区域时 , 操作虚拟DOM树添加列表项节点 , 同时通过bridge在原生端进行相应列表项组件的添加 , 这里从虚拟DOM节点到原生端的组件 , 都需要从头完成组件创建、挂载组件树这一过程 , 直至渲染到屏幕上 。
?原生端列表的reuseId是一个不会重复的唯一值



??


该方案已经被京东金融业务100+页面使用 , 在复杂的列表页面性能表现也非常好 。 优点也是显而易见 , 由于跨端的核心原理决定了我们必须操作VDOM节点树和组件树 , 过程中涉及JS线程和UI线程的频繁通信 , 最终行为是否一致 , 是否能达到我们想要的结果 , 这个过程涉及的细节非常多 , 因此一个简单的逻辑是保证正确性的比较好的手段 。 这当然也得益于iOS和Android系统本身性能的优越 。 从上文可知我们其实无论在VDOM节点树中 , 还是原生端组件树中 , 新的VDOM节点/列表项组件创建或删除的时候 , 都没有复用节点或者利用系统本身的组件复用的能力 , 只有新创建和真删除 , 这种逻辑就非常简单明了 , 不容易产生bug 。 但是从头创建的过程会依赖系统本身的性能 。
1.2 移植后存在的问题然而 , 当我们把同样的方案移植到HarmonyOS上之后 , 使用ArkUI框架开发 , 发现肉眼可见的卡顿 , 抖动等掉帧现象非常严重 , 因此我们开始排查原因 。 并与iOS和Android系统进行对比分析 , 经过分析我们发现主要存在以下3个问题:
?UI层级过多 。 在ArkUI框架实现下 , 自定义组件本身必须增加一个包裹的容器 , 比如一个类似RomaDiv这样的业务里最常使用的 , 数量最多的自定义容器组件 , 里面必须有个类似Stack/Flex这样的容器组件才合法 , 因此这个组件本身就已经是两层了 , 比其他系统就多了一层 。 另外有些容器组件还有系统本身生成的类似__common__ 这种层级 , 也会导致层级变多 。 层级过多 , 每次创建 , 渲染过程中的计算就更多 , 耗时自然就更长 。
?跨语言通信链路长 。 原生组件的UI是基于ArkUI实现的 , 运行在方舟虚拟机中 。 JS代码运行在系统的JSVM中 , 在C++端 , 两种语言通过系统提供的NAPI通信 , 其中涉及各种数据类型转换 , 成本自然比其他系统要高 。 尤其在UI层级多的情况下 , 成本就更高了 。
?系统二次布局的问题 。 动态化系统架构中有三个核心线程:UI主线程 , JS线程和布局计算的线程 。 布局方案采用的是yoga布局 , 可以高效地进行组件的大小 , 位置的计算 。 但是系统在此布局之后还会重新进行布局一次 , 这个开销就完全没有必要 , 但是却增加了耗时 , 影响了性能 。
针对这几个问题 , 经过和华为专家沟通以后 , 建议我们直接使用C-API开发 , 但是经过深入开发和沟通之后 , 发现C-API目前尚有功能欠缺 , 而且文档不完善 , 不能满足我们当下的所有需求 , 因此我们决定支持ArkTS版本和C-API版本两个版本 , Q3先上线ArkTS版本 , 同时开发完CAPI版本 , 待华为进一步完善C-API后 , Q4上线 。


2. ArkTS版本解决方案在已经存在以上问题的前提下 , 我们需要尽可能的提高列表性能 , 创建慢的问题 , 首先考虑到的就是reuse的思路 。
2.1 ArkTS方案原理?原生端UI完全依赖系统提供的懒加载LazyForEach + 缓存列表项CacheCount + 组件复用@Reusable , 其中复用的reuseId设置为具体缓存池的类别 。
?虚拟DOM节点的创建 , 复用 , 回收和销毁的时机完全与原生端UI相对应的时机同步 。 由于ArkUI是声明式语法 , 因此整个过程是先由原生端触发UI占位 , 然后在对应的生命周期上相应的操作VDOM , 再通过JSI&NAPI与原生端通信 , 更新原生端组件 。





??




这个方案是真正做到了reuse/recycle的长列表 , 做到了比较丝滑的体验 。 但是由于有了recycle/reuse的过程 , 也增加了更多的复杂性 , 有很多细节需要处理 。
2.2 重点优化点1)更新数据后UI“闪”的问题 - 不要改变键值key + @ObjectLink + @Observed
这个问题的根本原因是lazyForEach的迭代器key generator的键值key发生了变化 。 如果键值key发生了变化 , 框架会将这个变化的组件整体先回收 , 然后再重新创建 。 经历这一个过程就会出现“闪”的问题 。
而且 , 改变键值key去刷新UI的方式代价很大 , 同一类别的列表项的结构非常类似 , 只是显示的文本和图片等不一样 , 不变化的组件不需要重新创建 , 只需要更新变化的部分即可 。 这种情况框架提供了装饰器@Observed和@ObjectLink , 可以监听变化的部进行局部更新 。 同时 , 复杂列表情况下 , 数据源大多都是多层嵌套的对象结构 , 建议使用@ObjectLink而不要用@Prop , 因为@Prop会进行深拷贝 , 会增加创建时间及内存的消耗 , 开销较大 , 而@ObjectLink指向数据源的指针 , 双向同步数据 , 因此这种情况下性能更优 。
2)刷新/更新数据后 , 数据先展示其他的数据然后快速再刷成最终结果
? 不要更新(可见+cacheCount)范围内的组件的键值key , 此范围外的部分改变键值key
?手动调用列表组件的方法只更新(可见+cacheCount)范围内的组件和对应的VDOM节点
首先产生这个问题的原因还是由于key发生了变化 , 每次重新创建的时候 , 如果当前类型的缓存池有数据 , 就从缓存池取出复用 , 然后再更新变化的部分 。 这个从缓存池取出的组件仍然带有原来的数据信息 , 因此我们会看到先展示其他数据然后再被刷成最终结果 。 为了避免这个现象 , 首先还是不要改变key 。 在UI上就是已经渲染了的那些组件 , 也即可视加上cacheCount范围内的组件 。 同时对此范围内的组件手动调用组件的更新方法 , 更新组件 , 这时JS引擎会对这个节点进行diff , 把变化的部分通过JSI与原生端通信 , 原生端完成最终UI的更新 。 范围外的部分就按需更新key和数据源 。


3)有些列表滑动过程中仍有卡顿现象
? 没有正确使用组件复用 - 使用了组件复用 , 实际上是无效的复用 , reuseId设置一定要正确 , 且必须为字符串类型

复用类型
描述
复用思路
标准型
复用组件之间布局完全相同
标准复用
有限变化型
复用组件之间有不同 , 但是类型有限
使用reuseId或者独立成两个自定义组件
组合型
复用组件之间有不同 , 情况非常多 , 但是拥有共同的子组件
将复用组件改为Builder , 让内部子组件相互之间复用
全局型
组件可在不同的父组件中复用 , 并且不适合使用@Builder
使用BuilderNode自定义复用组件池 , 在整个应用中自由流转
嵌套型
复用组件的子组件的子组件存在差异
采用化归思想将嵌套问题转化为上面四种标准类型来解决
无法复用型
组件之间差别很大 , 规律性不强 , 子组件也不相同
不建议使用组件复用



?标准型



?有限变化型



?组合型



?全局型



?嵌套型
此外 , 如果使用if/else条件语句来控制布局的结构 , 会导致在不同逻辑创建不同布局结构嵌套的组件 , 此时我们应该使用reuseId将if/else条件语句拆分为不同结构的组件
? 优先使用@Builder替代自定义组件@Component , 减少嵌套层级
ArkUI中使用自定义组件时 , 在build阶段将在在后端FrameNode树创建一个相应的CustomNode节点 , 在渲染阶段时也会创建对应的RenderNode节点 。 会造成组件复用下 , CustomNode创建和和RenderNod渲染的耗时 , 因此应该优先使用@Builder 。 同时减少一个自定义组件 , 也就是减少一次aboutToReuse的回调 , 也会节省耗时 。
?避免不必要的状态变量刷新 , 使用AttributeUpdater更新组件属性
?避免对@Link/@ObjectLink/@Prop等自动更新的状态变量 , 在aboutToReuse方法中再进行更新
?避免使用函数/方法作为复用组件创建时的入参
【鸿蒙跨端实践-长列表解决方案和性能优化】?避免在列表滑动过程中做大量计算或者耗时长的操作
?可以结合列表预加载 , 布局优化等其他常规手段进一步优化体验
3. C-API版本解决方案上文中我们已经提到CAPI的方案能解决UI层级过多 , 跨语言通信链路长两个核心问题 , 同时也减少了状态变量维护相应的耗时 , 是我们最终的解决方案 。 C++端我们还是采用了recycle/reuse的方案 , C-API实现上我们需要自己实现类似lazyForEach的能力 。
3.1 C-API方案原理? 系统提供了一个ArkUI_NodeAdapter对象来管理容器的子组件 , 这个对象类似事件的机制 , 通过相关事件通知按需生成组件 。



??


(图片来自鸿蒙官网)


?在监听事件的回调中处理创建 , 回收 , 复用 , 删除等逻辑 。



??




3.2 部分核心代码有兴趣的同学可以私下联系我 。
4. 性能对比分析使用JR APP购物车页面(页面结构较复杂) , 400条数据 , 分别用三种方案以及优化后测试 , 测试结果如下:
方案
ArkTS Create
ArkTS Reuse
C++ Reuse
完全显示所用时间
1s 804ms
1s 321ms
977ms
丢帧率
12.1%
0.0%
0.0%
独占内存
45.1M
42.3M
40.2M
测试结果表明 , lazyForEach , 组件复用 , cacheCount , 预加载等等这些方法的确提高了性能 , 尤其是滑动过程中出现的明显卡顿现象 , 同时减少UI层级 , 不跨语言通信能进一步提高性能 , 带来更好的体验 。


三、总结本文通过图文的方式介绍了HarmonyOS的长列表ArkTS解决方案以及原理 , 同时结合实际的实现过程介绍了ROMA动态化长列表的ArkTS和C++解决方案 , 相应的重点优化细节以及部分核心源码 , 最后对两者进行了性能对比分析 。
如果大家觉得有帮助 , 千万别忘了点赞+收藏 , 方便以后随时阅读!
动态化是一个涉及JavaScript、C++、iOS、Android、Java、Harmony、Vue、Node、Webpack、Shell等众多领域的综合解决方案 , 我们有各个领域优秀的小伙伴共同前行 , 大家如果想深入了解某个领域的具体实现或者提出宝贵意见 , 可以在评论中给我留言 , 随时交流~!

    推荐阅读