0%

Android性能优化之内存优化

概述

Android 内存优化是性能优化中最重要的一个优化点之一,在进行内存优化之前,需要对 Android 和 Java 的内存分配和管理机制有一定的理解,这样在平时的工作中才能时刻注意到内存优化这个点,内存优化涉及到开发中的方方面面,如避免内存泄漏,合理使用数据结构,合理使用 Bitmap 位图等等,本文只列举出部分优化场景。关于分析内存的工具可以参考 Android性能优化之工具篇

内存泄漏

内存泄漏定义

内存泄漏:当某个对象不再需要使用时,应该完整地执行其最后的生命周期,但由于某些原因,对象虽然已经不再使用,但是在内存中并没有结束其生命周期。

避免内存泄漏

  • 资源型对象使用完毕要关闭,如 Cursor 等
  • 注册对象需要解注册,如广播等
  • 类的静态变量尽量不要持有大数据对象
  • 容器中的对象未清理
  • WebView 新版本基本不会内存泄漏了,参考 Android-WebView内存泄漏
  • 非业务需要尽量避免把 Activity 等组件的 Context 上下文作为参数传递,如传给单例等(使用弱引用)
  • 非静态内部类和匿名内部类会持有外部 Activity 等的引用(使用静态内部类或独立类)
  • handler.postDelayed() 问题:1. 如果开启的线程需要传入参数,则可以使用弱引用;2. 销毁时记得清除队列 – removeCallbacksAndMessages()

内存抖动

内存抖动一般指的是在很短的时间内发生了多次的内存分配与释放,系统在垃圾回收上花费过多的时间和性能。内存抖动可以说明在给定时间内出现的已分配临时对象的数量过多,从而迫使垃圾回收事件发生。

内存优化整理

  • 尽量少用 + 来拼接字符串
  • 能使用基础类型的(int)避免使用装箱类型(Integer)
  • 内存复用:对象池等
  • 使用合适的数据结构: HashMap vs ArrayMap vs SparseArray 等
  • 使用 Dagger 2 实现依赖注入
  • 谨慎使用外部库
  • 针对序列化数据使用精简版 Protobuf,它是 Google 设计的一种无关乎语言和平台,并且可扩展的机制,用于对结构化数据进行序列化。该机制与 XML 类似,但更小、更快也更简单。
  • 枚举会比静态常量多占用内存,也可以使用注解替代枚举:
    1
    2
    3
    4
    5
    6
    7
    @Retention(RetentionPolicy.SOURCE)
    @Target({ElementType.FIELD, ElementType.PARAMETER})
    @IntDef({Status.STATUS_1, Status.STATUS_2})
    public @interface Status {
    int STATUS_1 = 1;
    int STATUS_2 = 2;
    }

Bitmap

概述

Android 中的图片是以 Bitmap 形式存在的,Bitmap 所占用内存的多少对性能的影响很重要。如果直接加载一张 Bitmap,那其所占的内存公式是:

1
Memory = width * height * 一个像素点占用的字节数

而如果从资源目录中加载,同一张图片放进不同的目录里会被压缩,见如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// frameworks/base/core/jni/android/graphics/BitmapFactory.cpp
static jobject doDecode(...) {
if (options != NULL) {
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
}
}
int scaledWidth = size.width();
int scaledHeight = size.height();
if (scale != 1.0f) {
willScale = true;
scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f);
scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f);
}
// ...
}

其中 targetDensity 是设备屏幕像素密度,density 是图片所在目录对应的像素密度。如下表:

density|0.75|1|1.5|2|3|4
:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:
dpi|120|160|240|320|480|560
folder|ldpi|mdpi|hdpi|xhdpi|xxhdpi|xxxhdpi

因此计算公式为:

1
Memory = width * height * 一个像素点占用的字节数 * (设备像素密度/图片目录对应的密度)^2

Android Bitmap 类中提供了一个方法获取 Bitmap 分配内存大小:

1
2
3
private fun getBitmapSize(bitmap: Bitmap): Float {
return bitmap.allocationByteCount / 1024f // Kb
}

