「性能优化系列」APP启动优化理论与实践(上)

6,340

应用启动的时间作为应用的门面,重要性可想而知。尤其在如今的快时代,一款秒开的App比一款启动需要耗费好几秒的App更容易被用户喜爱和长期使用,整的不好还容易被用户永久拉入黑名单。这时候,应用的启动优化就必不可少了。那么接下来就来了解下关于启动优化的一些注意事项。

一、应用启动类型

1.1.冷启动

冷启动是指应用程序从零开始,系统的进程在此启动之前没有创建应用程序的进程,或者由于系统杀死了应用后再启动。在冷启动开始时,系统有三个任务。这些任务包括:

  • 加载并启动应用程序。

  • 启动后立即显示一个空白的启动窗口。

  • 创建应用程序app进程。

一旦app进程创建完成,系统就开始下一阶段:

  • 创建app对象;
  • 启动主线程。
  • 创建主Activity
  • 开始对View进行布局。

1.2.热启动

热启动不同于冷启动,热启动在启动应用时,系统中已经有了该应用的进程,启动时也就少了创建进程等一系列耗时的操作。

1.3.温启动

温启动的启动速度处于冷启动和热启动之间,温启动会重新走Activity的onCreate生命周期。

二、应用启动流程

优化应用的启动速度主要是在于冷启动时,应用的启动耗时。冷启动时应用会从零开启,这就需要先了解一下当我们点击Launcher上app图标后,进程之间做了什么处理?

2.1.启动基本流程

  1. 点击App图标,Launcher进程向SystemServer进程发起startActivity请求;
  2. SystemServer进程收到请求后,向Zygote进程发送创建App进程的请求;
  3. Zygote进程fork出新的App进程,App进程就开始向SystemServer进程发出attachApplication请求,在此同时,App进程会执行bindApplication,即创建Application,调用Application的onCreate;
  4. SystemServer在收到attachApplication请求后,再次向App进程发送scheduleLauncherActivity请求;
  5. App进程收到请求后,通过handler向主线程发送LAUNCHE_ACTIVITY消息,主线程收到消息后通过反射机制创建目标Activity,并回调Activity.OnCreate()等方法。到此,App才正式启动,开始Activity的生命周期。

从应用的启动流程可以发现,应用的启动其实是App进程与SystemServer进程,Zygote进程相互配合的过程。对于启动速度的优化,应用层我们所要关注并且能够干预的也就是Application和Activity的创建。

2.2.Application

App运行时,会首先自动创建Application类并实例化 Application对象,且只有一个。Application的创建时间比Activity要早,在上面应用的启动流程中也提到,在启动App时,会创建Application,那么就需要先去了解下它的生命周期。

  • attachBaseContext():得到应用上下文的Context,在应用创建时会首先调用;
  • onCreate():同样在应用创建时调用,但比attachBaseContext()要晚;
  • onTerminate():应用结束时调用;
  • onConfigurationChange():系统配置发生变化时调用;
  • onLowMemory():系统低内存时调用;
  • onTrimMemory():系统要求应用释放内存时调用。

从Application的生命周期可以看到,应用创建时会依次调用attachBaseContext()和onCreate(),这两个生命周期包含在应用的启动流程中,启动速度优化可以以此为一个切入点。

2.3.Activity

从点击图标到用户看见前台数据所经历的生命周期,也就是Activity的onCreate(),onResume()。众所周知,Activity会在onCreate()中加载布局以及进行数据的初始化。既然包含在应用的启动中,那么也可以作为一个切入点。

2.4.小结

从上面的启动流程到Application和Activity的介绍,应用启动优化的切入点也就如下图所示:

作为应用层所能监控并且能处理的,第一点是属于Application的创建,第二点就是Activity的创建。那么接下来就需要去监测各个部分所耗费的时间,再针对性的进行优化。

三、启动耗时的监测

3.1.logcat生成所有log

在连接上设备后可以在串口或者adb中利用命令打印出所有的log:

  1. 生成log文件:logcat > /data/xxx.txt
  2. 利用adb将文件pull出来:adb pull /data/xxx.txt

执行完上面两条命令后,就会生成一个全log的txt文件,pull出文件后,可以在文件中查看所有的信息,包括启动发生的时间,类名,线程号等。

logcat生成的xxx.txt虽然包含了很详细的信息,但是还需要我们自己去计算各个生命周期间所耗费的时间。

