支付宝 KJS Compose 动态化方案与架构设计

编者按:在 KMP + Compose 成为主流原生 UI 技术栈的背景下,业务对"动态化"的诉求正从依赖 WebView 或独立渲染体系,转向在不破坏现有渲染链路、不新增 DSL、且不影响核心页面性能的前提下,实现更细粒度、可控的动态交付能力。

本文由支付宝终端技术团队潘云逸(法慧)编写,结合工程实践,提出了一种基于 Kotlin/JS + Compose Runtime + Native Skia 的局部动态化方案:由 JS 侧负责 UI 计算,Native 侧复用既有 Skia 渲染栈完成最终上屏,在原生 Compose 页面中实现区块级、脚本驱动的动态 UI 嵌入。

背景与目标

1.1 业务背景:为什么又要谈"动态化"

大家对"动态化"的直觉基本等同于:"JS + WebView/Native 渲染引擎 + 一套新 DSL/框架"。

端内也有 Cube、H5/小程序、Bundle2.5 等等,但在 MYKMP 的技术体系下,有一些可见的痛点:

  • 我们已经有一套成熟、统一的 Compose 布局体系,业务习惯用 Compose DSL 写 UI,对于非复用资产,写一套卡片成本较高,同层嵌入下会有很多兼容性问题需要处理。

  • 首页、Tab 等核心场景对性能非常敏感,不希望在这些关键页面内耦合 webview;

同时,业务侧的诉求又很明确:希望在已有 KMP 原生页面中,局部"插一块"动态区域,这块区域可以通过下发脚本快速更新,而不需要上架发版。

所以,我们需要的是这样一种能力:

  • UI 代码依然是 Compose DSL,甚至可以通过注解决定当前组件是否为 Remote 组件

  • 动态化的载体是 JS 脚本(易分发、易管理);

  • 可以在原生 Compose 页面里"局部嵌入"动态 UI;

  • 不依赖 WebView,也不破坏现有 compose Native 侧渲染栈。

1.2 我们的目标:Compose 逻辑动线 JS 闭环,Native 只负责上屏渲染

在不破坏现有 Native 渲染链路的前提下,我们给自己设定了三层目标:

  1. 体验目标:业务侧"像写普通 Compose 一样写动态 UI"

    • 业务同学继续用 Compose DSL 写 UI,不需要学习新的 DSL 或框架;

    • 动态与静态页面在代码结构上尽量一致,只是"跑的地方"不同:

      • 静态 UI:即原生 KMP Compose 代码,直接在 Native Compose Runtime 中执行;

      • 动态 UI:即编译生成的 JS 代码,在 JS Runtime 里执行 Compose 逻辑。

  2. 架构目标:拆分"计算 UI"与"渲染 UI"

我们把整个 UI 渲染链路拆成两段:

换句话说:

JS 世界只负责"算 UI、出图纸",Native 世界负责"照图纸画"。

  • JS 侧:

    • 搭起一套 JS 版本的 Kotlin Runtime + Compose Runtime;

    • 负责状态管理、重组、布局、测量;

    • 把最终需要绘制的内容,归约为一组"Canvas 绘制指令"(落在 CanvasDrawScope 上);

    • 不做真正的像素绘制,也不依赖 Web Canvas 或 Skiko Wasm。

  • Native 侧:

    • 继续使用现有的 Kotlin/Native + Skia 渲染栈;

    • 负责接收 JS 侧通过 JS Binding 传来的绘制指令;

    • Skia PictureRecorder 做"录制",形成一份可重放的 Picture

    • 在需要上屏时,把这份 Picture 绘制到对应的 Compose 容器区域。

  1. 能力目标:先支持"局部动态化嵌入",后续再拓展到整个页面。 我们并不追求"一上来就把整页交给脚本",而是聚焦在更具体、可控的能力:

这样做的好处是:

  • 在一个原生 Compose 页面中,某个区域由动态脚本驱动;

  • 这个区域可以独立更新、独立灰度;

  • 其余部分仍然是正常的原生 Compose 代码;

  • 支持后续扩展为多块动态区域、甚至组合嵌入。

  • 对现有业务页面改造成本小(添加一个动态容器 Composable 即可);

  • 动态能力可以从简单场景逐步扩展到复杂场景;

  • 一旦出现问题,可以快速降级为静态实现或关闭某个动态区域。

