Android 性能优化(三)启动优化

1,057 阅读11分钟

文章内容根据《性能优化入门与实战》总结而来

为什么要做启动优化

App启动是指从用户点击桌面图标、展示Splash(闪屏页)到App首页展出并可交互的整个过程。因此启动时间对业务的触达和转化有着重要的影响,如果启动时间过久会让用户的满意度下降甚至放弃使用。

技术人员曾提到过:App启动耗时每减少1s,用户流失率会降低6.9%。因此我们需要在保证产品功能可正常使用的前提下,尽可能地缩短启动时间,提高用户触达业务的速度,提升用户体验

线上监控启动数据

在 Android 中,根据进程、Activity是否已存在,可以将App启动分为冷启动、温启动和热启动3种,如下图所示。

image.png

线上监控就需要对这三种情况进行统计,从而获取从启动开始到启动结束的总耗时和各区间的耗时。

获取整体的耗时

Android 的启动代码执行顺序如下:

  1. Application构造函数。
  2. Application#attachBaseContext。
  3. ContentProvider#onCreate。
  4. Application#onCreate。
  5. Activity#onCreate。
  6. Activity#onStart。
  7. Activity#onResume。
  8. View#onDraw。
  9. Activity#onWindowFocusChanged。

因此,我们可以把 Application 构造函数作为启动的起点。而启动的终点,则有两个选择,分别是 View#onDrawActivity#onWindowFocusChanged。两种方式技术的对比如下图所示:

image.png

一般在项目中,我们选择onWindowFocusChanged作为启动终点较多。

获取启动各个阶段耗时

获取各个阶段的耗时一般有两种方式:手动埋点和编译时AOP(Aspect Oriented Programming,面向切面编程)。

  • 手动埋点

手动埋点比较简单,在启动相关的生命周期方法中添加一行统计代码,然后在一个类中集中管理各个阶段的时间即可。代码示例如下:

public class DemoApplication extends Application {
 
    public DemoApplication() {
        StartupMonitor.onBegin();
        //…
    }
 
    @Override
    protected void attachBaseContext(Context base) {
         StartupMonitor.onApplicationAttachBaseContext();
         super.attachBaseContext(base);
         //…
    }
 
    @Override
    public void onCreate() {
         StartupMonitor.onApplicationCreate();
         super.onCreate();
         //…
    }
}
  • AOP

编译时AOP是指在编译期对要统计耗时的函数进行插桩,在它执行前后统计耗时。我们可以提供一个注解,当某个方法要记入启动阶段统计时,使用注解标记这个方法。然后在编译时使用Aspectj或者APT(Annotation Processing Tool,注解处理器)等AOP框架,拦截注解标注的方法,在执行前后记录耗时。

使用 Aspectj 的示例如下:

@Aspect
public class StartupIncludeAspect {
 
//参数表示拦截 top.shixinzhang.performance.startup.aop.StartupInclude 相关函数的执行
    @Pointcut("execution(@top.shixinzhang.performance.startup.aop.
              StartupInclude * *(..))")
     public void StartupIncludeMethod() {}
 
     @Around("StartupIncludeMethod()")
     public Object recordStartupMethodCost(ProceedingJoinPoint joinPoint) throws
                                           Throwable {
         long begin = System.currentTimeMillis();
 //执行被拦截的方法
         Object result = joinPoint.proceed();
         long cost = System.currentTimeMillis() - begin;
         if (joinPoint.getSignature() != null) {
               StartupMonitor.onMethodCost(joinPoint.getSignature().toString(),
                                           cost);
         }
 
         return result;
    }
}

启动性能数据

前面的统计方式,都是会获取 Wall time(墙上时间,也就是客观过去的时间)。除此之外,我们还需要获取 App的CPU时间、线程优先级和被抢占次数等数据。

当内存不足时触发的GC会对启动有不小的影响,因此我们还需要获取启动期间的GC执行次数和耗时。代码示例如下:

    @RequiresApi(api = Build.VERSION_CODES.M)
    public static long getGcInfoSafely(String info) {
         try {
             return Long.parseLong(Debug.getRuntimeStat(info));
         } catch (Throwable throwable) {
             throwable.printStackTrace();
             return -1;
         }
     }
public static void getGCInfo() {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
             long gcCount = getGcInfoSafely("art.gc.gc-count");
             long gcTime = getGcInfoSafely(“art.gc.gc-time”);
             long blockGcCount = getGcInfoSafely("art.gc.blocking-gc-count");
             long blockGcTime = getGcInfoSafely("art.gc.blocking-gc-time");
 
             long deltaGcCount = gcCount - sGCInfo[0];
             long deltaGcTime = gcTime - sGCInfo[1];
             long deltaBlockGcCount = blockGcCount - sGCInfo[2];
             long deltaBlockGcTime = blockGcTime - sGCInfo[3];
 
             sGCInfo[0] = gcCount;
             sGCInfo[1] = gcTime;
             sGCInfo[2] = blockGcCount;
             sGCInfo[3] = blockGcTime;
         }
     }

线下分析启动数据