3.2.adb命令执行

除了3.1提到的用logcat打印全log外,adb还有一条命令可以直接生成应用启动的时间。adb shell am start -W [packageName]/[AppstartActivity]

执行完后台会生成ThisTimeTotalTimeWaitTime这三个时间,ThisTime代表一连串启动 Activity 的最后一个 Activity 的启动耗时;TotalTime表示应用的启动时间,包括创建进程,Application初始化和Activity初始化到界面显示,一般来说与ThisTime一样;WaitTime则表示AMS启动Activity的总耗时,一般比TotalTime大。

对于监测应用的启动速度,我们只需要关注TotalTime这个值。

如上所述,利用adb命令得到启动时间,也只是一个阶段的总时间,却不能如3.1一样监测到每个生命周期所耗费的时间,无法得到具体的耗时,无疑对启动速度针对性优化没有多大的帮助。

3.3.代码打点

代码打点是通过代码编写一个工具类,通过代码的形式获取每个方法的执行的时间,这个方法与3.1所达到的目的是一致的,都能得到每个周期具体的耗时。唯一不同的是3.1的方式需要不断敲击命令,再在文件中去查找有效信息,无疑是耗费人力的,而通过代码打点的方式可以实时监测每个方法的耗时,也可以生成信息上传到服务器。

下面是一个基本的代码打点的案例:

public class TimeMonitorManager {
    private static final String TAG = "TimeMonitorManager";

    private HashMap<String, Long> mTimeTagMap = new HashMap<>();
    private long mStartTime = 0;
    private static volatile TimeMonitorManager mMonitorManager;
    
    private TimeMonitorManager() {

    }

    public static TimeMonitorManager getInstance() {
        if (mMonitorManager == null) {
            synchronized (TimeMonitorManager.class) {
                if (mMonitorManager == null) {
                    mMonitorManager = new TimeMonitorManager();
                }
            }
        }
        return mMonitorManager;
    }

    /**
     * 开始监听.
     */
    public void startMonitor() {
        if (mTimeTagMap.size() > 0) {
            mTimeTagMap.clear();
        }

        mStartTime = System.currentTimeMillis();

    }


    /**
     * 结束监听.
     * @param tag 所要打印的tag.
     */
    public void endMonitor(String tag) {
        if (mTimeTagMap.get(tag) != null) {
            mTimeTagMap.remove(tag);
        }

        long time = System.currentTimeMillis() - mStartTime;
        mTimeTagMap.put(tag, time);
        showData();
    }

    private void showData() {
        if (mTimeTagMap.size() <= 0) {
            return;
        }

        for (String tag: mTimeTagMap.keySet()
             ) {
            long time = mTimeTagMap.get(tag);
            Log.d(TAG, tag + ": " + time);
        }
    }

}

在需要打点开始的地方调用startMonitor(),在结束的地方调用endMonitor(String tag),例如Activity在加载布局,也就是setContentView(R.layout.activity_main)时是耗时的,根据打点规则,在setContentView(R.layout.activity_main);前后调用TimeMonitorManager的方法,以达到监测setContentView所耗费的时间.如下:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        TimeMonitorManager.getInstance().startMonitor();
        setContentView(R.layout.activity_main);
        TimeMonitorManager.getInstance().endMonitor(TAG + " onCreate setContentView");
    }

运行后Logcat中打印的效果如下:

TimeMonitorManager: MainActivity onCreate setContentView: 60

可以发现,Activity在setContentView()时所耗费的时间大约为60ms.

利用TimeMonitorManager打点的好处在于可以清楚的监测到每一处的耗时,更加精准;

从第二节应用启动流程就了解到,应用启动耗时能够监测的切入点为Application的创建和Activity的创建,我们就可以通过代码打点的方式在以下这些地方进行打点:

  • Application的onCreate()
  • Activity的onCreate(),onStart(),onResume()
  • 初始化对象的方法,注册等方法
  • 一些耗时的操作

四、优化方向

从上面三节可以了解到,影响应用启动时间的要素一般可分为Application里一些数据的准备,Activity的布局以及在初始化的耗时操作。下面也将从这几个方面分别描述下优化策略。

4.1.布局优化

4.1.1.按需选择布局方式

我们都知道onCreate()里的setContentView()是用来加载布局,应用启动时会去解析xml中的结构,当一个xml里结构嵌套过多,系统需要去解析的时间就大大增加了。

