编者按:在 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 渲染链路的前提下,我们给自己设定了三层目标:
-
体验目标:业务侧"像写普通 Compose 一样写动态 UI"
-
业务同学继续用 Compose DSL 写 UI,不需要学习新的 DSL 或框架;
-
动态与静态页面在代码结构上尽量一致,只是"跑的地方"不同:
-
静态 UI:即原生 KMP Compose 代码,直接在 Native Compose Runtime 中执行;
-
动态 UI:即编译生成的 JS 代码,在 JS Runtime 里执行 Compose 逻辑。
-
-
-
架构目标:拆分"计算 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 容器区域。
-
- 能力目标:先支持"局部动态化嵌入",后续再拓展到整个页面。 我们并不追求"一上来就把整页交给脚本",而是聚焦在更具体、可控的能力:
这样做的好处是:
-
在一个原生 Compose 页面中,某个区域由动态脚本驱动;
-
这个区域可以独立更新、独立灰度;
-
其余部分仍然是正常的原生 Compose 代码;
-
支持后续扩展为多块动态区域、甚至组合嵌入。
-
对现有业务页面改造成本小(添加一个动态容器 Composable 即可);
-
动态能力可以从简单场景逐步扩展到复杂场景;
-
一旦出现问题,可以快速降级为静态实现或关闭某个动态区域。
1.3 最终希望达到的状态
从业务视角来看,我们希望最后呈现出来的是这样一种体验:
-
写 UI 的方式不变:依旧是熟悉的
@Composable、Column、Row、Text等; -
多了一种"部署方式":
-
可以把这段 Compose 编译到 Native,随 App 发版;
-
也可以把这段 Compose 编译到 JS,通过脚本下发,在 JS Runtime 中运行;
-
-
在页面里用起来,大致是这样的:
@Composable fun HomePage() { Column { Header() // 原生静态区域
lessRemoteView( // 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,完成以下路径即可:
-
下发的 Compose DSL 解析-> Kuikly 组件树🌲
-
Native 侧 Kotlin 完成测量布局
-
通过渲染接口更新原生控件
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 则负责最终的像素级绘制。

但如果我们把这套设计直接搬到客户端原生环境,会遇到几个核心问题:
- 强依赖 Web 环境
-
KJS for Web 默认存在
document、Canvas、事件模型等浏览器能力; -
这意味着要在我们的场景中落地,通常需要引入 WebView,这是首页 / Tab 等关键场景无法接受的。
- 重复图形栈:多一套 Wasm Skia 没意义
-
客户端 Native 侧已经有一整套围绕 Skia 的渲染 pipeline;
-
再加载一个
skiko.wasm,等于在 App 里用 Wasm 再跑一份 Skia ------ 包体、内存、初始化成本都非常高,而且和现有渲染栈重复。
- 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的潜在问题是
-
官方能力仅支持 Android,目前为 Snapshot 版本
-
需要维护一个服务端应用,用来做状态同步,驱动服务端 compose 重组和渲染指令重录
-
交互场景可能会因为网络抖动发生延迟
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 录制与重放完成真实渲染。
这其中有三个关键点:
-
KJS 只承载逻辑侧的 Compose Runtime
-
JS 侧不引入
skiko.wasm,也不跑 Wasm 版 Skia; -
只保留 Kotlin/JS Runtime + Compose Runtime(重组、布局、测量);
-
在
CanvasDrawScope这一层统一收口所有绘制动作。
-
-
通过 JS Binding 把 Canvas 绘制指令映射到 Native
-
在 JS 侧改造
compose-ui / ui-graphics / ui-text的 JS target; -
剥离对 Skiko 的依赖,把最终的
drawRect/drawPath/drawText/...变成一组"跨语言绘制指令"; -
通过 JSI/NativeBinding 将这些指令传递到 Native。
-
-
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 中:
-
启动 Kotlin/JS Runtime + Compose Runtime;
-
运行动态区域对应的
@Composable函数; -
完成第一次重组 / 布局 / 测量;
-
进入改造后的
CanvasDrawScope,生成一组绘制指令(逻辑上类似:drawRect、drawText、drawImage等)。
此时,所有绘制动作都不直接画,而是以指令的形式,通过 JS Binding 送到 Native。
Step 3:Native 录制 Skia Picture
Native 侧:
-
收到"开始录制"的信号,创建一个
Skia PictureRecorder; -
遍历 JS 传来的绘制指令,逐条调用 Skia 对应 API,比如:
-
canvas.drawRect(...) -
canvas.drawPath(...) -
paragraph.paint(canvas)(文字链路我们通过封装进行适配,最终产出的也是);
-
-
结束录制,得到一份
Picture对象; -
通过 callback 或状态更新,将这份
Picture提交给业务层。
Step 4:业务 Compose 把 Picture 上屏
RemoteView 内部维护一个 State<Picture?>:
-
当 Native 录制完成,更新 Picture;
-
Compose 检测到状态变化,重新执行
RemoteView的draw逻辑; -
在对应的
DrawScope中调用drawPicture(picture),将 Picture 绘制到该容器区域。
到此为止,动态区域的首次展示完成。
Step 5:后续状态变化与局部重绘
当 JS 侧状态发生变化(例如:数据返回、用户点击、内部动画触发):
-
JS 中的 Compose Runtime 触发重组 → 布局 → 再次进入绘制阶段;
-
新一轮绘制指令通过 Binding 下发到 Native;
-
Native 重新录制一份新的
Picture; -
业务层再次更新 Picture 状态,触发该区域的局部重绘。
整个过程对业务来说是透明的,状态在 KJS 内部流转,最终呈现的是像素变化。这种解决方案的一个可见的好处是:动态化区块的渲染结果是通过原生画布承接的,天然的不存在任何同层问题,在 KJS 实现中,通过剥离实体 Surface,使得驱动页面渲染绘制成为可能。同样的,在适配多平台时,因为最终的 js 产物多端一致,只需要实现一些原子化的 JS Binding 指令即可,迁移到其他平台的成本相对较低。
3.2 状态与交互
动态 UI 不可能只有"画一张静态图",交互是必须的。我们在两端都做了设计:
JS 侧:保留 Compose 的状态模型
在 KJS 侧,我们依然使用 Compose 的状态模型,例如 remember、mutableStateOf 等,用来管理组件内部状态:
kotlin
@Composable
fun DynamicButton() {
var clicked by remember { mutableStateOf(false) }
Button(onClick = { clicked = !clicked }) {
Text(if (clicked) "Clicked" else "Click Me")
}
}
这些状态变化会自然驱动 KJS 侧重组和重新绘制,从而生成新的绘制指令。
Native 侧:事件分发与同步
为了让用户的点击 / 滑动事件能反馈到 JS 侧,我们提供了一条事件通路:
-
Native Compose 页面捕获点击 / 滑动等原生事件;
-
将相关信息(坐标、类型等)通过 Native Binding 传给 JS 侧的 composeScene;
-
在 JS 侧,映射到 Compose 的输入事件系统(如点击、滚动事件),触发对应的处理逻辑;
-
状态变化后,重新生成绘制指令 → 新 Picture → 局部重绘。
大部分图形绘制指令可以比较直接映射为 Skia 的 API(直线、矩形、圆形、路径、图片等)。
文字渲染则相对复杂,需要一些额外的工作:
3.3 文字渲染链路
在 Kotlin Native 侧,Compose 的主要工作是:
-
把声明式样式翻译成 Skia 能识别的字体资源和段落样式(读取、注册、解析、fallback);
-
拿到 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 线下数据对比
以上述简单的图文视图为例:
-
KJS Native 最终的渲染指令为 113 条,从端侧创建 composeWindow 为起点,首帧耗时为 256 ms。
-
在 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 在端上渲染展示,实现"按需生成、按需更新"的交互体验。这不仅能扩展动态化的应用场景(如个性化内容编排、运营位智能生成、任务引导动态拼装等),也为后续探索"端侧智能 + 可控动态渲染"的组合提供了工程基础。
随着方案逐步完善并达到可规模化上线标准,我们将进一步补齐文档、示例、接入规范与基建能力(调试工具、性能监控、异常治理与安全策略等),在内部稳定运行一段时间后推动对外开源,促进社区共建与生态扩展。