七猫免费小说 APP 内存优化实践(一)
2022年以来,七猫客户端基础组加强了对内存问题的重视,对于内存优化做了很多工作。通过线上及线下的监控手段,有效的解决了已有的线上问题,同时能够尽早发现尽早解决新增的内存问题。本文主要阐述我们团队做了哪些工作,为什么做以及如何做的(侧重于安卓端)。
一、我们为什么要做内存优化?
通过对免费小说APP线上崩溃及卡顿问题的分析,很多问题是由于内存占用过高引起的。单独的几处内存不合理占用也许不会有问题,但是随着使用时间的累积,复杂的用户场景越来越多, 内存情况就会越来越差,会发生 OOM、卡顿、黑屏的可能性也会随之升高。加之阅读类 APP 平均使用时间更长,问题也就尤为突出。
二、内存问题的表现形式及优化方案
1、内存抖动
内存抖动是指在短时间内有大量的对象被创建或回收。内存抖动的频繁,会导致垃圾回收机制频繁运行,从而造成系统卡顿。
(1)容易被忽视的可能会造成内存抖动的代码写法:
String str = "";
for (int i = 0; i < 100; i++) {
str + = "abc";
}
上述代码看起来并没有频繁创建对象,但是,实际上每次执行 str + = "abc" 等同于执行了如下代码:
new StringBuilder().append(str).append("abc").toString();
这样问题就显而易见了,每次循环都会创建一个StringBuilder对象。与之类似的,有时为了排查问题,会在循环中写如下日志:
Log.i("TAG",arg1 +"——"+ args2);
这样一行日志看起来无足轻重,但是如果写在循环中,频繁创建的StringBuilder对象还是可能会引起内存抖动的。
在自定义View时,onDraw方法创建对象也是项目中比较常见的错误写法:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint paint = new Paint();
}
这种错误的写法在刚入门安卓开发时由于经验不足比较常见,因为此处没有循环,和高频创建好像并不沾边,但是,在安卓的页面绘制流程中onDraw方法是会被多次调用的,因此在这里创建对象也应该是明令禁止的。
(2)解决方案
内存抖动更多的是代码规范的问题,因此更多是依赖于代码 Review 过程中,对循环,onDraw等方法格外注意,避免错误的写法产生。此外可以使用Android Profiler的内存监控工具,对内存抖动的情况进行线下监控。
图1
通过上图1可以发现内存已出现明显的抖动,垃圾回收也在频繁的进行,通过对具体页面代码的分析,我们可以进一步定位问题。
2、内存泄漏
内存泄漏问题,是我们要解决的重点问题,因为时间积累,泄漏的内存会越来越多,引发的问题也会更加严重。
(1)常见的导致内存泄漏的场景
- 静态持有
public class test {
static List list = new ArrayList<>();
public void leak() {
Object object = new Object();
list.add(object);
}
}
object 被 static 持有,如不主动移除则会造成泄漏。
- 单例模式
和静态持有导致内存泄露的原因类似,因为单例生命周期和整个APP的生命周期一样长,所以如果单例对象持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。
- 内部类持有外部类
非静态内部类默认会包含外部类的引用,如果内部类的生命周期长于外部类,则会造成内存泄漏。如:
public class MemoryTest {
private void doSth(){};
public class MyThread extends Thread{
@Override
public void run() {
super.run();
MemoryTest.this.doSth();
}
}
}
正确的写法应为:
public class MemoryTest {
private void doSth(){};
public static class MyThread extends Thread{
WeakReference<MemoryTest> memoryTestWeakReference;
public MyThread(MemoryTest memoryTest){
memoryTestWeakReference = new WeakReference<>(memoryTest);
}
@Override
public void run() {
super.run();
MemoryTest memoryTest = memoryTestWeakReference.get();
if(memoryTest!=null){
memoryTest.doSth();
}
}
}
}
- IO 未关闭
当进行文件读写,网络等 IO 操作时,需要及时将连接关闭,否则 GC 不会回收该对象,从而造成内存泄漏。
InputStream is = null;
BufferedReader in = null;
try {
is = context.getAssets().open(assetFileName);
in = new BufferedReader(new InputStreamReader(is));
...
} catch (IOException e) {
return "";
} finally {
//在finally中确保所有的io操作被关闭
try {
if (is != null) {
is.close();
}
if (in != null) {
in.close();
}
} catch (Throwable ignore) {
}
}
对于内存泄漏而言,如果泄漏的较少用户是无感知的,更多时候我们写代码时要避免出现内存泄漏的场景。线下内存泄漏检测可以使用LeakCanary等工具进行检测。对于线上问题,内存泄漏导致最严重的后果就是内存溢出,下面着重说下我们遇到的内存溢出问题及解决方案。
3、内存溢出(OOM)
如果说内存抖动和泄漏对于用户来说还是无感知或者轻微感知的,那么 OOM 会直接导致 APP 闪退,严重影响用户体验。通过 bugly 的崩溃分析,我们遇到的OOM主要分为如下几类:
图2
针对以上几点我们进行了如下的几项优化或监控:
- Bitmap 显示格式由 ARGB8888 改为 RGB565 ,图片占用内存减小为原来的一半。
- 进行大图治理,与服务端沟通,将返回的图片尺寸改为更适合 View 大小的尺寸。客户端加载图片时设置宽高,减少不必要的内存浪费
- 进行线程数监控(之前团队成员已进行过详细介绍)。
- 进行线上 OOM 用户 hprof 获取。
- FD 泄漏监控(建设中)。
- Native 内存监控(建设中)。
其中,线上 OOM 用户hprof文件的获取,对我们分析线上问题提供了极大的帮助,下面举例说明。
1、堆内存单次分配过大举例:
图3
由图3可以看到,单次申请内存超过了 100M,因为 Java 堆常见的最大可用内存为 256M 或 512M ,由此可判断本次 OOM 发生的原因为单次内存申请过大导致的。此类问题较为容易解决,因为申请内存的调用方法栈是明确的,可以直接排查代码解决问题。但是本次问题线上偶现的,通过排查代码无法找到明确的问题。我们通过获取线上出现问题用户的 hprof 文件。通过分析排查到了如图4中所示的问题--KMAdSlot中token长度过大。
图4
通过这个线索,结合代码与三方沟通,进一步定位到了申请对象过大的原因是百青藤 SDK 获取 oaid 过大导致的。我们线上的 hprof 文件获取的方案建设极大有效的推进了该问题的解决。
2、堆内存累计分配过大举例:
图5
由图5可以看到,单次申请内存为 3M 多时就发生了内存溢出,由此可见,此次的内存申请只是“压死骆驼的最后一棵稻草”,对于此类问题,单单依靠崩溃时的堆栈是不易解决的。我们通过获取线上问题用户的 hprof 文件可以分析具体的内存使用状况,可以帮助我们更快的定位问题。如图6所示,定位到该问题是由于穿山甲MediaPlayer创建过多,没有释放导致的。
图6
由上述可见线上hprof获取方案的建设对于内存优化,起着至关重要的作用。
三、线上hprof文件获取
线上的堆转储文件(hprof)获取我们使用的是快手的 KOOM 方案。通过 MAT 工具分析该文件,可以获取 OOM 发生时所有 Java 堆中的对象,引用链及大小。线上获取 hprof 文件需要解决如下几个问题:
- 采集 hprof 的常用方案会使应用卡死几秒钟的时间,虽然免费小说 APP 是在OOM 发生时才进行 dump ,但是同样会造成很大的影响,此时 bugly 正在进行问题采集上报等操作都会受到影响。
- 采集到的hprof文件通常有几百兆大小,这样对用户的流量及上报的成功率都会产生巨大的影响。
快手的KOOM方案很好的提供了以上两点问题的解决方案:
针对问题1:
KOOM利用COW(Copy-on-write,写时复制)机制,在dump内存镜像前先暂停虚拟机,然后fork子进程来执行dump操作,父进程在fork完成后立刻恢复虚拟机的运行。整个过程父进程卡顿的时间仅有几毫秒,这对用户来说是可以接受的。
关键dump代码流程如下:
public boolean dump(String path) {
...
// 适配 Android 11 ,和下面流程差不多
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return dumpHprofDataNative(path);
}
...
try {
// 调用 native 方法,挂起当前的主进程,并 for 出子进程,该挂起仅仅只是更改 ThreadList 变量的线程状态为 suspend
int pid = trySuspendVMThenFork();
if (pid == 0) {
// 子进程开始 dump hprof
Debug.dumpHprofData(path);
// 结束子进程
exitProcess();
} else {
// 恢复挂起的主进程
resumeVM();
// 等待子进程的 dump
dumpRes = waitDumping(pid);
}
} catch (IOException e) {
e.printStackTrace();
}
return dumpRes;
}
针对问题2
相对于问题1来说,问题2的解决方案较为简单,我们仅需要对 hprof 文件进行裁剪,只保留分析 OOM 的必需数据。如对于所有的 Bitmap 对象的像素均设为0,所有的字符串的 byte 均设置为0,这样我们仅保留了对象的大小,可以使压缩后的文件由几百兆缩减为了几兆。同时裁剪也是对数据进行脱敏,对于数据的具体内容不进行传输和上报,这样极好的保护的用户的隐私。同时为了提高文件上报的成功率,我们还对文件进行了分割处理,将一整个大文件,分割成几个小文件分别进行上传。整体的hprof文件的获取分析流程如下。
图5
通过近期的一系列内存优化手段,线上OOM问题已经得到了有效的改善,对于新出现的Java内存问题也能够及时监控分析。目前我们Native的内存分析监控正在开展,通过对Native层的内存分析优化,会进一步提高我们APP的稳定性及分析解决问题的能力。
参考:
快手KOOM方案解析