ByteKMP Compose ArkUI 原生渲染解決方案

一、背景
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 等三方庫,由于他們采用類似于 Compose SourceSet 結構存在 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")
}
}將會在 ComposeController 中生成以下鍵值對
健 | 值 |
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(
content: @Composable () -> Unit, ...
) {
// 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 中,只需要將 harko Canvas 包裝為 Compose Canvas,即可完成向 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 版本。