通过Logcat搜索关键字Displayed来查看启动时间

我们可以直接在 Android Studio Logcat或者直接执行adb logcat,在结果里搜索Displayed关键字,就可以看到系统统计的App启动耗时。示例如下:

ActivityTaskManager: Displayed top.xxxx.performance/.MainActivity: +5s602ms

通过 adb shell am start 获取每一次的启动耗时

我们可以通过adb shell am start实现多次自动启动App并获取每一次的启动耗时。示例如下:

adb shell am start -S -W -R 3 top.xxxx.performance/.MainActivity
Stopping: top.shixinzhang.performance
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.
                   LAUNCHER] cmp=top.shixinzhang.performance/.MainActivity }
Status: ok
LaunchState: COLD
Activity: top.shixinzhang.performance/.MainActivity
TotalTime: 5583
WaitTime: 5595
Complete

其中:

  1. am start是ActivityManagerService提供的命令,可以用来启动Activity。
  2. -S即Stop,表示在每次启动前,先强制停止App运行,以实现冷启动。
  3. -W即Wait,表示执行后等待启动完成再退出,以统计整个启动的耗时。
  4. -R即Repeat,表示重复执行启动的次数,-R 3表示重复启动3次。
  5. top.xxxx.performance是要启动的App包名。
  6. .MainActivity是在AndroidManifest.xml里配置的入口Activity。

前面提到的两种方式,统计的是从App启动到Activity首次调用onWindowFocusChanged的时间。如果我们想统计从App启动到数据请求成功后某个布局完全展示出来的耗时,可以在启动终点调用Activity#reportFullyDrawn,通知当前已经完全绘制完成,然后在Logcat里过滤Fully drawn就可以看到整个流程的耗时.

Perfetto

使用 Perfetto 分析App启动数据。在 Perfetto展示的数据中有一个“Android App Startups”区块,统计了从Launcher收到点击事件到首帧绘制结束的耗时。如下图所示:

image.png

单击“Android App Startups”中的包名后,按键盘“M”键可显示启动耗时;按键盘 “A、S、W、D”键缩放查看App包名的主线程区块,即可查看各个阶段的耗时;框选主线程启动部分的区块,可以看到如下4种信息。

  • Android Logs:以看到Logcat输出的日志,包括系统的和自定义的。通过这个功能我们可以分析日志输出时执行的函数。
  • Thread States:可以看到主线程的各种状态的占比。通过这个功能,我们可以查看项目启动过程中是否非Running/Runnable状态占比较多。如果是的话,就需要从I/O、锁、sleep(休眠)等角度出发找优化点。
  • Slices:可以查看不同操作的排行情况,支持按照总耗时、平均每次耗时、发生次数等维度进行排序。通过这个功能,我们可以找到启动过程中执行耗时较多和执行频繁的函数。
  • Flow Events:可以查看Binder调用等事件的去向。通过这个功能,我们可以查看启动过程中Binder调用的关系。

如何进行启动优化

绑定大核提升启动速度

App代码可能运行在低频率的小核CPU上,代码执行慢。因此我们可以把 App 代码绑定到大核来提升启动速度。我们可以通过修改进程的处理器亲和度(一个进程会被调度到同一个CPU上的可能性)来实现绑定大核的效果。主要有三步:

  1. 通过CPU_ZERO初始化一个cpu_set_t。
  2. 通过CPU_SET设置进程运行在哪个CPU上,可以调用多次。
  3. 执行sched_setaffinity设置亲和度。

代码示例如下:

void set_affinity() {
      cpu_set_t set;
      CPU_ZERO(&set);
 
      CPU_SET(3, &set);
      CPU_CLR(2, &set);
 
      int ret = sched_se taffinity(0, sizeof(cpu_set_t), &set);
      LOG("set_affinity ret: %d", ret);
 
      struct timeval time{};
      time.tv_sec = 3;
      select(0, nullptr, nullptr, nullptr, &time);
 
      int cpu = sched_getcpu();
      LOG("after sleep, run in cpu%d", cpu);
 
      get_affinity();
}
 
//测试代码入口函数
void affinity_test() {
      LOG(" ");
      LOG(" affinity_test >> max cpu_set size: %d", CPU_SETSIZE);
 
      int cpu = sched_getcpu();
    LOG("run in cpu%d", cpu);
 
    set_affinity();
}

获取当前进程度的流程也是类似的,代码示例如下:

void get_affinity() {
     //1.初始化结构体
     cpu_set_t cpu_set;
     CPU_ZERO(&cpu_set);
 
     //2.获取当前进程的处理器亲和度
     int ret = sched_getaffinity(0, sizeof(cpu_set_t), &cpu_set);
 
     if (ret == -1) {
          perror("sched_getaffinity failed");
          return;
     }
     LOG("sched_getaffinity ret:%d, cpu_set size: %ld", ret,
          sizeof(cpu_set) / sizeof(cpu_set_t));
 
     //3.遍历读取数据
     for (int i = 0; i < CPU_SETSIZE; ++i) {
           int in_set = CPU_ISSET(i, &cpu_set);
           LOG("cpu%d is in set? %d", i, in_set);
    }
}

