微医App启动优化之路

微医项目经过N个版本的迭代,到现在已经很庞大了。这自然就产生了各种问题,其中就包括冷启动慢,白屏时间长问题。本篇文章是对自己优化启动时间的一个总结。

优化前的项目现状

这里以微医 3.4.0 Release包(未优化前)为例,分别对欢迎页(app 第一个页面)及主页在不同手机做连续5次冷启动数据统计。

机型 白屏 欢迎页平均时间 主页平均时间
华为CAM-TL00H 6.0 5.039s(无广告会多停留1.5s) 2.1198s
oppoA59S 5.0 4.237s(同上) 1.0678s

以上的白屏时间为欢迎页启动的时间。

1
2
3
4
5
6
7
8
9
连续5次冷启动(启动后再杀掉进程)时间详细统计
华为欢迎页时间:
6.766s,4.89s,4.788s,4.322s,4.429ms
华为主页时间
2.573s,2.94s,1.817s,1.709s,1.56s
oppo欢迎页时间
4.324s,4.45s,4.23s,3.841s,4.34s
oppo主页时间
1.193s,1.11s,1.16s,0.946s,0.93s

现状总结:通过以上数据可以发现,首页冷启动时间过慢,而且每次都会伴有长时间的白屏,用户体验很差,亟需我们优化。

优化前的理论分析

  • app的启动方式

    安卓应用的启动方式分为三种:

    • 冷启动(cold start)

      在应用启动前,系统没有该应用的任何进程信息(包括Activity,Service等),用户点击图标的启动称之为冷启动。比如说设备开机后应用的第一次启动,杀掉进程后(包括系统主动回收和用户手动kill)再次的启动。

      在冷启动过程中,系统会做以下事件。

      1. 开始加载并启动应用

      2. 应用启动后,显示一个空白的启动窗口(白屏来由)

      3. 创建应用的进程信息。创建进程后,应用要做以下几个事情

        • 初始化Application。每创建一个进程,就会初始化一次。而创建一个进程是需要消耗系统很多资源的,另外如果在Application中的初始化操作不加以区分进程的化,会重复多次的。

        • 启动UI线程

        • 创建第一个Activity

        • 加载内容视图,计算视图在屏幕上位置

        • 绘制视图。只有当完成第一次绘制后,系统当前展示的空白背景才会消失,被加载好的内容替代

    • 暖启动(warm start)

      当Activity被销毁,但在内存常驻时,这种启动方式成为暖启动。相比冷启动,暖启动少了对象初始化,布局加载等过程,启动时间更短。但启动时,以然会有一个空白背景,直到第一个页面内容呈现为止。

    • 热启动(luckwarm start)

      热启动比暖启动做的事情更少了,启动时间会更短。这种场景常见为:返回键退出app又快速重启,或者只是按Home回到桌面,或者app本身做了进程重启的机制。

    1
    2
    3
    4
    5
    研究app的启动模式后,发现主要优化的就是冷启动。只要冷启动时间优化了,热启动其实也跟着优化了。	  从程序的角度分析,冷启动的时间 = initProcess + Applicaton初始化时间 + 欢迎页的初始化时间
    从用户的角度分析,点击启动图标,应该立即看到app页面,而不是看到长时间的白屏,并且也应该尽早地看到app的主页。
    所以冷启动的时间在上面的基础上再加上欢迎页的停留时间+主页的初始化时间。
    另外页面的初始化时间不只是看onCreate()、onStart()、onResume()这几个生命周期方法的时间,
    因为这几个方法调用完毕后, View树还没有完全构建完毕。
  • 启动时间的统计手段

    • Display Time

      API 19后,Android在系统Log中增加了Display的Log信息。这个时间实际上是Activity启动,到Layout全部显示的过程,但是要注意,这里并不包括数据的加载。

    • ADB命令

      1
      adb shell am start -W [packageName]/[packageName.AppstartActivity]

      执行后会得到三个时间:

      ThisTime:一般和TotalTime时间一样,如果在应用启动时开了一个过度的全透明的页面(Activity)预先处理一些事,再显示出主页面(Activity),这样将比TotalTime小。
      TotalTime:应用的启动时间,包括创建进程+Application初始化+Activity初始化到界面显示。

      WaitTime:一般比TotalTime大些,包括系统影响的耗时。

    • Hugo

      执行时间打印神器,集成至项目后,只需在想要测试的地方加上注解@DebugLog即可。可以是Class级别,也可以是method级别。Github传送门

    本文上面的统计页面的初始化时间采用的是系统日志Displayed, 在优化过程中,统计函数的方法是自己手打Log。

