October 1, 2020

深入应用冷启动优化

背景

为什么做冷启动优化

冷启动时间是用户对app的第一印象,线上AB实验表明,冷启动时间会直接影响用户的留存。

冷启动时间口径

起点:Application#attachBaseContext()
结束时间:第一个Activity的onWindowFocusChanged()
为什么是这两个点?Application#attachBaseContext()是应用代码被执行的最早节点,第一个Activity的onWindowFocusChanged()是第一帧完成绘制的时间。
这里有一个小坑,之前Launcher Activity还是SplashActivity的时候,我们发现SplashActivity的onWindowFocusChanged()永远不会被执行,因为还没执行到onWindowFocusChanged()时,SplashActivity就finish了,因此我们的冷启动上报做在了MainActivity的onWindowFocusChanged()里,这也导致了之前版本线上数据偏高。

区分冷启动

首先我们来定义一下冷启动,冷启动指的是系统不存在应用进程的情况下,启动应用。如果我们只在SplashActivity或者MainActivity第一次创建时上报,以下几个场景会被误报成冷启动:

  1. 应用接收到广播,拉起了主进程,之后再点击桌面图标进入
  2. 主进程通过ContentProvider被拉起,之后再点击桌面图标进入
  3. 通过点击push进到了一个落地页,按返回键退出后,再点击桌面图标进入

要把冷启和非冷启最关键的差别,就在于Application是否需要创建。目前主端的做法是,在Application#onCreate()结束时,记一个时间ts1,在Launcher Activity#onCreate()开始时再记一个时间ts2,如果ts2 - ts1 < 200ms,就认为是冷启动。在大部分情况下,这个判断都是准确的,一定不会出现误报,但是通过点击push进落地页的情况,如果进程不存在也算冷启动,会漏报。
那么有没有一个完美的解决方案呢?我们先看一张应用冷启动的时序图:
Timeline
我们知道,这里的消息都会被分发到ActivityThread的Handler上处理,关注两个消息,BIND_APPLICATION和EXECUTE_TRANSACTION,处理BIND_APPLICATION消息时,会调用Application#onCreate()方法,而这个时候,消息队列中已经有了EXECUTE_TRANSACTION消息,处理EXECUTE_TRANSACTION消息会创建Activity,走Activity的生命周期。所以只需要在Application#onCreate()方法中post一个消息到主线程,在这个消息中检测,如果已经有Activity创建过,那么就认为这是冷启动,前面说的几种情况:

  1. 广播导致的进程被拉起,因为没有创建Activity,因此不会上报冷启动
  2. Content Provider导致的进程拉起同上
  3. 点击push进落地页的漏报,这个方案也能解决。

示例代码:

class MyApp : Application() {

  override fun onCreate() {
    super.onCreate()

    var firstActivityCreated = false

    registerActivityLifecycleCallbacks(object :
        ActivityLifecycleCallbacks {

      override fun onActivityCreated(
          activity: Activity,
          savedInstanceState: Bundle?
      ) {
        if (firstActivityCreated) {
          return
        }
        firstActivityCreated = true
      }
    })
    Handler().post {
      if (firstActivityCreated) {
        // 上报冷启动
      }
    }
  }}

误区

很多项目都做过启动优化,大部分都经历过如下阶段:

  1. 野蛮生长阶段,大量逻辑写在Application#onCreate()中,启动阶段串行执行
  2. 意识到问题,开始做启动阶段任务解耦,异步执行部分任务
  3. 引入启动任务调度框架,解决异步任务的时序和依赖问题
    很多项目做到这里就结束了,在很多人的理解中,高度异步 == 总时间最短。这是一个想当然的结论,高度异步化之后依然有不少可改进的空间。

发现问题

从代码上来看,启动任务已经做到了高度并行,很难找到一些简单又见效快的手段,因此需要对启动过程做粒度更小的细分,找到可能的优化点。这里使用了systrace工具来分析启动阶段。

为什么用systrace

相比线上监控用的埋点,日志等方法,线下分析使用systrace能获得更全面的信息。systrace提供了全局的视角,能清楚的看到CPU当前负载以及调度情况,比如下面这个case:
init_trace_1
这个启动任务的Wall Duration有112ms,如果通过打日志的方式,我们得到的结论就是这个任务耗时过长,但是从systrace上,我们看到CPU Duration只有18ms,真正占用了很多时间的,是多次锁的竞用。
因此这个任务优化的重点应该是解决锁竞用的问题,如果用打日志的方式,只能看到表面现象,很容易把优化方向带偏了。
如果是过度并行,导致很多任务在Runnable的状态等待CPU时间片,这种情况通过日志也会得出错误的信息,线下分析还是建议用systrace。

