Jetpack Compose 内存泄漏案例学习与最佳实践
写 Compose 代码时总遇到内存泄漏?别慌!本文总结了 Compose 代码中最容易踩的泄漏坑,附带代码示例和修复方案,看完直接避开 90% 的内存问题~
一、remember 块里的 Lambda 捕获陷阱
问题场景:在 remember 块里写 Lambda 时,不小心捕获了外部的 Composition 上下文,导致组件销毁后上下文还被引用,没法被 GC 回收。
举个反面例子:
@Composable
funLeakyButton(context: Context) {
// 错误写法:remember 里的 Lambda 捕获了外部的 context
val onClick = remember {
{
// 这里用到了 context,相当于把整个 Composition 上下文抓在了手里
Toast.makeText(context, "clicked", Toast.LENGTH_SHORT).show()
}
}
Button(onClick = onClick) {
Text("click me")
}
}
泄露原因:remember 会让 Lambda 一直存活,保存在 Composable 的 Composition 上。而 Lambda 又抓着 context,如果这是一个 Activity 的 Context,当 Composition 生命周期长于 Activity 时,会导致内存泄漏。虽然大多数情况下 Composition 的生命周期依附当前的 Activity,但是存在一些情况,即使 Activity 已经销毁(比如用户返回、屏幕旋转),Composable 的 Composition 生命周期仍未结束(或因 Bug 导致 Composition 未被清理)。
修复方案:用 DisposableEffect 管理,组件销毁时主动清理引用
@Composable
funSafeButton(context: Context) {
var onClick by remember { mutableStateOf<() -> Unit>({}) }
DisposableEffect(context) {
// 初始化时绑定 Lambda(此时捕获的 context 是当前 Effect 的参数)
onClick = {
Toast.makeText(context, "clicked", Toast.LENGTH_SHORT).show()
}
// 组件销毁时清空引用
onDispose {
onClick = {}
}
}
Button(onClick = onClick) {
Text("click me")
}
}
二、ViewModel 里存 Composable 引用,直接锁死上下文
问题场景:把 Composable 函数或者它的上下文存在 ViewModel 里——ViewModel 生命周期比 Composable 长多了,这相当于给 Composable 套了个“永生锁”。
反面例子:
// 错误的 ViewModel:存了 Composable 的引用
classLeakyViewModel : ViewModel() {
privatevar onButtonClick: (() -> Unit)? = null
// 把 Composable 里的 Lambda 存到 ViewModel
funsetOnClick(callback: () -> Unit) {
onButtonClick = callback
}
}
@Composable
funLeakyScreen(viewModel: LeakyViewModel) {
// 把 Composable 里的逻辑传给 ViewModel
viewModel.setOnClick {
// 这里的逻辑绑定了当前 Composable 的 Composition
Toast.makeText(LocalContext.current, "clicked", Toast.LENGTH_SHORT).show()
}
Button(onClick = { viewModel.onButtonClick?.invoke() }) {
Text("click me")
}
}
泄漏原因:当用户退出页面,Composable 本应被销毁,但 ViewModel 还活着,且手里的 onButtonClick Lambda 还持有 Composable 的上下文(LocalContext.current),导致整个 Composable 实例及其关联资源无法被 GC 回收,造成长期内存泄漏。
修复方案:ViewModel 只存数据/状态,不存 Composable 引用
// 安全的 ViewModel:只存状态,不存 Composable 逻辑
classSafeViewModel : ViewModel() {
// 用 State 传递事件,而不是存 Lambda
privateval _clickEvent = MutableSharedFlow<Unit>()
val clickEvent = _clickEvent.asSharedFlow()
funtriggerClick() {
viewModelScope.launch {
_clickEvent.emit(Unit)
}
}
}
@Composable
funSafeScreen(viewModel: SafeViewModel) {
val context = LocalContext.current
// 监听 ViewModel 的事件,在 Composable 内部处理逻辑
LaunchedEffect(Unit) {
viewModel.clickEvent.collect {
Toast.makeText(context, "clicked", Toast.LENGTH_SHORT).show()
}
}
Button(onClick = { viewModel.triggerClick() }) {
Text("click me")
}
}
三、协程作用域没管好,组件销毁了协程还在跑
问题场景:在 Composable 里启动协程时,没用 rememberCoroutineScope 或 LaunchedEffect,而是用了 GlobalScope 这类 “全局作用域”—— 协程生命周期和 Composable 完全脱节,组件销毁后协程还在执行,同时拽着上下文不放。
反面例子:
@Composable
funLeakyCoroutine() {
// 错误:直接用 GlobalScope,和 Composable 生命周期没关系
GlobalScope.launch {
delay(5000)
// 此时 Composable 可能已经销毁了,但协程还在引用它的上下文
Log.d("Leak", "Coroutine is still running...")
}
Text("click me")
}
泄漏原因:GlobalScope 是应用级作用域,只要应用没退出,协程就会一直执行。而且协程会隐性捕获 Composable 的上下文(比如 LocalContext.current、state 等),导致 Composable 实例无法被回收,即使用户已经离开页面。
修复方案:用 rememberCoroutineScope 绑定 Composable 生命周期
@Composable
funSafeCoroutine() {
// 用 rememberCoroutineScope:作用域和 Composable 生命周期一致
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
delay(5000)
Log.d("Safe", "Coroutine will run untill Composition over")
}
}) {
Text("click me")
}
}
或者用 LaunchedEffect(自动绑定 Composable 生命周期,组件销毁时协程会取消):
@Composable
funAutoCancelCoroutine() {
LaunchedEffect(Unit) {
delay(5000)
// 组件销毁的话,这个协程会自动取消
Log.d("Safe", "coroutine within LaunchedEffect")
}
Text("click me")
}
四、CompositionLocal 用了静态引用,全局锁死上下文
问题场景:给 CompositionLocal 传了个静态/长生命周期的对象,导致这个对象一直拽着 Composable 上下文,没法被回收。
反面例子:
// 错误:静态对象当 CompositionLocal 的值
privateval staticContext = ContextProvider.staticContext
val LocalStaticContext = compositionLocalOf { staticContext }
@Composable
funLeakyLocal() {
// 用了静态的 CompositionLocal,相当于把上下文全局持有了
val context = LocalStaticContext.current
Text("Dangerous CompositionLocal")
}
泄漏原因CompositionLocal 的值如果是静态的,就会一直存在于应用生命周期中。如果这个静态值间接持有了 Composable 的上下文(比如通过回调、状态引用),就相当于给 Composable 加了个 “全局引用”,即使 Composable 退出组合,也会被静态对象拽着,无法 GC。
修复方案
CompositionLocal只传递 “组合级” 数据,不传递静态对象传递上下文时,优先用 LocalContext.current(自动匹配 Composable 生命周期)如果必须传递全局对象(如 Application 上下文),确保它不持有任何 Composable 相关引用
正确示例:
// 正确:传递组合级数据,或用系统提供的 LocalContext
@Composable
funSafeLocal() {
// 用系统提供的 LocalContext.current(组合级上下文,随 Composable 生命周期变化)
val context = LocalContext.current
// 或自定义 CompositionLocal,传递临时数据(非静态)
val LocalTempData = compositionLocalOf { "临时数据" }
CompositionLocalProvider(LocalTempData provides "当前页面数据") {
valdata = LocalTempData.current
Text("Safe CompositionLocal:$data")
}
}
五、remember 没传对 Key,旧对象一直占着内存
问题场景:remember 没传 Key 或者 Key 不对,导致 Composable 重组时,旧的对象没被替换,反而一直存在内存里。
反面例子:
@Composable
funLeakyRemember(itemId: String) {
// 错误:没传 Key,itemId 变了也不会重建对象
val leakyObject = remember {
HeavyObject(itemId) // itemId 变化时,旧的 HeavyObject 还在内存里
}
Text("Dangerous remember")
}
// 模拟重量级对象(占内存大)
classHeavyObject(val id: String) {
// 内部可能持有资源、数据等(占内存)
}
泄漏原因:remember 没传 Key 时,只会在 Composable 第一次进入组合时创建对象,之后重组都复用同一个。当 itemId 变化时,我们需要创建新的 HeavyObject,但旧的对象还被 remember 缓存着,无法被 GC 回收,导致内存中同时存在多个无用的重量级对象,长期积累会造成内存溢出。
修复方案:给 remember 传正确的 Key(依赖变化时自动重建对象)
@Composable
funSafeRemember(itemId: String) {
// 正确:把 itemId 当 Key,itemId 变了就会重建 HeavyObject
val safeObject = remember(itemId) {
HeavyObject(itemId) // 旧对象会被 GC 回收
}
DisposableEffect(Unit) {
onDispose {
safeObject.cleanup() // 主动清理资源
}
}
Text("Safe remember")
}
关键原则:remember 的 Key 要包含 “所有影响对象创建的依赖”—— 只要其中一个 Key 变化,就会重建对象,旧对象会自动失去引用,被 GC 回收。
怎么检测内存泄漏?
1. 用 Android Studio 的内存分析器
步骤很简单:
打开 Android Studio → 点击底部「Profiler」→ 选择「Memory」 运行 App,跳转到目标 Composable 页面,再退出页面(模拟用户操作) 点击内存分析器里的「Capture heap snapshot」(捕获堆快照) 在快照中搜索 Composable 对应的类名(比如 LeakyButton) 查看「Reference Chain」(引用链):看是谁在持有这个 Composable 实例(比如 ViewModel、GlobalScope、静态对象)
2. 集成 LeakCanary
直接在项目里加 LeakCanary,它会自动检测内存泄漏并弹通知:
// build.gradle 里加依赖
dependencies {
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.12"
}
集成后,App 运行时如果有内存泄漏,LeakCanary 会在通知栏提示,点击可查看详细的引用链,快速定位问题根源。
预防泄漏的最佳实践
内存泄漏的核心原因是「生命周期不匹配」+「无用引用没清理」,总结以下几个“保命习惯”,帮你从源头减少泄漏:
1. 牢记 “作用域匹配原则”
协程作用域:Composable 内用 rememberCoroutineScope/LaunchedEffect,ViewModel 内用 viewModelScope,Activity/Fragment 内用 lifecycleScope——坚决不用 GlobalScope(除非是应用级长期任务)。 数据持有:短期数据(Composable 级)用 remember,页面级数据用 ViewModel,应用级数据用单例 ——不跨生命周期持有引用。
2. 用 “事件流” 解耦,替代直接传 Lambda
ViewModel 与 Composable 通信时,优先用 SharedFlow/StateFlow 传递事件,而不是让 ViewModel 持有 Composable 的 Lambda 回调。 好处:事件流是 “单向通信”,Composable 监听事件时,引用由 Composable 自己管理,ViewModel 不持有任何 Composable 相关对象,从根源避免泄漏。
3. 主动清理 “强引用”,用 DisposableEffect 兜底
凡是持有上下文、资源(流、数据库连接、监听器)的逻辑,都用 DisposableEffect 管理:在 onDispose 里清空引用、关闭资源。 比如:监听器注销、流取消、第三方 SDK 销毁(如地图、播放器)——“谁创建,谁销毁”,不让资源成为 “孤儿引用”。
4. remember 要 “精准传 Key”,不滥用缓存
不要为了 “图方便” 不给 remember 传 Key,也不要传无关的 Key(比如固定传 Unit)。 Key 的选择标准:所有影响对象创建的依赖都要包含(比如 itemId、user、config 等),确保依赖变化时旧对象能被替换,避免冗余对象堆积。
5. 慎用静态对象和单例
静态对象、单例的生命周期和应用一致,绝对不能让它们持有 Composable 上下文、ViewModel 引用。 如果单例需要上下文,优先用 Application 上下文(而非 Activity/Composable 上下文),且避免通过单例传递与页面相关的状态。
6. 定期做 “泄漏检测”,早发现早修复
开发阶段:用 LeakCanary 实时监控,每次迭代后跑一遍关键流程(如页面跳转、组件重组),看是否有泄漏提示。 测试阶段:用内存分析器做 “压力测试”(比如反复进入 / 退出页面 100 次),观察内存是否持续增长,若增长则大概率有泄漏。
7. 避免在 Composable 内创建 “长生命周期对象”
不要在 Composable 函数内直接创建单例、数据库实例、网络客户端等 —— 这些对象应该由依赖注入框架(如 Hilt)管理,或放在 ViewModel/Repository 中。 Composable 函数的核心是 “描述 UI”,频繁重组会导致这些长生命周期对象被重复创建,既浪费内存,又可能引发泄漏。
遵循这些实践,不仅能避免内存泄漏,还能让你的 Compose 代码更规范、更易维护 —— 毕竟 “预防泄漏” 远比 “修复泄漏” 更高效~
-- END --