ByteKMP Compose ArkUI 原生渲染解决方案

B站影视 日本电影 2025-10-24 09:34 1

摘要:Compose 官方对于 native 各平台的底层渲染接口由 Skia 提供,因此在 24 年我们率先实现了基于 Skia 的渲染链路。但在实际使用中,我们发现创建底层渲染通道会增加额外 Graph 内存(正比于屏幕像素,对于全屏页面约为55MB)。如果存在

一、背景

Compose 官方对于 native 各平台的底层渲染接口由 Skia 提供,因此在 24 年我们率先实现了基于 Skia 的渲染链路。但在实际使用中,我们发现创建底层渲染通道会增加额外 Graph 内存(正比于屏幕像素,对于全屏页面约为 55MB)。如果存在多个页面且未在应用层进行复用,很容易会触发 OOM,同时引入 skia 亦会带来较大包增量。随着业务接入 KMP 页面的增多,skia 实现带来的性能瓶颈愈发难以忽视。

我们注意到 ArkUI 提供了一套底层 CAPI 的高性能渲染接口 Native Drawing,通过初步的实验和测试,发现其可以在保证性能的同时避免 Graph 内存增量,并且不会带来额外包增量。对此,我们从 25Q1 启动了适配工作,并于近期整体适配完成。

二、整体架构

Compose作为最外层UI框架,向上提供各个UI组件及其他UI相关API(如动画、样式等),向内管理布局树及更新状态,向下封装具体UI渲染实现。

Harko 作为OHRender的 Kotlin 封装库,主要用途是将OHRender的 C++ API 通过cinterop 机制封装为 Kotlin API。同时以平台代码的方式实现RenderNode和帧回调绑定。整体架构定位平行于官方Skiko仓库。

OHRender 作为具体图形渲染库,大部分复用 Skia 头文件与接口设计。通过向下封装 Native Drawing 图形接口,来提供渲染能力。

三、项目结构变化

3.1 Compose 项目结构变化

一个典型的 KMP 项目会通过 Target SourceSet来构建基础的项目结构(可参考官网描述),Compose 也是如此,只是没有使用默认的SourceSet依赖结构。Compose 的 SourceSet 依赖关系如下(省略非移动端目标):

在我们通过 Skiko 来实现 Compose 渲染时,基于代码最大化复用原则,我们将 ohos 的 SourceSet 整体置于 jsNative 之下(可见上图红色虚线部分),即可与 iOS、WASM 等共用基于 Skiko 的接口封装与实现。

然而当我们不再使用 Skiko 后,原本所有 Native 平台均基于 Skiko 的设计就会出现问题。对于短期来说,我们通过将 ohos 重新置于 jb 之下并拷贝部分可复用实现来解决依赖结构问题,这是为了避免结构大量变更对后续 Compose 升级及同步造成不利影响。长期来看,需要抽出不依赖 Skiko 的统一 Native 抽象层来应对代码可复用问题。

3.2 其他基础库适配

对于业务场景等上层使用场景来说,由于直接依赖的 Compose API 未发生变化,且通常不会有类似 skikoMain等额外针对 Skiko 的SourceSet 层级,因此无需关心底层图形接口的变更。但对于一些依赖底层图形接口实现的基础库,情况就会有所不同。

首先是一些二方基础库仅仅支持移动端并用到了底层图形接口,只需修改图形接口实现即可。 其次是 coil 、compottie 等三方库,由于他们采用类似于 ComposeSourceSet 结构存在 skiko 层,还需要像Compose一样变更SourceSet依赖关系。

四、渲染流程

由于 Native Drawing 的原生组件载体发生变化(由 XComponent 变更为Rendernode),需要重新处理渲染内容绑定、帧回调等部分,同时绘制过程也因切换了底层实现有些变化。下面将从这三方面出发以图完整展示整个渲染流程。

4.1 渲染内容绑定

编译时注册

在编码过程中,RD 可在 @Composable方法上添加@ArkTsexportComposable 这个自定义注解,用于表示该方法预期导出至端侧使用。在@ArkTsExportComposable 注解中,存在成员变量:id(不传默认为包名 + 方法名),代表渲染内容类型。

在编译时,通过 KSP 识别上述注解,自动生成注册逻辑代码,以id为 key,@Composable 方法及一些其他配置项为 value,注册入统一全局容器对象ComposeController (基于动态需要,也支持运行时特定条件下热插拔)。