1.3 最终希望达到的状态

从业务视角来看,我们希望最后呈现出来的是这样一种体验:

  • 写 UI 的方式不变:依旧是熟悉的 @ComposableColumnRowText 等;

  • 多了一种"部署方式":

    • 可以把这段 Compose 编译到 Native,随 App 发版;

    • 也可以把这段 Compose 编译到 JS,通过脚本下发,在 JS Runtime 中运行;

  • 在页面里用起来,大致是这样的:

    @Composable fun HomePage() { Column { Header() // 原生静态区域

    less 复制代码
        RemoteView(
            // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
            scriptId = "home_feed_dynamic_v1", // 对应某个下发脚本
            modifier = Modifier.fillMaxWidth().height(200.dp)
        )
    
        Footer() // 原生静态区域
    }

    }

  • 而对于这块 RemoteView

    • 首次进入时:在后台创建一个 JSContext,加载业务下发的 JS(里面是 Compose + Kotlin Runtime),执行初始绘制,生成一份 Skia Picture

    • 业务状态变化时:JS 侧触发重组,重新生成绘制指令,Native 录制新的 Picture,局部刷新该区域;

    • 业务无感知整个渲染细节,只关注"这块区域的 UI 和状态"。

从架构视角来看,这套方案在动态化能力与现有原生栈之间搭了一座桥:

  • 上层统一使用 Compose DSL;

  • 中间用 Kotlin/JS Runtime + Compose Runtime 实现"跨语言的 UI 计算";

  • 底层统一落在 Skia 渲染,沿用现有性能优化和渲染流水线。

这就是我们这次方案设计背后的背景与目标:
在不引入 WebView、不重复引入一整套 Wasm Skia 渲染栈的前提下,将 Compose 的动态化能力"搬到 JS + Native Binding"之上,让业务在最小心智负担下获得扎实可控的局部动态化能力。

现有方案分析

2.1 Kuikly 动态化

Kuikly 是腾讯开源的一套跨平台 UI 渲染解决方案,它的技术路线是保留 Kotlin 侧的 UI 树/组件体系,复用 ComposeDSL,在 Kotlin 内部内化 Build -> Measure -> Layout,Native 侧仅保留了原子化的 View 能力和通用的视图树增删改接口的映射实现(创建控件、挂载子控件、设置属性、设置布局结果等),Kotlin 侧最终通过渲染接口,直接把布局和属性同步变更到 Native View 上,再由 Native View 上屏渲染。

在这个技术架构体系里,提供动态化能力就相对比较简单了,只需要适配 ComposeDSL,完成以下路径即可:

  1. 下发的 Compose DSL 解析-> Kuikly 组件树🌲

  2. Native 侧 Kotlin 完成测量布局

  3. 通过渲染接口更新原生控件

2.2 KJS For Web

Kotlin/JS(简称 KJS)其实已经提供了一套比较完整的动态化能力:

把 Kotlin 代码编译成 JS,并带上一整套 Kotlin Runtime,在浏览器环境里跑。

它典型的产物结构是三件套:

  • composeApp.js:业务代码 + Kotlin/JS Runtime + Compose Runtime;

  • skiko.js:JS 与 Skia(Wasm)的胶水层;

  • skiko.wasm:真正的 Skia 图形引擎(Wasm 版)。

在 Web 场景下,这套方案是合理的:浏览器提供了 JS 引擎和 Canvas,KJS 提供了 Kotlin/JS Runtime 和 Compose 逻辑,Skiko/Skia 则负责最终的像素级绘制。

但如果我们把这套设计直接搬到客户端原生环境,会遇到几个核心问题:

  1. 强依赖 Web 环境
  • KJS for Web 默认存在 document、Canvas、事件模型等浏览器能力;

  • 这意味着要在我们的场景中落地,通常需要引入 WebView,这是首页 / Tab 等关键场景无法接受的。

  1. 重复图形栈:多一套 Wasm Skia 没意义
  • 客户端 Native 侧已经有一整套围绕 Skia 的渲染 pipeline;

  • 再加载一个 skiko.wasm,等于在 App 里用 Wasm 再跑一份 Skia ------ 包体、内存、初始化成本都非常高,而且和现有渲染栈重复。

  1. iOS 等平台的环境限制
  • iOS 不允许应用自行 JIT,WebAssembly 在非 Web 环境要靠解释或 AOT 运行;

  • 在这种约束下,再塞进一套重型 Wasm 渲染引擎,性价比很低。