存储位置

- 2.3(API 10)之前 3.0-7.1(API 11-25) 8.0(API 26)
Bitmap对象 Java Heap Java Heap Java Heap
像素数据 Native Heap Java Heap Native Heap
迁移原因 - Bitmap生命周期不太可控,可能会内存泄漏 优化了Bitmap生命周期管理,减轻 Java Heap 的压力,且只要 RAM 有剩余空间,则可以一直在Native heap上申请空间,当然如果 RAM 快耗尽了,系统就会杀进程释放RAM。

编码

可以通过改变编码格式来改变 Bitmap 占用的内存,Android 在 Bitmap.Config 中提供了几种编码方式:

Bitmap.Config Note
ALPHA_8 A = 8, 一个像素点占用 1 个字节,没有颜色,只有透明度
ARGB_4444 A=4, R=4, G=4, B=4, 每个像素占 2 个字节,在 API 29 中弃用,画质太差,建议使用 ARGB_8888 替代
ARGB_8888 A=8, R=8, G=8, B=8, 每个像素占 4 个字节,默认选项
HARDWARE Special configuration, when bitmap is stored only in graphic memory.
RGBA_F16 A=16, R=16, G=16, B=16, 每个像素占 8 个字节
RGB_565 R=5, G=6, B=5, 没有透明度,每个像素占 2 个字节

举例如下:

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
fun click(view: View) {
loadByConfig("/sdcard/head.png", Bitmap.Config.RGB_565)
loadByConfig("/sdcard/head.png", Bitmap.Config.ARGB_8888)
}

private fun loadByConfig(path: String, config: Bitmap.Config) {
val options = BitmapFactory.Options()
// 不加载到内存中,只返回图片属性
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, options)
val outHeight = options.outHeight
val outWidth = options.outWidth
Log.d("LLL", "width = $outWidth, height = $outHeight")
options.inPreferredConfig = config
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, options)
val size = getBitmapSize(bitmap)
Log.d("LLL", "size = ${size}Kb, width = ${bitmap.width}, height = ${bitmap.height}")
}

// 输出
D/LLL: width = 291, height = 290
D/LLL: size = 329.64844Kb, width = 291, height = 290
D/LLL: width = 291, height = 290
D/LLL: size = 164.82422Kb, width = 291, height = 290

可以看出宽高没变,但是占用内存变化了。

采样

按照 Bitmap 占用内存的计算方式,可以减小其宽高达到减少内存占用的目的:

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
fun click(view: View) {
loadBySample("/sdcard/head.png", 1)
loadBySample("/sdcard/head.png", 2)
}

private fun loadBySample(path: String, sample:Int) {
val options = BitmapFactory.Options()
// 不加载到内存中,只返回图片属性
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, options)
val outHeight = options.outHeight
val outWidth = options.outWidth
Log.d("LLL", "width = $outWidth, height = $outHeight")
// 任何小于等于1的值都会置为1
options.inSampleSize = sample
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, options)
val size = getBitmapSize(bitmap)
Log.d("LLL", "size = ${size}Kb, width = ${bitmap.width}, height = ${bitmap.height}")
}

// 输出
D/LLL: width = 291, height = 290
D/LLL: size = 329.64844Kb, width = 291, height = 290
D/LLL: width = 291, height = 290
D/LLL: size = 82.12891Kb, width = 145, height = 145

复用

  • 可以使用 inBitmap 复用 Bitmap 内存: BitmapFactory.Options.inBitmap = Bitmap。图像解码时,会尝试复用该设置的 inBitmap 内存,在 Android 4.4 版本以上,只要被解码后的 Bitmap 对象字节大小小于等于 inBitmap 的字节大小就可以复用成功,编码不必相同,被复用的图像的像素格式 Config 会覆盖设置的 BitmapFactory.Options.inPreferredConfig 参数。
  • 可以使用 Lru 管理 Bitmap 池。

Android内存管理

进程分类

