读者点单·03|Compose 与传统 View 混用的 12 个真实坑

读者点单·端午投票系列 · 第3/10篇

基于端午《聊聊学习节奏》评论区读者票选生成的系列文章

第1篇:Android 性能治理的「全景图」:从机型分级到指标体系

第2篇:Android 启动优化实战:Trace 抓取→冷启动全流程拆解

第3篇:Compose 与传统 View 混用的 12 个真实坑(本篇)

你的 App 一半 Compose 一半 XML?恭喜,你已经进入了「混合地狱」------滚动打架、主题撕裂、内存暴涨、生命周期对不齐......这篇我把实际踩过的 12 个坑逐一摊开,每个坑附上复现条件和解法。不是教科书式的 API 文档翻译,是真实工程里交过的学费。

为什么大厂都卡在「半 Compose」状态

说个真实数据:我们项目组做过一次统计,团队里 6 个 App 模块,4 个已经引入 Compose,但没有一个是「全 Compose」的。最多的那个模块也才迁移了 60% 的页面。

原因很简单------历史包袱。你有上百个 XML 布局、几十个自定义 View、各种和 Fragment 生命周期耦合的逻辑。不可能一夜全换。Google 自己也知道这事儿,所以给了 ComposeView 和 AndroidView 两个桥梁。

但问题来了:桥梁好搭,桥上的坑可不少。这篇文章我把过去一年踩过的 12 个坑整理出来,按严重程度分组。每个坑附复现条件、根因分析和解法。

坑 1-4|双向嵌套的经典翻车

坑 1:ComposeView 在 Fragment 中泄漏 Composition

这是最经典的新手坑。你在 Fragment 的 onCreateView 里创建 ComposeView 并 setContent,但 Fragment 被放进 ViewPager2 的时候,切换 tab 后 Composition 没有被正确销毁。

根因 :ComposeView 默认的 ViewCompositionStrategy 是 DisposeOnDetachedFromWindow,但 ViewPager2 会 detach/reattach View 而不销毁 Fragment。

kotlin 复制代码
//  错误写法:默认策略
override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedState: Bundle?
): View {
    return ComposeView(
        requireContext()
    ).apply {
        setContent {
            MyScreen()
        }
    }
}
kotlin 复制代码
//  正确写法:匹配生命周期
override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedState: Bundle?
): View {
    return ComposeView(
        requireContext()
    ).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy
            .DisposeOnViewTree
            LifecycleDestroyed
        )
        setContent {
            MyScreen()
        }
    }
}

经验法则:只要你的 ComposeView 不是直接放在 Activity 的 setContentView 里,就应该显式设置 DisposeOnViewTreeLifecycleDestroyed。

坑 2:AndroidView 的 update 回调时机不符合直觉

在 Compose 里嵌入传统 View 用 AndroidView,它有个 update 参数。很多人以为 update 只在状态变化时调用------错了,它在每次 recomposition 都会调用。

scss 复制代码
//  每次 recompose 都重建
AndroidView(
    factory = { ctx ->
        MapView(ctx).apply {
            onCreate(null)
        }
    },
    update = { mapView ->
        // 这里每次 recompose
        // 都会执行!
        mapView.setCenter(pos)
    }
)

解法 :在 update 里做幂等操作,或者用 remember 缓存上一次的值做 diff。

scss 复制代码
//  只在位置真正变化时更新
var lastPos by remember {
    mutableStateOf(pos)
}
AndroidView(
    factory = { ctx ->
        MapView(ctx).apply {
            onCreate(null)
        }
    },
    update = { mapView ->
        if (pos != lastPos) {
            mapView.setCenter(
                pos
            )
            lastPos = pos
        }
    }
)

坑 3:AndroidView 内的 View 收不到触摸事件

把一个自定义的手势处理 View(比如签名板、画板)用 AndroidView 嵌入 Compose 后,发现手指滑动事件被外层的 Compose 滚动容器吃掉了。

根因:Compose 的手势系统优先级高于 AndroidView 内的 View 触摸处理。你需要显式告诉 Compose「这块区域的触摸事件交给 View 处理」。

scss 复制代码
//  在 factory 中禁用
//    父级拦截
AndroidView(
    factory = { ctx ->
        SignatureView(ctx).apply {
            parent?.
            requestDisallowIntercept
            TouchEvent(true)
        }
    },
    modifier = Modifier
        .fillMaxWidth()
        .height(200.dp)
)

如果上面的方法不够(某些嵌套场景下 Compose 的 PointerInputScope 仍会抢事件),可以用 Modifier.pointerInteropFilter 来桥接。

坑 4:ComposeView 在 DialogFragment 中宽度异常

