

📰 科技要闻
• LeakCanary v3.0-alpha-8 发布,与 Kotlin 协程深度整合,内存泄漏检测迈入新架构时代
• Coil 3.4.0 正式发布,图片解码与内存缓存全面升级,是目前最新稳定版
• Kotlin 2.4.20-dev-478 今日最新构建落地,编译器优化持续迭代,对 R8/D8 链路产生影响
• Android Weekly Issue #722 重点推荐 Compose Hot Reload,渲染管线热更新机制正式进入工程视野
• Moshi 2.0.0-alpha.1 推进 Kotlin-first 无反射序列化,有望改善冷启动阶段的类加载开销
有一个问题我一直想认真聊:内存泄漏,到底是"偶发的技术债",还是"系统性的设计失误"?
很多团队的处理方式是:等 LeakCanary 报了再查,查完打个补丁,下个版本上线。听起来合理,实际上是在救火,而不是防火。协程普及之后,这个问题变得更复杂了——协程的生命周期管理和传统 Android 生命周期不在同一个体系里,新的泄漏模式出现了,但老的工具链没跟上。
LeakCanary 3.0 alpha 系列正在解决这个问题。我们来认真拆一拆。
一、协程泄漏:你以为安全,其实在漏
先说一个经典误解:用了 viewModelScope 就不会泄漏。
这个说法对了一半。viewModelScope 会在 ViewModel 清除时自动取消,但问题从来不只在 ViewModel 层。
看这段代码:
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 不一定能准确定位它的根源。
更常见的泄漏模式是这个:
// 在协程里捕获了 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 启动的协程的存活状态:
// 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 引入了"路径精简"规则,优先展示应用代码中的节点,框架内部的跳转默认折叠:
// 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,内存缓存策略和协程生命周期原生集成:
// 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 项目里用起来有点别扭:
// 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,不是跑完测试看日志那种,而是让它真正拦截构建:
// 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/预发布版本)
内存使用量的绝对值没有意义,有意义的是相对基线的偏差。建立一套页面级别的内存快照机制:
object MemoryBaseline {
private val snapshots = mutableMapOf<String, Long>()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,但可以跑一个轻量的内存水位监控:
// 线上内存监控(只上报,不触发 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) * 100if (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 作为"官方答案"其实也有问题:
// 看似安全: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 里
// 错误:在 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:
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 怎么和现有监控体系打通。这两个话题都有点复杂,留着下次聊。
如果这篇文章对你有帮助,欢迎转发给团队里负责性能优化的同学。