总体来看,KJS for Web 是为"在浏览器里跑 Compose"设计的,而我们现在要解决的是"在原生环境里跑动态 Compose",两者的前提条件完全不同。

2.3 RemoteCompose

其本质是用"可序列化的绘制文档"+"通用 Player",把 Compose UI 从 App 本地编译时,解耦成运行时可远程下发和播放的形态。

1.声明式文档序列化

  • 把 Compose UI 的绘制过程"捕获"为一份二进制文档(类似"绘制指令的录像带");

  • 文档里不是 JSON 组件树,而是很底层的绘图操作、布局信息、状态、交互定义。

2.平台无关的文档回放(渲染)

  • 客户端拿到这份文档,不需要原来的 @Composable 代码,也不需要业务 ViewModel;

  • 只用一个 RemoteDocumentPlayer 组件按指令在 Canvas 上画出来;

  • 同一文档可以在不同 Android 设备(手机 / 平板 / 折叠屏 / Wear)以原生方式渲染。

RemoteCompose的潜在问题是

  1. 官方能力仅支持 Android,目前为 Snapshot 版本

  2. 需要维护一个服务端应用,用来做状态同步,驱动服务端 compose 重组和渲染指令重录

  3. 交互场景可能会因为网络抖动发生延迟

KJS 动态化方案和架构设计

为了满足业务对于 Compose 动态化的诉求,我们也评估了一些目前现有的技术路线,例如 KJS for Web、RemoteCompose 等方案:

  • KJS for Web 强依赖 Web 环境 + Wasm Skia,不适用于纯 Native 页面;

  • RemoteCompose 更偏"后端生成文档 + Android 客户端播放",且目前主要定位在 Android 生态。

最终,我们基于 Kotlin/JS(KJS)和现有 Native Skia 渲染栈,设计了一套 "KJS Native"的局部动态化方案。

方案核心思路:JS 做重组布局,Native 做渲染重放。

用 KJS 在 JS Runtime 中跑一套 Compose Runtime,只负责重组 / 布局 / 测量,并生成"绘制指令";

再通过 JS Binding 把这些指令下发到 Native,用 Skia 的 Picture 录制与重放完成真实渲染。

这其中有三个关键点:

  1. KJS 只承载逻辑侧的 Compose Runtime

    • JS 侧不引入 skiko.wasm,也不跑 Wasm 版 Skia;

    • 只保留 Kotlin/JS Runtime + Compose Runtime(重组、布局、测量);

    • CanvasDrawScope 这一层统一收口所有绘制动作。

  2. 通过 JS Binding 把 Canvas 绘制指令映射到 Native

    • 在 JS 侧改造 compose-ui / ui-graphics / ui-text 的 JS target;

    • 剥离对 Skiko 的依赖,把最终的 drawRect/drawPath/drawText/... 变成一组"跨语言绘制指令";

    • 通过 JSI/NativeBinding 将这些指令传递到 Native。

  3. Native 使用 Skia PictureRecorder 做录制与重放

    • Native 收到绘制指令后,用 Skia PictureRecorder 开始一次录制:

      • beginRecording():接收并执行指令,真实调用 Skia API;

      • endRecording():得到一份 Picture

    • 页面实际渲染时,只需要在对应区域 Canvas.drawPicture(picture) 即可;

    • 动态化组件设置成 graphicLayer 做层级提升,降低重绘的开销

下面以一个动态容器 RemoteView 为例,串一下实际执行链路。

3.1 整体流程介绍

Step 1:业务声明动态容器

在原生 Compose 页面中,业务只需要用一个容器承接动态区域:

ini 复制代码
@Composable
fun HomePage() {
    Column {
        Header() // 原生静态区域
        // scriptId 只是个唯一标识符,可以是业务自定义的 bizId,也可以是模块 artifactId
        RemoteView(pageId =pageId, 
                   viewId = viewId, 
                   scriptUrl = "https://kmp_demo/composeApp.js", 
                   modifier =  Modifier.height(
                   height = 150.dp).fillMaxWidth())

        Footer() // 原生静态区域
    }
}

