Android 内存优化

运行内存

手机运行内存(RAM)其实相当于 PC 中的内存。是手机中作为 APP 运行过程中临时性数据暂时存储的内存介质。
但考虑到体积和功耗,手机不使用 PC 的 DDR 内存,而是 LPDDR RAM(低功耗双倍数据速率内存)。其中 LP 就是 Lower Power 低功耗的意思。
目前主流的运行内存有 LPDDR3、LPDDR4 以及 LPDDR4X。性能方面,高频好于低频。

手机内存不是越大越好,也跟使用的 LPDDR RAM 的类型有关。
并且,内存也不是个孤立的概念。它跟操作系统、应用生态这些因素都有关。


内存引发的问题

1、异常:比如 OOM、内存分配失败、整体内存不足导致应用被杀死、设备重启等问题。
2、卡顿:比如 Java 内存不足会导致频繁 GC 造成卡顿(GC 触发之后,所有的线程会暂停)。或是物理内存不足时系统会触发 low memory killer(低内存 killer) 机制,系统负载过高造成卡顿。


内存优化和架构设计时的两个误区

1、内存占用越少越好:
应根据实际情况。当系统内存充足时,可多用一些获得更好的性能。当不足时,可以”用时分配,及时释放”。
Android Bitmap 内存分配的变化:

  • Android 3.0 之前:
    Bitmap对象 放在 Java 堆,而像素数据放在 Native 内存中。如果不手动调用 recycle,Bitmap Native 内存的回收完全依赖 finalize 函数回调,而这个时机不太可控。
  • Android 3.0 ~ Android 7.0:
    Bitmap 对象和像素数据统一放到 Java 堆中,这样就算不调用 recycle,Bitmap 内存也会随着对象一起被回收。但 Bitmap 是内存消耗大户,而手机的最大 Java 堆存在限制,即使物理内存足够,依然会因 Java 内存不足导致 OOM。
    Bitmap 放在 Java 堆的另外一个问题会引起大量的 GC,对系统内存也没有完全利用起来。
  • Android 8.0:
    使用 NativeAllocationRegistry 辅助回收 Native 内存的机制,来实现像素数据放到 Native 内存中。
    还新增了硬件位图 Hardware Bitmap,它可以减少图片内存并提高绘制效率。

2、Native 内存不管用:
即使在 Android 8.0 重新将 Bitmap 内存放回到 Native 中,也不意味着可以随心所欲的使用图片。
当系统物理内存不足时,lmk 会开始杀进程,从后台、桌面、服务、前台、直到系统重启。


测量方法

对于系统内存和应用内存的使用情况,可参考 Android Developer 中《调查 RAM 使用情况》

1、Java 内存分配:
对于跟踪 Java 堆内存的使用情况,最常用的有 Allocation Tracker可参考《Android 内存申请分析》 和 MAT 这两个工具。

2、Native 内存分配:
Android 8.0 后,可根据这个指南来使用 AddressSanitize 做内存泄漏检测。
关于 Native 内存分配工具,可参考 Android Developer 的一些相关文档调试本地内存使用

  • Malloc 调试
    调试 Native 内存的一些使用问题,如堆破坏、内存泄漏、非法地址等。Android 8.0 之后支持在非 root 的设备做 Native 内存调试,不过跟 AddressSanitize 一样,需要通过wrap.sh做包装。
    1
    adb shell setprop wrap.<APP> '"LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper"'
  • Malloc 钩子
    是在 Android P 之后,Android 的 libc 支持拦截在程序执行期间发生的所有分配/释放调用,这样就可以g构建出自定义的内存检测工具。
    1
    adb shell setprop wrap.<APP> '"LIBC_HOOKS_ENABLE=1"'

内存优化探讨

1、设备分级
内存优化首先要根据设备环境来综合考虑。同时,在架构设计时需要做到以下几点:

  • 设备分级
    使用 device-year-class 的策略对设备分级,对于低端机可关闭复杂的动画,或是某些功能,使用 565 格式图片,使用更小的缓存内存等。
  • 缓存管理
    需要一套统一的缓存管理机制,可适当的使用内存。使用 onTrimMemory 回调,根据不同状态决定释放多少内存。
  • 进程模型
    减少应用启动的进程数、减少常驻进程、有节制的保活。
  • 安装包大小
    安装包中的代码、资源、图片、以及 so 库的体积,跟它们占用的内存有很大的关系。

2、Bitmap 优化

  • 统一图片库
    如低端机使用 565 格式、更加严格的缩放算法,可以使用 Glide、Fresco 或自研。而且要将 Bitmap.createBitmap、BitmapFactory 相关的接口也一并收拢。
  • 统一监控
  • 大图片监控
  • 重复图片监控
  • 图片总内存