你在 DialogFragment 里用 ComposeView 写弹窗 UI,结果弹窗宽度变得非常窄,内容被挤压。这是因为 DialogFragment 默认给 Window 设置了 WRAP_CONTENT 的宽度。

kotlin 复制代码
//  在 onStart 中修复宽度
override fun onStart() {
    super.onStart()
    dialog?.window?.setLayout(
        WindowManager
            .LayoutParams
            .MATCH_PARENT,
        WindowManager
            .LayoutParams
            .WRAP_CONTENT
    )
}

更优解:直接用 Compose 的 Dialog 组件替代 DialogFragment。如果你还在用 DialogFragment 纯粹是因为历史包袱,趁这个坑赶紧迁移。

坑 5-7|NestedScroll 滚动冲突三层地狱

坑 5:View 的 RecyclerView 嵌套在 Compose LazyColumn 里滚不动

场景:你有个历史页面用 RecyclerView 实现,现在嵌入到 Compose 的 LazyColumn 中作为其中一个 item。结果 RecyclerView 完全不响应滚动。

根因:Compose 的滚动容器(LazyColumn)和 View 的滚动容器(RecyclerView)之间的 NestedScroll 协议不能自动互通。Google Issue Tracker #174348612 已经挂了好几年。

ini 复制代码
//  方案:给 RecyclerView
//    固定高度 + 禁用内部滚动
AndroidView(
    factory = { ctx ->
        RecyclerView(ctx).apply {
            isNestedScrolling
                Enabled = false
            layoutManager =
                LinearLayoutManager(
                    ctx
                )
            adapter = legacyAdapter
        }
    },
    modifier = Modifier
        .fillMaxWidth()
        .height(400.dp)
)

注意:给 RecyclerView 固定高度意味着你放弃了它的滚动能力,所有滚动交给外层 LazyColumn。如果你的列表有上千个 item,更好的做法是把整个页面迁移成 LazyColumn,而不是嵌套。

坑 6:CoordinatorLayout + ComposeView 联动失效

你想用 Compose 替换 CoordinatorLayout 内部的某个 Fragment,但替换之后 AppBarLayout 的折叠/展开不跟手了。

根因 :CoordinatorLayout 依赖 NestedScrollingChild 接口。ComposeView 在老版本不实现这个接口,所以 AppBar 感知不到内部滚动。

scss 复制代码
//  解法:用
//    rememberNestedScrollInterop
val nestedScroll =
    rememberNestedScrollInterop
        Connection()

Modifier
    .nestedScroll(nestedScroll)
    .verticalScroll(
        rememberScrollState()
    )

rememberNestedScrollInteropConnection() 是 Compose UI 1.2 加入的桥接 API,它会把 Compose 的滚动事件转发给父级 View 的 NestedScrolling 协议。如果你的 Compose BOM 还在 2022 年的版本,赶紧升。

坑 7:View→Compose→View 三层嵌套滚动完全失控

最恶心的场景:外层是 View 的 ScrollView,中间嵌了 ComposeView(内部是 LazyColumn),LazyColumn 里又有 AndroidView 包裹了一个 RecyclerView。三层滚动容器,每层的协议不同,互相不认。

ScrollView (View 层)

↓ NestedScrolling V2

ComposeView → LazyColumn

↓ Compose NestedScroll

AndroidView → RecyclerView

两次协议转换 = 两次潜在断层

我的建议:不要做三层嵌套。认真的。如果你的架构强迫你这么做,说明迁移策略有问题。应该把整个滚动容器统一到一层:要么全 View(RecyclerView 作主容器),要么全 Compose(LazyColumn 作主容器)。

坑 8-9|主题穿透断裂

坑 8:MaterialTheme 颜色传不进 AndroidView

你在 Compose 里用 MaterialTheme.colorScheme.primary 设置了主色,但 AndroidView 里的传统 View 仍然用的是 XML 主题的颜色。两套主题系统完全独立。

scss 复制代码
//  在 AndroidView 中
//    手动桥接颜色
val primary =
    MaterialTheme.colorScheme
        .primary.toArgb()

AndroidView(
    factory = { ctx ->
        Button(ctx).apply {
            setBackgroundColor(
                primary
            )
        }
    },
    update = { btn ->
        btn.setBackgroundColor(
            primary
        )
    }
)

坑 9:AppCompat 主题传不进 ComposeView

反过来也一样。你的 App 用 AppCompat 的 Theme.MaterialComponents 定义了品牌色,但 ComposeView 里的 MaterialTheme 完全不认这些。两套 UI 颜色各走各的。