按照重要性排序:

  1. 前台进程:用户目前执行操作所需的进程,系统中只有少数此类进程,而且除非内存过低,导致连这些进程都无法继续运行,才会在最后一步终止这些进程。如果以下任一条件成立,则进程会被认为位于前台:
    • 它正在用户的互动屏幕上运行一个 Activity(其 onResume() 方法已被调用)。
    • 它有一个 BroadcastReceiver 目前正在运行(其 BroadcastReceiver.onReceive() 方法正在执行)。
    • 它有一个 Service 目前正在执行其某个回调(Service.onCreate()、Service.onStart() 或 Service.onDestroy())中的代码。
  2. 可见进程:正在进行用户当前知晓的任务,因此终止该进程会对用户体验造成明显的负面影响。相比前台进程,系统中运行的这些进程数量较不受限制,但仍相对受控。这些进程被认为非常重要,除非系统为了使所有前台进程保持运行而需要终止它们,否则不会这么做。在以下条件下,进程将被视为可见:
    • 它正在运行的 Activity 在屏幕上对用户可见,但不在前台(其 onPause() 方法已被调用)。举例来说,如果前台 Activity 显示为一个对话框,而这个对话框允许在其后面看到上一个 Activity,则可能会出现这种情况。
    • 它有一个 Service 正在通过 Service.startForeground()(要求系统将该服务视为用户知晓或基本上对用户可见的服务)作为前台服务运行。
    • 系统正在使用其托管的服务实现用户知晓的特定功能,例如动态壁纸、输入法服务等。
  3. 服务进程:包含一个已使用 startService() 方法启动的 Service。虽然用户无法直接看到这些进程,但它们通常正在执行用户关心的任务(例如后台网络数据上传或下载),因此系统会始终使此类进程保持运行,除非没有足够的内存来保留所有前台和可见进程。已经运行了很长时间(例如 30 分钟或更长时间)的服务的重要性可能会降位,以使其进程降至缓存 LRU 列表。这有助于避免超长时间运行的服务因内存泄露或其他问题占用大量内存,进而妨碍系统有效利用缓存进程。
  4. 缓存进程:目前不需要的进程,因此,如果其他地方需要内存,系统可以根据需要自由地终止该进程。运行良好的系统将始终有多个缓存进程可用(为了更高效地切换应用),并根据需要定期终止最早的进程。只有在非常危急(且具有不良影响)的情况下,系统中的所有缓存进程才会被终止,此时系统必须开始终止服务进程。这些进程通常包含用户当前不可见的一个或多个 Activity 实例(onStop() 方法已被调用并返回)。只要它们正确实现其 Activity 生命周期,那么当系统终止此类流程时,就不会影响用户返回该应用时的体验,因为当关联的 Activity 在新的进程中重新创建时,它可以恢复之前保存的状态。

缓存进程保存在伪 LRU 列表中,列表中的最后一个进程是为了回收内存而终止的第一个进程。此列表的确切排序政策是平台的实现细节,但它通常会先尝试保留更多有用的进程(比如托管用户的主屏幕应用、用户最后看到的 Activity 的进程等),再保留其他类型的进程。还可以针对终止进程应用其他政策:比如对允许的进程数量的硬限制,对进程可持续保持缓存状态的时间长短的限制等。

com.android.server.am.ProcessList 类中可以看到进程的 adj 值,用来表示进程的优先级,adj值越小,进程的优先级越高,越不容易被 kill 掉。

保活方案

进程保活有两个方面的内容:

  1. 使应用在后台的时候,提升其优先级而不致被系统杀死;
  2. 应用被系统杀死后,也可以被拉起。

进程存活时间

提高进程的存活时间可以参考上述进程优先级相关的思想,具体做法网上已经有许多方案,有的有效有的由于Android版本的升级而失效。

拉起app

  • 当app被强制杀死后,Android系统会杀掉其进程组,因此开启一个运行在子进程的service来重启主进程的方式,随着Android版本的升级已经失效了。
  • 可以采取app进程互相拉起的方式,这种的话需要有一个存活的app来拉取。
  • 一个有效的保活方案可以参考Android来电秀实践,该方案可以在用户强制杀掉进程后重启app。
  • 与手机厂商合作,将应用加入白名单。

