七猫免费小说 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方案解析