打开release systrace

看systrace建议使用release包,debug包的很多行为和release包不一样,一些步骤的耗时也很不一样,debug包得出的一些结论无法平移到release包,比如debug包dex加载时间会特别长,release没有这个问题,优化dex的ROI就很低。
release包默认是关闭了systrace的,在ActivityThread里有这么一段:

// Allow application-generated systrace messages if we're debuggable 
boolean isAppDebuggable = (data.appInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; 
Trace.setAppTracingAllowed(isAppDebuggable);

再看看Trace类:

/**
* Set whether tracing is enabled in this process.  Tracing is disabled shortly after Zygote
* initializes and re-enabled after processes fork from Zygote.  This is done because Zygote
* has no way to be notified about changes to the tracing tags, and if Zygote ever reads and
* caches the tracing tags, forked processes will inherit those stale tags.
*
* @hide
*/
public static void setTracingEnabled(boolean enabled, int debugFlags) {
    nativeSetTracingEnabled(enabled);
    sZygoteDebugFlags = debugFlags;

    // Setting whether tracing is enabled may change the tags, so we update the cached tags
    // here.
    cacheEnabledTags();
}

没有什么坑,只需要在Application#attachBaseContext反射调用这个方法,就可以打开release包的systrace。附上一个工具方法:

fun enableReleaseTrace() {
    try {
     Class.forName("android.os.Trace").getDeclaredMethod("setAppTracingAllowed", Boolean::class.java).invoke(null, true)
    } catch (tr: Throwable) {
        Log.e(TAG, Log.getStackTraceString(tr))
    }
}

分析现状

下面这张图是优化前的trace,只看主线程这根线,基本上都是绿色的,但是CPU存在两块区域有大量空白的时间片,分别对应的Application#attachBaseContext()之前,Application#attachBaseContext()和Application#onCreate()之间的时间。这两个时间片里,只有主线程在跑,没有利用好CPU。启动优化的本质,就是把CPU打满。
init_trace_2
在ApplicationonCreate()之后,有两个TransactionExecutor.execute的tag,说明启动了两个Activity,低端机上创建Activity打来的开销非常明显:
init_trace_3
然后还是上面这张图,在SplashActivity的创建过程中,出现了大段主进程的主线程在Sleeping的情况,但是这个时候并没有其他线程在执行任务,所以我们要从CPU入手。之前主线程运行在CPU3上,我们看看CPU3的时间片分配给谁了:
init_trace_4
大量的时间片分给了我们应用的push进程,对应pid为32729,勾选上32729,刚好这段时间在做Application的创建:
init_trace_5
再往后到Activity#onResume()之前的阶段:
init_trace_6
布局的inflate占了大部分的时间,其中最长的这一条占用了234ms,也是一个优化收益比较大的地方。

优化手段

明确了问题之后,优化思路就很清晰了,优先解决下面几个问题:

  1. 打满Application创建阶段的CPU,避免出现大块空闲时间片
  2. 合并SplashActivity和MainActivity
  3. 启动阶段不要拉起子进程
  4. 缩短主线程inflate时间

打满Application创建阶段的CPU

这里要分两步来,分别解决我们前面发现的两段空闲时间片

利用好attachBaseContext()前的时间

从trace中可以看出,Application#attachBaseContext()之前的时间,只有主线程在执行,因为在调用super.attachBaseContext()之前,调用getApplicationContext()会为空,而很多SDK以及业务代码获取Context都是这么写的,之前我们所有的任务都是在调用了super.onAttachBaseContext()之后开始调度的,确实省事,但也浪费了这部分的时间片。所以这里我们做了个小改动,覆写了Application的getApplicationContext()方法

override fun getApplicationContext(): Context {
    return this
}

这么改之后,大部分获取Context的场景就没有问题了,但是如果获取的是baseContext,还是会有问题,在调用super.onAttachBaseContext()之前,ContextWrapper的mBase是没有赋值的。用到mBase的地方还是会挂掉,不过问题不大,attachBaseContext之前的一小段时间不算长,在这个阶段初始化也不是每个SDK都能适配,因此只选了一小部分任务,强制指定在这个阶段执行。

抹除attachBaseContext()结束到onCreate()开始前的gap

attachBaseContext()结束到onCreate()开始前还有一段时间,在我这台手机上大概是70ms左右,这段时间只有主线程在执行任务,其他线程时间片都浪费掉了。
上面两步做完后,CPU已经没有明显的大片空闲:
![init_trace_7}(https://github.com/shunix/BlogImages/blob/master/20200930_init_trace_7.png?raw=true)

合并Activity

项目的SplashActivity仅仅是一个占位的Activity,当时做这个闪屏页是考虑到后续可能有开屏广告,运营活动之类的,想从一开始就把开屏逻辑和主页面分开,方便迭代维护。现在看起来是有点过度设计了,现在这个SplashActivity的作用就是展示一个开屏背景图,没有复杂逻辑。
然而启动一个Activity涉及到和AMS的IPC,资源加载,视图创建等,整体耗时在低端机上能有一两百毫秒,启动过程省掉一个Activity收益是很可观的。
因此,我们的SplashActivity为数不多的几行代码就整合进了MainActivity。冷启动时间在我这台手机上大概下降了100+ms,收益还是很可以的。

启动阶段不拉起子进程

从上面的trace中,我们可以看到CPU资源并不会因为新起一个进程而变成双份,启动阶段拉子进程一定会导致主进程时间片被抢。很多项目的Application中都会有关于进程的判断,如果非主进程,跳过一些逻辑,但这都是治标不治本的方法,fork进程,加载资源,加载类,这些都要CPU,还会造成一些IO。因此在启动阶段最好不要拉起子进程。经过评估,我们push进程的任务不需要这么早初始化,因此拉起子进程的任务在MainActivity的onWindowFocusChanged之后再开始调度。这一条的收益大概是50~100ms,波动比较大,不能精确量化。

Inflate优化

接下来我们解决主线程inflate耗时长的问题,布局Inflate本身就是一个很耗时的过程,中间涉及到读取XML文件,反射创建View等。所以Inflate优化一般有两个思路,一种是把XML布局转为对等的Java代码,另一种就是异步Inflate。转Java的方法比较复杂,要考虑兼容系统已有的一些逻辑,比如AppCompat的兼容转换等,经验数据是能节约2/3的inflate时间。这里要重点介绍的是异步Inflate的思路。
虽然Android的UI组件并不是线程安全的,但是Inflate在满足特定条件的情况下,是可以放在异步线程做的。Android SDK也提供了一个工具类AsyncLayoutInflater。一个Layout如果满足如下条件就可以被异步Inflate:

  1. 父布局的generateLayoutParams方法是线程安全的
  2. 包含的View不能在构造器中创建Handler或者获取Looper,因为非主线程没有创建Looper
  3. XML中不包含Fragment

直接用官方的AsyncLayoutInflater,效果不会很好,AsyncLayoutInflater不允许设置Factory和Factory2,我们无法做到对Context的替换,这就意味着,我们最早也只能在Activity的onCreate()方法中开始异步Inflate,这个时间太晚了,很多时候异步线程还没来得及Inflate完,主线程已经要取了,这就会造成主线程等待,或者是主线程二次Inflate。而且Activity#onCreate的时候,CPU负载本来就比较重,增加并发任务不一定有效果,尤其在低端机上。可以看下面这张图,很明显可以看到一大段锁等待:
init_trace_8
最理想的情况是在Application创建过程中就完成异步Inflate,进到Activity时,肯定有已经可用的结果。这就带来一个问题,Application里面的Context和Activity是不一样的,用Application的Context inflate出来的布局塞会Activity里会出问题,这就要求我们能做到Context的替换,结合Factory2和MutableContextWrapper,我们可以完成上述流程。
先是Factory2的代码:

class LInflateFactory : LayoutInflater.Factory2 {

    private val sClassPrefixList = arrayOf(
        "android.widget.",
        "android.view.",
        "android.webkit."
    )

    private val sConstructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)

    private val sConstructorMap: ArrayMap<String, Constructor<out View?>> = ArrayMap()

    override fun onCreateView(
        parent: View?,
        name: String,
        context: Context,
        attrs: AttributeSet
    ): View? {
        var realName = name
        if (name == "view") {
            realName = attrs.getAttributeValue(null, "class")
        }
        return if (-1 == name.indexOf('.')) {
            for (i in sClassPrefixList.indices) {
                val view =
                    createViewByPrefix(context, attrs, realName, sClassPrefixList[i])
                if (view != null) {
                    return view
                }
            }
            null
        } else {
            createViewByPrefix(context, attrs, name, null)
        }
    }


    private fun createViewByPrefix(
        context: Context,
        attrs: AttributeSet,
        name: String,
        prefix: String?
    ): View? {
        var constructor = sConstructorMap[name]
        return try {
            if (constructor == null) {
                val clazz = Class.forName(
                    if (prefix != null) prefix + name else name,
                    false,
                    context.classLoader
                ).asSubclass(View::class.java)
                constructor = clazz.getConstructor(*sConstructorSignature)
                sConstructorMap[name] = constructor
            }
            constructor.isAccessible = true
            constructor.newInstance(MutableContextWrapper(context).apply {
                val originalTheme = context.packageManager.getApplicationInfo(packageName, 0).theme
                setTheme(originalTheme)
            }, attrs)
        } catch (e: Exception) {
            null
        }
    }



    override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
        return null
    }
}

