Android 性能优化系列(二): 启动优化

64 阅读9分钟

为什么做启动优化

APP启动值得是用户从桌面点击APP icon 到APP 页面第一帧数据渲染出来的时间,如果启动时间过长会让用户满意度下降,甚至放弃使用,因此我们要保证功能前提下,尽量降低启动时间

首先复习一下App启动

Android App启动过程

Android ContentProvider启动流程

  • 首先是点击App图标,此时是运行在Launcher进程,通过ActivityManagerServiceBinder IPC的形式向system_server进程发起startActivity的请求
  • system_server进程接收到请求后,通过Process.start方法向zygote进程发送创建进程的请求
  • zygote进程fork出新的子进程,即App进程
  • 然后进入ActivityThread.main方法中,这时运行在App进程中,通过ActivityManagerServiceBinder IPC的形式向system_server进程发起attachApplication请求
  • system_server接收到请求后,进行一些列准备工作后,再通过Binder IPC向App进程发送scheduleLaunchActivity请求
  • App进程binder线程(ApplicationThread)收到请求后,通过Handler向主线程发送LAUNCH_ACTIVITY消息
  • 主线程收到Message后,通过反射机制创建目标Activity,并回调ActivityonCreate

而我们的Appcation的启动是在第四步的attachApplication中请求的开始的,下面我们就具体看源码分析

ActivityManagerService.java

 public final void attachApplication(IApplicationThread thread, long startSeq) {
        synchronized (this) {
            int callingPid = Binder.getCallingPid();
            final int callingUid = Binder.getCallingUid();
            final long origId = Binder.clearCallingIdentity();
            attachApplicationLocked(thread, callingPid, callingUid, startSeq);
            Binder.restoreCallingIdentity(origId);
        }
    }
private final boolean attachApplicationLocked(IApplicationThread thread,
        int pid) {
    ....
    thread.bindApplication(processName, appInfo, providers,
            app.instr.mClass,
            profilerInfo, app.instr.mArguments,
            app.instr.mWatcher,
            app.instr.mUiAutomationConnection, testMode,
            mBinderTransactionTrackingEnabled, enableTrackAllocation,
            isRestrictedBackupMode || !normalMode, app.persistent,
            new Configuration(getGlobalConfiguration()), app.compat,
            getCommonServicesLocked(app.isolated),
            mCoreSettingsObserver.getCoreSettingsLocked(),
            buildSerial);
}

这里调用了threadbindApplication方法,thread的类型是IApplicationThread,是一个binder用于跨进程通信,实现类是ActivityThread的内部类ApplicationThread

App 的 application 创建是在 ActivityThread 的 handleBindApplication 方法完成的。

    private void handleBindApplication(AppBindData data) {
        ...
        // If the app is Honeycomb MR1 or earlier, switch its AsyncTask
        // implementation to use the pool executor.  Normally, we use the
        // serialized executor as the default. This has to happen in the
        // main thread so the main looper is set right.
        if (data.appInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.HONEYCOMB_MR1) {
            AsyncTask.setDefaultExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }

        ...
        final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);
        ...
            app = data.info.makeApplication(data.restrictedBackupMode, null);

            ...

            mInitialApplication = app;

            ...
            try {
                mInstrumentation.callApplicationOnCreate(app);
            ...
    }

handleBindApplication 通过 LoadedApk 的 makeApplication 构造 application。

LoadedApk 的 makeApplication 方法如下:

    public Application makeApplication(boolean forceDefaultAppClass,
            Instrumentation instrumentation) {
        ...
            ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
            app = mActivityThread.mInstrumentation.newApplication(
                    cl, appClass, appContext);
            appContext.setOuterContext(app);
        ...
        return app;
    }

makeApplication 通过 ContextImpl.createAppContext 构造了 contextImpl,然后用 newApplication 构造了 application。

Instrumentation 的 newApplication 方法如下:

    public Application newApplication(ClassLoader cl, String className, Context context)
            throws InstantiationException, IllegalAccessException, 
            ClassNotFoundException {
        Application app = getFactory(context.getPackageName())
                .instantiateApplication(cl, className);
        app.attach(context);
        return app;
    }

