EN

Android 代码内存优化建议 - Android 资源篇

字数统计: 3.8k阅读时长: 14 min
2015/07/20
loading

Android 内存优化系列文章:

  1. Android代码内存优化建议-Android官方篇
  2. Android代码内存优化建议-Java官方篇
  3. Android代码内存优化建议-Android资源篇
  4. Android代码内存优化建议-OnTrimMemory优化

我写这篇文章的初衷,是希望在动手写代码前,先把容易出内存问题的地方弄清楚,少留些问题到后面排查。下面从 Bitmap、数据库查询、列表复用和绘制等方面展开,看看资源由谁创建、谁持有、什么时候不再需要。这里有资源滞留、重复分配,也有泄漏,几种情况要分开处理。

技术复核:2026-10-13。 本文最初发布于 2015 年。旧版“统一手动 recycle Bitmap”的建议已不适合作为通用做法,MAT 中的像素表现也有系统版本限制。本次补齐 Cursor、Adapter 和 Handler 示例,修正 NinePatch、生命周期与资源所有权说明;保留原来的八节结构和两张示意图。

Android资源优化

1. Bitmap优化

Bitmap 可能是图片密集型应用的主要内存来源,但不能据此断言所有 Android 内存问题都来自 Bitmap。先测量存活对象、缓存和分配来源,再决定优化重点。

Bitmap 的像素位置有版本差异,不能在所有 HPROF 中都期待找到一块对应的 byte[]:

Android 版本 像素存储的典型位置
Android 2.3.x 及更早(API 10 及以下) native 内存,Java 堆中的 Bitmap 是包装对象。
3.0 / API 11 至 7.1 / API 25 随 Bitmap 存在于托管堆中。
8.0 / API 26 及以上 native 堆;只看 Java 包装对象不能代表像素总占用。

依据 Managing Bitmap Memory。具体位图类型、图形分配和厂商实现仍应结合实际工具输出分析。对象还没被回收,也不一定就是泄漏:有界缓存、正在显示或处理的图像都有合理的持有期。

1.1 主动释放Bitmap资源

不要把 recycle() 当作每张图片都必须执行的清理步骤。 旧指南特别针对 API 10 及以下给出主动回收建议;现代代码先处理解码尺寸、缓存容量和所有权。

当前 Bitmap.recycle() 文档 将它描述为立即释放像素数据,适用于确定不再使用的图像,例如独占的变换中间缓冲区;同时明确警告不要随意回收已交给 View 或可能被其他地方引用的 Bitmap。Java 包装对象本身仍由 GC 管理。旧文引用的 mBuffer、nativeRecycle() 片段属于旧实现,不能拿来解释所有版本。

isRecycled() == false 只说明还没被标记为已回收,不能证明没有其他使用方,也不能消除线程竞争。清掉自己持有的引用、解除显示关系和取消加载任务,是不同操作;在方法里把形参赋成 null,也不会清掉调用方的字段。使用图片加载库时,应遵守该库的目标清理和 Bitmap 池所有权,不要绕过它手动 recycle。

1.2 主动释放ImageView的图片资源

getDrawable() 可能返回 VectorDrawable、ColorDrawable、LayerDrawable 等类型,不能直接强转为 BitmapDrawable。即使它是 BitmapDrawable,内部 Bitmap 也可能被缓存或其他 View 共享。

在 UI 线程取消相关异步加载后,可以用下面的方法移除这个 ImageView 的图片引用:

1
2
3
4
5
private static void clearImageViewImage(android.widget.ImageView imageView) {
if (imageView != null) {
imageView.setImageDrawable(null);
}
}

这只解除该 View 的图片关系,并不承诺立即释放全部像素内存。异步请求也必须停止向旧目标提交结果,否则清空后仍可能被重新填入。库管理的目标优先使用库自身的 clear/cancel 接口。

1.3 主动释放ImageView的背景资源

背景和 src 是两条独立的引用。背景可以是任意 Drawable,以下方法不做类型转换,也不回收共享 Bitmap;setBackground() 从 API 16 提供:

1
2
3
4
5
private static void clearViewBackground(android.view.View view) {
if (view != null) {
view.setBackground(null);
}
}

只有背景已经不需要时才清空,避免破坏状态反馈或视觉效果。Bitmap 是否仍被持有,要继续检查其他 View、缓存和正在进行的绘制,不能仅凭一次赋空判断。

1.4 用合适的NinePatch替代大背景图

NinePatch 本身也是 PNG,应用资源文件的扩展名是 **.9.png**,不是 .9.webp。它通过边缘标记指定拉伸区和内容区,适合气泡、边框等可以局部拉伸的背景;并不是所有照片都适合,也不保证选错拉伸区域后仍然不变形。

用较小的源图覆盖多种尺寸,可能省去多张大背景图,但最终开销仍受源图尺寸、解码和使用方式影响。Android Studio 的 Draw 9-patch 工具 可以编辑标记并预览不同尺寸。

左:原图;右:选定区域拉伸后的示意

1.5 使用大图之前,先按目标尺寸解码