3、内存泄漏
内存泄漏主要分两种情况:

  • 同一个对象泄露
  • 每次都会泄露新的对象,可能会出现几百上千个无用的对象。

对内存泄漏的监控:

  • Java 内存泄露。建立类似 LeakCanary 自动化检测方案。可以对生成的 Hprof 内存快照文件做一些优化,裁剪大部分图片对应的 byte 数组减少文件大小。
  • OOM 监控
  • Native 内存泄漏监控
  • 针对无法重编 so 的情况
  • 针对可重编 so 情况

开发过程中内存泄漏排查可以使用 Android Profiler 和 MAT 工具配合使用。


内存监控

  1. 采集方式
    用户在前台时,可每五分钟采集一次 PSS、Java 堆、图片总内存。并按用户抽样,对其在一天内持续采集数据。
  2. 计算指标
    通过上面的数据,可计算出下面一些内存指标:
  • 内存异常率
    可反映出内存占用的异常情况。比如出现新的内存使用不当或是内存泄漏的场景。
    1
    2
    3
    4
    /**
    * PSS 的值可通过 Debug.MemoryInfo 拿到
    */
    内存 UV 异常率 = PSS 超过 400MB 的 UV / 采集 UV
  • 触顶率
    可反应 Java 内存的使用情况,如果超过 85% 最大堆限制,GC 会变得更加频繁,容易造成 OOM 和卡顿。
    1
    内存 UV 触顶率 = Java 堆占用超过最大堆限制的 85% 的 UV / 采集 UV
    其中是否触顶可通过下面的方式计算得到:
    1
    2
    3
    4
    5
    long javaMax = runtime.maxMemory();
    long javaTotal = runtime.totalMemory();
    long javaUsed = javaTotal - runtime.freeMemory();
    // Java 内存使用超过最大限制的 85%
    float proportion = (float) javaUsed / javaMax;
  1. GC 监控
    Android 6.0 后,可通过下面的方法拿到更精准的 GC 信息:
    1
    2
    3
    4
    5
    6
    7
    8
    // 运行的 GC 次数
    Debug.getRuntimeStat("art.gc.gc-count");
    // GC 使用的总耗时,单位是毫秒
    Debug.getRuntimeStat("art.gc.gc-time");
    // 阻塞式 GC 的次数
    Debug.getRuntimeStat("art.gc.blocking-gc-count");
    // 阻塞式 GC 的总耗时
    Debug.getRuntimeStat("art.gc.blocking-gc-time");

    OOM

OOM(内存溢出):

是指内存占有量超过了 VM 所分配的最大值。

出现OOM的原因:

1:加载的对象过大。

2:相应资源过多,来不及释放。

3:内存泄露没有得到及时解决。(个人理解。内存有限,却还一直被无用对象占有,可供使用的内存自然就少了)。

在内存引用上做些处理,在内存中加载图片时直接在内存中处理,如边界压缩,动态回收内存,优化Dalvik虚拟机的堆内存分配,自定义堆内存大小等。

通过命令行获取内存情况

执行:adb shell

查看瞬时进程:ps 或 ps -ef(Mac)

打印对应进程的内存情况:dumpsys meminfo 包名

  • Heap Size:总内存

  • Heap Alloc:已使用

  • Heap Free:还剩下

通过代码来获取内存情况

1
2
3
4
5
6
7
8
// 内存获取
ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
// 最大内存
int max = am.getMemoryClass();
// 开启硬件加速后的最大内存
int large = am.getLargeMemoryClass();

LogUtils.d("max: " + max + " large: " + large); // 单位是:兆(兆字节,MB)
1
2
3
4
5
6
// 获取运行时的内存
float total = Runtime.getRuntime().totalMemory() * 1.0f / (1024*1024);
float free = Runtime.getRuntime().freeMemory() * 1.0f / (1024*1024);
float max = Runtime.getRuntime().maxMemory() * 1.0f / (1024*1024);

LogUtils.d("total: "+ total+" free: " + free + " max: " + max);

减少内存占用

可以从如下几个方面去展开说明:

  1. AutoBoxing(自动装箱): 能用小的坚决不用大的。
  2. 内存复用
  3. 使用最优的数据类型
  4. 枚举类型: 使用注解枚举限制替换 Enum
  5. 图片内存优化(这里可以从 Glide 等开源框架去说下它们是怎么设计的)
  6. 基本数据类型如果不用修改的建议全部写成 static final,因为 它不需要进行初始化工作,直接打包到 dex 就可以直接使用,并不会在 类 中进行申请内存
  7. 字符串拼接别用 +=,使用 StringBuffer 或 StringBuilder
  8. 不要在 onMeause, onLayout, onDraw 中去刷新 UI
  9. 尽量使用 C++ 代码转换 YUV 格式,别用 Java 代码转换 RGB 等格式,真的很占用内存

