作者简介
携程鸿蒙框架技术团队,负责携程旅行鸿蒙系统原生应用开发,为鸿蒙生态用户提供一站式旅行服务。
团队热招岗位:资深移动端工程师、高级iOS开发工程师
一、RN在携程业务使用现状
二、技术选型(为什么选择CRN)
三、CRN适配实践
3.1 版本升级
3.2 差异化工作
3.3 原生组件开发
3.4 组件C化
四、遇到的问题和解决办法
五、性能优化
5.1 CRN预加载
5.2 RN TurboModule运行在Worker线程
5.3 RN 指令精简
5.4 分帧渲染
5.5 后续性能优化
六、成果和未来规划
一、RN在携程业务使用现状
2019年,携程开始在线上使用RN框架,并结合自身的业场景,对RN框架进行了开发和改造,研发了CRN框架(以下简称CRN)。2021年,CRN成为携程主流的开发框架。集团内有20+个App接入CRN框架,其中核心的App都已接入。携程旅行App中,200+个业务Bundle在线上运行,业务页面数量超过2000个,超过80%的业务使用CRN。
二、技术选型(为什么选择CRN)
从新技术的选择到落地的实践上看,业务对技术的要求往往是以下几个方面:
1)功能全,全量业务都能快速的适配上线
2)性能好,用户体验多端一致
3)成本低,复用现有在其他平台的运行的代码
为了满足业务需求,鸿蒙的实现技术上我们选择了CRN,主要考虑:
1)基建成熟度高:有配套研发/测试/发布/运营监控系统,内部交流活跃,知识沉淀深
2)业务适配成本小:业务不需要重新再开发一遍,可以使用现有的业务代码
3)开发能快速上手:业务开发还是使用原有的技术进行开发,在鸿蒙上运行
4)产品迭代效率:支持每个周期的产品迭代,快速在鸿蒙系统的手机上线
CRN框架覆盖了文档、工具、开发框架、发布、监控、排障全链路。对应框架的改造也从这几个方面进行。
1)在文档方面,我们编写了详细的业务升级文档,列出业务方需要关注的点和常见问题。
2)在工具方面,提供了一键式CLI升级工具,只需在业务工程执行一行升级命令,即可完成工程升级改造。
3)在开发框架方面,改造涉及点比较多,包括:
对Native运行时升级,升级RN 0.72.5 核心库,合并对官方RN库的自定义改动点。
对JS打包工具升级,支持现有的拆包逻辑,合并对官方RN库的自定义改动点。
梳理使用到的社区三方库,统一三方库版本升级至鸿蒙RN三方库要求版本。
对Hermes引擎进行升级,合并自定义改动点。
对RN自定义组件和API进行新架构改造。
5)在监控方面,实现鸿蒙端的监控数据上报,接入到现有的监控系统,方便线上监控。
6)在排障方面,实现鸿蒙端的异常数据上报,接入现有排障系统,方便线上排障。
3.1.3 业务工程改造
在升级过程中,工作量最大的部分是“RN自定义组件和API实现新架构改造”。
Turo Modules 模块系统,替换老架构中的Native Modules,用于JS到Native的API同步调用。
Farbic 组件系统,替换老架构中Native Component,支持同步渲染。
已实现了官方RN大部分组件、API
已实现社区常用的三方库
自定义组件和API需要应用开发自行实现
100+自定义组件和API,基于鸿蒙原生开发实现,再封装提供给RN调用
按优先级分阶段实现这些自定义组件和API,保持上层JS接口不变
添加react-native-harmony和react-native-harmony-cli依赖库
适配Platform.OS,Platform.select等API
实现xxx.harmony.js文件,逻辑与IOS保持一致
升级三方库版本,如react-native-gesture-handler,从1.X版本升级到2.X版本
三方库版本升级后,对不兼容的地方做适配
3.3 原生组件开发
2)HarmonyOS Next逐步完善,与Android、iOS在某些特性上有差异
开发过程中发现了很多HarmonyOS Next功能不完善、存在若干Bug的地方,毕竟是一个新系统,我们与华为同学紧密合作,一一解决了问题,这个过程见证了鸿蒙系统的愈发成熟。
出于安全考虑,鸿蒙系统有一些新特性,比如选取图片视频进行编辑的场景,在Android、iOS中,申请用户权限之后便可以拿到整个系统相册的图片视频,这确实可能存在一些安全隐患。鸿蒙在最开始就切割了这一操作,即使App经用户同意申请了读相册权限,也无法拿到系统主相册的图片视频,本意是让App直接跳到系统相册选取图片之后返回,只提供当次选中的图片信息给App,从而彻底断绝了App侵犯用户隐私的可能。
但我们的多媒体场景比较复杂,用户选取图片、视频后会跳入编辑页,且可以重回相册页选择其他图片,也就是说我们的图片视频选择页与编辑页存在联动,鸿蒙提供的这种跳入系统相册的方式显示无法满足我们的需求。
后续经过讨论,鸿蒙提供了相册Picker的方案,将系统相册页封装为组件提供给开发者,我们的图片视频选择页可以内嵌相册Picker,从而解决了联动的问题。但这个需求从开始评审、开发、测试到最终实现,花费了几个月的时间。
3)RN组件C化
RN代码中存在标签<></>嵌套的组件被视为容器结点,此类型组件需使用C-API实现。
可以通过this.ctx获取RNOHContext,进而获取RNInstance,从而获取一系列RN端JS传入的信息,如View宽高、style等,也可执行发送事件、接收事件、获取TurboModule进行其他操作等等。
在RNInstanceImpl构造函数中有一个arkTsComponentNames字段,可以传入所有我们自定义叶子结点Fabric组件的名称,用于在RNOH SDK内部进行指令分发优化。实现ArkTS端Fabric组件后,需要将Fabric组件的名称加入此列表中。
假设存在实现过于复杂或者其他原因无法C化的容器组件,RNOH SDK内部指令优化代码需修改(这也意味着RNOH SDK需重新打包编译),关键代码见下文‘性能优化-5.3 RN 指令精简章节。
3.4 组件C化
<RNComponent>
<Text/>
<Image/>
</RNComponent>
经确认,携程端存在四个需强制C化的容器结点组件,分别为:
名称 | 描述 |
简而言之,需要把ArkTS端实现的组件用C-Api再次实现。
3.4.1 CRNModal C化
CRNModal 在开发测试过程中一步步探索了实现方案,经过多轮测试、方案讨论调整,最终确定了C化方案。
1)在CAPI instance中声明的Node节点,必须在全局声明,否则会导致node节点不能收到node_event等消息;
2)设置node属性构建ArkUI_AttributeItem的时候,如果设置的值是一个ArkUI_NumberValue类型,需要指定size,这个size的计算必须除去类型的长度,如下:
ArkUI\_NumberValue value\[] = {{.i32 = alignItem}};
ArkUI\_AttributeItem item = {value, sizeof(value) / sizeof(ArkUI\_NumberValue)};
4)设置Stack背景,导致子组件布局错误,是因为Stack被作为同级组件从而导致子组件的postion参数异常,需要手动处理好position问题;
5)可以通过以下方式在C++层调用arkTS方法,获取相关数据:
方案1:在ArkTS里实现一个TurboModule方法,然后通过rnInstance->getTurboModule<XXTurboModule>获取对应的TurboModule,调用方法,获取返回值。但此方案涉及C++与ArkTS的跨端调用,性能会差一些,优点是实现简单。
方案2:通过ArkTSBridge,添加一个ArkTS方法的桥,然后就可以在C++里直接调用这个ArkTS方法。具体实现可以参考NapiBridget.ArkTSBridgeHandler里任意方法。此方案性能好,但实现起来稍微麻烦一点。
6)可以通过以下Api获取设备的高宽
auto displayMetrics = ArkTSBridge::getInstance()->getDisplayMetrics();
displayMetrics.screenPhysicalPixels.width / displayMetrics.screenPhysicalPixels.scale //直接获取到是px单位,需要进行转换,也可以自行修改TurboModle的初始化值:
IOS Animated.timing 设置 useNativeDriver:true 后,内嵌按钮无法点击
IOS TouchableOpacity 内嵌 Aminated.View ,Aminated.View 开启动画变更位置后,无法点击
IOS Image样式设置 borderRadius 显示不全
IOS minimumFontScale maxFontSizeMultiplier 不生效
Aminated.View 内嵌Modal组件,内部TouchableOpacity点击不响应
FlatList、ScrollView stickyHeaderIndices 吸顶功能多次滑动后失效
Aminated.View 、Animated.ScrollView、layoutAnimation 动画卡顿
样式中使用了zIndex属性层级可能不生效,尝试添加 position:relative属性后生效
组件需要设置默认高宽,不然布局展示可能发生截断
五、性能优化
在前置页面通过FrameNode预加载一个RNSurface,利用这个RNSurface去加载rn_common,完成后可以理解为后台存在了一个具备所有框架能力的空白页面。
用户点击跳转RN页面时,添加一个用户几乎不可感知的延时去加载rn_business。
充分利用这个跳转延时 + 页面创建 + 页面切换的动画时间去加载业务bundle、渲染等。
业务Bundle加载完成后,动态替换业务自定义的intialProps
做到了rn bundle加载、 渲染与页面生命周期的完全隔离。
目前预加载1.0方案在全业务默认使用,基本解决了RN页面首帧白屏问题。
在前置页面通过FrameNode预加载了一个真实的RN页面,完成了加载rn_commom、rn_business、接口请求、渲染等一系列流程。
前置页面中影响下一个页面关键参数发生改变时,发消息给后台预加载的的RN页面,RN页面接收到事件,拿到关键参数后进行网络请求,得到数据后对页面进行刷新。
用户点击跳转到目标页面时,直接将后台已经预渲染好的页面上屏展示。
因为页面已经在后台被真实渲染,有影响前置页面的风险,虽然我们在RN SDK层面已经做了一层拦截,但这种拦截不可能cover所有场景,所有接入了预加载2.0方案的业务都必须在上线前经过完整回归测试。
目前,我们在机票列表页及火车票详情页使用了预加载2.0方案。
性能优化开启:
前段时间,鸿蒙RN SDK也是加入了TurboModule运行在Worker线程这个能力。RNInstance在创建时会同步创建一个worker线程,专门用于TurboModule运行。我们要做的是对工程中TurboModule代码进行适配改造,使之可以运行到worker线程中。
整个适配过程也存在一系列的问题。
首先,鸿蒙的ArkTS衍生自TS语言,基于Actor线程模型,内存不共享,线程间数据通信非常麻烦。
为了解决线程间通信流程繁琐的问题,鸿蒙提供了Sendable注解,可以理解为被这个注解修饰的对象会在共享内存创建。但Sendable存在一个问题,Sendable对象的成员变量只能是Sendable对象或其他特定的数据类型,也就是说我们如果对一个对象进行Sendable改造,就必须对他的所有成员变量进行Sendable改造,也需要对成员变量的成员变量进行Sendable改造,那这个改造过程就存在指数级扩散的问题。
另外,Sendable注解提供的时候,我们大部分代码都已经完成了,在这种成熟的大型项目中再重新进行Sendable改造的成本非常高,大家各自App如果还没开始或者刚开始开发,一定要考虑Sendable适配的问题,比如数据类型默认使用collection下属map、array,class默认添加Sendable注解等。
目前,我们适配完成了7个TurboModule,其他TurboModule做Sendable适配的成本非常高,正在逐步进行中。
facebook::react::ShadowViewMutationList MountingManagerCAPI::getValidMutations(
facebook::react::ShadowViewMutationList const& mutations) {
...
//需要特殊处理,保留容器组件及其子组件所有指令的容器组件名称
std::unordered_set<std::string> whiteListArkTsComponentNames = {
"AdapterMap", "AdapterMapMarkersContainer", "AdapterMapMarker"};
//第一次遍历:只遍历create,从前到后找到混合组件名称,只保存tag
for (auto mutation : mutations) {
if (mutation.type == facebook::react::ShadowViewMutation::Create) {
...
//特殊保留地图容器组件tag
if (whiteListArkTsComponentNames
.count(newChild.componentName)) {
arkTsComponentTags.push(newChild.tag);
}
}
}
if (!arkTsComponentTags.empty()) {
// 第二次遍历:只遍历insert,找到混合组件tag和它的子组件的tag。采用广度遍历方式,这里也只保存tag
for (auto mutation : mutations) {
if (mutation.type == facebook::react::ShadowViewMutation::Insert) {
...
//保存地图容器组件tag和它的子组件的tag
}
}
}
//第三次遍历:根据2中齐全的tag,重新过滤所有指令,保留这些tag的create、insert、update、remove指令。
for (auto mutation : mutations) {
...
//根据组件Tag,保留需要传递给ArkTS的所有指令
}
return validMutations;
}
页面 | ||
private myDisplaySync?: displaySync.DisplaySync
updateStage() {
if (this.stages == 0) {
this.myDisplaySync = displaySync.create();
this.myDisplaySync.start();
this.myDisplaySync.on('frame', (frameInfo: displaySync.IntervalInfo) => {
this.updateStage();
});
}
this.stages++;
if (this.stages == 3) {
this.myDisplaySync?.stop();
}
}
...
build() {
Column() {
Scroll(this.scroller) {
Row() {
//默认加载宫格首屏
if (this.stages > 0){
this.genFirstCell(0)
}
//三个渲染帧之后,加载宫格二屏
if (this.stages > 2){
this.genFirstCell(1)
}
}
...
}
用户体验
用户体验和性能一直是我们关注的重点,CRN在鸿蒙系统上还有很大的优化空间。我们会持续在性能上继续打磨和提升。
技术布局
为了追求高效率、低成本的研发模式,未来携程业务开发会大量使用一码多端的框架xTaro。后续xTaro会支持鸿蒙系统,真正实现让业务的一套代码能在多端多平台多应用场景上全矩阵运行。
鸿蒙生态的发展是一个持续且快速的过程。随着鸿蒙系统的不断迭代升级和生态的逐步完善,我们会持续为用户提供更加智能、安全、便捷的一站式的旅行应用。
【推荐阅读】
“携程技术”公众号
分享,交流,成长