这里需要区分“压小文件”和“减少解码像素”。把 JPEG 质量调低,通常只是让压缩文件变小;宽高和像素格式不变时,解码后仍可能占用相近的像素内存。先解码完整大图再缩放,也已经产生了大图的峰值分配。

只显示缩略图时,应在解码前确定所需尺寸;若支持放大或编辑,则把这些需求也算进去。使用 BitmapFactory 时,可以分两步处理:

  1. 设 inJustDecodeBounds = true 读取宽高和类型,不分配完整像素。检查 outWidth、outHeight 是否有效,不能把失败结果继续当成尺寸。
  2. 按目标像素大小计算 inSampleSize,再关闭 bounds 模式进行实际解码。资源 density 缩放等因素可能继续影响输出尺寸,应检查实际结果。

Android 9(API 28)起还可使用 ImageDecoder.setTargetSize() 指定输出尺寸,并按需求维护宽高比例。已有加载库则优先传入正确的目标尺寸,让它统一处理解码、缓存和取消。

解码和大图处理应避免阻塞 UI 线程。当前官方说明:Loading Large Bitmaps Efficiently。

2 查询数据库没有关闭游标

直接查询得到的 Cursor 应由取得它的代码负责关闭;异常路径同样需要处理。不要只在 moveToNext() 成功时关闭,也不要把 query() 返回 null 当作空结果集。

下面是 API 19 及以上、Java 7+ 语法的完整示例,用计数演示 try-with-resources 的关闭范围。调用方提供有权限访问的 URI 和必要列,并在合适的后台线程执行查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import android.content.ContentResolver;
import android.database.Cursor;
import android.net.Uri;

public final class QueryExample {
public static int countRows(ContentResolver resolver, Uri uri,
String[] projection) {
try (Cursor cursor = resolver.query(uri, projection, null, null, null)) {
if (cursor == null) {
throw new IllegalStateException("Provider returned no cursor");
}
return cursor.getCount();
}
}
}

计数为 0 表示有效 Cursor 中没有记录;返回 null 的情况在这个示例中作为错误处理。代码没有把整批行复制到集合中,但 getCount() 仍可能执行实际查询工作,不能当成廉价操作。低版本项目若使用显式 finally,也应覆盖异常路径;不要无条件吞掉关闭异常。

如果 Cursor 的所有权已经交给 Loader、适配器或其他管理组件,应按相应契约转交和关闭,不能在组件仍使用它时提前关闭。参考 Cursor.close() 与 ContentResolver.query()。

3 构造Adapter时,没有使用缓存的convertView

ListView 的 convertView 是可复用的旧行 View,首次或没有合适旧行时可能为 null。每次重新创建会增加分配、布局和 GC 压力,但不等于对象必然持续泄漏。

复用时必须检查 View 类型,并在新建和复用两条路径上都绑定当前数据。

ListView 的行 View 复用示意

下面是完整的单一行类型示例,使用系统单行布局。列表是构造时的副本;这里只演示展示,不包含数据更新接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;

public final class LabelsAdapter extends BaseAdapter {
private final LayoutInflater inflater;
private final List<String> labels;

public LabelsAdapter(Context context, List<String> labels) {
inflater = LayoutInflater.from(context);
this.labels = new ArrayList<>(labels);
}

@Override public int getCount() { return labels.size(); }
@Override public String getItem(int position) { return labels.get(position); }
@Override public long getItemId(int position) { return position; }

@Override
public View getView(int position, View convertView, ViewGroup parent) {
TextView label;
if (convertView == null) {
convertView = inflater.inflate(
android.R.layout.simple_list_item_1, parent, false);
label = (TextView) convertView.findViewById(android.R.id.text1);
convertView.setTag(label);
} else {
label = (TextView) convertView.getTag();
}
label.setText(getItem(position));
return convertView;
}
}

复杂列表还要重置可见性、选中状态和旧图片请求,避免把上一行状态带到下一行。多种行布局应正确实现 getItemViewType() / getViewTypeCount()。RecyclerView 同样需要完整绑定数据并处理回收,不会自动替应用消除所有持有关系。参考 Adapter.getView()。

4 释放对象的引用

检查一个对象为什么仍在内存中,应沿强引用链找到持有者和 GC Roots。把某个变量设为 null 只移除这一条引用;其他缓存、回调和队列仍可能持有它。

示例 A:Handler 与 Runnable

旧例把 obj 保存到局部变量 o,再设 obj = null。这能移除 Activity 的那个字段引用,但 Runnable 仍持有 o,调用 Activity 的实例方法还可能捕获 Activity 本身。任务没有执行或取消前,相关对象仍可能被队列保留。Handler 在其 Looper 所在线程执行,并不自动切到后台线程。

下面的完整类演示一个仅服务于可见界面的延迟标题更新:在 UI 线程、页面可见时调用 showTitleLater(),新任务替换旧任务,onStop() 移除尚未执行的回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import android.app.Activity;
import android.os.Handler;
import android.os.Looper;