以下述代码为例:

@ArkTsExportComposable(id = "hello")
@Composable
internal fun Hello {
Column {
Text("hello")
}
}
hello@Composable {hello }

RenderNode 运行时绑定

ArkTS 侧的运行时绑定,首先需要创建 NodeController

@Component
export struct ComposeView {
private nodeController: HarkoNodeController | undefined

aboutToAppear: void {
if (!this.NodeController) {
this.nodeController = new HarkoNodeController('hello' /* 对应上节 id */, ...)
}
}

build {
Stack {
NodeContainer(this.nodeController)
...
}
}
}

export class HarkoNodeController extends NodeController {
private id: string
...

constructor(id: string, ...) {
this.id = id
}
}

其次在 NodeController 添加RenderNode节点:

export class HarkoNodeController extends NodeController {
private rootNode: FrameNode | null = null
private harkoNode: HarkoRenderNode | null = null

...

makeNode(uiContext: UIContext): FrameNode | null {
this.rootNode = new FrameNode(uiContext)
const rootRenderNode = this.rootNode.getRenderNode
this.harkoNode = new HarkoRenderNode(this.id, ...)
if (rootRenderNode) {
rootRenderNode.appendChild(this.harkoNode)
}
}
}

export class HarkoRenderNode extends RenderNode {
constructor(id: string, ...) {
...
}

...
}

最后在 HarkoRenderNode 构造时调用 KMP 工程通过 FFI 暴露的接口并获取返回句柄,完成整体绑定:

export class HarkoRenderNode extends RenderNode {
private nativeView: ESObject

constructor(id: string, ...) {
...
this.nativeView = initRenderNode(id, ...) // FFI 暴露接口
}

...
}

Compose 绑定

在上文通过 FFI 暴露接口,我们在 Kotlin 运行环境中获取了 ArkTS 层需要渲染的类型 id,因此我们可以通过id反查ComposeController 获取渲染内容(以及一些其他配置项)。

@ArkTsExportFunction // 此注解代表该方法需要导出至 ArkTS 使用
fun initRenderNode(id: String, ...): ShellRenderView { // 该返回接口提供了宿主需要的Compose组件能力,例如绘制
val view = ComposeController.initRenderNode(id, ...)

...
}

ComposeController 中,进一步构造ComposeScene(Compose UI 内容的抽象容器):

object ComposeController {
...

fun initRenderNode(id: String, ...): FrameRenderView {
// 反查渲染内容
val content = getContent(id) ?: error("failed to get content for $id")
return RenderingUIView(content, ...)
}
}

/*
UI组件接口父类,用于处理帧相关
*/
abstract class frameRenderView {
...
}

/*
Compose UI组件实现类
*/
class RenderingUIView(
content: @Composable -> Unit, ...
) : FrameRenderView {
private val mediator = ComposeSceneMediator(content)

...
}

/*
ComposeScene 中间类
*/
class ComposeSceneMediator(

) {
// ComposeScene 实例
private val scene = MultiLayerComposeScene(...)

fun setContent(...) {
scene.setContent {
...

CompositionLocalProvider(
...
content = content
)
}
}
}

ComposeScene 构造完成后,便已完成整个内容的绑定。而在后续的必要时机,即会调用setContent 方法来完成最终的渲染内容设置。

4.2 帧回调

在上节构造 ComposeScene时,还需传入一个 invalidate 方法,用于在 Compose 页面需要重新组合、重新渲染时申请下一帧:

class ComposeSceneMediator(
content: @Composable -> Unit,
invalidate: -> Unit,
...
) {
private val scene = MultiLayerComposeScene(
invalidate = invalidate,
...
)
}

class RenderingUIView(...) {
private val mediator = ComposeSceneMediator(
content, this::invalidate
)

override fun invalidate {
...
super.invalidate
}
}

XComponent 不同,RenderNode需要在 ArkTS 层向系统注册帧回调,因此我们需要通过类似注入的方式,来调用帧请求,并在帧回调时让 ArkTS 层调用对应方法延续调用链:

/*
暴露给 ArkTS 侧需要注入帧相关能力
*/
interface FrameImportApi {
fun postFrame(export: FrameExportApi)
...
}