RemoteView 内部做的事情:

  • 根据 scriptUrl,异步从本地 / 网络获取对应的 JS 产物;

  • 通过 KJSEngine 创建一个 JSContext

  • 在这个 JSContext 里加载并执行业务的 Compose JS 代码(KJS 编译产物)

Step 2:JS Runtime 中首次 Composition 与绘制指令生成

在 JSContext 中:

  1. 启动 Kotlin/JS Runtime + Compose Runtime;

  2. 运行动态区域对应的 @Composable 函数;

  3. 完成第一次重组 / 布局 / 测量;

  4. 进入改造后的 CanvasDrawScope,生成一组绘制指令(逻辑上类似:drawRectdrawTextdrawImage 等)。

此时,所有绘制动作都不直接画,而是以指令的形式,通过 JS Binding 送到 Native。

Step 3:Native 录制 Skia Picture

Native 侧:

  1. 收到"开始录制"的信号,创建一个 Skia PictureRecorder

  2. 遍历 JS 传来的绘制指令,逐条调用 Skia 对应 API,比如:

    • canvas.drawRect(...)

    • canvas.drawPath(...)

    • paragraph.paint(canvas)(文字链路我们通过封装进行适配,最终产出的也是);

  3. 结束录制,得到一份 Picture 对象;

  4. 通过 callback 或状态更新,将这份 Picture 提交给业务层。

Step 4:业务 Compose 把 Picture 上屏

RemoteView 内部维护一个 State<Picture?>

  • 当 Native 录制完成,更新 Picture;

  • Compose 检测到状态变化,重新执行 RemoteViewdraw 逻辑;

  • 在对应的 DrawScope 中调用 drawPicture(picture),将 Picture 绘制到该容器区域。

到此为止,动态区域的首次展示完成。

Step 5:后续状态变化与局部重绘

当 JS 侧状态发生变化(例如:数据返回、用户点击、内部动画触发):

  1. JS 中的 Compose Runtime 触发重组 → 布局 → 再次进入绘制阶段;

  2. 新一轮绘制指令通过 Binding 下发到 Native;

  3. Native 重新录制一份新的 Picture

  4. 业务层再次更新 Picture 状态,触发该区域的局部重绘。

整个过程对业务来说是透明的,状态在 KJS 内部流转,最终呈现的是像素变化。这种解决方案的一个可见的好处是:动态化区块的渲染结果是通过原生画布承接的,天然的不存在任何同层问题,在 KJS 实现中,通过剥离实体 Surface,使得驱动页面渲染绘制成为可能。同样的,在适配多平台时,因为最终的 js 产物多端一致,只需要实现一些原子化的 JS Binding 指令即可,迁移到其他平台的成本相对较低。

3.2 状态与交互

动态 UI 不可能只有"画一张静态图",交互是必须的。我们在两端都做了设计:

JS 侧:保留 Compose 的状态模型

在 KJS 侧,我们依然使用 Compose 的状态模型,例如 remembermutableStateOf 等,用来管理组件内部状态:

kotlin 复制代码
@Composable
fun DynamicButton() {
    var clicked by remember { mutableStateOf(false) }
    Button(onClick = { clicked = !clicked }) {
        Text(if (clicked) "Clicked" else "Click Me")
    }
}

这些状态变化会自然驱动 KJS 侧重组和重新绘制,从而生成新的绘制指令。

Native 侧:事件分发与同步

为了让用户的点击 / 滑动事件能反馈到 JS 侧,我们提供了一条事件通路:

  1. Native Compose 页面捕获点击 / 滑动等原生事件;

  2. 将相关信息(坐标、类型等)通过 Native Binding 传给 JS 侧的 composeScene;

  3. 在 JS 侧,映射到 Compose 的输入事件系统(如点击、滚动事件),触发对应的处理逻辑;

  4. 状态变化后,重新生成绘制指令 → 新 Picture → 局部重绘。

大部分图形绘制指令可以比较直接映射为 Skia 的 API(直线、矩形、圆形、路径、图片等)。

文字渲染则相对复杂,需要一些额外的工作:

3.3 文字渲染链路