当布局比较复杂,可以使用ConstraintLayout布局,ConstraintLayout是Android Studio 2.2新增的一个功能,它的一大特点就是为了解决布局嵌套。具体使用方法可参考

另外,当版本较低且布局复杂,RelativeLayout布局优化的效果是要优于LinearLayout。但是当布局简单时,LinearLayout却优于RelativeLayout,所以大家可依照具体情况进行选择。

4.1.2.< include >、< merge >

< include >与< merge >是布局优化的两个利器。< include >标签是可以允许在一个布局当中引入另外一个布局,当多个布局中有用到相同的部分,就可以采用< include >标签将相同的部分提取出来,利用< include >将公告部分替代。

而< merge >标签的作用是作为< include >标签的一种辅助扩展来使用的,它的主要作用是为了防止在引用布局文件时产生多余的布局嵌套。

4.1.3.ViewStub

ViewStub是一个比较轻量级的控件,没有大小,不需要绘制,同时也不参与布局,所以消耗的资源是非常小的。ViewStub的使用就在于当我们存在时而需要显示时而不显示的view的时候,就可以使用它。例如我们进行网络请求时的loading bar,请求时,会显示,当请求结束,loading bar就会消失,这个时候就可以使用ViewStub,减少资源的消耗的同时也减少了布局解析的时间。

另外,针对布局的优化,AS提供了Profile和Hierarchy View两个工具分别检查View的绘制和分析布局。

具体使用可参考

4.2.逻辑加载优化

逻辑耗时一般分为Application中和Activity中的逻辑加载。在Application或者Activity中进行初始化的时候,有些的逻辑初始化是必要的,而有些初始化非必要,可以适当的延时去加载。例如在最近的项目中,应用启动时需要提前去连接服务并且去注册回调接口,为了提前连接上服务,将连接的操作就放置在了Application里进行初始化。这就是属于必要的逻辑操作。对于不同优先级的逻辑,我们可以大致分为以下几点:

4.2.1.异步加载(必要且耗时)

有些逻辑处理的优先级比较高,并且初始化耗时,可以采用异步加载的方式,利用RxJava,HandlerThread,IntentService等在后台进行加载,这样就不会阻塞主线程,UI展现到用户眼前的时间也会缩短。

4.2.2.延时加载(非必要且耗时)

当逻辑操作的优先级不是很高时,可以采取延时加载的方式,也就是应用启动过程中暂时不去初始化这些逻辑,将之前在Application或者Activity onCreate()中的操作移除,在主线程空闲的时候再进行加载操作。

另外,MessageQueue内部有一个接口IdleHandler,可以很好的处理延时问题。IdleHandler在looper里面的message都处理完了的时候就会回调这个接口,返回false,就会移除它,返回true就会在下次message处理完了的时候继续回调。

举个例子,一般情况下,应用启动时会去绘制布局,会去调用measure, layout, draw等方法,在执行这些操作后,用户才会去看见UI,而之前优先级不高的初始化,就可以延时在这些操作后加载,那么主要的问题就是我们如何去判断measure, layout, draw等操作已经完成了?延时加载的时机在哪?IdleHandler就帮我们解决了这个问题,上面也都知道IdleHandler是在队列为空的时候会去回调它,measure, layout, draw都可以作为一个个message,IdleHandler就会在他们执行完成后响应。这个时候就可以进行之前需要延时的初始化操作。

另外,当代码中同时有UI绘制和逻辑加载,可以在IdleHandler回调中再去处理逻辑加载,UI绘制与逻辑分开操作,可以减少数据空白时间长的问题。

4.2.3.分步加载

当初始化对象有很多时,且必要,可以采取分步加载的方式,将逻辑的优先级区分开来,优先级高的先加载。

五、总结

启动优化需要针对不同的业务做出不同的优化方式,例如可以采用Multidex预加载优化,但是虚拟机在5.0以上默认就使用ART,对于项目是5.0以上版本就不需要去优化此方面。

总的来说,优化方向可以分为布局优化,减少解析xml和绘制的时间;逻辑优化,将必要且耗时的操作异步加载,将非必要的采用延时加载,另外,将操作优先级高的可以优先加载。

最后,启动速度优化是一个大工程,后续还需要针对具体场景继续深度挖掘。

参考:《Android应用性能优化最佳实践》