/*
暴露给 ArkTS 侧帧相关方法
*/
interface FrameExportApi {
fun onFrame(...)
...
}

abstract class FrameRenderView(
private val importApi: FrameImportApi,
...
) : FrameExportApi {
private var needDraw = false // 避免方法重复调用

override fun invalidate {
if (needDraw) {
return
}
needDraw = true
importApi.postFrame(this)
}

override fun onFrame(...) {
if (!needDraw) {
return
}
needDraw = false

... // 进入到绘制过程
}
}

在 ArkTS 侧,实现帧请求并在系统回调时调用对应暴露方法,并在 4.1 节绑定时传入该实现类的实例完成注入:

export class ShellFrameCaller implements FrameImportApi, ... {
private uiContext: UIContext
private frameCallback = new RenderFrameCallback
...

onFrame(view: ShellFrameExportApi): void {
...

this.frameCallback.nativeView = view
this.uiContext.postFrameCallback(this.frameCallback)
}
}

export class RenderFrameCallback extends FrameCallback {
nativeView: ShellFrameExportApi | null = null

onFrame(frameTimeNanos: number) {
...
this.nativeView?.onFrame(...)
}
}

export class HarkoRenderNode extends RenderNode {
...

constructor(id: string, frameCaller: ShellFrameCaller, ...) {
...
this.nativeView = initRenderNode(id, frameCaller, ...)
}

...
}

4.3 绘制过程

在上节收到系统帧回调后,在 onFrame 中会利用 CInterop 先切换至 C 层逻辑(由于 Kotlin Native 指针相关语法相当繁琐,因此针对 ArkUI CAPI 调用尽量使用 C 实现再通过 CInterop 调用):

abstract class FrameRenderView {
...

protected val renderNode = RenderNode

override fun onFrame(...) {
...
renderNode.notifyRedraw
}
}

class RenderNode {
...

fun notifyRedraw {
nRenderNodeNotifyRedraw // 外部C函数
}
}

在 C 层,由于在帧回调内部不能直接操作 RenderNode 节点(操作的目的留到下一章叙述),因此需要先切换调用栈:

void nRenderNodeNotifyRedraw {
const napi_value jsNode = getJsNode; // 在4.1节绑定时会获取RenderNode的NAPI value
if (jsNode == nullptr) {
return;
}
OHRenderNode::RenderNodeNotifyRedraw(env, jsNode); // 在 so 加载时可以获得 NAPI env
}

void OHRenderNode::RenderNodeNotifyRedraw(napi_env env, napi_value jsNode) {
void *ptr = nullptr;
OHRenderNode* node = nullptr;

// 获取 OHRenderNode 指针
napi_value napi_ptr;
napi_status status = napi_get_named_property(env, jsNode, "OHRenderNodePtr", &napi_ptr); // 此处也在4.1节绑定
status = napi_get_value_external(env, napi_ptr, (void **)&ptr);
node = (OHRenderNode *)ptr;

if (node && node->fAsyncTask != nullptr) {
uint result = 0;
// ensure the task can be done (keep node alive).
napi_reference_ref(env, node->fJsObject, &result);
// 异步执行 fAsyncTask
// uv_async_init(loop, fAsyncTask, OHRenderNode::RenderNodeDoRedraw);
uv_async_send(node->fAsyncTask);
}
return;
}

void OHRenderNode::RenderNodeDoRedraw(uv_async_t *handle) {
OHRenderNode *node = (OHRenderNode *)handle->data;
if (node) {
...
node->doRedraw;
}
}

在切换调用栈后,即可通过 RenderNode NapiValue 获得OH_Drawing_Canvas 指针,通过一定包装将其通过回调传回 kotlin 层进行进一步绘制。

在 Kotlin 层,我们已经获取到了 Canvas,便可将其包装为 ComposeCanvas继续后续的渲染流程:

// 对 C 层 Canvas 的 KT 包装类
class Canvas internal constructor(ptr: NativePtr, ...) { ... }

