读者点单·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 自己也知道这事儿,所以给了 ComposeViewAndroidView 两个桥梁。

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

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

坑 1:ComposeView 在 Fragment 中泄漏 Composition

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

根因 :ComposeView 默认的 ViewCompositionStrategyDisposeOnDetachedFromWindow,但 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 在作祣。

相关推荐
程序员陆业聪2 小时前
读者点单·02|Android 启动优化实战:Trace 抓取→Application 编排→冷启动全流程拆解
android
Coffeeee2 小时前
帮你快速理解AI Agent之我想招个Android实习生
android·人工智能·agent
恋猫de小郭3 小时前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
黄林晴3 小时前
告别无效重建:Gradle 9.6.0 解决 CI 构建缓存失效痛点告别无效重建:Gradle 9.6.0 解决 CI 建筑缓存失效痛点
android·gradle
张风捷特烈4 小时前
Flutter 类库大揭秘#01 | path_provider架构与设计
android·flutter
_阿南_13 小时前
Android文件读写和分享总结
android
通玄21 小时前
Jetpack Compose 入门系列(六):Navigation 3 页面导航
android
rocpp1 天前
Android 多语言切换实战:从 Context 到 Android 13 应用语言适配
android·kotlin
释然小师弟1 天前
Android开发十年:反思与回顾
android·后端·嵌入式