Compose First 与 View/Compose 互操作性能实战
Compose 与 View 互操作有两个方向:
AndroidView把一个传统View接入 Compose 的布局、绘制、输入与生命周期;ComposeView把一个 Compose 根接入既有 View 树。
两种方向的桥接成本来源不同,我们排查混合页面的性能问题时,要先分清自己面对的是哪个方向:AndroidView 这一侧的成本在 View 创建、measure/layout/draw、事件转发和实例复用;ComposeView 这一侧的成本在 Composition(组合)根、宿主提供的生命周期与状态保存能力、状态恢复,以及池化容器中的销毁时机。
池化容器会暂存离屏子项以便再次使用,RecyclerView 是典型例子。把混合页面笼统概括成“更慢”,会盖掉几处关键差异:实例有没有复用、属性设置调用是否重复,嵌套滚动和独立 Surface 也各有各的机制。本文就沿着这几处差异展开。
版本基线与术语校正
版本基线如下:
| 层级 | 基线 | 适用范围 |
|---|---|---|
| Android 平台 | Android 17 / API 37 / android-17.0.0_r1 | ViewRoot、HWUI、Surface、帧率请求与显示路径 |
| Jetpack Compose | Compose BOM 2026.08.00;androidx.compose.ui、androidx.compose.runtime、androidx.compose.foundation 1.12.0 | AndroidView、ComposeView、复用、跟踪与测试 |
| Kotlin / Compose 编译器 | Kotlin 2.4.10 / Compose Compiler Gradle 插件 2.4.10 | 编译报告与生成代码边界 |
| Android 公共内核参考 | android17-6.18-2026-06_r6;设备记录实际内核及厂商配置 | 调度、同步栅栏(fence)等设备侧机制;不决定互操作 API 语义 |
Compose 与 Android 平台分别发布,所以两边的源码要分开核对:android-17.0.0_r1 能固定 ViewRootImpl、SurfaceView、TextureView 和 HWUI 的平台实现;AndroidX Compose 的实现不在其中,要按对应构件的源码核查。
截至 2026 年 8 月 15 日,Google Maven 中最新的稳定 Compose BOM 是 2026.08.00,其中 androidx.compose.ui、androidx.compose.runtime 和 androidx.compose.foundation 都映射到 1.12.0。
BOM(物料清单)只协调 Compose 库版本,依赖的添加和 Kotlin 编译器插件仍由项目自己管理。Kotlin 2.0 起,Compose Compiler Gradle 插件与 Kotlin 使用同一版本,因此这里都取 2.4.10。
本文仍保留 1.11.4 的源码链接,用于说明上一轮核验的依据,并不表示推荐继续使用旧版本。
本文固定的 ui-android:1.12.0 AAR 元数据明确要求 minCompileSdk=37、minAndroidGradlePluginVersion=9.1.0;不能把任意 AGP 9.x 都当作满足要求。compileSdk 决定编译时能引用哪些平台 API,应用的 targetSdk 行为另由目标版本决定;构建工具一时升不上来的项目,就在自己当前能用的 Compose 版本上核验本文机制和基准数据。
Compose First 路线下的新页面与存量应用
先看官方路线的取向。在 Compose First 路线里,新的 UI 能力、示例和工具投入都优先落在 Compose 上;以 android.widget 控件为代表的 View 工具包,以及 Fragment、RecyclerView 等官方列出的基于 View 的库已进入维护模式,继续接收关键修复,但不再有显著功能更新;android.view 仍作为 Compose 等 UI 工具包的底层设施受到支持。对存量应用,这条路给出的是渐进迁移的建议:官方仍支持 View/Compose 互操作,既有页面的支持照旧。
那么存量页面按什么顺序迁?我们把迁移候选按业务改版计划、状态模型、平台适配收益和当前性能基线排序;统一技术栈本身,撑不起一次性重写的理由。
新页面可以默认使用 Compose,但相机、地图、广告、WebView、播放器和厂商 SDK 仍可能只提供 View 或独立 Surface,所以每个长期共存的互操作交界,都要记录宿主归属、替换条件、生命周期、状态来源和性能负责人。页面已经稳定、指标达标,又依赖复杂 View SDK 时,没有性能基线就贸然迁移,往往不如保留 View 可控。
迁移工具能覆盖的范围也有限:它们完成 XML、主题和组件结构转换,导航、状态保存、焦点、输入法(IME)、无障碍、资源释放与帧性能是否等价,要靠逐项验证来回答。所以每批只动一个范围清楚的区域,保留旧实现的截图、发布构建基准结果和性能跟踪,验证通过后再扩大范围。
Compose Multiplatform 属于另一项跨平台决策,Android 页面迁移成功之后,iOS、桌面或 Web 的组件、输入与性能表现仍要分别评估。
此外,有三项常见说法需要收窄,我们逐条对照源码:
| 常见说法 | Compose UI 1.12.0 中的边界 |
|---|---|
ViewTreeHostingRegistry 优化 | 公开 API 名为 ComposeViewContext,1.12.0 已去掉实验注解;当前源码没有名为 ViewTreeHostingRegistry 的通用公开 API |
PausableComposition 让 AndroidView 区域变快 | 可暂停的预组合能让 Lazy 布局和 SubcomposeLayout 的预取分批执行 Composition;View 的 factory 一旦调用,创建、测量和绘制仍由 View 自己完成 |
Modifier.Node 优化 View 事件链 | 基于节点的修饰符实现能减少 Compose 一侧的对象分配与更新成本;MotionEvent 仍要经过 pointerInteropFilter,再进入 View 的事件分发链 |
回到性能分析,我们要分别判断 Compose 侧成本、View 侧成本和显示系统成本;某个 Compose 版本特性只覆盖其中一段,整个混合页面有没有提速,要用基准数据回答。
一、两种互操作方向经过哪些对象
AndroidView:一个 Compose 布局节点代理一个 View 承载器
先看第一个方向。Compose UI 1.12.0 的 AndroidView 会创建 ViewFactoryHolder,它的父类 AndroidViewHolder 是一个 ViewGroup;这个承载器(holder)同时挂着一个 Compose LayoutNode,也就是 Compose 布局树里的节点。一帧之内,两者按下面的顺序配合:
- Compose 测量策略把自身的
Constraints(可用尺寸约束)与 View 的LayoutParams合成为MeasureSpec; - 承载器调用被包装 View 的
measure(); - Compose 用 View 的
measuredWidth、measuredHeight确定LayoutNode尺寸; - 放置阶段调用 View 的
layout(); - Compose 绘制修饰符经
AndroidViewsHandler调用承载器的draw(); - 指针输入、嵌套滚动、无障碍语义、窗口避让信息和“滚入可见区域”请求经过各自的互操作桥。
这一整套流程里,View 始终是 View:它没有被转成可组合函数,自己的测量、布局、绘制和事件模型都还在;被接管的只是外层调度,现在归 Compose LayoutNode 管。

