本文来自于腾讯Bugly公众号(weixinBugly),未经作者同意,请勿转载,原文地址:https://mp.weixin.qq.com/s/FbiSLPxFdGqJ00WgpJ94yw
导语
精神哥前阵子去参加了好友小青在北京办的T沙龙,探讨移动端热更新相关的话题。Bugly 曾为大家介绍过不少腾讯内部的热更新的框架,正好这次看到了美团,去哪儿以及微博同学在应用热更新方面的实践。
上周为大家整理了《美团大众点评 Hybrid 化建设》,本周我们继续带来“去哪儿网 无线 iOS 技术总监”分享的《跨平台 ListView 性能优化》。
正文
大家好!今天由我来分享《跨平台 ListView 性能优化》主题。
先介绍一下自己,我叫姜琢,2011年加入去哪儿网,在从2013年开始负责酒店的 iOS 团队,平时我会关注包括像跨平台,iOS 架构以及客户端基础设施的一些技术方向。
Qunar 的 RN 之路
回到我们今天的主题,今天的主题主要是讲 Native,对于 RN,其实在做 RN 之前我们一直都在用 Hybrid。对于 Hybrid,可能在当时我们觉得没法达到与客户端体验一致的效果。所以看到 Native 创新的架构出来以后,我们团队把很多的时间放在这个上面去做一些研究。
在2016年3月份的时候,当时 RN 的版本是 0.22,我们第一个承载业务的版本上线了。
第一个业务我们做的是在酒店的客户端首页进去的首页,因为这个页面本身于酒店来说,其实还挺重要的。但在当时,要把这个页面改成更适合于运营的一个方向。而且之前的和现在RN的代码冗余比较大,正好考虑重构,所以把它共享出来,有一些风险,新的技术用了这么重要的一个页面上面,所以我们当时也做了一些热修复,或者说热替换的这样一个方式。
到现在为止,去哪儿旅行中酒店业务总共大约有18个页面采用了 RN 的方案来做。这就是当前 Qunar 在 RN 上面的一些数据。
RN 的 ListView 是如何做的
刚才说我是2011年开始做 iOS,在当时作为一个 iOS 程序员可以用一句话概括:
当时所有的 APP 都是在使用 TableView 来做主要的页面设计。
对于2016年,如果你是一个 RN 开发的话,会产生一个疑问:
如果你学会 ListView,是不是就学会开发RN了呢?
我觉得还不完全是,大家可能也看过很多 RN 性能相关的文章,都提到了 ListView 的性能问题,我们需要了解这些问题产生的原因,才能更好的去优化并使用 RN。
1. RN 如何实现的 ListView?
我们先了解一下 RN 到底如何来实现 ListView 的。
首先RN的 ListView 其实是基于 RN 的 RCTScrollView 来实现的。它也实现了类似 UIKit 中通过 DataSource 来控制数据,以及是否要做一些界面的刷新。
它还有一个很重要的特性,是从 RN 的 RCTView 里面继承的一个特性
当 removeClippedSubviews
等于 true ,listview进行滑动的时候,RN会把界面上已经移到页面之外的从你的父视图上面移出去,他所有在外面外的子视图都会做 removeFromSuperView,他调的方法就是 updateClippedSubviews
。
更直观一点看,我使用到了新的 XCode 的 View Memory Graph Hierarchy 工具,当你在屏幕上,大家可以明显的看到,这个View会有一个 RCTView 会引用它。当这个 View 被移出屏幕之外,再观察他的内存引用时,它就只被 RCTUIManager 引用了。
RN 为什么没有去把这个 View 释放掉,而是被 RCTUIManager 来持有?RN 为了能够保持一定的 UI 上的性能,他用 UImanager 来管理所有的 UI 元素,只要创建过的,还有可能被显示在界面上的东西,他都用这个 UImanager 来去管理,从而在进行 Dom Diff 时能够减少 View 的创建和销毁。
2. ListView 多做了什么?
然后,我们再来看看 ListView 本身比 RCTScrollView 多做的哪些东西,首先 ListView 包含两个属性 --- initialListSize
和 pageSize
,initialListSize
决定了第一屏加载item的数量,pageSize
则是当你需要加载更多的时候,每次需要载入多少的item,这样做的主要目的在尽量减少你手机加载第一屏时所需要的时间。
还有就是它还实现了从JS端实现了 Section Header,Header,Footer 的封装,以及实现了监听 onScroll
事件,随着 View 的滚动动态的添加 row view。
3. 相对于 TableView 少了点什么?
那么ListView相当于UITableView少了一点什么呢?
怎么没有提到复用?
在ListView官网上面找了一个ListView的例子,这个例子有一行,我用红色的框标出来,他用了一个叫 RecyclerViewBackedScrollView
,如果大家对Android有一点了解的话, RecyclerView 在Android上是在列表上面用来做重用的一个控件。
4. RecyclerViewBackedScrollView 是什么?
那么 RecyclerViewBackedScrollView
是如何实现的呢?我们需要去看下他的源码。
我们先看一下 iOS 的 JS,JS里面只有一行代码
module.exports = require('ScrollView');
里面什么都没做,RecyclerViewBackedScrollView
和 ScrollView
完全是一个东西,我觉得好像 RN 只是埋了一个坑期望社区在社区的演进中解决。
我又看了一下Android的,Android里面的代码做了什么事情呢?
就是它确实引入了一个原生的 RecycleView 来去做布局,那么再深入来看一下,Android在 Native代码中是怎么来做的,我们我们重点看 onCreateViewHolder
和 onBindViewHolder
的实现
在 onBindViewHolder
他做的一件事情,传入 item 的 Position,从 mViews
中获得这个row的view对象
我们再看一下 mViews
是什么东西,他是一个数组,他的元素都是在addView
时加入到对应的 index 上的,而 index 就是 item 的 Position,说明他只是把实体的 row 通过 index 缓存起来了而已,并没有实现复用。
解决方案
基于RN的复用问题,在去哪儿我们做了两个方向的尝试。
前端的同学觉得我们可以改进 RN 中 ListView 的 JS 实现,通过在 onScroll 事件中将被移除出去的 Cell Dom 元素通过 JS 把他们移动到需要复用的位置上
而客户端的同学认为通过把 UITableView bridge 到 RN 中可以解决这个问题。
1. 用JS写一套Cell的重用逻辑
先说说前端的想法,我们实现完了之后,它实现的方式是说,也是基于 RN 的 ScrollView,我们也监听 OnScroll(),哪些 View 可以补上来?
他往上滑的时候,我们需要把上面的 cellComponent 挪下来,挪到上面去用。但是这个方式最终的效果并不是特别好。
缺点
问题在于,如果我们所有的 Cell 都是一样高的,里面的元素不是很多的情况下,性能还相对好一些,我们每次 OnScroll
的时候,他处理的Cell比较少。如果你希望有一个界面滚动能够达到流畅的话,所有的处理都需要在 16ms 内完成,但是这又造成了 onScroll
都要去刷新页面,导致这样的交互会非常非常多,导致你从 JS,到 native 的 bridge 要频繁的通讯,JS 中的很多处理方式都是异步的,使得这个方案的效果没有达到很好的预期。
我们再看看客户端同学想出来的办法,Bridge 一个 UITableView 到 JS 环境中。
2.Bridge 一个 UITableView
在RN中我们要 bridge 一个 RN 的 View 组件,我们需要实现 RCTComponent 这个 protocol,这里有两个很重要的方法
- (void)insertReactSubview:(id<RCTComponent>)subview atIndex:(NSInteger)atIndex;
- (void)removeReactSubview:(id<RCTComponent>)subview;
这两个方法是 RN 做 Dom Diff 的关键
什么是Dom Diff呢
在界面发生变化前,界面存在一个 Dom Tree,发生业务变化之后是另外一个 Dom tree,Tree中的每个元素都有自己的引用值,Diff 其实就是找出两个 Tree 的差异点来确定需要进行更新的节点。最终确定一个需要插入和删除的 View 的列表,并通知相应的 Dom 节点来处理。
但是RN的UI处理方式和原生对UI处理完全不一样,我们如何 Bridge 一个 TableView 呢,我们想到了一个方法。
我们创建一些 VirtualView,他只是遵从了 RCTComponent 协议,他其实并不是一个真正的 View,我把它形成一个组件,把它 Bridge 到 JS,这就使得,你在写 JSX 的时候,就可以直接用 VirtualView 来去做布局了。在RN里面做布局的时候我们用VirtualView来做布局。但是最终在 insertReactSubview
时,我们把这些 VirtualView 当做数据去处理,通过 VirtualView 和RealView 的对应关系,把它转化成一个真实的 View 对象添加到 TableView 中去。
用这个图来说,更清晰一些。
首先我们写的是一个 JSX,React 把它转化成 Dom Tree,在进行 Dom Diff 后,React 会调用 insertReactSubview
传入 VirtualView,我们通过 VirtualView 生成 Tree Data,
通过 VirtualView 和 RealView 的对应关系,我们创建 RealView 去真正的添加到原生的 View 上。
但是这里又产生另外一个问题,大家会自定义一个 cell 的一个对象来去做的。这个对象,能够接收你特定的数据,对这个 cell 重新去 set 一些控件的值,然后把界面更新。
但是在JS里面我们并没有办法这样做,在 RN 中,我们不可能动态的去往 Native 里面去加一个类。
那么我们是如何做到,在复用的时候对于 Cell 上面的子View能够去设置更新他的数据?
我们在所有子 view 上面我们也加上了 tag 属性,在更新数据的时候我们通过 tag 找到更新的子 view上面的 view 对他做数据的更新的。所以并不是只有Cell有这样的tag,包括子 view 也会有这样的 tag,这样就做到了可以获取到对应 tag 的子 view 并对子 view 的数据进行更新。
最后,为了客户端的同学在使用这个 TableView 时更好上手一些,我们把几乎整套的 TableViewDataSource 方法,全部照搬到了 RN 中,所以我们在创建这个 ListView 的时候我们需要去设置很多的回调方法,这样做也是为了能够更快的做一些界面的迁移工作。
缺点
前面说了这个东西怎么来做的,我们来说一下这个东西的缺点,或者说他的限制,首先既然它需要做映射,我们肯定需要做一个 Virtualview 到 NativeView,大多数的 cell 里面如果做展示来用的话,Label 和 Image 基本上能够满足大多数的需求了。所以我们现在只是做了 Label 和 Image 的对应工作,但在RN的一些官方控件,在这个 view 里面都是没法直接使用的。
还有一个缺点就是说,因为我们是按照 TableView 的逻辑去做的,这个逻辑其实在 Android 上可能不适用,因为 Android 的 ListView 实现跟iOS完全不是一个逻辑,导致使用这个 ListView 的 RN 代码,可能没法直接应用到 Android 里面去。
关于这个控件的话,其实在我们首页的两个子页面上都有使用,一个是酒店的城市的页面,还有酒店的整个收藏的页面。
关于 Tableview 往 ListView 上过渡,还有一个 github 的项目。
react-native-tableview
https://github.com/aksonov/react-native-tableview
两种UITableView实现差别
同样是 Bridge UITableView,这个开源项目跟我们的实现方式还有一点差别,它在考虑使用组建这块的时候,对于每一个 Tableview,他都是用 RCTRootView 做基础的 contentView,他对于每一个 cell,他都有一套 JS 和 Native 的 Bridge。我们就觉得这样的方式稍微来说有点重。但是它的好处在于,在RN里面所有我们注册的控件都是直接可以使用的,相对来说灵活性更强。
这个开源组件还有一个复杂的地方在于,对于每一个重用 cell,我们在去做写RN的代码的时候,我们都要注册到 RN 的 AppRegistry 里面去,他需要注册组建把它当做一个独立的组建去使用。
这里有一个截图,他需要注册每一个 TableviewCell 去做他的组建。
Weex 的 ListView 又是如何做的?
最后我们来看一看 weex 在 RN 的基础上做了优化开发以及优化更多的思考。
weex 的 ListView 是通过原生来实现的,而且它是在Android和iOS两端都是原生的,即使是两个平台实现不太一致的地方也在 JS 端进行了统一,比如 iOS 的 Section Header,Android SDK 中没有相关的实现,weex 就引入了 StikyHeader 来实现。
那么Weex实现Cell复用了么?
回到刚才说的复用问题,Weex 到底有没有实现复用呢?
我们跟着代码看一下,这个是weex 在 iOS 上的实现。
在 cellForRowAtIndexPath
中,weex 使用了统一的 reuseIdentifier。但我们注意这样一个方法
WXCellComponent *cell = [self cellForIndexPath:indexPath];
通过 indexPath 拿到一个 cell,会不会里面实现了复用呢?
这段代码也只是通过 Section 和 Row 获取到了一个 CellComponent 对象。所以他仍然只是一个缓存,那么缓存,他就是把所有的 Cell 都缓存起来而已。它仍然没有达到复用的一个效果。
但是后来我又看了看Android, Android的实现有些不同
首先它用了 recyclerView,我们找到了 weex 实现的一个方法 generateViewType
在 weex 代码里面从 JS 端可以设置一个叫做 scope 的一个属性,Recycview会调用 getItemViewType` 来获取对应 position 的 viewType
@Override
public int getItemViewType(int position) {
return generateViewType(getChild(position));
}
private int generateViewType(WXComponent component) {
long id;
try {
id = Integer.parseInt(component.getDomObject().getRef());
String type = component.getDomObject().getAttrs().getScope();
if (!TextUtils.isEmpty(type)) {
if (mRefToViewType == null) {
mRefToViewType = new ArrayMap<>();
}
if (!mRefToViewType.containsKey(type)) {
mRefToViewType.put(type, id);
}
id = mRefToViewType.get(type);
}
} catch (RuntimeException e) {
WXLogUtils.eTag(TAG, e);
id = RecyclerView.NO_ID;
WXLogUtils.e(TAG, "getItemViewType: NO ID, this will crash the whole render system of WXListRecyclerView");
}
return (int) id;
}
然后通过 ViewType 来创建 ViewHolder,在复用时调用 onBindViewHolder
来更新数据
@Override
public void onBindViewHolder(ListBaseViewHolder holder, int position) {
if (holder == null) return;
holder.setComponentUsing(true);
WXComponent component = getChild(position);
if ( component == null
|| (component instanceof WXRefresh)
|| (component instanceof WXLoading)
|| (component.getDomObject()!=null && component.getDomObject().isFixed())
) {
if(WXEnvironment.isApkDebugable()) {
WXLogUtils.d(TAG, "Bind WXRefresh & WXLoading " + holder);
}
return;
}
if (component != null&& holder.getComponent() != null
&& holder.getComponent() instanceof WXCell) {
holder.getComponent().bindData(component);
}
}
我们再进入到 Component 的 bindData
方法,发现他最终通过 updateProperties 将 Component的属性设置到 ViewHolder 的子控件上
public void bindData(WXComponent component){
if(!isLazy()) {
if (component == null) {
component = this;
}
mCurrentRef = component.getDomObject().getRef();
updateProperties(component.getDomObject().getStyles());
updateProperties(component.getDomObject().getAttrs());
updateExtra(component.getDomObject().getExtra());
}
}
结论
所以其实在这里,weex 在 Android 上最终解决了这个复用的问题。
总结
最后做一个简单的总结,大概前面说了这么多种方法,一个是包括,首先说RN的方法,说了我们在做JS上面做 RecycleView 的方法,还有我们在 Native 上面拓展 UITableView。
从性能上来看,因为从顺序上来说,我觉得我们客户端实现的那个相对来说比较好一点,因为它用的这个相对来说,从内存上面来说,占用比这个上面更少一些,但是这个也要看需求。weex 本身会比 RN 以及用JS端的实现更好。
从跨平台上来看,其实RN和JS去实现的跨平带上做的更好一些,原因是它纯粹是 JS 实现,JS 在各个平台上只有性能的差异,不会有实现的差异。其次是 weex,能够做到在两个端实现同样的代码,但是两端的性能上是有差异的。
再其次就是React,以及最后我们在客户端实现的,大概就是这样的情况。
我今天的分享就到这儿,大家看看有没有什么问题。
互动问答
Q1:像咱们这套是基于RN最新的版本去进行开发的是吧?
姜琢:我们就做RN的时候,其实这个是一个很大的困扰的点,因为RN本身官方的代码不断去更新,然后后面我们不可能说每次RN代码Cell我们都跟着更新,导致每次框架更新一次,导致整个测试成本成倍的提升,如果每次更新每次都要做一次回归的话很耗费时间。
Q2:咱们大概有哪些策略?
姜琢:最开始我们去改一些官方的框架的时候,可能稍微会有一些,相对来说改会有一点问题。现在的话,我们尽量的把不去侵入整个RN本身,即使是有些侵入的东西,我们也尽量保证在他核心代码的里面做最少的改动,把它传到外部插件中去,保证以后在Merge的时候,最好工作量的去完成。但是每一次回归仍然是必要的,或者我们也会去关注每次更新的时可能会产生一些问题,对于测试可能会更多的去关注。
Q3:咱们RN之前做过版本的回顾,刚才讲RN遇到一个很大的问题,这个是一个什么方式呢?
姜琢:这是纯组件,侵入主要涉及到RN本身的一些JS加载这块的东西,以及包括更新这块的东西。
关于这个分享以及本身RN,因为这个分享准备的还相对补是特别充分,所以可能讲的时候稍微漏了一些问题。大家可以看一下,刚才我提到的,在去哪儿旅行酒店里面的两个模块,去体验一下本身用bridge的方式去希望实现建构的一个效果。
Q4:能不能切到刚才给的三个截图?拿首页这个来说的话,里头如果用RN写组建的话你们会怎么做拆分。
姜琢:按照Native的方式,因为这个是这样的,相对来说,从首页上来说这个页面还不是很长。下面推荐的内容没有特别特别的多,运营的内容没有那么多。对于这种,这种不是太有复用性的这种,用ScrollView来实现就好了
Q5:你们整个界面全都是用RN,有没有Native跟RN混用的界面。
姜琢:现在应该没有,但是同一个布局的界面里头不会说上面是Native,下面是NR的这种情况。其实我觉得,反正跨平台这块,其实总游离在一个相对来说比较尴尬的一个位置。不管Hybrid还是RN,原因是大家主要是不清楚谷歌和苹果以后会走一个什么样的路线。他们俩,因为现在从各种开发的SDK上面的话,越来越体现出这种差异化。不是往一个统一化的方式来走。大家都是考虑自己平台上的东西来去做这个SDK,就会导致说跨平台的东西很难去说能够绝对的对于所有的需求都能够达到统一。
然后我还提一点,facebook和wexx两个公司考虑的一个方式可能不太一样,Facebook是觉得,我做一个框架,我应该去实现能够达到所有的需求的一个目的。weex并不完全是,他考虑在现有的情况下的应用,他在做ListView的时候,并不是像Facebook做一个特别通用性的,它相对来说保证性能的条件下能够达到最大的业务的适用范围。虽然RN性能不怎么样,但是他可以实现你现在所有的需求。
Q6:我再问一个问题,你刚才开始也讲了,现在是iOS开发也会写RN代码?
姜琢:其实这块的话,我们最开始做RN有一个特别大的原因就是说,因为去哪儿网之前是在web上面起家,而且web上面的业务非常多。其实在公司内部前端的同学比客户端的同学更多的。但是整个的流量,从web端往客户端去切的话,人不可能那么快去切换。所以会涉及到的一个问题,前端的同学如何参与客户端开发的问题,最开始都是客户端的同学,之后我们前端的同学能否加入进来。相对来说技术比较好一点的,其实做RN我觉得是没问题的。确实这个东西所需要学习成本对两个端的同学都不少,客户端学前端可能比前端学客户端还要难。前端主要考虑的就是说,他学客户端他只需要关注UI本身就可以了,因为逻辑都是前端来写的,所以他主要关注UI上面展示和前端的有什么差异性。但是客户端去了解前端,其实从JS本身的*就太多了。而且我感觉还有一点JS代码可读性和iOS其实差挺多的。JS都会也需要进行打包转译,你写的时候是一种样子,运行的时候是另外一个样子。
Q7:咱们JS这块的代码的质量是怎么保证的?比如说我们客户端可能Native的代码我们可以通过各种测试,各种检查,让它保证一定的安全性,JS这块怎么保证的?
姜琢:这块确实现在还没有一个严格的规定吧。但是相对来说,因为基本上都是客户端里相对来说学习能力比较强的人,前端也是相对学习能力比较强的人在做这件事儿,相对来说,从人上还过得去。
还有测试。
追问:有测试,等于自动化测试现在覆盖的还不是那么的多是吗?
姜琢:对,是,本身客户端的自动化测试还有前端的自动化测试都没法保证特别全面,因为本身测试的case的成本也不低。不过现在确实,应该主流的大多数公司都在做了。我们也会有一些,但是主要不会做一些新功能的测试,所以真的有人写了一个特别不太好测的这种,可能确实不太好弄。而且这个RN,如果要测试的话,他相当于跨了两个平台,需要保证代码质量,但是它又不像现有的这种,反正现有的前端的检测工具,不一定能查得出来。
追问:等于说在发版之前可能做一些,在RN的代码发版之前可能要做一些基本的测试?
姜琢:对。应该其实对于,像美团跟去哪儿,所有的东西你改一行代码都要测的,除非是非主要业务,只要是主要业务肯定都是要测的。
更多精彩内容欢迎关注腾讯 Bugly的微信公众账号:
腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的情况以及解决方案。智能合并功能帮助开发同学把每天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同学定位到出问题的代码行,实时上报可以在发布后快速的了解应用的质量情况,适配最新的 iOS, Android 官方操作系统,鹅厂的工程师都在使用,快来加入我们吧!