public final class DeferredTitleActivity extends Activity {
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private Runnable pendingTitle;

public void showTitleLater(final String title, long delayMillis) {
cancelPendingTitle();
pendingTitle = new Runnable() {
@Override public void run() {
pendingTitle = null;
setTitle(title);
}
};
if (!uiHandler.postDelayed(pendingTitle, delayMillis)) {
pendingTitle = null;
}
}

private void cancelPendingTitle() {
if (pendingTitle != null) {
uiHandler.removeCallbacks(pendingTitle);
pendingTitle = null;
}
}

@Override protected void onStop() {
cancelPendingTitle();
super.onStop();
}
}

这里清理的是该页面拥有的指定 Runnable,不会误删共享 Handler 上别人的工作。removeCallbacks() 只移除仍在队列中的任务,不能中断已经开始执行的工作;移除操作需要扫描队列,这个例子用于少量页面任务。需要持续运行的后台任务应单独设计生命周期和取消机制。参见 Handler API。

示例 B:监听器与订阅

旧文以锁屏对象注册 PhoneStateListener 为例说明长生命周期对象持有短生命周期对象的风险。这个方向有用,但不能断言忘记注销一定导致 system_ui OOM:是否泄漏取决于系统版本、监听实现和实际强引用链,需要堆证据确认。

PhoneStateListener 在 Android 12(API 31)弃用,新代码使用 TelephonyCallback,并配对调用 registerTelephonyCallback() / unregisterTelephonyCallback()。当前注册 API 说明框架只保存回调的弱引用,因此调用方需要在订阅期间持有它;不能把旧监听器的持有模型原样套过来。按具体事件满足权限要求,结束订阅时注销同一个实例。参考 PhoneStateListener 和 TelephonyManager。

5 在Activity的生命周期中释放资源

释放时机应与获取时机和用途成对:只在可见期需要的监听可在 onStart() 获取、onStop() 释放;只在交互期需要的工作可以跟随 onResume() / onPause()。API 24 起的多窗口场景中,onPause 后的页面仍可能可见,不宜只按“暂停就是看不见”处理。

onDestroy() 不是系统终止进程前保证调用的清理钩子。需要持久化的用户状态不能只等它保存,也不要在生命周期回调中做大量同步 IO。跨配置变化保留的数据与某一代 Activity/View 的引用要分开管理。参考 Activity 生命周期 与 进程生命周期。

6 消除过度绘制

过度绘制是同一帧中一个像素被绘制多次。文字叠在背景上可以是正常需要;应减少的是没有视觉作用的重复背景和覆盖层,而不是一律删除背景。

多余绘制可能增加 GPU 工作和内存带宽需求,但 View 层次深并不必然等于每层都在覆盖同一区域,移除一次绘制也不保证按比例释放 Bitmap 或降低进程堆占用。结合裁剪、透明度、实际帧耗时与 GPU 证据判断收益。

可继续阅读本博客的 过度绘制及其优化 和官方 Reduce overdraw。

7 使用Android系统自带的资源

系统公开的字符串、drawable、样式和布局,可以在语义及外观适合时复用,减少应用自带的重复资源。但系统版本、主题或设备差异可能影响效果,需要检查目标设备;不要依赖隐藏的内部资源。

例如下面使用系统公开的 android:id/list 标识:

1
2
3
4
<ListView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />

这个 ID 不是一个共享的 ListView 对象。 View 仍由应用布局实例化,使用系统 ID 本身不证明节省了运行时内存。实际减少的资源文件、APK 大小和运行时分配应分别检查。参考 访问平台资源。

8 使用内存相关工具检测

很难一次就把内存管理做好,所以每个开发阶段都要检查进入退出页面、滚动、图片加载和前后台切换。关注对象是否超出预期生命周期,而不是只看某一时刻内存是不是偏高。

Android Studio 的 堆转储 可以帮助查看 Java/Kotlin 对象和引用链;MAT 可继续分析兼容的 HPROF,native 和图形内存还需相应工具。Lint 提供静态线索,不能单独证明“没有泄漏”。

对于 Bitmap 问题,可以结合本博客的 MAT 中还原 Bitmap 中的版本限制;只有 native 地址的堆转储不能凭空还原像素。所有修复都应回到原来的操作路径复查分配、存活和用户体验。

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

CATALOG
  1. 1. Android资源优化
    1. 1.1. 1. Bitmap优化
      1. 1.1.1. 1.1 主动释放Bitmap资源
      2. 1.1.2. 1.2 主动释放ImageView的图片资源
      3. 1.1.3. 1.3 主动释放ImageView的背景资源
      4. 1.1.4. 1.4 用合适的NinePatch替代大背景图
      5. 1.1.5. 1.5 使用大图之前,先按目标尺寸解码
    2. 1.2. 2 查询数据库没有关闭游标
    3. 1.3. 3 构造Adapter时,没有使用缓存的convertView
    4. 1.4. 4 释放对象的引用
    5. 1.5. 5 在Activity的生命周期中释放资源
    6. 1.6. 6 消除过度绘制
    7. 1.7. 7 使用Android系统自带的资源
    8. 1.8. 8 使用内存相关工具检测
  2. 2. 关于我 && 博客