图:固定外框可以减少尺寸波动;不因此跳过 View 自己的测量工作。

图:ViewFactoryHolder / AndroidViewHolder 把两个树的测量、放置和绘制连接起来。
ComposeView:一个 ViewGroup 承载一个 Compose 根
再看 ComposeView 方向。AbstractComposeView 是 ViewGroup,内部只允许 Compose 创建的 AndroidComposeView 子节点;ComposeView.setContent 会保存内容函数,View 已经附着到窗口时 Composition 立即建立,尚未附着时通常等到首次附着再建立。创建 Composition 时,Compose 按以下顺序解析父级 CompositionContext:
- 显式设置的父级
CompositionContext; - View 树中可找到的
CompositionContext; - 仍然有效的缓存
CompositionContext; - 当前窗口的
Recomposer。Recomposer负责接收状态变化并安排重组。
同一窗口中的多个 ComposeView 通常会找到相同的窗口 Recomposer,但这只是共享了 Recomposer;每个实例仍是独立的 Compose 根,有自己的 Composition、AndroidComposeView、LayoutNode 树、语义管理对象、状态注册和 View 测量边界。

图:解析优先级不合并各个 ComposeView 的 Composition、节点和状态。
普通互操作的出图路径
只包含普通 View 与 Compose 内容时,AndroidView 和 ComposeView 仍走应用主窗口(App Window)的标准出图路径:
Snapshot / Recomposer → Compose measure/layout/draw → AndroidComposeView → ViewRootImpl / HWUI → RenderThread → App Window BLASTBufferQueue → SurfaceFlinger → HWC → present
这条链路上的角色,我们按出现顺序过一遍:Snapshot 是 Compose 跟踪状态变化的快照系统,RenderThread 是 HWUI 执行渲染工作的线程,present 表示缓冲区进入显示阶段;BLASTBufferQueue 管理应用窗口缓冲区的提交,HWC 是显示硬件合成器。
在这条路径上,桥接本身增加的只是主线程对象与调用,缓冲区流仍是主窗口这一条。要独立缓冲区,得靠特定组件:SurfaceView 会维护独立的内容 Surface、BufferQueue 和 SurfaceFlinger 子层;TextureView 的外部缓冲区由宿主 HWUI 采样进应用主窗口,输入流通常也合在宿主窗口里,看不到独立图层。
标准硬件加速 WebView 的页面主体同样通过 HWUI 接口合入宿主窗口;要等到页面里出现网页视频、受保护内容或自定义 Surface,才可能多出一个媒体覆盖层。
地图、视频、相机控件走哪种结构,取决于控件自身的实现,控件名字说明不了出图结构。排查时要看的,是帧生产者(Producer)、缓冲队列和图层树。
二、AndroidView 的成本模型
factory、update、onReset 与 onRelease
成本从哪来,先看四个回调。官方契约和 1.12.0 源码给出的生命周期如下:
| 回调 | 调用语义 | 适合执行的工作 |
|---|---|---|
factory | 每个 View 实例调用一次,在 UI 线程执行 | 构造 View、设置一次性属性、注册长期监听器 |
update | factory 后至少调用一次;读取的 Compose 状态改变后可再次调用,在 UI 线程执行 | 以幂等方式把当前界面模型写入 View |
onReset | 使用支持复用的重载时,在兼容实例准备复用前调用 | 清除瞬时状态、动画、按压、临时监听器或旧列表项内容 |
onRelease | 实例永久离开 Compose 管理时调用一次 | 释放播放器、WebView、传感器、线程、回调等资源 |
其中 factory 最容易吃掉 UI 线程时间:加载复杂 XML、构造 WebView 或启动播放器,都在当前 UI 线程上直接花时间。把 View 预先放进 remember 绕不过这个成本,还可能破坏宿主、附着和复用语义,所以官方建议在 factory 内创建 View。
再次触发 update 的条件,是它这次执行读取了哪些状态:AndroidViewHolder 会用 OwnerSnapshotObserver 观察其中读取的 Snapshot(快照)状态,也就是 Compose 可跟踪的状态。举例来说,如果 model.title 与 model.image 分别由独立的 Snapshot State 支撑,而 update 只读取前者,那么后者变化不会直接命中这次读集。若两字段来自同一个整体 State<Model>,读取这个 State 就会观察整个对象;普通可变字段也不会凭字段名自动获得 Snapshot 观察能力。
普通参数捕获走另一条路径:宿主可组合函数重组并提供新的 update 函数时,承载器会尝试运行新函数。1.12.0 的 runUpdate 还要求 holder 已附着窗口、内部 View 的父节点仍是该 holder,且已安装 update block;未附着或停用时会延后到附着或重新激活后执行。两条路径最后汇到同一处风险上——昂贵的属性设置调用可能把一次轻量状态更新扩大成 View 的重新布局、重绘或资源请求。
下面的宿主实现展示一次性监听器、幂等更新和资源释放各自所在的位置。
@Composable
fun LegacyChartHost(
model: ChartModel,
onSelectionChanged: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val currentSelectionCallback by rememberUpdatedState(onSelectionChanged)
AndroidView(
modifier = modifier,
factory = { context ->
LegacyChartView(context).apply {
setOnSelectionChangedListener { id ->
currentSelectionCallback(id)
}
}
},
update = { view ->
if (view.model !== model) {
view.model = model
}
},
onRelease = { view ->
view.setOnSelectionChangedListener(null)
view.release()
},
)
}
监听器只安装一次,并通过 rememberUpdatedState 取得当前回调;update 在引用未变化时跳过属性设置;终止型清理放在 onRelease。
还有一个配套条件:如果模型是可变对象、只在原对象上更新内容,引用比较就识别不出变化,这时应改用不可变界面模型、版本号或字段比较。
AndroidViewBinding 沿用同一套桥接与复用语义
AndroidViewBinding 来自 androidx.compose.ui:ui-viewbinding:1.12.0。它调用生成的 ViewBinding 工厂函数加载 XML,把绑定对象保存在根 View 上,再用 AndroidView 承载这个根节点。省掉的是手写 findViewById 和类型转换;XML 加载、View 测量/布局/绘制和输入桥的成本,原来有多少现在还是多少。
1.12.0 同样提供带 onReset、onRelease 的重载:提供非空 onReset 时,View 与绑定对象就能在结构兼容的 Lazy 列表项之间复用;两个回调的时序与 AndroidView 一致。布局包含 FragmentContainerView 时还有几点要留意:
AndroidViewBinding会优先使用父 Fragment 的LayoutInflater,让 XML 中的 Fragment 成为父 Fragment 的正确子 Fragment;- 永久释放时先运行调用方的
onRelease,再遍历根布局中的FragmentContainerView; - 找到 Fragment 且
FragmentManager.isStateSaved == false时,内部用commitNow移除;状态已经保存时不会强行提交事务; - 官方源码不建议为承载 Fragment 的绑定对象刻意启用 Lazy 复用,因为 Fragment 有独立的 View 生命周期,通常也不处于能稳定获益的复用场景。
因此,普通 XML 列表项可以按 AndroidView 的复用方法处理;包含 Fragment 的布局则应优先保证 FragmentManager、状态保存和销毁顺序正确,View 创建次数只是其中一个观察维度。
状态变化到 View 工作的传导
一次 Compose 状态变化会触发哪些工作?答案取决于读取位置和属性设置行为:
- 状态在
update中被读取:Snapshot 状态观察器会安排再次执行update; - 属性设置只调用
invalidate():AndroidViewHolder最终调用LayoutNode.invalidateLayer();View 报告的局部脏区不会原样传到 Compose 图层; - 属性设置调用
requestLayout():AndroidViewsHandler.requestLayout()找到承载器对应的LayoutNode,并调用requestRemeasure()请求重新测量; - View 的测量尺寸变化:Compose 在重新测量后再次放置受影响节点;
- 状态只在父级修饰符或布局中读取:可能改变 Compose 布局,却不会运行无关的 View 属性设置。
Compose UI 1.12.0 还处理了绘制期间发生的 View 失效请求。AndroidViewHolder 若正在绘制,会用 postOnAnimation 把图层失效延后到下一帧,让本次绘制中发生的失效请求能在后续动画回调重新请求图层绘制;这不是保证每帧只调用一次 draw 的全局去重机制。
所以分析性能时,我们要区分四个计数:update 次数、View requestLayout 次数、View invalidate 次数和最终帧数;后三项是重组计数替代不了的。
避免 View → Compose → View 的反馈循环
双向控件容易形成下面的循环:
- Compose 把值写入 View;
- View 的属性设置同步触发监听器;
- 监听器又写入 Compose 状态;
- 新状态促使
update再写 View。
处理方式是确定一个状态所有者,并在两边都做等值保护。我们以传统滑块为例。
@Composable
fun LegacySliderHost(
value: Int,
onValueChange: (Int) -> Unit,
) {
val currentOnValueChange by rememberUpdatedState(onValueChange)
AndroidView(
factory = { context ->
LegacySlider(context).apply {
setOnValueChangedListener { newValue, fromUser ->
if (fromUser) {
currentOnValueChange(newValue)
}
}
}
},
update = { slider ->
if (slider.value != value) {
slider.value = value
}
},
onRelease = { slider ->
slider.setOnValueChangedListener(null)
},
)
}
这份实现里,Compose 参数是唯一状态源,View 只上报用户操作,update 避免重复赋值。如果旧控件没有 fromUser 参数,可以在宿主维护“正在应用外部状态”的标记,或在监听器中比较新旧值;update 内的无条件回写,则要避开。

图:rememberUpdatedState 让一次安装的监听器读取当前处理器;旧控件另需来源或等值保护。
View 成本按操作拆分
| 操作 | 常见主线程成本 | 可能引起的后续工作 |
|---|---|---|
| XML 加载 / 构造 | XML 解析、反射或构造、资源读取、子 View 创建 | 测量、布局、首次绘制 |
| 属性设置 | 文本测量、可绘制资源(drawable)更新、监听器通知 | requestLayout() 或 invalidate() |
| 测量 | ViewGroup 遍历、文本或图片尺寸计算 | 父子布局变化 |
| 布局 | 坐标分配、滚动、焦点和窗口避让处理 | 绘制、位置回调 |
| 绘制 | Canvas 指令记录、图片上传、复杂路径或着色器 | RenderThread / GPU 工作 |
| 输入桥 | MotionEvent 转发、手势识别、嵌套滚动 | 状态更新与新帧 |
| 永久释放 | 停止异步任务、解绑监听器、销毁原生资源 | 回收抖动或泄漏风险 |
固定控件尺寸能减少一部分测量波动,View 自身的测量仍要照常进行。同理,重复设置同一段文本未必是空操作,具体取决于控件实现;业务层是否添加幂等保护,应以目标 View 源码或基准测试为依据。
三、Lazy 容器中的 AndroidView 复用
列表场景会把 View 创建成本放大:离屏子项被反复回收、再反复要回来,复用安排不好,创建就会反复发生。这一节我们围绕复用展开:onReset 的语义、复用时要清理什么,以及用哪些计数判断复用有没有生效。
onReset 与 View 实例复用
Compose UI 提供两组 AndroidView 重载,复用与否由参数决定:带非空 onReset 的版本会创建可复用节点,没有 onReset 的版本不会在可复用内容容器中复用 View。两个 AndroidView 能否互换,取决于它们在 Compose 中是否具有相同的分组结构,业务 key 只是条件之一。
下面的列表项宿主明确区分“准备给新内容复用”和“实例永久退出”两种情况。
@Composable
fun LegacyVideoRow(
item: VideoRowModel,
modifier: Modifier = Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context -> LegacyVideoPreview(context) },
update = { preview ->
preview.bind(item)
},
onReset = { preview ->
preview.pause()
preview.clearTransientUi()
preview.setPressed(false)
},
onRelease = { preview ->
preview.release()
},
)
}
onReset 之后,View 可能进入 deactivated(暂时停用但仍由 Compose 保留)状态,下一次 update 什么时候到来并没有保证,所以重置后的 View 必须先回到安全状态,把旧列表项的内容清掉。onRelease 则是终止语义,同一实例后续不会再复用。
复用时应清理什么
复用检查至少覆盖:
- 旧列表项的监听器、标签、选择态、按压态、激活态与无障碍文案;
- 尚在运行的动画、延迟任务、图片请求、协程或播放器任务;
RecyclerView、WebView、地图、视频组件自己的滚动与资源状态;- 瞬时状态和焦点;
- 与旧
LifecycleOwner、SavedStateRegistryOwner或业务宿主绑定的订阅; - 失败占位、错误文案与内容描述。
清理的分寸两头都有代价:清理过少会让不同列表项串内容,清理过度会让每次复用接近重新创建。资源型 View 可以保留可复用引擎,把列表项专属会话停在 onReset,把原生引擎销毁留到 onRelease,前提是组件 API 明确支持这种生命周期。
创建数与绑定数的记录
只观察平均帧率很难定位复用是否生效。我们建议为每种 View 记录:
factory调用数;onReset、update、onRelease调用数;- 单次加载或创建与数据绑定的实际耗时;
requestLayout()和invalidate()频率;- 滚动期间的对象分配量、垃圾回收次数与未释放实例;
- UI 线程、RenderThread 与 FrameTimeline 中的帧期限错过情况。FrameTimeline 是 Perfetto 中关联应用帧、合成帧和显示期限的轨道。
判断就落在这两组数上:同一数据集和相同滚动脚本下,factory 次数明显下降而数据绑定耗时稳定,才说明复用减少了创建成本;若 onReset + update 比新建还贵,就应回到控件实现,重新划分哪些工作放在重置、哪些放在销毁。
四、ComposeView 嵌入 View 树
方向反过来之后,问题的重心从 View 创建换成了 Composition 的建立与销毁:它什么时候建、什么时候拆,直接决定 Fragment 重建和列表回收这两个场景的开销。我们先看销毁策略。
Composition 的销毁策略
销毁时机上,Compose UI 1.12.0 的默认策略是 DisposeOnDetachedFromWindowOrReleasedFromPool:
- 在普通 View 树中,从窗口分离会销毁 Composition;
- 位于
RecyclerView等池化容器时,列表项短暂分离会保留 Composition; - 列表项从池中永久释放、容器与窗口分离或池满丢弃时,池化监听器会销毁 Composition。
Fragment 场景最容易踩错的地方在这里:View 的生命周期短于 Fragment 实例的生命周期,所以推荐把 Composition 绑定到下一次附着时找到的 View 树 LifecycleOwner。下面的写法适用于 Fragment 的 View。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val composeView = view.findViewById<ComposeView>(R.id.compose_content)
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
AppTheme {
ProfileContent()
}
}
}
该策略在 Fragment 的 View 生命周期进入 ON_DESTROY 时销毁 Composition,Fragment 实例即使仍然存活,也不会继续持有已经销毁的 View 树。换成自定义宿主或手工创建的窗口时,我们要额外确认 ViewTreeLifecycleOwner 与 SavedStateRegistryOwner 是否存在。
RecyclerView 临时回收时的 Composition 处理
默认池化策略就是为 RecyclerView 的短暂分离设计的,临时回收时让 Composition 留在池里即可;在每次 onViewRecycled() 中都调用 disposeComposition(),反而会失去对象池保留 Composition 的收益,并在列表再次需要该 ViewHolder 时重新创建整个 Composition。
一种稳定写法是在 ViewHolder 初始化时调用一次 setContent,之后的数据绑定只更新 ViewHolder 持有的 Compose 状态。
class ComposeItemHolder(
parent: ViewGroup,
) : RecyclerView.ViewHolder(ComposeView(parent.context)) {
private var model by mutableStateOf<ItemUiModel?>(null)
init {
(itemView as ComposeView).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
)
setContent {
AppTheme {
model?.let { item ->
key(item.id) {
FeedRow(item)
}
}
}
}
}
}
fun bind(newModel: ItemUiModel) {
model = newModel
}
fun clearBinding() {
model = null
}
}
setContent 只执行一次,RecyclerView 的数据绑定转为状态更新;适配器应在合适的 onViewRecycled(holder) 路径调用 clearBinding();它把 holder 的 model 引用置空,Composition 继续留在池中。示例假定 ItemUiModel.id 是稳定唯一的业务 ID,key(item.id) 使不同实体的组合身份分开;普通 remember 在实体切换时重建,但跨回收或进程重建保留的业务状态仍应提升。清空状态需要经过后续组合才会移除旧子树,不能代替资源组件立即停止旧会话的生命周期协议。若列表项含有通过 remember 保存的专属状态,应使用稳定 key、把状态提升到列表模型,或明确设计保存与恢复策略。
多个 ComposeView 的内存模型
“每个 ComposeView 都有一个 Recomposer”是常见误解,默认窗口路径上同一窗口通常共享窗口 Recomposer;每个 ComposeView 仍保留以下实例级对象:
- 自己的 Composition 和槽位表(slot table);槽位表是 Compose 运行时记录组合结构与
remember数据的内部表; AndroidComposeView与 LayoutNode 树;- 无障碍语义、焦点、指针输入和 Snapshot 状态观察;
- 每个根各自的测量、布局和绘制边界;
SaveableStateRegistry的接入与注册内容;- 业务层
remember、副作用、图片和缓存对象。
所以十个很小的 ComposeView 与一个包含十个子节点的 ComposeView,在内存、调度和检查器结构上并不相同;差额没有跨项目通用的固定字节数,我们应在目标版本上用堆转储、对象分配分析和 Macrobenchmark 测量——Macrobenchmark 是从应用进程外驱动启动、滚动等完整操作路径的基准测试框架。
多个静态 ComposeView 还要使用唯一 View ID,官方文档明确要求这样才能正确恢复 savedInstanceState。RecyclerView 列表项内的局部界面状态更适合提升到稳定的列表项模型;把业务状态交给 ViewHolder 的临时 ID 保存,会受复用和进程重建的影响。
Compose UI 1.12 的 ComposeViewContext
ComposeViewContext 在 Compose UI 1.11 引入,1.12.0 已去掉实验注解。多个 ComposeView 落在兼容的 Context 和同一宿主范围内时,它可以共享一部分宿主对象,例如:
- 配置状态、资源 ID 缓存与矢量图缓存;
- 字体加载器与字体解析器;
- 无障碍、剪贴板、触觉反馈和窗口信息相关对象;
- 共享绘制作用域、
CanvasHolder等绘制辅助结构。
共享范围受 View Context、LifecycleOwner、SavedStateRegistryOwner 和部分功能开关约束;Fragment 建立新的宿主边界后,查找会在这个边界停住,或者新建一个上下文。合在一起的只是宿主对象,每个 Composition 的业务状态仍归自己。
该 API 还支持一种预热用法:先用一个已附着的 View 创建 ComposeViewContext,再为尚未附着的 ComposeView 调用 createComposition(context),可用于 RecyclerView 预组合。ComposeViewContext 会延迟解析部分宿主值,不能把要求简化成“构造函数调用瞬间一律检查附着”。公开契约要求参考 View 在使用该上下文的 ComposeView.setContent 前已附着;createComposition(context) 还会明确检查参考 View 的 isAttachedToWindow,且该 View 必须在上下文有效期间保持附着。
若预创建的 ComposeView 始终没有附着,调用方必须执行 disposeComposition()。这条预热路径的取消与销毁都要自行管理;所有 ComposeView 都会自动预取的保证,在这里并不存在。