至于如何判断哪个CPU频率高,我们则可以通过adb shell查看各个CPU的最高频率。

adb shell cat /sys/devices/system/cpu/cpu1/cpufreq/cpuinfo_max_freq
1766400
adb shell cat /sys/devices/system/cpu/cpu5/cpufreq/cpuinfo_max_freq
2803200

在代码中可以通过Runtime.getRuntime().exec() 执行查看CPU最高频率的命令。

    Process proc = Runtime.getRuntime().exec("cat /sys/devices/system/cpu/cpu5/
                   cpufreq/cpuinfo_max_freq");
 
    proc.waitFor();
    InputStream inputStream = proc.getInputStream();
 
    reader = new BufferedReader(new InputStreamReader(inputStream));
    resultBuilder = new StringBuilder();
    String line = "";
    while (null != (line = reader.readLine())) {
         resultBuilder.append(line);
    }
    String result = resultBuilder.toString();

通过遍历获取到当前设备上频率最高的CPU,然后将其设置到进程的处理器亲和度集合中,我们就可以实现将进程绑定到大核CPU上,从而提升启动速度。

需要注意,为了减少耗电和对其他业务的影响,我们在App启动结束后最好解除强制绑定,交还给操作系统调度。

通过框架管理启动任务

App启动速度慢最常见的原因之一是启动期间执行的代码没有被统一管理。可以使用阿里巴巴开源的alpha来管理任务,它支持指定依赖关系、优先级和执行线程。

减少ContentProvider初始化耗时

对于 ContentProvider,即使它没有被调用,也会在启动阶段执行onCreate方法,并且是在主线程执行。因此很多框架库喜欢在ContentProvider#onCreate中初始化SDK,比如androidx.lifecycle。

这样的初始化代码多了以后,很容易出现耗时比较多的代码(比如数据库查询代码),直接影响启动速度。一种比较好的优化方式是提供一个ContentProvider封装类和一个异步的onCreate方法,然后在编译时将继承ContentProvider的类修改为继承AsyncContentProvider,同时将它的onCreate方法名改为onCreateAsync。代码示例如下:

public abstract class AsyncContentProvider extends ContentProvider {
     @Override
     final public boolean onCreate() {
          AsyncTask.execute(new Runnable() {
             @Override
             public void run() {
                 onCreateAsync();
             }
         });
         return true;
     }
 
     public abstract boolean onCreateAsync();
}

Jetpack中的App Startup也可以优化ContentProvier的初始化耗时。我们可以通过使用App Startup的Initializer,将通过多个ContentProvider完成的初始化工作合并到一个ContentProvider中,从而减少创建ContentProvider的成本。

减少.so文件加载耗时

使用 System.loadLibrary 加载 so 库时,会执行到 .so文件的JNI_OnLoad方法。由于在JNI_OnLoad中我们常常会做其他库的动态链接、Java类的查找、文件读写等操作,因此很容易出现由于JNI_OnLoad方法耗时过久导致的启动变慢的情况。

因此建议为了减少 .so文件加载和JNI_onLoad方法对启动速度的影响,我们需要将System.loadLibrary放到子线程执行。代码示例如下:

public class MySDK {
     static {
          AsyncTask.execute(new Runnable() {
             @Override
             public void run() {
                 System.loadLibrary("shixin-lib");
             }
         });
     }
}

延迟子进程的创建

复杂的项目中往往会使用多个进程,比如将推送、播放等与UI无关的功能放到单独的进程。在实际测试中我们会发现,启动时就创建子进程,会导致主进程启动耗时增加几十到几百毫秒不等。

一般创建子进程的方式是先在AndroidManifest.xml里声明一个多进程的组件,然后在启动时创建这个组件,这样就会触发执行fork系统调用,我们可以通过hook fork的方式,拦截启动过程中对fork的调用,从而将其延迟执行。如下图所示:

image.png

低端机启动逻辑降级

根据听云《2021移动应用性能管理白皮书》的数据,随着Android官方对系统的优化,在高版本的手机上冷启动时间更短,低端机上启动速度受硬件资源限制更明显。因此我们有必要针对高端设备、低端设备做不同的策略处理,在低端设备上做更激进的优化方式,换来更快的启动速度,从而提升产品整体的使用时长。

不同产品的启动任务有所不同,无法一概而论,但有如下几点通用的思路:

  1. 在做任务延迟时,最好区分高端机和低端机延迟的时间,低端机适当延迟久一点。
  2. 在将任务异步处理时,需要考虑当前的线程数,在低端机上尽量减少额外的线程。
  3. 在读取大配置文件时,适当选择效率更高的方式,比如将SharedPreferences替换为MMKV(基于mmap的高性能通用key-value组件)。
  4. 通过ClassLoader记录启动时加载的类列表及耗时,然后在启动时异步预加载所需的类,从而减少启动阶段的类加载耗时。
  5. 通过hook文件I/O记录启动时主线程读取的文件,然后在后续启动时异步提前读取这部分文件,从而减少启动阶段的文件读取耗时