Android内存优化:当LeakCanary遇上协程,内存泄漏治理进入新阶段

有一个问题我一直想认真聊:内存泄漏,到底是"偶发的技术债",还是"系统性的设计失误"?

很多团队的处理方式是:等 LeakCanary 报了再查,查完打个补丁,下个版本上线。听起来合理,实际上是在救火,而不是防火。协程普及之后,这个问题变得更复杂了------协程的生命周期管理和传统 Android 生命周期不在同一个体系里,新的泄漏模式出现了,但老的工具链没跟上。

LeakCanary 3.0 alpha 系列正在解决这个问题。我们来认真拆一拆。

一、协程泄漏:你以为安全,其实在漏

先说一个经典误解:用了 viewModelScope 就不会泄漏。

这个说法对了一半。viewModelScope 会在 ViewModel 清除时自动取消,但问题从来不只在 ViewModel 层。

看这段代码:

kotlin 复制代码
class UserRepository(
    private val context: Context,  // Application Context,看似安全
    private val scope: CoroutineScope  // 外部传入的 scope,危险点
) {
    fun startPolling() {
        scope.launch {
            while (isActive) {
                fetchUser()
                delay(5000)
            }
        }
    }
}

// 调用方:某个 Fragment
class ProfileFragment : Fragment() {
    private val repo = UserRepository(
        requireContext().applicationContext,
        lifecycleScope  // Fragment 的 lifecycleScope
    )
}

这段代码乍看没问题,但如果 UserRepository 被某个单例持有(比如通过依赖注入注册为 Singleton),那 lifecycleScope 对应的 Fragment 销毁后,scope 已取消,但 Repository 本身仍被单例引用------这是一条隐形的引用链,LeakCanary 2.x 不一定能准确定位它的根源。

更常见的泄漏模式是这个:

scss 复制代码
// 在协程里捕获了 View 的引用
viewModelScope.launch {
    val bitmap = withContext(Dispatchers.IO) {
        BitmapFactory.decodeResource(view.resources, R.drawable.banner) // 捕获了 view
    }
    imageView.setImageBitmap(bitmap)
}

withContext 切到 IO 线程时,整个 lambda 捕获了外部的 view,如果 IO 操作耗时过长(网络超时、磁盘慢),Activity 已经 destroyed,但 View 还被这个协程 lambda 持有着。这类泄漏持续时间短,不容易被监控到,但在弱网环境下会反复触发。

二、LeakCanary 3.0 的核心变化

LeakCanary 2.x 的检测机制本质上是:对象应该被回收时,检查它是否真的被回收了 。具体是通过 WeakReference + ReferenceQueue 实现的------在 Activity/Fragment onDestroy 后,用弱引用包裹对象,等 GC 触发后检查弱引用是否被清除。

这套机制对经典泄漏(Handler、匿名内部类、静态引用)很有效,但对协程有盲区:

• 协程的 Job/Continuation 不是 Android 生命周期对象,LeakCanary 不知道该在什么时机检查它们

• SharedFlow/StateFlow 的订阅者持有引用,但订阅关系不经过传统的 destroy 路径

• structured concurrency 的层级很深时,泄漏路径分析变得极其冗长,报告可读性差

LeakCanary 3.0-alpha-8 做了几件事:

协程感知的对象监视

3.0 引入了 CoroutineScope 的监视扩展,可以追踪由特定 scope 启动的协程的存活状态:

kotlin 复制代码
// LeakCanary 3.0 alpha 的新 API(实验性)
class MyViewModel : ViewModel() {
    init {
        // 自动监视 viewModelScope 下所有协程的生命周期
        AppWatcher.objectWatcher.expectWeaklyReachable(
            this,
            "ViewModel should be cleared"
        )
    }
}

// 更直接的方式:使用新的扩展函数
viewModelScope.watchForLeaks() // 3.0 新增,自动注册监视点

更清晰的泄漏路径报告

2.x 的报告里经常出现一堆框架内部类,让人看不出问题在哪。3.0 引入了"路径精简"规则,优先展示应用代码中的节点,框架内部的跳转默认折叠:

arduino 复制代码
// 2.x 报告(节选,经常20行以上)
┬───
│ GC Root: Thread
│
├─ kotlinx.coroutines.scheduling.CoroutineScheduler$Worker
│    thread name: DefaultDispatcher-worker-1
│    ↓ CoroutineScheduler$Worker.localQueue
├─ kotlinx.coroutines.scheduling.WorkQueue
│    ↓ WorkQueue.buffer
├─ ...(中间省略10行框架代码)
╰→ com.example.ProfileFragment  ← 真正的问题在这里,但已经被淹没了

// 3.0 报告(同样的泄漏)
┬ GC Root: Thread
│  [kotlinx.coroutines 内部,已折叠 8 个节点]
╰→ com.example.ProfileFragment
     ↓ ProfileFragment.binding (ViewBinding)
     ↓ FragmentProfileBinding.imageView
     LEAK: ImageView held by coroutine lambda

这个改进听起来只是可读性的优化,但对实际工作影响很大。之前遇到协程相关泄漏,定位往往要花半小时以上,3.0 能把这个时间压到5分钟。

三、图片加载的内存陷阱:Coil vs Glide,该怎么选

图片加载是 Android 内存问题的重灾区,这一点在 2026 年依然成立。Coil 3.4.0 刚刚正式发布,我们来聊聊它和 Glide 5.0 在内存管理上的实际差异。

先给结论:新项目选 Coil 3.x,存量大型项目谨慎迁移。理由如下:

Coil 3.x 完全 Kotlin-first,内存缓存策略和协程生命周期原生集成:

scss 复制代码
// Coil 3.4.0:自动绑定 Lifecycle,页面销毁时取消加载
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(imageUrl)
        .size(Size.ORIGINAL)
        .memoryCachePolicy(CachePolicy.ENABLED)
        .diskCachePolicy(CachePolicy.ENABLED)
        .build(),
    contentDescription = null,
    modifier = Modifier.fillMaxWidth()
)

// Coil 3.4.0 新增:精细的内存缓存控制
val imageLoader = ImageLoader.Builder(context)
    .memoryCache {
        MemoryCache.Builder()
            .maxSizePercent(context, percent = 0.25) // 最多用25%的可用内存
            .build()
    }
    .bitmapPool(BitmapPool(100 * 1024 * 1024)) // 100MB Bitmap 复用池
    .build()

Glide 5.0 在内存管理上依然是行业标杆,LruResourceCache + BitmapPool 的组合非常成熟,但 Java-centric 的 API 在 Compose 项目里用起来有点别扭:

kotlin 复制代码
// Glide 5.0:需要手动处理 Compose 的生命周期
val painter = rememberGlidePainter(
    request = imageUrl,
    requestBuilder = { 
        override(400, 300) // 明确指定采样尺寸,避免加载全尺寸图
        diskCacheStrategy(DiskCacheStrategy.RESOURCE)
    }
)
Image(painter = painter, contentDescription = null)

// Glide 的内存管理配置(Application 级别)
@GlideModule
class AppGlideModule : AppGlideModule() {
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        val memoryCacheSizeBytes = 1024 * 1024 * 20 // 20MB
        builder.setMemoryCache(LruResourceCache(memoryCacheSizeBytes.toLong()))
        builder.setBitmapPool(LruBitmapPool(memoryCacheSizeBytes.toLong()))
    }
}

两者在内存占用上有一个重要差异:Coil 3.x 默认开启 Bitmap 采样(根据 View 的实际尺寸自动缩放),而 Glide 需要明确调用 override() 才会采样。在 RecyclerView 场景下,这个细节直接影响内存峰值。一个填满高清图的 RecyclerView,Coil 默认配置下的内存占用通常比 Glide 默认配置低 30%-40%,但如果你给 Glide 正确配置了 override(),差距会缩小到 5% 以内。

四、内存优化的工程化路径

说完工具,说方法论。内存优化不应该是"发现问题再修",而应该是系统性的防御体系。我见过太多团队在 OOM 上反复踩坑,原因基本都是缺少可量化的内存基线

我推荐的工程化路径分三层:

第一层:自动化检测(CI 阶段)

把 LeakCanary 的报告接入 CI,不是跑完测试看日志那种,而是让它真正拦截构建:

