HarmonyOS 鸿蒙Next 基于ArkUI声明式开发范式的RN UI桥接方案

发布于 1周前 作者 nodeper 来自 鸿蒙OS

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)。在两个元素节点创建一对父子关系的同时,渲染器也会为对应的影子节点创建一样的父子关系。

影子树创建完成后,渲染器会触发了一次元素树的提交。

cke_15742.png

阶段二:提交(Commit)

在影子树完全创建后,渲染器会触发提交。提交阶段(Commit Phase)由两个操作组成:布局计算和树的提升:

布局计算(Layout Calculation):这一步会计算每个影子节点的位置和大小。实际的计算需要考虑每一个影子节点的样式。还需要考虑影子树的根节点的布局约束,这决定了最终节点能够拥有多少可用空间。绝大多数布局计算都是 C++ 中执行,只有某些组件的布局(比如:Text、TextInput 组件等等)计算是在宿主平台执行的。文字的大小和位置在每个宿主平台都是特别的,需要在宿主平台层进行计算。

树提升,从新树到下一棵树(Tree Promotion,New Tree → Next Tree):这一步会将新的影子树提升为要挂载的下一棵树。这次提升代表着新树拥有了所有要挂载的信息,并且能够代表元素树的最新状态。下一棵树会在 UI 线程下一个“tick”进行挂载。

cke_23808.png

阶段三:挂载(Mount)

挂载阶段会将已经包含布局计算数据的影子树,转换为以像素形式渲染在屏幕中的宿主视图树(Host View Tree)。

渲染器为每个影子节点创建了对应的宿主视图,并且将它们挂载在屏幕上。然后会为宿主视图配置来自影子节点上的属性,这些宿主视图的大小位置都是通过计算好的布局信息配置的。

cke_29528.png

更详细地说,挂载阶段由三个步骤组成:

树对比(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主线的打断。

cke_35233.png

Ark UI桥接方案

渲染器是跨平台框架的核心系统,不但需要实现上述三个阶段的渲染过程,通常还需要提供与两边(UI描述语言和宿主平台)通信的 API,宿主平台与渲染器通信主要包含两种情况:

(i) 宿主平台与渲染器通信

(ii) 渲染器与宿主平台通信

cke_40910.png

关于 (i) 宿主平台与渲染器的通信,包括宿主平台系统事件和监听的组件事件(event),比如 onLayout、onKeyPress、touch 等。

关于 (ii) 渲染器与宿主平台的通信,包括在屏幕上挂载(mount)宿主视图,包括 create、insert、update、delete 宿主视图,和监听用户在宿主平台产生的事件。

在Ark UI实现挂载(mount)操作,需要实现一个组件工厂,可以按需递归创建UI组件,并完成状态变量绑定。还需要根据实际情况设置组件属性和事件监听。同时跨平台框架在提交阶段的布局计算过程中,还需要使用宿主平台的测量能力实现文本组件的布局计算。下面按照章节介绍相关内容。

组件工厂和状态绑定

cke_48072.png

在上面的挂载过程中下,首先应该在Ark UI宿主平台创建并维护一个Ark UI组件树,该组件树与影子树保持一致。其次需要使用Ark UI的@Builder装饰的方法遍历该组件树,根据每个节点的实际类型创建相应的组件。

Builder

对于该场景,组件工厂场景的实现主要包含以下步骤:

  1. 将需要工厂化的组件通过全局@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>

  2. 还需要一个工厂方法,将封装好的组件方法加入其中。
    @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>
  3. 如果是容器组件,还需要递归调用工厂方法。
    @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>
  4. 最后,结合上面的代码,实现一个自定组件。其中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)
    }

    @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触摸事件 } }<button style="position: absolute; padding: 4px 8px 0px; cursor: pointer; top: 8px; right: 8px; font-size: 14px;">复制</button>

设置属性和监听事件

组件工厂封装在跨平台框架开发过程中,其中定义的组件,在业务场景下会被多次复用。为了使它具备所有能力以支持不同业务场景下的实际需要,需要在定义的组件内穷举所有的属性。

@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等)回调函数都在绘制流程中同步调用的,微小时间开销都有可能被放大很多倍。

因此不建议在这些函数增加耗时的操作,例如:文件和数据读写、跨进程调用、网络同步访问等。建议通过数据预取、异步化等办法降低在绘制同步流程中的执行时间。

cke_83324.png

其他相关方案设计

跨语言调用对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>

代码示例:

cke_98500.jpeg

cke_103780.png

关键线程调度

三方框架都有独立的运行线程,负责处理用户的交互、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”。

3 回复
大佬,RN什么时候出啊?

HarmonyOS 鸿蒙Next的ArkUI声明式开发范式为RN UI桥接提供了强大支持。ArkUI通过声明式UI开发方式,简化了UI开发流程,提高了开发效率。对于RN UI桥接方案,ArkUI能够桥接RN框架,使其能在鸿蒙系统上运行,实现跨平台开发。桥接过程中,ArkUI负责将RN定义的UI元素渲染到鸿蒙系统,确保UI的一致性和流畅性。

如果问题依旧没法解决请加我微信,我的微信是itying888。

回到顶部