在 Kotlin Native 侧,Compose 的主要工作是:

  1. 把声明式样式翻译成 Skia 能识别的字体资源和段落样式(读取、注册、解析、fallback);

  2. 拿到 Skia 算好的尺寸并把它画到 Skia Canvas 上

    真正的「断行、字形选择、光栅化」全部由 Skia 完成。

我们先来看看一次正常的文字渲染流程是啥样的:

阶段

输入

动作(关键源码)

输出

1 入口

TextLayoutInput(text, style, constraints, maxLines, overflow...)

layout(textLayoutInput)被调用

开始布局流程

2 预排版

同上

MultiParagraphIntrinsics(...) 构造

得到段落骨架 nonNullIntrinsics,含每段 intrinsics、字体、占位符等,但还没真布局

3 决定排版宽度

constraints + 骨架.maxIntrinsicWidth

val width = if (min==max) max else maxIntrinsicWidth.coerceIn(min, max)

最终用于布局的 width(像素值)

4 修正 maxLines

softWrap、overflow

val overwrite = !softWrap && overflow.isEllipsis val finalMaxLines = if(overwrite) 1 else maxLines

修正后的行数限制

5 创建 MultiParagraph

骨架、修正后的 width、maxLines、overflow

MultiParagraph(...)``init{} 内: ① 逐段创建 Paragraph ② 累加 height/lineCount ③ 提前 break 若已超限

得到真正的排版实体: - 总宽 = constraints.maxWidth.toFloat() - 总高 = currentHeight - lineCount - didExceedMaxLines - paragraphInfoList(含每段 y/行号/字符区间) - placeholderRects

6 包装成结果

原始 TextLayoutInput + MultiParagraph

TextLayoutResult(layoutInput = ..., multiParagraph = ..., size = ...)

返回的 TextLayoutResult 实例,所有查询/绘制都依赖它

总结下来其实是:

  • Kotlin/Native 侧的文字渲染依赖 Skia 的排版能力,向上层 kotlin 侧透出 SKParagraph,通过拿到 TextLayoutResult 来融合进 Compose 整体的布局测量流程,真正绘制时会调用 paragraph.paint(canvas)

  • 原生 KMP Compose 中,Skiko 把这部分封装好直接用,现在我们只能在 KJS 里封装了一套完整对应能力:

    • JS 侧描述文本内容、样式、约束(宽度、高度等);

    • Native 根据描述构建 Skia Paragraph,对文本布局与绘制进行处理,返回 TextLayoutResult,融合进已有的 Compose 布局体系。

    • 在录制阶段,把文字绘制透过 RecordingCanvas 纳入 Picture。

方案对比

4.1 与 KJS For Web 的对比

  • KJS For Web:

    • 强依赖 Web 环境(DOM、Canvas、WebAssembly);

    • 引入 skiko.wasm,在 JS + Wasm 中跑 Skia;

    • 通常需要 WebView,在首页/Tab 等原生核心场景难以接受。

  • 我们:

    • 不依赖 WebView,不使用 Wasm;

    • 完整渲染权留在 Native Skia;

    • KJS 只做逻辑层的 Compose Runtime,不启动第二套图形栈。

4.2 与 RemoteCompose 的对比

  • RemoteCompose:

    • 服务端生成"绘制文档";客户端通过 Remote Player 解释执行;

    • 更偏"服务端驱动 UI",客户端仅负责播放和行为回调;

    • 文档协议、操作模型、状态系统都是单独的一套。

  • 我们:

    • 动态产物是 KJS 编译后的 JS + Kotlin/JS Runtime;

    • 没有引入额外通用文档协议,也没有 Remote Player 黑盒;

    • 直接对接现有 Compose 框架与 Skia 渲染栈,掌控力更强,贴合 KMP 架构。

从业务视角看,我们的方案更接近于:

在原生 Compose 页面中,插入一块由 JS 驱动的 Compose 子树,这棵子树负责自己重组和"画什么",

而"怎么画"仍然交给原生 Skia。

性能基线

5.1 产物包大小

目前线下产物采用单产物方便快速验证,单产物 DCE + 混淆后,大小为 1.52 MiB(包含 kotlin runtime + compose foundation/ui/ui-graphics/ui-text 等必要的基础库)。

5.2 线下效果对比