arduino 复制代码
//  用 Accompanist 桥接
//    build.gradle.kts
implementation(
    "com.google.accompanist:" +
    "accompanist-themeadapter-"
    + "material3:0.34.0"
)
scss 复制代码
// ComposeView 中使用
Mdc3Theme {
    // 这里 MaterialTheme
    // 会自动继承 XML 主题
    MyComposeContent()
}

动态主题切换场景:如果你的 App 支持深色/浅色主题切换,需要在 Configuration change 时重新触发 Mdc3Theme 的重组。可以用 LocalConfiguration.current 作为 key。

坑 10-11|RecyclerView 持有 ComposeView 的内存陷阱

坑 10:RecyclerView Item 中的 ComposeView 内存暴涨

很多团队的渐进迁移策略是:先把 RecyclerView 的每个 Item 换成 ComposeView,外层容器先不动。听起来很稳,但你会发现内存开始飙升。

根因:每个 ComposeView 都会创建独立的 Composition。RecyclerView 的复用机制复用的是 ViewHolder,但 Composition 不会被复用------它在 detach 时销毁,reattach 时重建。

方案 内存 滚动性能
RecyclerView + XML Item ~30MB 60fps
RecyclerView + ComposeView Item ~90MB 45-55fps
LazyColumn (纯 Compose) ~35MB 58-60fps

数据来自我们实际项目的内存 Profiling,测试场景是 200 个复杂 Item 的列表。RecyclerView + ComposeView 的内存占用是纯 XML 的 3 倍,而纯 Compose 的 LazyColumn 跟纯 XML 差不多。

坑 11:ComposeView.disposeComposition() 调用时机错误

为了解决坑 10,有人在 onViewRecycled 里调用 disposeComposition()。这的确能释放内存,但引入了新问题:快速滚动时频繁创建/销毁 Composition,导致卡顿。

kotlin 复制代码
//  正确做法:
//    设置 Pooling 策略
class ComposeVH(
    val composeView: ComposeView
) : RecyclerView.ViewHolder(
    composeView
) {
    init {
        composeView
            .setViewComposition
            Strategy(
            ViewCompositionStrategy
            .DisposeOnViewTree
            LifecycleDestroyed
        )
    }
}

// ViewHolder 复用时只更新内容
override fun onBindViewHolder(
    holder: ComposeVH,
    pos: Int
) {
    holder.composeView.setContent {
        ItemContent(
            data = items[pos]
        )
    }
}

最佳实践:如果 Item 内容足够简单(纯文本+图片),每个 Item 里不要单独创建 ComposeView。直接把整个 RecyclerView 替换成 LazyColumn,一步到位。中间态(RV + ComposeView Item)只适合复杂 Item 的过渡期。

坑 12|性能基准对比:什么时候该回 View

说实话,Compose 不是万能的。有些场景用传统 View 仍然更合适:

场景 推荐 理由
地图/WebView/视频 View SDK 只提供 View
表单页面 Compose 状态管理优势明显
复杂动画 看情况 Compose 动画简单场景更简洁,复杂场景 View 更可控
超长列表(10000+) View RecyclerView 内存回收更成熟
新建功能页 Compose 开发效率高、可维护性好

我的判断:2026 年的 Android 17 时代,新代码应该默认用 Compose。但如果你的场景是上万个 Item 的列表、或者需要集成只提供 View SDK 的第三方库,利用好 AndroidView 的桥接能力,不用强迫自己全 Compose。

渐进迁移策略:从局部替换到全 Compose 化

踩完上面 12 个坑,我总结出一套渐进迁移路线图,在我们团队实践下来效果不错:

Phase 1:新页面全 Compose

↓

Phase 2:存量简单页替换

范围 → 设置页、个人中心、关于页面等流量低页面 ↓

Phase 3:核心页 Item 级迁移

注意 → 这个阶段最容易踩坑 10/11,注意内存监控 ↓

Phase 4:整页迁移 + 删除 XML

目标 → 消灵 Fragment / XML layout / ViewBinding

关键经验:Phase 3 是最危险的阶段。当你把 RecyclerView 的 Item 换成 ComposeView 时,一定要同时做内存 Profiling。如果内存涨幅超过 50%,说明你应该跳过这个中间态,直接把整个 RecyclerView 替换成 LazyColumn。

写在最后:这 12 个坑覆盖了 Compose 与 View 混用时最常见的问题。核心教训就一句------混用是过渡态,不是终态。每个坑都在告诉你:尽快结束过渡,少在中间态停留。下一篇「读者点单·04」我们进入 Android 内存治理实战------从 PSS 看到 LeakCanary 的全链路。如果你的 App 也有内存问题,别忘了先检查下是不是坑 10 在作祣。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone4 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui