Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_r1ViewRoot、HWUI、Surface、帧率请求与显示路径
Jetpack ComposeCompose BOM 2026.08.00;androidx.compose.ui、androidx.compose.runtime、androidx.compose.foundation 1.12.0AndroidView、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 布局树里的节点。一帧之内,两者按下面的顺序配合:

  1. Compose 测量策略把自身的 Constraints(可用尺寸约束)与 View 的 LayoutParams 合成为 MeasureSpec;
  2. 承载器调用被包装 View 的 measure();
  3. Compose 用 View 的 measuredWidth、measuredHeight 确定 LayoutNode 尺寸;
  4. 放置阶段调用 View 的 layout();
  5. Compose 绘制修饰符经 AndroidViewsHandler 调用承载器的 draw();
  6. 指针输入、嵌套滚动、无障碍语义、窗口避让信息和“滚入可见区域”请求经过各自的互操作桥。

这一整套流程里,View 始终是 View:它没有被转成可组合函数,自己的测量、布局、绘制和事件模型都还在;被接管的只是外层调度,现在归 Compose LayoutNode 管。

Constraints 与 LayoutParams 如何得到 MeasureSpec

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

AndroidView 的布局与绘制桥

图:ViewFactoryHolder / AndroidViewHolder 把两个树的测量、放置和绘制连接起来。

ComposeView:一个 ViewGroup 承载一个 Compose 根

再看 ComposeView 方向。AbstractComposeView 是 ViewGroup,内部只允许 Compose 创建的 AndroidComposeView 子节点;ComposeView.setContent 会保存内容函数,View 已经附着到窗口时 Composition 立即建立,尚未附着时通常等到首次附着再建立。创建 Composition 时,Compose 按以下顺序解析父级 CompositionContext:

  1. 显式设置的父级 CompositionContext;
  2. View 树中可找到的 CompositionContext;
  3. 仍然有效的缓存 CompositionContext;
  4. 当前窗口的 Recomposer。Recomposer 负责接收状态变化并安排重组。

同一窗口中的多个 ComposeView 通常会找到相同的窗口 Recomposer,但这只是共享了 Recomposer;每个实例仍是独立的 Compose 根,有自己的 Composition、AndroidComposeView、LayoutNode 树、语义管理对象、状态注册和 View 测量边界。

ComposeView 解析父级 CompositionContext

图:解析优先级不合并各个 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、设置一次性属性、注册长期监听器
updatefactory 后至少调用一次;读取的 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 的反馈循环

双向控件容易形成下面的循环:

  1. Compose 把值写入 View;
  2. View 的属性设置同步触发监听器;
  3. 监听器又写入 Compose 状态;
  4. 新状态促使 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 Hz16.67 ms
90 Hz11.11 ms
120 Hz8.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 的阅读顺序

对互操作卡顿,我们按下面的顺序建立判断依据:

  1. 用 FrameTimeline 找到错过显示期限的应用帧;
  2. 查看应用主线程中的 Compose、View 树遍历、自定义跟踪区段和线程调度状态;
  3. 对齐 RenderThread 的 DrawFrame、GPU 完成时间与缓冲区出队/入队操作;
  4. 确认页面属于标准 App Window、SurfaceView、TextureView 或混合出图;
  5. 有独立 Surface 时,单独跟踪对应的帧生产者、BufferQueue、图层事务与同步栅栏;
  6. 回到调用计数,确认问题来自创建、数据绑定、重新测量、重绘、垃圾回收,还是显示侧等待。

图层事务负责把尺寸、位置、层级等变化提交给 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、同设备 APKAPK 哈希、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 各自只覆盖一段链路,整帧的结论仍要靠完整测量来建立。

相关章节

参考资料