onTrimMemory

Android 4.0(API 14) 中添加了 onTrimMemory() 回调,在早期版本有 onLowMemory() 回调,此回调大致相当于 TRIM_MEMORY_COMPLETE 事件,可以在 Application 中实现 onTrimMemory(level: Int) 回调以响应不同的与内存相关的事件,其中 level 表示事件等级。

TRIM_MEMORY_UI_HIDDEN 比较常用,与其它等级独立开:

  • TRIM_MEMORY_UI_HIDDEN: 表示应用的 UI 不可见了,此时应该释放一些资源。

下面三个等级是应用真正运行时的回调:

  • TRIM_MEMORY_RUNNING_MODERATE: 表示应用正常运行且不会被杀掉。但是目前设备内存已经有点低了,系统可能会开始根据 LRU 规则来杀死进程。开发者应该释放一些不必要的资源。
  • TRIM_MEMORY_RUNNING_LOW: 表示应用正常运行且不会被杀掉。但是目前设备内存已经非常低了。
  • TRIM_MEMORY_RUNNING_CRITICAL: 表示应用仍然正常运行,但是系统已经根据 LRU 规则杀掉了大部分缓存的进程了。开发者应当尽可能释放所有不必要的资源,否则系统可能会继续杀掉所有缓存中的进程,并且开始杀掉一些本来应当保持运行的进程,比如说后台运行的服务。

当应用是缓存状态的,则会收到以下几种类型的回调:

  • TRIM_MEMORY_BACKGROUND: 表示目前设备内存已经很低了,系统准备开始根据 LRU 缓存来清理进程。此时我们的程序在 LRU 缓存列表的最近位置,不太可能被清理,但这时去释放掉一些比较容易恢复的资源能够让手机的内存变得比较充足。
  • TRIM_MEMORY_MODERATE: 表示目前设备内存已经很低了,且我们的程序处于 LRU 缓存列表的中间位置,如果手机内存还得不到进一步释放的话,那么我们的程序就有被系统杀掉的风险了。
  • TRIM_MEMORY_COMPLETE: 表示目前设备内存已经很低了,且我们的程序处于 LRU 缓存列表的最边缘位置,系统会最优先考虑杀掉我们的程序,在这个时候应当尽可能地把一切可以释放的东西都进行释放。

查看内存状态

  • 使用 ActivityManager.getMemoryInfo(MemoryInfo) 方法获取系统内存的一些通用状态。
  • 使用 ActivityManager.getMemoryClass() 方法获取单个进程最大可用内存,单位为兆字节。

MemoryInfo 中有几个可用的参数:

  • availMem: 系统可用内存,由于内核的性质,该部分内存实际上可能正在使用中。
  • totalMem: 内核可访问的总内存。
  • threshold: 内存不足的阈值。
  • lowMemory: 是否处于低内存状态。

使用方法:

1
2
3
4
5
6
7
8
9
10
11
fun getAvailableMemory(context: Context): ActivityManager.MemoryInfo {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return ActivityManager.MemoryInfo().also { memoryInfo ->
activityManager.getMemoryInfo(memoryInfo)
}
}

fun getMemoryClass(context: Context): Int {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return activityManager.memoryClass
}

应用限制

Android 对每个应用程序(进程)的堆(Dalvik heap)大小设置了限制,所以理论上可以将一些耗内存的操作放入一个单独的进程中,减小主进程的压力。

这个限制不针对 Native heap,只要 RAM 有剩余空间,则可以一直在 Native heap 上申请空间,当然如果 RAM 快耗尽了,系统就会杀进程释放RAM。

总结

内存优化在于平时点点滴滴的开发习惯,不过通常情况下不一定非要追求某些优化,比如说枚举,如果用枚举时可读性和可扩展性更好,那么没必要避免使用它,它占用的内存相对于整个程序而言就是沧海一粟而言。高级语言诞生本身就是在硬件提升的背景之下的,牺牲某些性能来降低开发门槛,提高了开发效率也是可取的。