可以看出 newApplication 先构造了 Application,然后再 attach context,这一点和 Activity 的构造类似,都是先创建,再 attach 一些内容。

Application 的 attach 方法如下:

    /* package */ final void attach(Context context) {
        attachBaseContext(context);
        mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;
    }

到这里就调用了attachBaseContext

看了上面分析和文章,你就会发现,这个流程系统启动过程我们是无法干预的,但是当启动到了自己的App进程,就可以知己处理了

  • Appcation 构造方法
  • Appcation attachBaseContext
  • ContentProvider启动
  • Appcation onCreate
  • Activity onCreate
  • Activity onStart
  • Activity onResume
  • View onDraw
  • Activity#onWindowFocusChanged()

这几个流程就是我们可以控制的流程,我们的优化也是在这几个流程中进行优化

启动时间检测

查看Logcat关键字 Displayed

在Android Studio Logcat中过滤关键字“Displayed”,可以看到对应的冷启动耗时日志。

ActivityTaskManager: Displayed com.example.wanandroid/.MainActivity: +331ms

这种方式最简单,适用于收集 App 与竞品 App 启动耗时对比分析。

adb shell 命令查看启动耗时

adb shell am start -W [packageName]/[启动activity的全路径]

比如

adb shell am start -W com.example.wanandroid/com.example.wanandroid.MainActivity

然后会有结果

Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.wanandroid/.MainActivity }
Status: ok
LaunchState: WARM
Activity: com.example.wanandroid/.MainActivity
TotalTime: 348
WaitTime: 351
Complete
  • TotalTime 表示所有activity 的启动耗时
  • WaitTime 表示aws 启动activity的耗时

TotalTime 就是应用启动耗时 他包括进程启动+Application启动+Activity初始化到ui显示时间 这种适合线下使用,对比竞品的启动速度

前面提到的俩种方式,统计的是App启动到Activity首次调用onWindowFocusChanged的时间,如果我们想要统计,App启动到网络数据请求之后的总耗时,可以在终点调用, activity.reportFullyDrawn(),通知当前已经绘制完成,然后就可以在Locat中看到

Displayed com.example.wanandroid/com.example.wanandroid.MainActivity: +3s171ms
Fully drawn com.example.wanandroid/com.example.wanandroid.MainActivity: +4s459ms

自己打点计时

上面我们已经分析APP的启动流程

  • Appcation 构造方法
  • Appcation attachBaseContext
  • ContentProvider启动
  • Appcation onCreate
  • Activity onCreate
  • Activity onStart
  • Activity onResume
  • View onDraw
  • Activity#onWindowFocusChanged()

我们自己打点可以在Appcation attachBaseContext 作为起始点,Activity#onWindowFocusChanged() 作为终点来计算真正的启动时间

终点的选择其实有俩种第一种就是Activity#onWindowFocusChanged() 这种的话可能不是首帧,可能是2-3帧,统计时间会多几帧的耗时,但是相对来说稳定

如果首帧的渲染需要通过网络请求之后才能渲染数据,那么这个就不是很准确,因为Activity#onWindowFocusChanged() 统计的是默认UI的显示(就是写死的ui)

这种情况可以选择列表的第一个ItemView的perDrawCallback() 回调来计算真正的时间,当列表的第一个Item 显示数据,表示已经网络加载完成

// itemView添加预绘制回调监听
itemView.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
    override fun onPreDraw(): Boolean {
        return false
    }
})

获取启动各个阶段的耗时

上面我们计算了App启动的整体耗时,这个只能得出总耗时的结论,我们还需要知道启动各个阶段的耗时,这样我们才能确定到底是哪里的代码导致的,获取各个阶段的耗时,一般有俩种方法

  • 手动埋点
  • 编译时AOP

手动埋点

手动埋点比较简单,在需要的地方加上统计代码即可

编译时AOP

编译时AOP表示在编译期间要对耗时的函数进行插桩,在他计时前后进行耗时统计,这个后序单独出一篇博客讲解

启动优化工具

除了获取启动时间,线下测试如果想要更进一步的获取启动耗时点,可以使用工具进行进一步分析,比如TraceView,CPU Profiler SysTrace Perfetto 这些之后也会单独开一篇文章