在创建View的时候,我们把Context用MutableContextWrapper包了一层,这里要注意,theme要处理好,有一些View,比如AppBarLayout会检测theme是否正确,所以要从原来的context里把theme取出来,给MutableContextWrapper设置上。
然后是自定义的LayoutInflater:

class LLayoutInflater(context: Context) : LayoutInflater(context) {
    companion object {
        fun inflate(context: Context, @LayoutRes layoutId: Int): View {
            return LLayoutInflater(context).run {
                LayoutInflaterCompat.setFactory2(this, LInflateFactory())
                inflate(layoutId, null, false)
            }
        }
    }

    override fun cloneInContext(newContext: Context?): LayoutInflater {
        return LLayoutInflater(context)
    }
}

最后提供一个工具类,在取的时候传入Acitivity,自动完成context的替换:

object LAsyncInflateHolder {
    private val mPool = SparseArray<View>()
    private val mLock = ReentrantLock()

    private const val TAG = "LAsyncInflate"

    fun asyncInflate(@LayoutRes layoutId: Int) {
        ThreadManager.inflateExecutor.submit {
            mLock.withLock {
                mPool.append(layoutId, LLayoutInflater.inflate(BaseApplication.instance, layoutId))
            }
        }
    }

    @UiThread
    fun tryGetView(activity: Activity, @LayoutRes layoutId: Int): View? {
        mLock.withLock {
            return if (mPool.indexOfKey(layoutId) < 0) {
                Log.d(TAG, "$layoutId null")
                null
            } else {
                val view = mPool[layoutId].apply {
                    (context as? MutableContextWrapper)?.baseContext = activity
                }
                mPool.remove(layoutId)
                view
            }
        }
    }
}