图:预热由调用方安排和取消,不是所有 ComposeView 都自动预取。
五、RecyclerView 与 Lazy 容器混合滚动
两类容器叠在一起后,同一份帧预算要分给两侧的输入、动画、布局和渲染,滚动控制权也需要明确归属。我们按帧预算、列表项组织、嵌套滚动和迁移比较的顺序来看。
整帧共享的帧预算
常见刷新率对应的理论周期如下:
| 刷新率 | 周期 |
|---|---|
| 60 Hz | 16.67 ms |
| 90 Hz | 11.11 ms |
| 120 Hz | 8.33 ms |
这些周期只提供刷新节奏参考,不是给每一阶段各发一份预算。Compose 与 View 的同帧 UI 工作通常共享主线程;RenderThread、GPU、SurfaceFlinger 之间则可能流水或重叠,不能把所有阶段墙钟简单相加。应从 FrameTimeline 的目标帧与期限,沿实际依赖、调度等待和同步栅栏判断关键路径;“Compose 8 ms + View 8 ms”本身不足以证明 60 Hz 下按时。
AndroidView 放进 LazyColumn
外层使用 LazyColumn 时,我们检查以下六点:
- 提供非空
onReset,验证实例复用; - 给列表项使用稳定
key和合理contentType; - 避免数据绑定时无条件请求布局;
- 尽量保持 View 高度稳定,异步内容到达后减少反复改尺寸;
- 在
onReset中停止旧列表项的动画和异步请求; - 在
onRelease中关闭不可复用资源。
若 AndroidView 自己又包含竖向 RecyclerView,外层 LazyColumn 与内层列表会争夺滚动、预取、测量和可见性管理。短内容可以让内层一次性展开;大数据场景通常只保留一个负责竖向滚动的容器,保留按需创建、有限预取与复用缓存的虚拟化收益;离屏缓存、预取或固定项目仍可能存在。
ComposeView 放进 RecyclerView
方向反过来,把 ComposeView 放进 RecyclerView 时,列表项按以下规则处理:
- ViewHolder 初始化时只调用一次
setContent; - 数据绑定只更新稳定、不可变的界面模型;
- 保留默认池化策略,让 Composition 在短暂分离期间继续存在;
- 列表项状态按稳定 ID 存入模型或 ViewModel,不依赖 ViewHolder 位置;
- 记录 ViewHolder 创建、数据绑定、重组与测量次数;
- 为图片、富文本和自定义布局建立单独的性能跟踪区段。
RecyclerView 预取与 Compose 的组合、布局各有调度成本。Compose UI 1.12 的 ComposeViewContext 可以提前完成一部分 Composition,但预热过多列表项会提前消耗 CPU 和内存。预取数量应由滚动速度、列表项成本和设备档位共同决定。
嵌套滚动桥的传递范围
滚动衔接上,AndroidViewHolder 实现了 NestedScrollingParent3:内部 View 开启嵌套滚动后,承载器会把滚动前、滚动后和惯性滑动数据交给 Compose 的调度器,常见的嵌套滚动因此可以跨越 View/Compose 边界。
控制权的冲突要另外对待,以下情况仍需专项验证:
- 两个同方向、都可无限滚动的容器;
- 内层使用
requestDisallowInterceptTouchEvent改变手势控制方; - RecyclerView 列表项内嵌高度不受限的 LazyColumn;
- 惯性滑动交接时两边都启动衰减动画;
- 越界回弹、下拉刷新、AppBar 和无障碍滚动同时存在;
- SurfaceView/地图等组件还有自己的手势和帧循环。
所以多数页面只保留一个主要的竖向滚动容器;竖向列表中放横向轮播,是范围清楚、容易单独验证的常见例外。
分批迁移与全量迁移怎样比较
迁移策略应按风险和测量结果选择:
| 策略 | 优点 | 风险 | 适合场景 |
|---|---|---|---|
| 页面级 ComposeView | 宿主和回退边界清楚;便于 A/B 对比 | 新建一个 Compose 根;页面内仍可能调用旧 View | 独立新页面、功能边界完整 |
| Fragment 内局部替换 | 改动范围小;可逐块验证 | 多个根、焦点/状态/布局交界增多 | 老页面中低耦合区域 |
| RecyclerView 列表项使用 ComposeView | 可按列表项类型迁移 | 高频数据绑定、池化、状态恢复要求高 | 列表项类型少且基准完备 |
| Compose 中保留 AndroidView | 可复用成熟地图、视频、编辑器 | View 创建、布局、事件和资源生命周期仍在 | 暂无等价 Compose 组件 |
| 一次全量迁移 | 根与滚动控制方容易统一 | 回归面大,难定位差异来源 | 页面较小、测试充分、旧实现负担高 |
全 Compose 导航栈同样要靠测量说话:它减少部分 View/Compose 边界,也可能引入新的组合、导航、图片或布局成本,所以每个阶段都应使用同一条用户操作路径、相同数据与发布构建进行比较。
六、Layout Inspector、编译器指标与 Perfetto
工具这一侧的分工是:Layout Inspector 负责找到对象,编译器报告负责宿主函数的生成属性;时间最终花在哪里,要靠自定义跟踪区段和 Perfetto 量出来。
Layout Inspector 上的结构与重组线索
混合页面在 Layout Inspector 中会同时出现 View 层级与 Compose 节点:AndroidView 仍能在 View 一侧找到被包装实例,ComposeView 中会显示对应的 Compose 根、语义与布局信息。
它适合回答:
- 页面有多少 Compose 根和 AndroidView;
- 某个可组合函数是否频繁重组或跳过;
- View/Compose 边界的尺寸、位置和宿主是否符合预期;
- 相同列表项是否不断创建新根。
Layout Inspector 会插入额外观测代码并增加开销,所以它的分工是定位对象:先用它找到位置,再关闭工具,在发布构建上运行 Macrobenchmark 与 Perfetto 采集最终性能数据。
编译器指标的适用范围
编译器报告会把 AndroidView 的宿主可组合函数当作普通可组合函数分析,能显示函数能否重新执行、能否跳过,以及参数稳定性。下面这些数据不在它的范围内:
factory调用了多少次;- View 的测量、布局和绘制花了多久;
- 属性设置是否触发
requestLayout(); - 监听器是否形成反馈循环;
- RecyclerView 是否复用了 ViewHolder;
- SurfaceView 的帧生产者是否按时提交缓冲区。
所以编译器报告适合检查宿主函数的生成属性,View 内部交给自定义跟踪区段、计数器和系统跟踪。
用自定义跟踪区段标出桥接工作
下面用 androidx.tracing.trace 标记昂贵的 View 更新和列表项数据绑定。
AndroidView(
factory = { context ->
trace("LegacyMap.factory") {
LegacyMapView(context)
}
},
update = { mapView ->
trace("LegacyMap.update") {
mapView.render(model)
}
},
onRelease = { mapView ->
trace("LegacyMap.release") {
mapView.destroy()
}
},
)
这些时间区段会出现在系统跟踪中,便于和 Choreographer#doFrame、View 树遍历、RenderThread 及 FrameTimeline 对齐。命名上保持稳定、避开用户数据;高频、低成本的属性设置就直接跳过,免得测量开销干扰样本。
Compose 运行时跟踪能提供可组合函数级跟踪信息,但某个 AndroidView 中的全部 View 方法要我们自己补齐:工厂创建、数据绑定、异步结果处理和资源释放,都应加上自定义时间区段。
Perfetto 的阅读顺序
对互操作卡顿,我们按下面的顺序建立判断依据:
- 用 FrameTimeline 找到错过显示期限的应用帧;
- 查看应用主线程中的 Compose、View 树遍历、自定义跟踪区段和线程调度状态;
- 对齐 RenderThread 的
DrawFrame、GPU 完成时间与缓冲区出队/入队操作; - 确认页面属于标准 App Window、SurfaceView、TextureView 或混合出图;
- 有独立 Surface 时,单独跟踪对应的帧生产者、
BufferQueue、图层事务与同步栅栏; - 回到调用计数,确认问题来自创建、数据绑定、重新测量、重绘、垃圾回收,还是显示侧等待。
图层事务负责把尺寸、位置、层级等变化提交给 SurfaceFlinger;同步栅栏(fence)表示某次缓冲区读写何时完成。
主线程空闲时,SurfaceView 的帧生产者、RenderThread、GPU、SurfaceFlinger 或显示设备仍可能迟到,页面照样可能出问题;主线程繁忙时,传统 View 的 XML 加载、文本测量和监听器也可能占掉同一段时间。归因时先看跟踪里的依据,再判断是不是 Compose。
七、Compose UI 1.12 相关机制的适用边界
本节只解释这些 Compose 机制会怎样影响互操作边界;编译器诊断、稳定性判定和 Modifier 节点迁移的完整方法由 Compose 编译器、稳定性与 Modifier.Node 性能诊断 展开。
PausableComposition
先看 PausableComposition。这个 API 从 androidx.compose.runtime 1.8.0 起存在;Foundation 1.12.0 的 ComposeFoundationFlags.isPausableCompositionInPrefetchEnabled 默认值为 true,Lazy 预取可以把一个列表项的预组合分段执行,避免复杂列表项的全部 Composition 集中在同一个调度片段。
这个开关用于出现回归时暂时关闭新路径,且后续版本可能移除;性能报告应记录 Compose 版本和开关值,不要把该机制推广到所有 Composition。
以下成本仍需单独处理:
AndroidView.factory开始执行后,View 构造和 XML 加载仍占用 UI 线程;- View 的测量、布局和绘制不会被可暂停组合拆分;
- 复用仍要求非空
onReset和兼容的 Compose 分组结构; - WebView、地图、视频等组件的内部线程与原生资源不受 Compose 暂停语义管理;
- 预组合但未附着的
ComposeView仍要遵守主动销毁规则。
所以 PausableComposition 的定位是预取调度工具;互操作组件要提速,还是回到各自的成本模型上想办法。
Modifier.Node
AndroidViewHolder 的核心修饰符覆盖嵌套滚动、无障碍语义、pointerInteropFilter、绘制与全局位置回调。Compose UI 内部使用 Modifier.Node 架构后,修饰符更新可以减少部分对象创建和链遍历。
View 侧仍会收到标准 MotionEvent,继续执行 dispatchTouchEvent、手势识别、嵌套滚动和监听器。若地图或复杂手势控件出现输入延迟,我们要同时测量 Compose 指针桥和 View 自身的事件分发,修饰符的对象分配量只是其中一个维度。
ComposeViewContext
1.12.0 的共享实现能减少同一宿主区域内多个 ComposeView 的部分重复宿主对象;业务 Composition、每个根的 LayoutNode 和 remember 内容,仍留在各自实例上。
公开的 ComposeViewContext 在 1.12.0 已不再带实验注解。应用若显式使用它做离树预组合,仍应固定 Compose UI 版本,并测试附着、取消预取、宿主切换和始终不附着四条路径——API 稳定之后,资源的销毁仍由调用方负责。
八、SurfaceView、TextureView 与帧率适配
内嵌 View 会不会改变出图拓扑,宿主与内容的帧率请求要不要分别表达,是这一节的两个问题。SurfaceView/TextureView 的生命周期治理与自适应刷新率策略分别由本章对应专题展开。
三种出图拓扑
| 内嵌内容 | 内容缓冲区去向 | SurfaceFlinger 可见结构 | 常见额外风险 |
|---|---|---|---|
| 普通 View | 记录进宿主 HWUI / App Window | 主体仍是 App Window 图层 | 主线程遍历、RenderThread / GPU |
| TextureView | 外部缓冲区被 HWUI 采样进 App Window | 外部流通常没有独立可见图层 | 内容生产与宿主绘制两段工作、纹理采样、宿主帧依赖 |
| SurfaceView | 帧生产者提交到独立 Surface | 宿主图层加 SurfaceView 子层树 | 两条帧节奏、几何事务、同步栅栏与 HWC 合成 |
AndroidView 只描述 UI 宿主方式;出图类型要看内嵌内容本身——包装普通 TextView 与包装视频 SurfaceView 的性能模型相差很大。另一个独立边界是裁剪:AndroidView 不会自动把内容裁到 Compose 布局边界;需要时应按官方契约在子 View 设置 clipToOutline 并确认有效 outline,SurfaceView 子类尤其要验证实际内容是否越界。
自适应刷新率的请求边界
Android 15 QPR1(首个季度平台更新)起,受支持设备可以使用自适应刷新率(Adaptive Refresh Rate,ARR),让系统根据可见内容调整刷新率;Android 17 上仍要先用 Display.hasArrSupport() 确认设备支持。请求的入口分两侧:View 可以通过 requestedFrameRate 发出请求,Compose 从 1.9 起提供 Modifier.preferredFrameRate。
混合树中应遵守这些边界:
- ViewGroup 的帧率请求不会自动传播给子 View;
- Compose 修饰符的偏好不会自动设置
AndroidView内部 View 的requestedFrameRate; - 独立 Surface 的帧生产者还可能需要在 Surface、媒体或图形 API 边界表达自己的帧率;
- 系统会汇总可见内容的请求,最终显示模式仍受面板能力、功耗策略和其他图层约束;
- 60/90/120 Hz 的切换结果要从性能跟踪、SurfaceFlinger 信息和设备显示状态确认。
例如,我们可以让 Compose 外层动画请求高帧率类别,内嵌视频保持 24/30 fps,两条内容节奏各自独立:宿主动画按需触发布局或绘制,不必每帧重组;视频缓冲区仍由视频自己的节奏产生;SurfaceView 的视频更新同样走自己的帧节奏,宿主应用窗口无需跟着重画。
九、测试互操作边界
互操作的测试要盖住两侧的生命周期,纯 JVM 单测和 Android 仪器化测试各管一段。我们先看怎么把 View 构造隔离成窄接口,再看两套测试 API 怎么混用。
把 View 构造抽成窄接口
复杂 Android View 很难在纯 JVM 测试中忠实模拟,这时我们可以把创建 View 的工厂抽成窄接口,让宿主状态映射可以单独测试,同时让 Android 仪器化测试继续使用真实 View。
fun interface LegacyChartFactory {
fun create(context: Context): LegacyChartView
}
@Composable
fun ChartHost(
factory: LegacyChartFactory,
model: ChartModel,
) {
AndroidView(
factory = factory::create,
update = { chart -> chart.render(model) },
onReset = { chart -> chart.resetForReuse() },
onRelease = { chart -> chart.release() },
)
}
测试的分工由此清楚:单元测试覆盖 ChartModel 映射和工厂选择;Robolectric 或仪器化测试负责 Looper 消息循环、View 树宿主、测量/布局、输入与资源生命周期。大量模拟 View 内部行为,通常重现不了这些平台层面的行为。
Espresso 与 ComposeTestRule 混用
官方测试 API 支持在同一个仪器化测试中分别查询 View 与 Compose 节点,两套体系可以混用。若多个 RecyclerView 行或 Fragment 中有相同 Compose 文案,UI 1.12.0 的实验性 onRootWithViewInteraction 可先用 Espresso 唯一定位宿主 View,再把 Compose 查询限定到其后代根;需显式采用 ExperimentalTestApi。该 API 用于 Views 内的 Compose,Compose 内的传统 View 仍用 Espresso 查询。下面的测试先操作传统按钮,再断言 Compose 文案。
@get:Rule
val composeRule = createAndroidComposeRule<InteropActivity>()
@Test
fun legacyButtonUpdatesComposeState() {
onView(withId(R.id.legacy_increment)).perform(click())
composeRule
.onNodeWithText("Count: 1")
.assertIsDisplayed()
}
Compose 的虚拟 MainTestClock 驱动组合、动画与手势,不控制 Android 平台的 measure/draw 时钟;但这不等于测试规则完全不等布局绘制:waitForIdle() 也会等待待处理的布局与绘制。规则无法自动识别未注册的后台请求、播放器回调等外部工作,这些才需要额外的明确空闲条件。等待 View 或外部条件时,用能表达“何时空闲”的 IdlingResource,或用 waitUntil 轮询明确条件;固定时长的休眠只会把竞态盖住。
互操作测试范围
至少覆盖:
- 附着 → 分离 → 再次附着;
- Fragment 的 View 销毁后,Fragment 实例仍然存活;
- RecyclerView 回收、对象池复用、对象池丢弃;
- Lazy 列表项暂时停用、复用、永久释放;
- 配置变更、进程状态恢复与唯一 View ID;
- 无障碍焦点在 View/Compose 之间移动;
- 嵌套滚动、惯性滑动、返回手势与
requestDisallowInterceptTouchEvent; - SurfaceView/TextureView 可见性、尺寸、前后台和资源释放;
- 预组合取消且 View 始终不附着;
- 60/90/120 Hz 与支持 ARR 的设备上的帧率请求。
十、三阶段迁移与性能门槛
迁移本身也分阶段推进,每个阶段都有自己的观察项和验收标准,我们按单页面、Fragment 内混合、Compose 导航栈三步走。
阶段 1:单页面 ComposeView
这一阶段应确认:
- Fragment 使用正确的 Composition 销毁策略;
- 首次
setContent、第一次组合和首帧耗时; - 页面只有预期数量的 Compose 根;
- ViewModel、状态保存、返回操作、窗口避让和无障碍功能正常;
- 发布构建的 Macrobenchmark 与旧页面使用相同操作路径。
阶段 2:Fragment 内 Compose 与 View 混合
这一阶段增加以下观察项:
AndroidView的factory/update/onReset/onRelease计数;- View 的
requestLayout/invalidate是否被不必要的属性设置放大; - 焦点、输入法、嵌套滚动和手势控制方;
- 多个
ComposeView的未释放根节点与堆内存; - SurfaceView/TextureView 的独立帧生产路径。
阶段 3:Compose 导航栈
导航统一后继续监控:
- 冷启动、热启动与首个可交互帧;
- 导航目标 Composition 的创建、保留和销毁;
- 返回栈的保存与恢复;
- Lazy 列表、图片和动画的 P50/P90/P95/P99 帧耗时;这些数字分别表示第 50、90、95、99 百分位;
- Baseline Profile 覆盖;Baseline Profile 是随应用发布的热点代码清单,可让这些路径提前编译;
- 经 R8 代码压缩与优化的发布构建中的内存峰值、垃圾回收和应用无响应(ANR)风险。
可复现的 A/B 记录
| 维度 | 固定条件 | 记录指标 |
|---|---|---|
| 构建 | 同一代码提交、同一 AGP/Kotlin/Compose、同设备 APK | APK 哈希、R8、Baseline Profile |
| 数据 | 相同列表项数量、图片缓存状态和网络响应 | 创建、绑定、更新次数 |
| 设备 | 同型号、系统、刷新率、温度区间和电源状态 | 温控状态、频率、后台负载 |
| 操作路径 | 相同启动、滚动、点击和返回脚本 | 启动耗时、帧期限、交互延迟 |
| 跟踪 | 相同 Perfetto 配置与迭代次数 | 主线程、RenderThread、FrameTimeline、Surface |
| 统计 | 充分预热并执行多次迭代 | 中位数、P90/P95/P99、异常样本说明 |
迁移验收时,我们要把平均 FPS 和长尾一起看:平均值可能掩盖首次创建、快速滚动和对象池满后释放时的长尾,所以应同时设定错过帧期限的比例、P95/P99 帧耗时、启动耗时、内存峰值和功能正确性阈值。
十一、排查清单
AndroidView
-
View 在
factory内创建; -
factory没有可延后的重型初始化; -
update只读取必要状态; - 属性设置有等值保护,并确认是否会请求布局;
- View 监听器不会无条件回写 Compose 状态;
-
Lazy 容器需要复用时提供非空
onReset; - 重置后不会显示旧列表项内容;
-
onRelease关闭所有终止型资源; - SurfaceView/TextureView/WebView 按自身出图路径追踪。
ComposeView
-
Fragment 使用
DisposeOnViewTreeLifecycleDestroyed; - RecyclerView 保留能识别池化容器的默认策略;
-
ViewHolder 只在初始化时调用
setContent; - 数据绑定只更新状态,不反复重建 Composition;
- 多个 ComposeView 有唯一 ID;
- 列表项业务状态按稳定 ID 提升;
- 记录 Compose 根数量、堆内存与销毁时机;
- 显式预组合路径覆盖取消和主动销毁。
性能证据
- Layout Inspector 只用于结构定位;
- 编译器指标不代替 View 内部计时;
-
factory、数据绑定、update、onReset、onRelease有低开销计数; - 自定义跟踪区段与 FrameTimeline 对齐;
- 在发布构建、启用 R8、未连接 Layout Inspector 的环境采样;
-
每条独立 Surface 都有对应的帧生产者、
BufferQueue和同步栅栏证据; - Android 17 平台与 Compose 1.12.0 版本线分开记录;
- 内核标签只用于解释对应设备的内核机制。
全文小结
AndroidView 的主要成本来自 View 实例创建、Compose Constraints 到 MeasureSpec 的转换、View 测量/布局/绘制、输入桥和资源生命周期。update 内读取的 Snapshot 状态变化会由承载器的观察器触发执行;普通参数变化则可随宿主重组提供新的函数。是否重新测量或重绘,取决于 View 的属性设置是否发出 requestLayout() 或 invalidate()。
ComposeView 在同一窗口中通常共享窗口 Recomposer,但每个实例仍是独立的 Composition 与布局/语义根。Fragment 应按 View 树生命周期销毁 Composition;RecyclerView 应保留能识别对象池的策略,并避免每次数据绑定都重新调用 setContent。Compose UI 1.12 的稳定版 ComposeViewContext 能共享部分宿主对象并支持离树预组合,每个根的业务状态和布局成本则仍归各自实例。
互操作性能优化可以按这个顺序进行:确认出图结构,测量创建、数据绑定、更新、测量与绘制,修复状态反馈和生命周期问题,再比较不同迁移阶段。PausableComposition、Modifier.Node、运行时跟踪与 ARR 各自只覆盖一段链路,整帧的结论仍要靠完整测量来建立。
相关章节
- Compose 性能优化实战
- Compose LazyList/LazyGrid 滑动性能深度优化
- 自适应刷新率:ARR 与应用帧率治理
- SurfaceView 与 TextureView:渲染拓扑、生命周期与性能取舍
参考资料
- Android is Compose-first
- Compose migration strategy
- Views in Compose
- Compose in Views
- Compose UI releases
- Compose BOM
- Compose dependencies and compiler setup
- Google Maven:Compose BOM 2026.08.00 POM
- Google Maven:Compose BOM 2026.06.01 POM
AndroidViewAPIAndroidViewBindingAPIViewCompositionStrategyAPIComposeViewContextAPIPausableCompositionAPI- Compose phases
- Compose performance tracing
- Define custom trace events
- Compose testing interoperability
- Compose test synchronization
- Adaptive refresh rate
Modifier.preferredFrameRateAPI- Slow rendering
- Macrobenchmark overview
- Compose UI Android 1.12.0 sources
- Compose UI ViewBinding 1.12.0 sources
- Compose Foundation Android 1.12.0 sources
- Compose UI Android 1.11.4 sources
- Compose UI ViewBinding 1.11.4 sources
- Compose Foundation Android 1.11.4 sources
- Kotlin releases
- Android 17
ViewRootImpl - Android 17
Choreographer - Android 17
SurfaceView - Android 17
TextureView - Android 17 HWUI
WebViewFunctor - Android 17 kernel tag
android17-6.18-2026-06_r6