具体的优化点

基于上面的分析,从以下几点对app进行优化

  • 白屏优化

    从上面的分析得知,app启动后系统创建需要的进程,在完成页面First Draw前,系统会立即显示一个background window。这个就是白屏的来源,如果白屏时间过长,非常影响用户得体验。 知道这个原因,解决这个问题就比较简单了。无非就是将Theme里的windowBackground设置成我们想要让用户看到的画面就可以了。

    通常有以下两种办法解决:

    1. 将主题的背景图设置成透明,这样当用户点击app启动图标后,并不会立即进入app,而是在桌面停留一会儿,但其实app这个时候已经启动了,只是因为设置成了透明,用户看不到而已,强行帅锅给手机厂商。比如微信就是这样做的。这并不是最佳的解决方案。

    2. 将主题的背景图设置成app的Logo或者广告页。这样用户在点击启动图标后,会立即看到预设的Logo。而用户误以为这是第一个页面,从而让用户体验大大地提升。这个做法也是目前主流app所采用的。我这里优化的,也是采用此法。

      • 在res/drawable下新建一个layer-list,放置我们app的Logo

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        <?xml version="1.0" encoding="utf-8"?>
        <layer-list xmlns:android="http://schemas.android.com/apk/res/android"
        android:opacity="opaque">
        //这里android:opacity="opaque"参数是为了防止在启动的时候出现背景的闪烁。
        <item android:drawable="@color/white"/>
        <item>
        <bitmap
        android:gravity="center"
        android:src="@drawable/gh_cm_wy_logo"/>
        </item>
        </layer-list>
      • 新建一个启动主题,将背景图替换为Logo

        1
        2
        3
        4
        5
        6
        7
        8
        <style name="Theme.launch" parent="Theme.Guahao.NoActionBar">
        //设置Theme不透明并且默认渲染的背景图是我们必看影的Logo
        <item name="android:windowIsTranslucent">false</item>
        /替换Logo
        <item name="android:windowBackground">@drawable/gh_cm_launch_bg</item>
        //全屏展示,免得顶部状态栏显现颜色不一致过于脱节和突兀
        <item name="android:windowFullscreen">true</item>
        </style>
      • 将启动主题设置给欢迎页,在欢迎页创建后,再还原之前的主题

        1
        2
        3
        4
        5
        6
        7
        8
        9
        <activity
        android:name="欢迎页"
        //设置启动主题
        android:theme="@style/Theme.launch">
        <intent-filter>
        <category android:name="android.intent.category.LAUNCHER" />
        <action android:name="android.intent.action.MAIN" />
        </intent-filter>
        </activity>

        欢迎页创建后还原主题

        1
        2
        3
        4
        5
        6
        7
        @Override
        public void onCreate(final Bundle savedInstanceState) {
        //还原之前的主题 必须在super之前
        setTheme(R.style.Theme_welcome);
        SystemBarUtil.enableFullscreen(this);
        super.onCreate(savedInstanceState);
        }

        由于我们app debug模式默认的启动页是工厂页,做法是类似的,代码就不贴了。

  • Application优化

    经过上面的分析,对Application的优化分为以下几个方面。

    1. 进程优化

      系统创建进程需要消耗更多系统资源,而每创建一个进程就意味着Application又会初始化一遍。

      翻看我们app Application初始化的代码,发现我们目前有5个进程。

      (这里可能涉及到公司隐私,就不贴了)

      这里的优化点有两个:

      1. 尽可能减少欢迎页前创建其他进程。

        (这里可能涉及到公司隐私,就不贴了)

      2. Application的初始化工作区分进程

        翻看代码,发现我们这里对不同进程的初始化工作已经区分,故不用优化。

    2. 统计Application启动时各类方法的耗时。区分初始化操作的优先级,对不必要的耗时操作,可以延后加载,按需加载。不能延时的耗时操作,可以异步加载,并且降低线程的优先级,避免与主线程抢资源。

      1
      2
      3
      4
      //在这些异步操作中,都会调用降低线程优先级的方法
      private void lowerThreadPriority(){
      if (Looper.getMainLooper().getThread() != Thread.currentThread()){ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
      }

      统计完各类具体初始化操作的耗时后,同时要对所有的初始化操作分析优先级。这里可能涉及到公司隐私,具体就不贴了。

      分析后,发现项目中网络监控、推送、百度统计、Bugly等初始化完全可以延后至欢迎页或者主页。

  • 布局优化

    布局越是精简,系统在绘制的时候时间就会越短,从而降低启动时间。

    下图是在CSDN看到某文章后,作者对常用布局的性能统计。

    因此,在布局优化这块,我总结了以下几个原则。

    1. 减少多余的层级嵌套
    2. 同等层级下尽量使用帧布局,线性布局
    3. 减少不必要的背景绘制
    4. 空布局或者在某些条件才展示的布局使用ViewStub延迟初始化
    5. 如果以后引入了约束布局,可以用其优化

    分析我们项目,影响首页启动的布局有四个。欢迎页,主页Activity,以及主页嵌套的两个Fragment。

    • 欢迎页

      在查看了欢迎页的布局后,发现存在2个缺点:

      • 外层使用相对布局,完全可以用帧布局替换
      • 展示广告的Imageview和展示默认图片的ImageView完全可以共用一个。所以删除多余的布局,减少了一层嵌套
    • 主页

      由于主页是Activity内嵌Fragment,Fragment嵌套Fragment。所以有三个布局可以优化。

      打开手机的GPU过渡绘制,发现主页是一片红。查看布局,发现了以下问题:

      • 主页外层的Activity的布局里面存在很多无用View。因为主页Activity只负责提供一个嵌套Fragment的容器就行了。查看代码逻辑后,将布局只改成一个帧布局,其余全部删除
      • 内嵌的HomeFragment嵌套微医Tab Fragment的布局存在许多多余的背景,这是GPU过渡绘制飘红的原因,全部去除
      • 内嵌的HomeFragment的布局存在无用的相对布局嵌套相对布局,底部tab也是存在大量多余嵌套。将这些全部优化
      • 微医Tab Fragment的布局中也存在无用View和大量多余的布局嵌套。 全部优化
      • 主页引导图改为ViewStub

      布局代码过多,这里就不贴了

  • 欢迎页逻辑优化

    查看欢迎页代码后,发现有以下几个问题:

    1. 原先的广告图和默认图分别用不同的ImageView
    2. 在不需要广告时,为了让用户看到默认图,特意在页面停留1.5秒再去跳转,很不科学
    3. 在展示广告倒计时时,采用的是Handler延时策略,存在两个弊端。首先是时间不准确,因为系统消息的调度是需要时间的而且所需时间不定。其次,在页面销毁中也没有移除消息存在内存泄漏的问题。
    4. 页面onCreate方法中没有将数据的初始化延后至View绘制后。如果做数据操作需要耗时很多,将会很大程度影响UI的绘制,从而影响启动时间
    5. 欢迎页继承BaseActivity,而BaseActivity有很用冗余的操作,尤其是会初始化数据库,不但消耗时间,而且该操作完全可以延后到主页。
    6. Sp的提交使用的是commit
    7. 广告展示时,过于突兀,没有一个过渡效果

    解决办法:

    1. 广告图和默认图统一

    2. 广告的倒计时使用CountDownTimer实现,注意在页面销毁时,将timer取消掉。

    3. 将不影响View初始化逻辑的操作统一延后至View绘制后。

      这里我想到了两种解决办法。

      • MessageQueue.IdleHandler

        在looper里面的message暂时处理完了,会回调这个接口。还不明白点我

      • 两次post Runnable

        系统绘制会执行两次Performtraversal

    4. 去掉为了让用户看到默认图刻意停留的1.5秒,而由于初始化的延时加载,用户也一定会看到默认图,顺带就解决了这个问题

    5. 欢迎页不继承BaseActivity

    6. 将Application中的不必要初始化操作移到延时加载中。如百度统计,Bugly,推送。并且对于耗时操作放到异步线程中。

    7. 封装全局线程池,异步线程都由全局线程池提供,并且异步线程降低线程优先级

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      public class ThreadPoolManager {
      //饿汉式 保证速度
      private static final ThreadPoolManager mInstance = new ThreadPoolManager();

      private int mCorePoolSize;
      private int mMaximumPoolSize;
      private long mKeepAliveTime;
      private TimeUnit mUnit;
      private ThreadPoolExecutor mExecutor;

      private ThreadPoolManager() {
      mCorePoolSize = Runtime.getRuntime().availableProcessors() + 1;
      //用不到,但是需要赋值,否则报错
      mMaximumPoolSize = mCorePoolSize;
      mKeepAliveTime = 1;
      mUnit = TimeUnit.MINUTES;
      mExecutor = new ThreadPoolExecutor(mCorePoolSize, mMaximumPoolSize, mKeepAliveTime, mUnit,
      new LinkedBlockingQueue<Runnable>(128), Executors.defaultThreadFactory());
      // keepAliveTime同样作用于核心线程
      mExecutor.allowCoreThreadTimeOut(true);
      }

      public static final ThreadPoolManager getInstance() {
      return mInstance;
      }

      public void execute(Runnable runnable) {
      if (runnable == null) return;
      mExecutor.execute(runnable);
      }

      public void remove(Runnable runnable) {
      if (runnable == null) return;
      mExecutor.remove(runnable);
      }
      }
    8. 对提交结果不关心时,尽量sp使用apply而不是commit

    9. 在Glide中添加默认的渐变效果,避免出现突兀的现象

      (具体代码,公司隐私原因就不贴了)

  • 主页逻辑优化

    查看主页相关代码,发现存在以下问题

    1. 存在很多无用的全局变量

    2. 页面初始化逻辑过多,也没有放在View绘制后

    3. 很多SP提交操作使用commit

    4. 缓存webview逻辑已经没用了

    5. webview的loading只在onPageStart中展示

      WebView.loadUrl("url") 不会立马就回调 onPageStarted 或者 onProgressChanged 因为在这一时间段 , WebView 有可能在初始化内核 , 也有可能在与服务器建立连接。

    6. 主页的两次退出逻辑用TimerTask实现,没有处理内存泄漏问题

    解决:

    1. 对于没有使用到的全局变量删除。可以转为局部变量的转为局部变量
    2. 对不影响View初始化逻辑的操作延时加载。放到MessageQueue.IdleHandler中,耗时操作采用异步线程。同上
    3. sp用apply
    4. webview缓存操作删除
    5. 提前显示Loading
    6. 两次退出逻辑更改为计算连续两次点击时间的间隔

    (具体代码,公司隐私原因就不贴了)

优化后结果

机型 白屏 欢迎页平均时间 主页平均时间
华为CAM-TL00H 6.0 2.5212s(提升40.49%) 1.5858(提升25.19%)
oppoA59S 5.0 2.1554s(提升49.12%) 0.7988s(提升25.19%)
1
2
3
4
5
6
7
8
9
连续5次冷启动(启动后再杀掉进程)时间详细统计
华为欢迎页时间:
3.169s,1.565s,2.8s,2.52s,2.552ms
华为主页时间
2.2s,2.139s,1.283s,1.125s,1.182s
oppo欢迎页时间
2.173s,2.127s,2.176s,2.133s,2.168s
oppo主页时间
0.766s,0.824s,0.78s,0.786s,0.838s

总结

App的启动时间还有很多优化的空间,并且这是一个持续性的工作,需要我们在日后的工作持续改进。

参考资料

  1. 5分钟教你打造一个秒开的Android App
  2. 一触即发 App启动优化最佳实践
  3. Android 你应该知道的的应用冷启动过程分析和优化方案