启动优化方案

视觉优化启动速度

在冷启动的时候

  • 启动后立即显示应用程序空白的启动窗口。
  • 创建应用程序进程。

也就是说点击app后会先显示一个空白的window,如果启动时间过长,这样启动就会显示白框很久,所以体验会很不好

这种的解决办法就是,设置闪屏主题

    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowBackground">@drawable/lunch</item>  //闪屏页图片
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowDrawsSystemBarBackgrounds">false</item><!--显示虚拟按键,并腾出空间-->
    </style>

把这个style设置给闪屏activity,其他的不用换,这样就可以直接显示闪屏图片,解决白屏问题,但是这个是治标不治本

减少ContenProvider 初始化耗时

由于ContenProvider 有自动初始化的特性,在App启动时会自动调用onCreate() 并且是在主线程进行,很多框架都喜欢在ContenProvider#onCreate()进行初始化SDK,这样的初始化代码多了以后,很容易出现耗时比较长的代码(比如数据库查询),直接影响启动速度

一种比较好的处理方式,提供一个ContenProvider的封装类,和一个异步的onCreate方法,然后再编译时将继承ContenProvider的类,改为继承这个基类,同时把onCreate 改为 onCrateAsycn

这样就是实现了ContenProvider#onCreate()的异步化,减少对启动的影响

或者使用jetpack中的App Startup 也能起到优化作用,通过App StartupInitializer,将多个ContenProvider完成的初始化工作,合并到同一个ContenProvider,减少创建ContenProvider的成本

Application 阶段的优化

在应用启动的时候,可能有很多组件需要进行初始化,这样的初始化多了以后就会拖累启动速度,对于这种我们有俩种决解决思路

  • 异步化
  • 延迟加载

异步化

异步化理解起来很简单,就是把任务放到子线程,但是各个组件的初始化可能有依赖关系,如果只是简单的子线程恐怕达不到目的,所以我们需要一种框架他需要支持一下几个特点

  • 根据任务类型决定运行任务的线程
  • 根据依赖关系,自动将前一个依赖任务执行
  • 根据任务优先级顺序执行任务

比如阿里巴巴的开源框架 alpha 通过配置,生成有向无环图,俩保证依赖顺序,通过配置将任务分配到合理的线程

延迟优化

对于一些启动并优先级不高的的组件我们可以选择延迟优化,延迟优化,我们可以选择利用IdleHandler,只有在主线程空闲时才执行任务

Activity 阶段优化

这个阶段主要分为俩步

  • 布局优化
  • 本地数据缓存

布局优化

布局越复杂,加载布局的时间就越长,所以注意下面几点

  • 删除无用布局(再需求迭代中,有些布局不在使用)
  • 使用Viewstub 对布局进行懒加载
  • 降低布局层级
  • 使用megra 减少布局层级

也可以对布局进行预加载

加载XML 有以下三个流程

  • 将XML 文件加载到内存中, XmlResourceParser 的 IO 过程
  • 根据不同的name 反射View对象
  • 最终形成View树

这块我们可以直接用代码写布局代替xml,或者使用在 androidx 中已经有提供了 AsyncLayoutInflater 用于进行 xml 的异步加载

本地数据缓存

正常情况下,App启动都是需要请求网络,然后再进行数据渲染,我们可以把上一次的数据缓存下来,下一次启动的时候,直接加载缓存数据,来达到降低启动时间的目的

其他优化方式

绑定大核提升启动速度

CPU根据频率,cache(高速缓存)大小等,区分为大核和小核,一般大核执行频率更高,我们可以代码查看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_ZERO初始化一个cpu_set_t。
  • 通过CPU_SET设置进程运行在哪个CPU上,可以调用多次。
  • 执行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();
}

区分机型 低 中 高端机 优化分类

  • 任务延时时间,在低端机上适当延迟久一些
  • 任务异步,在低端机要考虑线程数,低端机减少是线程数
  • 通过ClassLoader记录启动的类及耗时,然后再启动时异步加载需要的类,从而减少加载耗时

参考

Android 性能优化与实践