图片来自:https://unsplash.com
本文作者:wyl
背景
时至 2022,精细化经营曾经成为了各大 App 厂商的强需要,阿里的 DinamicX、Tangram 大家应该都很相熟了,很多 App 厂商也自研了一些相似框架,基于 DSL 的动态化计划尽管有性能上的一些劣势,然而毕竟不是图灵齐备,一些须要逻辑动静下发的需要实现老本偏高,或因为 DSL 自身限度无奈实现,针对这个问题咱们应用 RN 进行了一下摸索尝试,利用咱们曾经绝对欠缺的 RN 基建,联合客户端列表能力低成本的实现了一套的动态化能力,同时兼顾肯定的性能体验。
基于 ReactNative 的动静列表计划简略来说就是将 ReactNative 容器内嵌在 RecyclerView 的 ViewHolder
中,因为页面主体框架还是由 Native 开发和渲染,所以首屏加载速度失去了保障,部分的 RN 实现也使页面取得动态化的能力,从而在性能、”齐备逻辑执行“的动态化能力之间获得了一个平衡点,依据咱们应用教训对几种动态化计划排序如下:
- 整体性能体验排序:
纯 Native > 基于 DSL 动态化计划 >= ReactNative 动静列表计划 > 纯 ReactNative 页面 > H5 - 动静能力排序:
H5 = 纯 ReactNative 页面 > ReactNative 动静列表计划 > 基于 DSL 动态化计划 > 纯 Native - 实现能力排序:
纯 Native >= RN 动静列表计划 = 纯 ReactNative 页面 > H5 > 基于 DSL 的动态化计划
从以上排序中能够看出 ReactNative 动静列表计划整体处于中等或中等偏上的一个地位,在实现能力上远胜余基于 DSL 动静计划,和 Native 能力根本对等,能够实现一些简单的 UI 交互成果,并且相比于纯 RN 实现的页面首屏速度会有十分大的劣势,另外不须要对页面整体框架进行更改就能比拟不便的嵌入,在开发保护老本上 RN 动静列表计划相比各种基于 DSL 的动态化计划会有比拟显著的劣势,不须要额定的开发组件治理平台,排查问题时也不必去读难懂的 dsl,最重要的是 RN 具备图灵齐备的能力,所以综合来看应用 RN 内嵌到 Native RecyclerView 来实现 Native 页面局部动态化的形式算是一种性价比绝对较高的形式了,值得一试。
技术计划介绍
这里从 Android 视角分享下咱们这套计划实现的一些技术细节、原理以及遇到的问题。首先咱们罕用的一些术语:
moduleName
是 RN 离线包的惟一 key,相当于离线包的名字;componentName
是 RN 中 registerComponent 的 component,对应一个 RN 实现的业务的执行入口;- 卡片指云音乐首页中每个 viewholder 外部的展现内容,展现的 UI 款式是卡片款式;
- RN 引擎指以 RN Bridge 为主的整个 JS 离线包运行时环境。
整体计划架构如下:
从图中能够看出整体计划采纳数据驱动的形式,服务端通过数据中携带的类型、component、moduleName 等字段来惟一指定是否是应用 RN 来渲染,执行 RN 离线包中的哪个 component 逻辑
整体计划上有几个细节点:
- 采纳数据驱动的形式,接入页面毋庸关注具体展现数据,只须要将数据透传到 RN 的 JS 侧即可
- 因为 RN 须要将离线包加载后能力执行 JS 生成客户端视图,在 RecyclerView 绑定数据时才开始加载 RN 的离线包势必会拖慢整个模块的展现,所以这里咱们做了整个离线包的预加载
- 首页列表中每个 ViewHolder 的展现元素咱们叫做一个卡片,目前采取的策略是多个卡片放在一个 RN 的离线包中,通过同一个 RN 容器来别离展现,防止多个容器耗费过多的资源。
上面从数据流角度拆解整个计划,整体计划能够分为服务端数据定义和下发,容器数据透传,JS 侧数据解析三个次要步骤:
- 服务端数据定义和下发
因为是服务端接口驱动 RecyclerView 中内容展现,接口下发数据中须要有 type 字段标识应用 RN 还是 Native 展现,能够服用 Native 展现款式标记字段,因为 RN 中具体展现的款式和运行哪些 JS 代码间接相干,所以服务端下发的数据中须要带上对应的 moduleName 和 componentName,整体数据结构定义如下:
[
{
"type":"rn",
"rnInfo":{
"moduleName":"bizDiscovery",
"component":"hotSong",
"otherInfo":{}},
"data":{"songInfo":{}
}
},
{
"type":"dragonball",
"data":{"showInfo":{}
}
}
]
获取到数据之后只须要依照 RecyclerView 失常的应用办法将数据和不同的 ViewHolder 绑定即可
- 容器数据透传
RN 容器间接间接内嵌在 ViewHolder 中,在 viewHolder 中只须要定义承载 RN JS 渲染视图的 ViewGroup container,RN Bridge 创立好 ReactRootView 后将创立好的 ReactRootView 调用 add 办法增加到 container 中即可,数据传递是透传的形式通过 RN 的 initialProperty 传入到 JS 侧,在 JS 侧解析和应用,数据传递代码如下:
mReactRootView?.startReactApplication(reactInstanceManager, componentName, initialProperties)
这外面须要留神的点是,因为所有应用 RN 展现的卡片都是对应的雷同的 RecyclerView type 即雷同的 ViewHolder,所以在 RecyclerView 复用时可能会呈现两种状况:1. 只有一个 RN 卡片,高低滑动 RecyclerView 时产生复用,这时根本不必解决,2. 存在两种不同类型的 RN 卡片,复用时会运行齐全不同的离线包代码,这种状况会导致 JS 侧从新执行渲染逻辑生成全新的视图,高低滚动时如果每次都呈现 JS 侧从新渲染,会极大的影响滑动时性能,造成滑动卡顿掉帧,针对这种问题咱们对 RN 的 ReactRootView 也做了缓存,整体架构如下:
从图中能够看到 ViewHolder 中的 container 和 RN 的 ReactRootView 是一对多的关系,RN 的 ReactRootView 在第一次初始化实现后还是挂在 RN 治理的虚构视图树中,在 RecyclerView 滑动切换不同的展现类型时只须要从 ViewHolder 的 container 中移除不展现的 ReactRootView 再从新 add 须要展现的 ReactRootView,不须要 JS 侧从新执行,从新 add ReactRootView 之后还须要将以后的数据再传入 JS 侧以适配雷同款式的卡片展现不同数据的需要。这外面的原理是个别状况下咱们一个 RN Bridge 只会创立一个 ReactRootView,然而查看 RN 源码,RN 其实反对一个 RN Bridge 绑定多个 RootView 的能力,代码如下:
public void addRootNode(ReactShadowNode node) {mThreadAsserter.assertNow();
int tag = node.getReactTag();
mTagsToCSSNodes.put(tag, node);
mRootTags.put(tag, true);
}
一个 ReactRootView 即一棵视图树,RN 在更新客户端视图时都会遍历所有的 ReactRootView,代码如下:
protected void updateViewHierarchy() {
....
try {for (int i = 0; i < mShadowNodeRegistry.getRootNodeCount(); i++) {int tag = mShadowNodeRegistry.getRootTag(i);
ReactShadowNode cssRoot = mShadowNodeRegistry.getNode(tag);
if (cssRoot.getWidthMeasureSpec() != null && cssRoot.getHeightMeasureSpec() != null) {
...
try {notifyOnBeforeLayoutRecursive(cssRoot);
} finally {Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
}
calculateRootLayout(cssRoot);
...
try {applyUpdatesRecursive(cssRoot, 0f, 0f);
} finally { }
...
}
}
} finally {Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
}
}
所以即便应用多个 ReactRootView RN 的渲染逻辑也能够失常执行,这里一个 ReactRootView 即对应 JS 实现中的一个 Component,咱们在运行 RN 业务代码会看到 startApplication 的实现在 ReactRootView 中,startApplication 传入的参数就是 Component,对应代码如下:
public class ReactRootView extends FrameLayout implements RootView, ReactRoot {
public void startReactApplication(
ReactInstanceManager reactInstanceManager,
String moduleName,
@Nullable Bundle initialProperties,
@Nullable String initialUITemplate) {...}
}
到此客户端侧的重点实现根本实现了,接下来就是 JS 侧。
- JS 侧写法变动
JS 侧的对于卡片开发的写法和失常的 RN 开发基本相同,惟一的区别是须要同时注册多个 component,客户端每个业务卡片启动时只须要启动对应的 Component 即可,代码示例如下:
AppRegistry.registerComponent('hotTopic', () => EStyleTheme(HotTopic));
AppRegistry.registerComponent('musicCalendar', () => EStyleTheme(MusicCalendar));
AppRegistry.registerComponent('newSong', () => EStyleTheme(NewSong));
- JS 和 Native 通信
至此整个渲染流程都曾经介绍实现,卡片曾经能够失常展现,不过既然 RN 具备图灵齐备的能力,势必会有一些用户交互导致的 UI 变动,比方点击卡片上的”叉“的不感兴趣操作,点击后须要告诉客户端弹出客户端的不感兴趣组件,多个卡片对应同一个 JS 引擎,JS 和 Native 的通信通道也是复用的,怎么决定由哪个卡片来弹出呢,咱们的做法是在卡片第一次渲染时就应用工夫戳的哈希值生成惟一的 key,将这个 key 作为 Native 侧和 JS 侧辨别不同业务的惟一标识,和具体展现的业务卡片关联起来在双侧都存储起来,这样后续每次通信时双侧就能够通过 key 来确认通信的对象,确保不会导致通信凌乱。
- RN 引擎预热
在整个 RN 的执行周期中离线包加载个别也会耗费比拟多的工夫,所以为了尽可能的晋升性能,咱们还对页面卡片对应的整个离线包进行了预热,即提前将离线包加载到内存中并筹备好业务逻辑的运行时环境,预热只须要创立好 ReactInstanceManager 并调用 createReactContextInBackground() 即可,调用后整个离线包会被交给 JS 引擎进行预处理,代码如下:
ReactInstanceManager.builder()
.setApplication(ApplicationWrapper.getInstance())
.setJSMainModulePath("index.android")
.addPackage(MainReactPackage())
...
.build()
.createReactContextInBackground()
这里还须要留神的一个点是代码调试能力,采纳内嵌的形式如果原来页面曾经有摇一摇这种手势,RN 原生的调试菜单会无奈呼出,这里须要减少额定的交互方式来解决,咱们在卡片上减少了一个悬浮按钮。
到此整体框架就都已介绍结束,在框架之外内存占用和正当的异样解决也是须要思考的重点。
内存
在整体技术实现之外,咱们另外关注的一个重点就是内存占用,咱们对以 RN Bridge 为外围的 RN 容器内存占用进行了统计,应用 Profiler 工具获取数据如下:
无 RN 容器(native/java) | 1 RN 容器(native/java) | 2 RN 容器(native/java) | 3 RN 容器(native/java) | 5 RN 容器(native/java) | |
---|---|---|---|---|---|
红米 k30pro 6G | 148/54.6 | 154/56 | 157/55.7 | 153/56.7 | 208/59.8 |
谷歌 Pixel 2XL 4G | 137.8/60 | 163/73 | 176/83 | 186/91 | 196/101 |
红米 k30 8G | 118/52 | 143/56 | 136/55 | 138/56 | 142/60 |
整体看来在 5 个以内 RN 容器的状况整体内存并没有减少很多,内存占用整体在可控状态,因为此计划采纳了一个 RN Bridge 对应多个卡片的形式,所以相当于只新增一个 Bridge,对内存影响较小,理论线上运行也没有新增 OOM 问题。
异样解决
- 出现异常如何解决
不论是 JS 写法起因还是 ReactNative 自身的稳定性起因,总有肯定概率会有异样呈现,这时须要正当的逻辑解决保障性能和用户体验不会受到比拟大的影响,咱们以后的解决策略是异样监听还是应用 NativeExceptionHandler 来监听 SoftException 和 FatalException,异样时在对立的回调中告诉下层业务(recyclerView 层),而后依据具体的业务状况,由业务层对立打消或者重建 RN 容器,保障体验不受影响或者影响较小,以云音乐首页应用场景为例目前卡片总 PV 约 1 亿,错误率不到万分之一,整体运行状况稳固,无相干用户反馈。
- RN 版本升级导致和数据不兼容如何解决
RN 应用离线包策略,为保障用户能失常获取到离线包和保障离线包能疾速高效的更新,咱们采取了兜底包集成、更新信息服务端接口搭车等策略,不过受限于用户的机型地区、网络状态等起因还是存在肯定概率的更新不胜利,对于这种状况咱们将以后 RN 离线包反对的卡片信息保留在离线包的配置文件中,通过离线包获取的接口裸露给业务方,业务在运行离线包前能够依据配置信息对网络申请后果进行过滤,保障新版数据匹配旧版的离线包时不会导致异样。
将来布局
短期内咱们心愿将 RN 动静列表计划联合咱们已有的 RN 低代码能力,实现首页经营动静搭建公布,另一方面次要在性能晋升,咱们目前还是应用的 RN 0.60.5 版本,JS 的执行效率和以后版本的多线程框架是咱们的最大的瓶颈,之后咱们会在新架构上进行更多的尝试。
本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!