less 复制代码
// leakcanary-android-instrumentation 配置
// build.gradle.kts
androidTest {
    dependencies {
        androidTestImplementation("com.squareup.leakcanary:leakcanary-android-instrumentation:3.0-alpha-8")
    }
}

// 自定义测试规则:让泄漏导致测试失败
@RunWith(AndroidJUnit4::class)
class LeakTest {
    @get:Rule
    val rule = DetectLeaksAfterEachTest()

    @Test
    fun testUserFlow() {
        // 模拟完整的用户流程
        onView(withId(R.id.login_button)).perform(click())
        // ... 
        // 测试结束后自动检测内存泄漏,有泄漏则 FAIL
    }
}

第二层:运行时基线监控(Debug/预发布版本)

内存使用量的绝对值没有意义,有意义的是相对基线的偏差。建立一套页面级别的内存快照机制:

kotlin 复制代码
object MemoryBaseline {
    private val snapshots = mutableMapOf()

    fun snapshot(pageName: String) {
        val runtime = Runtime.getRuntime()
        val usedMemory = runtime.totalMemory() - runtime.freeMemory()
        snapshots[pageName] = usedMemory
    }

    fun checkDelta(pageName: String, thresholdMb: Int = 10): Boolean {
        val currentUsed = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() }
        val baseline = snapshots[pageName] ?: return true
        val deltaMb = (currentUsed - baseline) / 1024 / 1024
        if (deltaMb > thresholdMb) {
            Log.w("MemoryBaseline", "$pageName: 内存增长 ${deltaMb}MB,超过阈值 ${thresholdMb}MB")
            if (BuildConfig.DEBUG) {
                // Debug 下触发 LeakCanary 强制分析
                AppWatcher.objectWatcher.expectWeaklyReachable(
                    Object(), "$pageName memory spike"
                )
            }
            return false
        }
        return true
    }
}

// 在 Fragment 中使用
override fun onResume() {
    super.onResume()
    MemoryBaseline.snapshot(javaClass.simpleName)
}

override fun onPause() {
    super.onPause()
    MemoryBaseline.checkDelta(javaClass.simpleName)
}

第三层:线上兜底(Profile 精简版)

线上不能跑完整的 LeakCanary,但可以跑一个轻量的内存水位监控:

kotlin 复制代码
// 线上内存监控(只上报,不触发 heap dump)
class MemoryMonitor {
    companion object {
        fun startPeriodicCheck(scope: CoroutineScope) {
            scope.launch(Dispatchers.Default) {
                while (isActive) {
                    val activityManager = context.getSystemService(ActivityManager::class.java)
                    val memInfo = ActivityManager.MemoryInfo()
                    activityManager.getMemoryInfo(memInfo)

                    val usedPercent = (1 - memInfo.availMem.toFloat() / memInfo.totalMem) * 100

                    if (usedPercent > 85f) {
                        // 内存使用超过85%,上报至监控平台
                        Analytics.event("memory_pressure", mapOf(
                            "used_percent" to usedPercent,
                            "available_mb" to memInfo.availMem / 1024 / 1024,
                            "page" to currentPage
                        ))
                    }
                    delay(30_000) // 每30秒检查一次
                }
            }
        }
    }
}

五、几个经常被忽视的内存泄漏场景

说几个我见过但文档里很少提的坑:

1. ViewModel 持有 Context

这个大家都知道不该做,但 AndroidViewModel 作为"官方答案"其实也有问题:

scss 复制代码
// 看似安全:AndroidViewModel 持有 Application Context
class MyViewModel(application: Application) : AndroidViewModel(application) {
    // 但如果你在这里做了这件事:
    private val prefs = application.getSharedPreferences("user", Context.MODE_PRIVATE)
        .apply {
            registerOnSharedPreferenceChangeListener { _, key ->
                // 这个 lambda 持有了 MyViewModel 的引用!
                // SharedPreferences 默认用 WeakReference 持有 listener,但
                // 如果你在别处 strongRef 了这个 listener,就泄漏了
                handlePrefChange(key)
            }
        }
}

正确做法是把 listener 存为成员变量,并在 onCleared() 中手动 unregister。

2. Flow 的 collect 在错误的 scope 里