此处为对比视频,见公众号文章

左:KJS Native

右:原生 KMP

5.3 线下数据对比

以上述简单的图文视图为例:

  1. KJS Native 最终的渲染指令为 113 条,从端侧创建 composeWindow 为起点,首帧耗时为 256 ms。

  2. 在 KMP 原生中,同样的视图首帧耗时为 180 ms。

总结与展望

从当前在鸿蒙端完成的端到端 POC 结果来看,KJS x Native 方案能够以相对低的改造成本,满足 KMP 业务对"动态化交付与快速迭代"的核心诉求:在不切换现有技术栈、不引入新的渲染体系、且不显著增加工程复杂度的前提下,把动态能力收敛在 KMP 技术栈内部,形成"产物生成---下发---加载---执行/渲染---监控回收"的闭环。这意味着业务仍可以沿用既有的 KMP 工程组织方式、研发协作方式与质量保障体系。

当然,要将 POC 推进到可规模化落地的工程能力,后续仍有不少工作亟需完善与产品化沉淀,包括但不限于:

  • 组件体系适配与体验对齐:推动 AntUI/Tecla 等组件库的接入与一致性适配,确保动态化场景下的样式、交互、无障碍、主题(深色/浅色)与多端一致性体验,尽可能做到业务侧"用起来像原生 KMP 开发"。

  • 工程化与链路完善:沉淀标准化的产物构建、版本管理、依赖治理与灰度发布策略,降低发布与运维成本。

  • 能力边界拓展与约束:在安全与性能可控的前提下,持续扩大可动态化的 UI/交互/数据能力,同时明确"哪些可以动态、哪些必须静态"的红线与最佳实践,形成可复用的业务接入规范。

业务侧引入该方案后,能直接获得更细粒度的"区块级动态化"能力:页面可以按区块拆分为可独立更新的单元,通过下发 JS 产物完成局部替换、样式/布局调整、交互逻辑调整等,避免传统整包发布的高成本与长周期。在此基础上,还可以进一步实现:

  • 区块级动态修复:线上出现问题时,可针对特定区块进行快速修复与回滚,把影响面控制在最小范围内,缩短故障处理链路。

  • 更灵活的逻辑/视图更新:在不触发完整发版的情况下,进行轻量的交互优化、文案与布局调整、策略实验等。

  • 快速 AB 与灰度验证:在性能与稳定性可接受的范围内,支持更高频的实验迭代,通过精细化人群与版本控制,加速验证效率与业务决策闭环。

从更前沿的业务形态看,方案也为 LLM 生成式 UI 打开了落地通路:可以由 KMP Agent 基于上下文与策略生成 UI/逻辑对应的 JS 产物,并结合风控与审核机制进行下发,再通过 KJS x Native 在端上渲染展示,实现"按需生成、按需更新"的交互体验。这不仅能扩展动态化的应用场景(如个性化内容编排、运营位智能生成、任务引导动态拼装等),也为后续探索"端侧智能 + 可控动态渲染"的组合提供了工程基础。

随着方案逐步完善并达到可规模化上线标准,我们将进一步补齐文档、示例、接入规范与基建能力(调试工具、性能监控、异常治理与安全策略等),在内部稳定运行一段时间后推动对外开源,促进社区共建与生态扩展。

相关推荐
AllinLin2 小时前
JS中的call apply bind全面解析
前端·javascript·vue.js
阿乐去买菜2 小时前
2025 年末 TypeScript 趋势洞察:AI Agent 与 TS 7.0 的原生化革命
前端
海绵宝龙2 小时前
Vue 中的 Diff 算法
前端·vue.js·算法
浩泽学编程2 小时前
内网开发?系统环境变量无权限配置?快速解决使用其他版本node.js
前端·vue.js·vscode·node.js·js
狗哥哥2 小时前
Vue 3 插件系统重构实战:从过度设计到精简高效
前端·vue.js·架构
巾帼前端2 小时前
前端对用户因果链的优化
前端·状态模式
不想秃头的程序员2 小时前
Vue3 中 Lottie 动画库的使用指南
前端
锐湃2 小时前
手写agp8自定义插件,用ASM实现路由跳转
java·服务器·前端
wordbaby2 小时前
TypeScript 类型断言和类型注解的区别
前端·typescript