从trace里可以看到效果:
init_trace_9

非必须任务延后

最后一项是偏业务的优化,应该从业务角度拆出来启动任务的优先级,这次启动优化,一共梳理了40+启动任务,有好几个都是没必要在启动阶段做的,完全可以放到业务使用时做懒加载,在增加启动任务时应该先考虑是不是一定要放在启动阶段初始化,能不能做懒加载?
从业务上来看,小概率场景需要的能力应该在用到时做懒加载,为了小部分用户牺牲大部分用户的体验是不划算的,研发在做业务开发时也要有这种意识。

效果

在Oppo 2G+8核联发科的手机上,启动时间从平均值2.22s降低到了1.58s左右
在小米MIX 2S(6G+骁龙845)上,启动时间均值0.51s

优化整体思路

不止局限于启动优化,性能优化的整体思路是通用的,可以归纳为如下流程:
opt_ring
线下观察的结果,因为人为误差或者机型不足的原因,可能不准确,一定要以线上数据为准。性能优化一般会涉及到业务的重构和改造,改的人不一定了解业务,QA的回归力度也不如之前业务开发的时候,所以灰度很有必要,观察有没有需要发版的问题。

防劣化

这里要重点说的就是防劣化,启动优化的防劣化要从两个方面入手,准入和持续监控。
我们项目把所有启动的Task收在一个包下,通过SPI的方式调用其他业务模块完成初始化,所以可以在MR的时候对这个包以及Application和MainActivity做强制CR,要求启动的owner通过后才能合入。这种力度的准入只能做到启动阶段不增加新任务,如果是已有的启动任务逻辑变更导致的性能劣化,通过CR很难发现,这就需要QA配合做持续的自动化测试。在git的pipeline中加入自动化也是很重要的防劣化手段。

可能的优化点

这次做启动优化的时间比较紧,只做了ROI高的部分,有一些因为优先级不高或者客观原因没有做的,我也列了下来,方便以后排技术需求或其他项目参考。

启动接口收敛

启动阶段网络请求应该收敛,可以通过延后请求或者接口合并的方式来做,需要服务端配合一起做,因为人力问题,暂未实现。

提前加载SharedPreferences

SP在第一次读取时,会一次性从磁盘读入内存,可以做预加载。因为项目业务代码已经全部使用了自研基于mmap的实现,未使用SP,只有部分二方三方库在使用SP,ROI较低。

提前加载启动阶段需要的类

开异步任务,提前用Class.forName()加载所有启动阶段用到的类。之前项目有用过,效果一般,能省个15~30ms左右,代价是非常难维护,需要用注解来标记每一个启动阶段要用到的类,或者自己维护列表。

Redex

接入Facebook的redex。时间比较紧张,没能对redex做技术预研,无法评估收益,担心影响线上稳定性,暂未使用。