解决OOM的办法;

1;按需求,考虑使用软引用或弱引用。

2;动态回收内存

3;自定义堆内存和虚拟机的内存分配

一些性能优化建议

  • 避免创建过多的对象
  • 不要过多使用枚举,枚举占用的内存空间要比整型大
  • 常量使用 static final 来修饰
  • 使用一些 Android 特有的数据结构,比如 SparseArray 和 Pair 等,它们都具有更好的性能
  • 适当使用软引用和弱引用
  • 采用内存缓存和磁盘缓存
  • 尽量采用静态内部类,可避免潜在的由于内部类而导致的内存泄漏

卡顿

卡顿的根本原因,第一个原理方面是绘制原理,另一个就是刷新原理。

绘制原理

Activity -> onCreate:当 Activity 成功创建,并且调用 onCreate 生命周期的时候,会执行将 setContentView 的布局 ID 转换为 View 对象的一个过程。

Activity -> onResume:拿到转换后的 View Tree ,请求接收 VSYNC 垂直同步的消息,收到消息之后会执行 performTraversals 函数最后实行绘制。

  • onMeasure : 用深度优先的原则递归所有视图的宽高,获取当前 View 的正确宽高之后,可以调用它的成员函数 Measure 来设置它的大小。如果当前正在测量的子视图 child 是一个容器,那么它又会重复执行操作,直到它的所有子视图的大小都测量完毕。
  • onLayout:用深度优先原则得到所有视图 View 的位置,当一个子 View 在应用程序窗口左上角的位置确定之后,再结合它在前面测量过程中确定的宽高,就可以完全确定它在应用程序窗口中的布局。
  • onDraw: 目前 Android 支持两种绘制方式,软件绘制和硬件加速(GPU),其中硬件加速在 Android 3.0 开始已经全面支持,很明显,硬件加速在 UI 的显示和绘制的效率远远高于 CPU 的绘制,但是硬件加速也有缺点:
    • 耗电问题: GPU 的功耗比 CPU 高
    • 兼容问题: 某些接口和函数不支持硬件加速
    • 内存大: 使用 OpenGL 的接口至少需要 8MB 的内存

刷新原理:

View 的 requestLayout 和 ViewRootImpl##setView 最终都会调用 ViewRootImpl 的 requestLayout 方法,然后通过 scheduleTraversals 方法向 Choreographer 提交一个绘制任务,然后再通过 DisplayEventReceiver 向底层请求 vsync 垂直同步信号,当 vsync 信号来的时候,会通过 JNI 回调回来,在通过 Handler 往消息队列 post 一个异步任务,最终是 ViewRootImpl 去执行绘制任务,最后调用 performTraversals 方法,完成绘制。

卡顿的根本原因:

从刷新原理来看卡顿的根本原理是有两个地方会造成掉帧:

  • 一个是主线程有其它耗时操作,导致doFrame 没有机会在 vsync 信号发出之后 16 毫秒内调用;
  • 还有一个就是当前doFrame方法耗时,绘制太久,下一个 vsync 信号来的时候这一帧还没画完,造成掉帧。

可从下面四个方面来监控应用程序卡顿:

  • 基于 Looper 的 Printer 分发消息的时间差值来判断是否卡顿。
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
//1. 开启监听
Looper.myLooper().setMessageLogging(new
LogPrinter(Log.DEBUG, "ActivityThread"));