kotlin 复制代码
// 错误:在 Fragment 里用了 ViewModel 的 scope
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewModel.uiState.collect { state ->  // 注意:这里不是 launchWhenStarted,而是直接 collect
        updateUI(state)
    }
    // 这个协程会一直运行,即使 Fragment 退出栈,UI 状态还在被消费
}

// 正确:使用 repeatOnLifecycle
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { state ->
                updateUI(state)
            }
        }
    }
}

这不是新知识,但我依然在代码评审里每周都能见到第一种写法。

3. Bitmap 回收时机

Bitmap 在 Android 8.0+ 的内存分布在 native heap,不占用 Java heap 配额,GC 不会主动回收它,只有调用 recycle() 或对象没有引用后触发 finalizer 才会释放。大量临时 Bitmap 处理(图片编辑、滤镜)时,养成习惯在 finally 块里 recycle:

kotlin 复制代码
suspend fun applyFilter(source: Bitmap): Bitmap = withContext(Dispatchers.Default) {
    var intermediate: Bitmap? = null
    try {
        intermediate = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
        // 处理...
        val result = Bitmap.createBitmap(intermediate)
        result  // 返回最终结果
    } finally {
        intermediate?.recycle()  // 临时 Bitmap 立刻回收
    }
}

六、从"发现泄漏"到"防止泄漏":思维转变

我想强调一个立场:内存泄漏的根源,九成来自生命周期管理不规范,而不是什么技术难题。LeakCanary 再强大,也只是发现问题的工具;真正防止泄漏,靠的是代码规范和 review 机制。

一个实用的检查清单,PR 评审时对照着过:

• 协程启动时,是否明确绑定了生命周期感知的 scope(lifecycleScope / viewModelScope)

• 所有 Flow collect,是否都在 repeatOnLifecycle 内

• 注册的回调、监听器,是否都有对应的 unregister(尤其是 SharedPreferences、SensorManager、LocationManager)

• ViewModel 是否直接或间接持有了 View / Context 的引用

• 图片加载是否配置了合理的采样尺寸,避免把 4K 图加载进 View

• 单例中是否有以 Activity/Fragment 为 key 的缓存(Map、LruCache 等)

这个清单不需要靠记忆,把它写进 PR template 里,每次提交时强制过一遍,比事后用工具查快得多。

总结

LeakCanary 3.0 对协程的深度整合,是 Android 内存工具链补上的一块重要拼图。它不会替你解决问题,但会让问题更早暴露、更容易定位。

Coil 3.4.0 的内存管理做得足够好,新项目可以放心用,存量项目迁移前做好基准测试。Glide 5.0 在复杂场景下依然是更稳健的选择,主要原因是它更成熟的 BitmapPool 实现。

内存优化最终拼的不是工具,是团队的工程文化。工具选对了,再配上可量化的基线监控和严格的 review 机制,OOM 这件事应该是极低频的线上事故,而不是常驻的技术债。

接下来值得深入的方向:Baseline Profiles + R8 全模式优化对冷启动内存峰值的影响,以及 Android 15 新增的 Memory Advice API 怎么和现有监控体系打通。这两个话题都有点复杂,留着下次聊。

如果这篇文章对你有帮助,欢迎转发给团队里负责性能优化的同学。

相关推荐
黄林晴2 小时前
解放双手!Android 发布官方 6 大技能,一键搞定迁移、优化、适配
android
REDcker3 小时前
iOS 与 Android:浏览器引擎、WebView 与生态差异概览
android·ios·内核·浏览器·webview
Kapaseker3 小时前
介绍一个新的 Compose 控件 — 浮动菜单
android·kotlin
空中海3 小时前
第二章:UI 开发——View 系统与 Jetpack Compose
android·ui
空中海3 小时前
安卓 第五章:网络与数据持久化
android·网络
fengci.3 小时前
php反序列化(复习)(第五章)
android·开发语言·学习·php
美狐美颜sdk3 小时前
视频平台如何实现实时美颜?Android/iOS直播APP美颜SDK接入指南
android·前端·人工智能·ios·音视频·第三方美颜sdk·视频美颜sdk
XiaoLeisj3 小时前
Android 短视频项目实战:从登录态回流、设置页动作分发到缓存清理、协议页复用与密码重置的完整实现个人中心与设置模块
android·mvvm·webview·arouter