abstract class FrameRenderView {
...

override fun onDraw(canvasPtr: NativePtr) {
...
onDraw(Canvas(canvasPtr, ...)
}

abstract fun onDraw(canvas: Canvas)
}

class RenderingUIView(...) : FrameRenderView {
...
private val mediator = ComposeSceneMediator(...)

override fun onDraw(canvas: Canvas) {
...
mediator.onRender(canvas, ...)
}
}

ComposeSceneMediator中,只需要将 harkoCanvas包装为 ComposeCanvas,即可完成向ComposeScene 的传递:

class ComposeSceneMediator(...) {
...
private val scene = MultiLayerComposeScene(...)

fun onRender(canvas: Canvas, ...) {
if (needSetContent) {
setContent
...
}
...
scene.render(canvas.asComposeCanvas, ...)
}
}

actual typealias NativeCanvas = com.bytedance.kmp.harko.skia.Canvas

fun NativeCanvas.asComposeCanvas: Canvas = HarkoCanvas(this)

internal class HarkoCanvas(val native: NativeCanvas) : Canvas {
override fun save {
native.save
}

...
}

4.4 时序图

内容绑定

帧回调及渲染

五、脏区管理

Compose 对于 native 平台统一采用 PictureRecorder来进行脏区管理(1.6版本),针对 ArkUI 也沿用了相同的设计。

在某个节点首次进行绘制时,会通过 PictureRecorder进行渲染命令录制,如果后续内容没有发生变化,便可以复用录制内容进行回放降低渲染耗时:

internal class RenderNodeLayer(...) : OwnedLayer {
...
private val pictureRecorder = PictureRecorder
private var picture: Picture? = null

override fun drawLayer(canvas: Canvas) {
if (picture == null) { // 首次或是已失效,开始录制
val pictureCanvas = pictureRecorder.beginRecording(...)
performDrawLayer(pictureCanvas.asComposeCanvas) // 实际绘制方法
picture = pictureRecorder.finishRecordingAsPicture
}

canvas.save
...
canvas.nativeCanvas.drawPicture(picture, null, null)
canvas.restore
}
}

在节点内容发生变化后,即会调用所持有的 RenderNodeLayer invalidate 方法,销毁 Picture

internal class RenderNodeLayer(...) : OwnedLayer {
...
private var picture: Picture? = null

override fun invalidate {
if (!isDestroyed && picture != null) {
picture?.close
picture = null
}
invalidateParentLayer // 使父节点失效
}
}

然而在 Native Drawing 中,PictureRecorder 对应的接口OH_Drawing_RecordCmdUtilsBeginRecording 并不支持嵌套使用,且官方并不会在短期支持其嵌套调用,因此需另辟蹊径来解决此问题。考虑到 ND 的宿主是 RenderNode,且RenderNode本身支持嵌套使用。故而可以将问题转换为通过嵌套RenderNode来进行脏区管理。在绘制过程当中,若遇到beginRecording调用,便会创建一个子RenderNode至节点树中,并保存其位置和宽高等属性信息(通过 FFI 存储在 ArkTS 层NodeStatusModify 中),并在后续渲染命令提交时进行还原,整体结构如下图所示:

值得关注的是,由于 RenderNode 的创建和上树命令均没有 CAPI,因此这些操作都需要通过 FFI 调用 ArkTS 接口完成,对此会造成额外渲染耗时以及 ArkTS 堆内存增量。

六、性能与展望

切换 Native Drawing 后能够如预期般很好地解决原Skia实现的几个痛点问题:减少约3MB包增量,以及降低整体内存57.5MB(以实际线上业务页面为例,主要得益于 GL 和 Graph 内存分项),可以基本解决多 KMP 页面造成的 OOM 问题。

但目前 Native Drawing 实现仍存在一些性能问题。其中内存方面,在 ArkTS 堆内存分项会有24MB左右的劣化;而在 FPS 方面,120 FPS 上限情况下依 Compose 页面复杂程度不同会有10%-15%的劣化(90或60 FPS 上限时可以基本对齐)。经分析其主要原因正是上节提到的部分 CAPI 缺失。

通过和 ArkUI 官方技术团队的交流和探讨,我们很高兴地看到缺失的 CAPI 将会在下次的系统大版本升级时补齐。通过其官方技术团队提供的实验室数据及本地验证,可以确认 ArkTS 堆内存劣化问题可被解决,同时 FPS 也能对齐 skia 版本。

本文最后,在过去数月的 Native Drawing 适配过程中,高迪、耿飞所代表团队提供了极大的技术支持并参与共建了一些关键的底层模块,有效地加速了整个方案落地,其专业性与责任感令人印象深刻,在此致以诚挚的谢意。

来源:字节跳动技术团队

相关推荐