//2. 只要分发消息那么就会在之前和之后分别打印消息
public static void loop() {
final Looper me = myLooper();
if (me == null) {
thrownew RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
final MessageQueue queue = me.mQueue;
...

for (;;) {
Message msg = queue.next(); // might block
...
//分发之前打印
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}

...
try {
//分发消息
msg.target.dispatchMessage(msg);
...
//分发之后打印
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
}
}
  • 基于 Choreographer 回调函数 postFrameCallback 来监控

    Android 系统从 4.1(API 16) 开始加入 Choreographer 类,用于同 Vsync 机制配合,实现统一调度界面绘图。 系统每隔 16.6ms 发出 VSYNC 信号,来通知界面进行重绘、渲染,理想情况下每一帧的周期为 16.6ms,代表一帧的刷新频率。开发者可以通过 Choreographer 的 postFrameCallback 设置自己的 callback,你设置的 callcack 会在下一个 frame 被渲染时触发。因此,1S 内有多少次 callback,就代表了实际的帧率。然后我们再记录两次 callback 的时间差,如果大于 16.6ms,那么就说明 UI 主线程发生了卡顿。同时,设置一个报警阀值 100ms,当 UI 主线程卡顿超过 100ms 时,就上报卡顿的耗时以及当时的堆栈信息。

    优点:

    • 通过调用系统函数自动获取数据,我们只需要在 APP 进行业务操作即可。
    • 每一次 APP 进行绘制轮询时 postFrameCallback 都会被调用,即时页面没有更新,能更准确计算帧率和掉帧。
    • 在卡顿出现的时刻可以获取应用堆栈信息。

    缺点:

    • 需要另开子线程获取堆栈信息,会消耗少量系统资源
  • 基于开源框架 BlockCanary 来监控

  • 基于开源框架 rabbit-client 来监控

怎么避免卡顿:

一定要避免在主线程中做耗时任务,总结一下 Android 中主线程的场景:

  • UI 生命周期的控制
  • 系统事件的处理
  • 消息处理
  • 界面布局
  • 界面绘制
  • 界面刷新

还有一个最重要的就是避免内存抖动,不要在短时间内频繁的内存分配和释放。

ANR

响应速度优化和 ANR 日志分析
响应速度优化的核心思想是避免在主线程中做耗时操作,耗时操作可采用异步的方式。

当一个进程发生 ANR 后,系统会在 /data/anr 目录下创建一个文件 traces.txt 。
Android 规定:

  • Activity 如果 5 秒钟之内无法响应屏幕触摸事件或者键盘输入事件就会出现 ANR
  • BroadcastReceiver 如果 10 秒钟之内还未执行完操作也会出现 ANR

Application Not Responding, 也就是”应用无响应”. 当操作在一段时间内系统无法处理时, 系统层面会弹出ANR对话框。
在Android里, App的响应能力是由Activity Manager和Window Manager系统服务来监控的. 通常在如下两种情况下会弹出ANR对话框:
1:KeyDispatchTimeout(5 seconds) –主要类型
按键或触摸事件在特定时间内无响应
2:BroadcastTimeout(10 seconds)
BroadcastReceiver在特定时间内无法处理完成
3:ServiceTimeout(20 seconds) –小概率类型
Service在特定的时间内无法处理完成

造成以上两种情况的首要原因就是在主线程(UI线程)里面做了太多的阻塞耗时操作,例如文件磁盘读写, 数据库读写, 网络查询,图像变换,大量创建新对象等。

应该UI线程尽量只做跟UI相关的工作。耗时操作放在单独线程中。尽量用Handler处理UI Thread和别的Thread之间的交互。

排查;log日志,代码,ANR成因,data目录下的trace.txt文件,检测ANR的watchdog,LeakCanary库。
FC(Force Close): Error,OOM,SOFE,Runtime,注意内存。

如何分析ANR?
ANR产生时, 系统会生成一个traces.txt的文件放在/data/anr/下. 开发人员可通过adb命令将其导出到本地 ($adb pull data/anr/traces.txt .)通过分析,可以根据具体的日志查看Anr原因( 如: 普通阻塞,CPU满负荷,内存泄露 )

UI线程主要包括如下:
Activity:onCreate(), onResume(), onDestroy(), onKeyDown(), onClick()
AsyncTask: onPreExecute(), onProgressUpdate(), onPostExecute(), onCancel()
Mainthread handler: handleMessage(), post(runnable r)
other

高性能编程及调优

  • 对于高性能编码:常见的比如说,常量使用static final修饰符,尽量使用系统封装好的API,静态优于抽象,使用增强型for循环,尽量访问本地变量而不是成员变量,对于for的第二个条件不要调用任何方法,避免在内部调用Getter/Setter,避免创建不必要的对象和临时对象,减少不必要的全局变量,采用三级缓存,但也尽量避免过量使用,对于图片合理的缩放,加载时可在内存中直接操作,对于能保存路径地址的就不要存放图片。

  • 对于调优:主要还是处理内存的问题,管理内存也就是内存的分配和内存的回收,分配由系统完成,主要关注的是内存的回收问题。回收靠的是GC操作,也就是垃圾回收机制,对于GC操作,首先要避免的就是内存泄漏的问题,其实,我们还可以在内存不足时或界面不可见时释放内存,使用优化过的数据集合,使用IntentService代替Service,避免在Bitmap上浪费内存。其他还有像布局优化,主要两种手段,一是删除无用控件和层级,并且尽量使用性能较低的ViewGroup。二是跟三个控件相关,对于绘制优化,像自定义View较多,所以避免在View的onDraw()方法中执行大量操作,像创建新的局部变量,耗时操作,大量的循环操作。
    对于项目来讲,在布局优化上主要采用的include,merge和viewStab,在性能上比如采用合适的集合。


备注

参考资料:
极客时间-Android开发高手课
Android 内存优化杂谈–张绍文
device-year-class