ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android 内存泄露排查实战:从 Context、Handler 到 WebView 的定位与修复

Android 内存泄露排查实战:从 Context、Handler 到 WebView 的定位与修复 1. 线上 OOM 频发先别急着加内存Android 内存泄露排查这件事我踩过的坑比想象中多。应用跑一段时间后开始卡顿接着 OOM 崩溃用户反馈集中在「用久了就闪退」。你打开 Android Studio Profiler 一看堆内存曲线一路向上GC 之后也降不下来——这就是典型的内存泄露。内存泄露的本质是本该被回收的对象因为被一条 GC Root 可达的引用链持有导致无法释放。Android 里最常见的三条泄露主线是 Context 被长期持有、Handler 非静态内部类持有 Activity、WebView 未销毁。这三个场景覆盖了线上大部分泄露 crash而且修复成本低、收益高。这篇文章面向需要快速定位并修复线上内存问题的 Android 开发者。我会给出可复制的排查步骤、修复代码骨架以及如何用 LeakCanary 和 Android Studio Profiler 验证泄露是否真的消除。你不需要是性能优化专家只要能看懂 Java/Kotlin 和基本的 Activity 生命周期就能跟着做。排查思路是先用 LeakCanary 在 Debug 包自动捕获泄露引用链再用 Profiler 手动确认最后按引用链定位到具体代码并修复。下面按 Context、Handler、WebView 三条主线展开。2. 接入前的准备TaoToken 与工具链在开始写修复代码之前先把排查工具链准备好。LeakCanary 是 Square 开源的泄露检测库Debug 包引入即可自动监控 Activity 和 Fragment 的销毁情况。Android Studio Profiler 是 IDE 自带的可以手动抓取堆转储Heap Dump并分析引用链。如果你在排查过程中需要调用大模型辅助分析堆栈或生成修复代码可以用 TaoToken 统一接入。它的 API 地址是 https://taotoken.net/api兼容 OpenAI 风格的接口你可以在排查脚本或 IDE 插件里直接调用。模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 适合快速问一些「这段引用链为什么没断」的问题。先拿到 API Key进入控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 在 API Keys 页面创建一个新 Key。创建后复制保存后面配置请求头要用。如果你打算长期做编码和 Agent 类任务可以看下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 按量或包月都行。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有完整的请求示例和参数说明。Claude Code 相关的配置参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。这些工具是辅助核心还是你自己看懂引用链。3. 三条主线的可复制修复配置3.1 Context 误用单例持有 Activity最常见的 Context 泄露是单例模式里持有了 Activity 的 Context。比如一个工具类写成单例构造时传入了 Activity这个 Activity 销毁后单例还活着引用链就断不掉。错误写法public class AppManager { private static AppManager instance; private Context context; private AppManager(Context context) { this.context context; // 如果传入的是 Activity就泄露了 } public static AppManager getInstance(Context context) { if (instance null) { instance new AppManager(context); } return instance; } }修复方式有两种。第一种是改用 ApplicationContext因为 Application 的生命周期和应用一样长不存在泄露public static AppManager getInstance(Context context) { if (instance null) { instance new AppManager(context.getApplicationContext()); } return instance; }第二种是在 Activity 销毁时手动置空。但这种方式容易漏不推荐作为主要手段。更稳妥的做法是单例里只存 ApplicationContext需要 Activity 的地方通过参数传入用完即弃。还有一个容易忽略的点静态变量持有 View 或 Bitmap。View 持有 Activity 的引用静态 View 就等于静态持有 Activity。检查你的代码里有没有static View、static Bitmap、static Drawable这类字段有的话改成非静态或者在合适时机置空。3.2 Handler 非静态内部类消息队列持有 ActivityHandler 泄露的经典场景是在 Activity 里用匿名内部类创建 Handler然后 postDelayed 一个延迟任务。这个 Message 会进入主线程的 MessageQueueMessage 持有 HandlerHandler 持有 Activity延迟没到之前 Activity 无法回收。错误写法public class MainActivity extends AppCompatActivity { private Handler handler new Handler() { Override public void handleMessage(Message msg) { // 更新 UI } }; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); handler.postDelayed(() - { // 延迟任务 }, 60000); } }修复方案是写成静态内部类 弱引用public class MainActivity extends AppCompatActivity { private static class SafeHandler extends Handler { private final WeakReferenceMainActivity activityRef; SafeHandler(MainActivity activity) { this.activityRef new WeakReference(activity); } Override public void handleMessage(Message msg) { MainActivity activity activityRef.get(); if (activity null || activity.isFinishing()) { return; } // 更新 UI } } private SafeHandler handler; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); handler new SafeHandler(this); handler.postDelayed(() - { // 延迟任务 }, 60000); } Override protected void onDestroy() { super.onDestroy(); handler.removeCallbacksAndMessages(null); } }关键点有两个静态内部类不持有外部类引用弱引用允许 Activity 被回收onDestroy 里清除所有消息和回调避免延迟任务在 Activity 销毁后还执行。3.3 WebView 未销毁最容易被忽视的泄露源WebView 是 Android 里出了名的泄露大户。它内部持有 Activity 的 Context而且加载过网页后各种回调、JS 桥、渲染线程都可能持有引用。如果你在 Activity 里直接 new 一个 WebView销毁时不做处理这个 Activity 就回收不了。修复骨架public class WebActivity extends AppCompatActivity { private WebView webView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); webView new WebView(getApplicationContext()); setContentView(webView); webView.loadUrl(https://example.com); } Override protected void onDestroy() { if (webView ! null) { webView.loadDataWithBaseURL(null, , text/html, utf-8, null); webView.clearHistory(); webView.removeAllViews(); ((ViewGroup) webView.getParent()).removeView(webView); webView.destroy(); webView null; } super.onDestroy(); } }注意几个细节WebView 用 ApplicationContext 创建可以避免持有 Activity但如果你需要弹窗、文件选择等交互还是得用 Activity。onDestroy 里先加载空页面再清历史、移除视图、调用 destroy最后置空。顺序不能乱否则 destroy 可能不生效。如果 WebView 是在 XML 里声明的onDestroy 里同样要执行这套清理流程。另外如果用了 WebViewClient 或 WebChromeClient检查里面有没有匿名内部类持有 Activity有的话同样改成静态内部类 弱引用。4. 验证请求用 LeakCanary 和 Profiler 确认泄露消除修复完代码怎么确认泄露真的没了分两步走。第一步引入 LeakCanary。在 app 模块的 build.gradle 里加依赖dependencies { debugImplementation com.squareup.leakcanary:leakcanary-android:2.12 }注意是 debugImplementation只在 Debug 包生效。装好后运行 App反复进出可疑页面比如打开关闭 WebActivity 十次如果存在泄露LeakCanary 会发通知点开能看到完整的引用链。引用链会告诉你哪个对象持有了 Activity顺着链子就能定位到代码。第二步用 Android Studio Profiler 手动确认。打开 Profiler选中你的进程点 Memory 区域然后反复操作页面手动触发 GC点垃圾桶图标。观察堆内存曲线如果 GC 后内存回到基线附近说明没有泄露如果每次操作后基线都往上抬说明还有泄露。需要抓堆转储时点 Heap Dump 按钮等几秒生成 hprof 文件。在 Profiler 里可以按类名搜索 Activity看实例数量。正常情况下你退出页面后该 Activity 的实例数应该是 0 或 1当前显示的。如果发现多个实例点开看引用链找到 GC Root 路径。如果你在分析引用链时不确定某条路径的含义可以把堆栈贴到 TaoToken 的模型对话里问一下让它帮你解释「这个 GC Root 为什么会导致泄露」。模型对话地址是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 用之前创建的 API Key 就能调。验证通过的标准是LeakCanary 不再报泄露Profiler 里 GC 后 Activity 实例数归零堆内存曲线平稳。5. 本篇常见错排查排查过程中有几个高频错误我列出来对照检查。第一个错误只改了一处漏了其他引用链。比如你修复了 Handler但 Activity 里还有个匿名 Runnable 持有它。排查时要全面LeakCanary 报的引用链可能有多条逐条断掉。第二个错误onDestroy 里清理顺序不对。WebView 的 destroy 必须在移除视图之后调用否则可能不生效。Handler 的 removeCallbacksAndMessages 要在 super.onDestroy 之前调用。第三个错误用弱引用但没判空。弱引用随时可能被回收get 之后必须判 null还要判断 Activity 是否 isFinishing否则可能操作已销毁的界面导致 crash。第四个错误静态集合没清理。比如static ListCallback注册了监听器退出时忘记 remove集合越来越大。检查所有静态集合在合适时机 clear 或 remove。第五个错误广播和 EventBus 未反注册。手动 registerReceiver 的onDestroy 里必须 unregisterReceiver。EventBus 的 register 和 unregister 要成对出现。第六个错误线程未停止。匿名 Thread 或 AsyncTask 持有 Activity任务没结束前 Activity 回收不了。用线程池管理或者在 onDestroy 里中断线程。如果遇到报错不确定怎么修可以查接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 或者在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里看 API 调用日志确认请求是否正常。6. 把排查流程固化下来内存泄露排查不是一次性任务建议把它固化到开发流程里。Debug 包常驻 LeakCanary每次提测前跑一遍核心页面确认没有新增泄露。发版前用 Profiler 抓一次堆转储对比上个版本的 Activity 实例数。对于长期做 Android 性能优化的团队可以考虑用 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 把模型调用集成到 CI 里自动分析堆转储报告并生成修复建议。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 管理可以按项目创建不同的 Key方便追踪调用来源。最后提醒一句修复泄露后一定要回归验证别改完就发版。LeakCanary 跑一遍Profiler 抓一遍确认 Activity 实例数归零再合并代码。这套流程走顺了线上 OOM 崩溃率会明显下降。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进