HarmonyOS 鸿蒙Next 基于ArkUI声明式开发范式的RN UI桥接方案
HarmonyOS 鸿蒙Next 基于ArkUI声明式开发范式的RN UI桥接方案
跨平台框架原理简介
在移动应用开发中,开发者经常会使用到跨平台开发框架,用于实现一套代码在不同的OS系统上运行的业务诉求,可以有效降低开发和维护成本。本文以ReactNative跨平台框架的鸿蒙化适配为基础,总结了基于ArkUI声明式开发范式的RN桥接实践。
ReactNative(后面以“RN”代替)跨平台框架是使用React开发语言实现页面开发,在不同的系统平台上,使用该系统的原生API组件渲染出实际的页面。RN框架会桥接几个基础的原生组件并结合丰富的属性设置能力,达成各种丰富页面的渲染能力。这种UI桥接方式具有自定义组件结构复杂和复用频率高的特点。
所谓UI桥接就是RN框架的适配层会包含一个渲染器,通过一系列加工处理,将框架定义的UI渲染到宿主平台。这一系列加工处理就是渲染流水线,它的作用就是UI初始渲染和UI状态更新。
RN的渲染流水线的可大致分为三个主要阶段:渲染、提交和挂载。
阶段一:渲染(Render)
创建跨平台框架自己的元素树(Element Trees)。然后在 C++ 中,用使用元素树创建影子树(Shadow Tree)。在两个元素节点创建一对父子关系的同时,渲染器也会为对应的影子节点创建一样的父子关系。
影子树创建完成后,渲染器会触发了一次元素树的提交。
阶段二:提交(Commit)
在影子树完全创建后,渲染器会触发提交。提交阶段(Commit Phase)由两个操作组成:布局计算和树的提升:
布局计算(Layout Calculation):这一步会计算每个影子节点的位置和大小。实际的计算需要考虑每一个影子节点的样式。还需要考虑影子树的根节点的布局约束,这决定了最终节点能够拥有多少可用空间。绝大多数布局计算都是 C++ 中执行,只有某些组件的布局(比如:Text、TextInput 组件等等)计算是在宿主平台执行的。文字的大小和位置在每个宿主平台都是特别的,需要在宿主平台层进行计算。
树提升,从新树到下一棵树(Tree Promotion,New Tree → Next Tree):这一步会将新的影子树提升为要挂载的下一棵树。这次提升代表着新树拥有了所有要挂载的信息,并且能够代表元素树的最新状态。下一棵树会在 UI 线程下一个“tick”进行挂载。
阶段三:挂载(Mount)
挂载阶段会将已经包含布局计算数据的影子树,转换为以像素形式渲染在屏幕中的宿主视图树(Host View Tree)。
渲染器为每个影子节点创建了对应的宿主视图,并且将它们挂载在屏幕上。然后会为宿主视图配置来自影子节点上的属性,这些宿主视图的大小位置都是通过计算好的布局信息配置的。
更详细地说,挂载阶段由三个步骤组成:
树对比(Tree Diffing): 这个步骤会对比“已经渲染的树”(previously rendered tree)和”下一棵树”之间的差异。计算的结果是一系列宿主平台上的原子变更操作,比如 createView, updateView, removeView, deleteView 等等。
树提升(Tree Promotion,Next Tree → Rendered Tree):在这个步骤中,会自动将“下一棵树”提升为“先前渲染的树”,以便下一个挂载阶使用适当的树计算diff。
视图挂载(View Mounting):这个步骤会在对应的原生视图上执行原子变更操作,该步骤是发生在原生平台的 UI 线程的。挂载阶段的调度和执行很大程度取决于宿主平台,也是Ark UI桥接方案的核心内容。
线程模型介绍
跨平台框架桥接Ark UI的实现中,通常会将整个系统拆分成4个线程:
UI主线程:Ark UI的主线程,负责最终的宿主UI渲染。
跨平台框架主线程(C++线程):跨平台框架主线程,负责执行跨平台框架的渲染器。
Background线程(C++线程):配合JS线程,完成可以并行化的计算,比如布局计算。
TurboModule线程(Worker线程):实现跨平台框架对宿主系统的功能调用,独立Worker可以避免对宿主UI主线的打断。
Ark UI桥接方案
渲染器是跨平台框架的核心系统,不但需要实现上述三个阶段的渲染过程,通常还需要提供与两边(UI描述语言和宿主平台)通信的 API,宿主平台与渲染器通信主要包含两种情况:
(i) 宿主平台与渲染器通信
(ii) 渲染器与宿主平台通信
关于 (i) 宿主平台与渲染器的通信,包括宿主平台系统事件和监听的组件事件(event),比如 onLayout、onKeyPress、touch 等。
关于 (ii) 渲染器与宿主平台的通信,包括在屏幕上挂载(mount)宿主视图,包括 create、insert、update、delete 宿主视图,和监听用户在宿主平台产生的事件。
在Ark UI实现挂载(mount)操作,需要实现一个组件工厂,可以按需递归创建UI组件,并完成状态变量绑定。还需要根据实际情况设置组件属性和事件监听。同时跨平台框架在提交阶段的布局计算过程中,还需要使用宿主平台的测量能力实现文本组件的布局计算。下面按照章节介绍相关内容。
组件工厂和状态绑定
在上面的挂载过程中下,首先应该在Ark UI宿主平台创建并维护一个Ark UI组件树,该组件树与影子树保持一致。其次需要使用Ark UI的@Builder装饰的方法遍历该组件树,根据每个节点的实际类型创建相应的组件。
Builder
对于该场景,组件工厂场景的实现主要包含以下步骤:
- 将需要工厂化的组件通过全局@Builder装饰的方法封装。
@Builder export function rnImageBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) { .width(descriptor.layoutMetrics.frame.size.width) .height(descriptor.layoutMetrics.frame.size.height) .backgroundColor(descriptor.props.backgroundColor) .position({ y: descriptor.layoutMetrics.frame.origin.y, x: descriptor.layoutMetrics.frame.origin.x }) .borderWidth(descriptor.props.borderWidth) .borderColor({ left: descriptor.props.borderColor?.left, top: descriptor.props.borderColor?.top, right: descriptor.props.borderColor?.right, bottom: descriptor.props.borderColor?.bottom, }) .borderRadius(descriptor.props.borderRadius) }
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button> - 还需要一个工厂方法,将封装好的组件方法加入其中。
@Builder export function rnComponentFactoryBuilder(ctx: RNOHContext, descriptor: Descriptor) { if (descriptor.type === “View”) { //如果组件类型等于View,则调用View的@Builder装饰的方法 rnViewBuilder(ctx, descriptor as ViewBaseDescriptor) } else if (descriptor.type === “Image”){ //同上,则调用Image的@Builder装饰的方法 rnImageBuilder(ctx, descriptor as ImageDescriptor) } else if(descriptor.type === “TextInput”) { //同上,则调用TextInput的@Builder装饰的方法 rnTextInput({ ctx: ctx, tag: descriptor.tag }) } else if(descriptor.type === “Text”) { //同上,则调用Text的@Builder装饰的方法 rnTextBuilder(ctx, descriptor as TextDescriptor) } }
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button> - 如果是容器组件,还需要递归调用工厂方法。
@Builder export function rnViewBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) { Stack() { ForEach(descriptor.childrenTags, (childrenTag: Tag) => { rnComponentFactoryBuilder(ctx, ctx.descriptorRegistry.getDescriptor(childrenTag)) }, (childrenTag: Tag) => childrenTag.toString()) } .width(descriptor.layoutMetrics.frame.size.width) .height(descriptor.layoutMetrics.frame.size.height) .backgroundColor(descriptor.props.backgroundColor) .position({ y: descriptor.layoutMetrics.frame.origin.y, x: descriptor.layoutMetrics.frame.origin.x }) .borderWidth(descriptor.props.borderWidth) .borderColor({ left: descriptor.props.borderColor?.left, top: descriptor.props.borderColor?.top, right: descriptor.props.borderColor?.right, bottom: descriptor.props.borderColor?.bottom, }) .borderRadius(descriptor.props.borderRadius) }
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button> - 最后,结合上面的代码,实现一个自定组件。其中Descriptor 就是上面提到的Ark UI组件树该自定义组件可以遍历完整代码如下。
@Builder export function rnViewBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) { Stack() { ForEach(descriptor.childrenTags, (childrenTag: Tag) => { rnComponentFactoryBuilder(ctx, ctx.descriptorRegistry.getDescriptor(childrenTag)) }, (childrenTag: Tag) => childrenTag.toString()) } .width(descriptor.layoutMetrics.frame.size.width) .height(descriptor.layoutMetrics.frame.size.height) .backgroundColor(descriptor.props.backgroundColor) .position({ y: descriptor.layoutMetrics.frame.origin.y, x: descriptor.layoutMetrics.frame.origin.x }) .borderWidth(descriptor.props.borderWidth) .borderColor({ left: descriptor.props.borderColor?.left, top: descriptor.props.borderColor?.top, right: descriptor.props.borderColor?.right, bottom: descriptor.props.borderColor?.bottom, }) .borderRadius(descriptor.props.borderRadius) }
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>@Builder export function rnComponentFactoryBuilder(ctx: RNOHContext, descriptor: Descriptor) { if (descriptor.type === “View”) { rnViewBuilder(ctx, descriptor as ViewBaseDescriptor) } else if (descriptor.type === “Image”) { rnImageBuilder(ctx, descriptor as ImageDescriptor) } else if (descriptor.type === “TextInput”) { rnTextInput({ ctx: ctx, tag: descriptor.tag }) } else if (descriptor.type === “Text”) { rnTextBuilder(ctx, descriptor as TextDescriptor) } }
@Component export struct RNSurface { ctx!: RNOHContext; @State descriptor: RootDescriptor = Object() as RootDescriptor; private touchDispatcher!: TouchDispatcher;
aboutToAppear() { this.descriptor = this.ctx.rnInstance.descriptorRegistry.getDescriptor(tag) this.touchDispatcher = new TouchDispatcher(tag, this.ctx.rnInstance, this.ctx.logger) }
aboutToDisappear() { this.cleanup?.(); }
build() { Stack() { ForEach(this.descriptor.childrenTags, (childrenTag: Tag) => { rnComponentFactoryBuilder(this.ctx, this.ctx.descriptorRegistry.getDescriptor(childrenTag)) }, (childrenTag: Tag) => childrenTag.toString()) } .width(“100%”) .height(“100%”) .onAreaChange((oldArea, newArea) => this.handleAreaChange(oldArea, newArea)) //桥接自定义组件布局事件。 .onTouch((e) => this.handleTouch(e)) //桥接自定义Surface触摸事件 } }
设置属性和监听事件
组件工厂封装在跨平台框架开发过程中,其中定义的组件,在业务场景下会被多次复用。为了使它具备所有能力以支持不同业务场景下的实际需要,需要在定义的组件内穷举所有的属性。
@Builder
export function rnImageBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) {
.width(descriptor.layoutMetrics.frame.size.width)
.height(descriptor.layoutMetrics.frame.size.height)
.backgroundColor(descriptor.props.backgroundColor)
.position({ y: descriptor.layoutMetrics.frame.origin.y, x: descriptor.layoutMetrics.frame.origin.x })
.borderWidth(descriptor.props.borderWidth)
.borderColor({
left: descriptor.props.borderColor?.left,
top: descriptor.props.borderColor?.top,
right: descriptor.props.borderColor?.right,
bottom: descriptor.props.borderColor?.bottom,
})
.borderRadius(descriptor.props.borderRadius)
.xxx //穷举Image其他属性赋值
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
当前方案的缺点如下:
自定义组件属性过多,影响执行效率:若需要使用系统组件的全量属性方法,则需在封装的自定义组件中注册穷举每个属性值。这样会大大影响每个组件的Build效率。
不利于后期维护:当自定义组件中的系统组件属性发生变更时,自定义组件也需要同步适配。
为了解决上述方案缺点,ArkTS为每个系统组件提供了attributeModifier属性方法。该方法将组件属性设置分离到系统提供的AttributeModifier接口实现类实例中,通过自定义Class类实现AttributeModifier接口对系统组件属性进行扩展。通过这个自定义Class类,可以按需根据每个组件实例实际使用到的属性进行设置。没有使用到的属性或者与默认值一致的属性值可以不用处理,从而较低后端的计算开销。
如上面的自定义组件rnImageBuilder,如果在某一个实例化节点上,只设置了该组件属性的宽高和位置信息,那么该实例化节点实际上可以简化为:
@Builder
export function rnImageBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) {
.width(descriptor.layoutMetrics.frame.size.width)
.height(descriptor.layoutMetrics.frame.size.height)
//.backgroundColor(descriptor.props.backgroundColor)
.position({ y: descriptor.layoutMetrics.frame.origin.y, x: descriptor.layoutMetrics.frame.origin.x })
//.borderWidth(descriptor.props.borderWidth)
//.borderColor({
// left: descriptor.props.borderColor?.left,
// top: descriptor.props.borderColor?.top,
// right: descriptor.props.borderColor?.right,
// bottom: descriptor.props.borderColor?.bottom,
//})
//.borderRadius(descriptor.props.borderRadius)
//.xxx //穷举Image其他属性赋值
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
这就大大减轻了后端对这些无用属性计算的性能开销。可以通过AttributeModifier实现类似上面的效果。改造后的组件如下所示:
export class ImageModifier implements AttributeModifier<ImageAttribute> {
private constructor() {}
protected descriptor: ViewBaseDescriptor = {} as ViewBaseDescriptor;
//提供方法设置该组件的描述信息,后面通过解析该描述信息得到该组件实例需要注册的属性和事件。
setDescriptor(descriptor: ViewBaseDescriptor): ImageModifier {
this.descriptor = descriptor;
return this;
}
//接口方法,ArkUI会调用该方法完成最终的ImageAttribute操作。
applyNormalAttribute(instance: ImageAttribute): void {
instance.width(this.descriptor.layoutMetrics.frame.size.width);
instance.height(this.descriptor.layoutMetrics.frame.size.height);
instance.position({ y: this.descriptor.layoutMetrics.frame.origin.y, x: this.descriptor.layoutMetrics.frame.origin.x });
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.descriptor.props.backgroundColor) {
instance.backgroundColor(<span class="hljs-keyword"><span class="hljs-keyword">this</span></span>.descriptor.props.backgroundColor);
}
<span class="hljs-comment"><span class="hljs-comment">/* ...... 其他需要设置的属性*/</span></span>
}
}
@Builder
export function rnImageBuilder(ctx: RNOHContext, descriptor: ViewBaseDescriptor) {
Image(this.imageSource.source) {
//通过AttributeModifier方法动态获取该组件实例化需要注册的属性和事件
.attributeModifier(this.imageModifier.setDescriptor(descriptor))
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
桥接系统文本测量能力
在跨平台框架桥接UI时,通常绝大多数布局计算都是可以自己完成的。但是有些组件(如:Text),大小和位置在每个宿主平台都是不同的,需要宿主平台提供计算能力。
由于ArkTS引擎是单线程工作,布局计算过程在ArkTS的主线程中执行,有可能与页面绘制时间冲突导致阻塞,从而影响整体的布局计算性能。
为了解决这个问题,系统提供了NDK API,可以在C++世界,非主线程(非UI线程)下直接完成文本测量操作,避免对ArkUI线程的依赖。
代码示例如下:
void MeassureText() {
// 创建排版样式对象typoStyle
OH_Drawing_TypographyStyle *typoStyle = OH_Drawing_CreateTypographyStyle();
// 选择从左到右/左对齐、行数限制排版属性设置到排版样式对象中
OH_Drawing_SetTypographyTextDirection(typoStyle, TEXT_DIRECTION_LTR);
OH_Drawing_SetTypographyTextAlign(typoStyle, TEXT_ALIGN_JUSTIFY);
OH_Drawing_SetTypographyTextMaxLines(typoStyle, 2);
// 通过排版样式创建排版处理对象handler
OH_Drawing_TypographyCreate *handler =
OH_Drawing_CreateTypographyHandler(typoStyle, OH_Drawing_CreateFontCollection());
// 创建文本样式对象txtStyle
OH_Drawing_TextStyle *txtStyle = OH_Drawing_CreateTextStyle();
// 设置文字大小、字重等属性设置到文本样式对象中
double fontSize = 15.0;
OH_Drawing_SetTextStyleFontSize(txtStyle, fontSize);
OH_Drawing_SetTextStyleFontWeight(txtStyle, FONT_WEIGHT_400);
OH_Drawing_SetTextStyleBaseLine(txtStyle, TEXT_BASELINE_ALPHABETIC);
OH_Drawing_SetTextStyleFontHeight(txtStyle, 3);
// 设置字体类型等
const char *fontFamilies[] = {“Colonna MT”};
OH_Drawing_SetTextStyleFontFamilies(txtStyle, 1, fontFamilies);
OH_Drawing_SetTextStyleFontStyle(txtStyle, FONT_STYLE_NORMAL);
OH_Drawing_SetTextStyleLocale(txtStyle, “en”);
// 将文本样式对象加入到handler中
OH_Drawing_TypographyHandlerPushTextStyle(handler, txtStyle);
// 设置文字内容
const char text = “Hello World Drawing”;
// 将文本内容加入到handler中(与文本样式对应)
OH_Drawing_TypographyHandlerAddText(handler, text);
// 根据handler对象生成文本排版布局typography
auto typography = OH_Drawing_CreateTypography(handler);
// 设置布局宽度限制
double maxWidth = 300.0;
OH_Drawing_TypographyLayout(typography, maxWidth);
// 获取文本布局结果的宽高
auto height = OH_Drawing_TypographyGetHeight(typography);
auto longestLineWidth = OH_Drawing_TypographyGetLongestLine(typography);
// 获取行数及每一行宽高
auto lines = OH_Drawing_TypographyGetLineCount(typography);
double lineWidths[lines];
double lineHeights[lines];
for (int i = 0; i < lines; i++) {
lineWidths[i] = OH_Drawing_TypographyGetLineWidth(typography, i);
lineHeights[i] = OH_Drawing_TypographyGetLineHeight(typography, i);
}
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
绘制关键路径上无耗时操作
在跨平台框架桥接Ark UI过程中,组件工厂中定义的组件在业务场景下会被反复使用。这些自定义组件的生命周期(如下图所示,aboutToApper、onPageShow、onPageHide、aboutToDisappear等)回调函数都在绘制流程中同步调用的,微小时间开销都有可能被放大很多倍。
因此不建议在这些函数增加耗时的操作,例如:文件和数据读写、跨进程调用、网络同步访问等。建议通过数据预取、异步化等办法降低在绘制同步流程中的执行时间。
其他相关方案设计
跨语言调用对UI桥接的性能影响
问题描述:
在典型的跨平台框架实现方案中,渲染器通常都是基于C或C++代码实现,从而保障跨平台使用的兼容性。这样在执行最后一步挂载(mount)过程就需要从C/C++到ArkTS的跨语言调用。
HarmonyOS系统中跨语言调用使用的是NAPI。需要通过NAPI创建ArkTS对象并赋值。
NAPI对象的创建和赋值可以使用两个方法实现,这样虚拟机内部会有多次跨语言调用处理逻辑,增加耗时。可以将创建和赋值合并到一个方法中,去掉虚拟机内部的多次跨语言调用处理逻辑。
API声明:
NAPI EXTERN napi_status napi_create_object_with_properties(napi_env env , napi_value result ,
size_t property_count , const napi_property_descriptor * properties);
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
代码示例:
关键线程调度
三方框架都有独立的运行线程,负责处理用户的交互、UI绘制(渲染)和业务逻辑运行,需要给予较高的调度优先级,在页面加载和滑动等场景中,保证CPU资源的供给。
QoS体现标记线程和绘帧流程/用户交互的关联程度。从调度角度来看,影响线程优先调度,是否立即被调度。OH5.0阶段计划开放以下QoS等级供开发者接入:
QoS等级 | 适用场景 | 负载特征 |
User interactive | 用户交互任务(UI线程任务、动效) | 几乎及时完成 |
Deadline request | 长时间运行的关键任务(页面加载刷新) | 一秒钟之内 |
User initiated | 用户触发并且可见进展,例如打开文档 | 几秒钟之内 |
Default | 默认 | 几秒 |
Utility | 不需要立即看到响应效果的任务,例如下载或导入数据 | 几秒到几分钟 |
Background | 用户不可见任务,例如数据同步、备份 | 几分钟甚至几小时 |
开发者通过调用QoS接口,在RN等三方绘制框架内标注关键线程,提升线程优先级。以RN框架的JavaScriptThread为例:
void *RN_JavaScriptThread(void *args) {
…… // 一些不重要的初始化操作
<span class="hljs-comment"><span class="hljs-comment">// 用户操作触发任务,线程设置QOS_DEADLINE_REQUEST等级</span></span>
<span class="hljs-keyword"><span class="hljs-keyword">if</span></span> (OH_QoS_SetThreadQoS(QosLevel::QOS_DEADLINE_REQUEST)) {
<span class="hljs-comment"><span class="hljs-comment">// 异常日志</span></span>
}
…… <span class="hljs-comment"><span class="hljs-comment">// 高QoS下的相关操作,例如DOM解析</span></span>
}
int main() {
// 创建工作线程处理异步任务
pthread_create(…, RN_JavaScriptThread, …);
……
}
<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>
针对前面的线程模型中提到的跨平台框架主线程和Background线程,因为需要及时响应用户的交互事件,所以线程优先级建议设置为:“User interactive”。
HarmonyOS 鸿蒙Next的ArkUI声明式开发范式为RN UI桥接提供了强大支持。ArkUI通过声明式UI开发方式,简化了UI开发流程,提高了开发效率。对于RN UI桥接方案,ArkUI能够桥接RN框架,使其能在鸿蒙系统上运行,实现跨平台开发。桥接过程中,ArkUI负责将RN定义的UI元素渲染到鸿蒙系统,确保UI的一致性和流畅性。
如果问题依旧没法解决请加我微信,我的微信是itying888。