深入应用冷启动优化
背景
为什么做冷启动优化
冷启动时间是用户对app的第一印象,线上AB实验表明,冷启动时间会直接影响用户的留存。
冷启动时间口径
起点:Application#attachBaseContext()
结束时间:第一个Activity的onWindowFocusChanged()
为什么是这两个点?Application#attachBaseContext()是应用代码被执行的最早节点,第一个Activity的onWindowFocusChanged()是第一帧完成绘制的时间。
这里有一个小坑,之前Launcher Activity还是SplashActivity的时候,我们发现SplashActivity的onWindowFocusChanged()永远不会被执行,因为还没执行到onWindowFocusChanged()时,SplashActivity就finish了,因此我们的冷启动上报做在了MainActivity的onWindowFocusChanged()里,这也导致了之前版本线上数据偏高。
区分冷启动
首先我们来定义一下冷启动,冷启动指的是系统不存在应用进程的情况下,启动应用。如果我们只在SplashActivity或者MainActivity第一次创建时上报,以下几个场景会被误报成冷启动:
- 应用接收到广播,拉起了主进程,之后再点击桌面图标进入
- 主进程通过ContentProvider被拉起,之后再点击桌面图标进入
- 通过点击push进到了一个落地页,按返回键退出后,再点击桌面图标进入
要把冷启和非冷启最关键的差别,就在于Application是否需要创建。目前主端的做法是,在Application#onCreate()结束时,记一个时间ts1,在Launcher Activity#onCreate()开始时再记一个时间ts2,如果ts2 - ts1 < 200ms,就认为是冷启动。在大部分情况下,这个判断都是准确的,一定不会出现误报,但是通过点击push进落地页的情况,如果进程不存在也算冷启动,会漏报。
那么有没有一个完美的解决方案呢?我们先看一张应用冷启动的时序图:
我们知道,这里的消息都会被分发到ActivityThread的Handler上处理,关注两个消息,BIND_APPLICATION和EXECUTE_TRANSACTION,处理BIND_APPLICATION消息时,会调用Application#onCreate()方法,而这个时候,消息队列中已经有了EXECUTE_TRANSACTION消息,处理EXECUTE_TRANSACTION消息会创建Activity,走Activity的生命周期。所以只需要在Application#onCreate()方法中post一个消息到主线程,在这个消息中检测,如果已经有Activity创建过,那么就认为这是冷启动,前面说的几种情况:
- 广播导致的进程被拉起,因为没有创建Activity,因此不会上报冷启动
- Content Provider导致的进程拉起同上
- 点击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) {
// 上报冷启动
}
}
}}
误区
很多项目都做过启动优化,大部分都经历过如下阶段:
- 野蛮生长阶段,大量逻辑写在Application#onCreate()中,启动阶段串行执行
- 意识到问题,开始做启动阶段任务解耦,异步执行部分任务
- 引入启动任务调度框架,解决异步任务的时序和依赖问题
很多项目做到这里就结束了,在很多人的理解中,高度异步 == 总时间最短。这是一个想当然的结论,高度异步化之后依然有不少可改进的空间。
发现问题
从代码上来看,启动任务已经做到了高度并行,很难找到一些简单又见效快的手段,因此需要对启动过程做粒度更小的细分,找到可能的优化点。这里使用了systrace工具来分析启动阶段。
为什么用systrace
相比线上监控用的埋点,日志等方法,线下分析使用systrace能获得更全面的信息。systrace提供了全局的视角,能清楚的看到CPU当前负载以及调度情况,比如下面这个case:
这个启动任务的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打满。
在ApplicationonCreate()之后,有两个TransactionExecutor.execute的tag,说明启动了两个Activity,低端机上创建Activity打来的开销非常明显:
然后还是上面这张图,在SplashActivity的创建过程中,出现了大段主进程的主线程在Sleeping的情况,但是这个时候并没有其他线程在执行任务,所以我们要从CPU入手。之前主线程运行在CPU3上,我们看看CPU3的时间片分配给谁了:
大量的时间片分给了我们应用的push进程,对应pid为32729,勾选上32729,刚好这段时间在做Application的创建:
再往后到Activity#onResume()之前的阶段:
布局的inflate占了大部分的时间,其中最长的这一条占用了234ms,也是一个优化收益比较大的地方。
优化手段
明确了问题之后,优化思路就很清晰了,优先解决下面几个问题:
- 打满Application创建阶段的CPU,避免出现大块空闲时间片
- 合并SplashActivity和MainActivity
- 启动阶段不要拉起子进程
- 缩短主线程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已经没有明显的大片空闲:
合并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:
- 父布局的generateLayoutParams方法是线程安全的
- 包含的View不能在构造器中创建Handler或者获取Looper,因为非主线程没有创建Looper
- XML中不包含Fragment
直接用官方的AsyncLayoutInflater,效果不会很好,AsyncLayoutInflater不允许设置Factory和Factory2,我们无法做到对Context的替换,这就意味着,我们最早也只能在Activity的onCreate()方法中开始异步Inflate,这个时间太晚了,很多时候异步线程还没来得及Inflate完,主线程已经要取了,这就会造成主线程等待,或者是主线程二次Inflate。而且Activity#onCreate的时候,CPU负载本来就比较重,增加并发任务不一定有效果,尤其在低端机上。可以看下面这张图,很明显可以看到一大段锁等待:
最理想的情况是在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里可以看到效果:
非必须任务延后
最后一项是偏业务的优化,应该从业务角度拆出来启动任务的优先级,这次启动优化,一共梳理了40+启动任务,有好几个都是没必要在启动阶段做的,完全可以放到业务使用时做懒加载,在增加启动任务时应该先考虑是不是一定要放在启动阶段初始化,能不能做懒加载?
从业务上来看,小概率场景需要的能力应该在用到时做懒加载,为了小部分用户牺牲大部分用户的体验是不划算的,研发在做业务开发时也要有这种意识。
效果
在Oppo 2G+8核联发科的手机上,启动时间从平均值2.22s降低到了1.58s左右
在小米MIX 2S(6G+骁龙845)上,启动时间均值0.51s
优化整体思路
不止局限于启动优化,性能优化的整体思路是通用的,可以归纳为如下流程:
线下观察的结果,因为人为误差或者机型不足的原因,可能不准确,一定要以线上数据为准。性能优化一般会涉及到业务的重构和改造,改的人不一定了解业务,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做技术预研,无法评估收益,担心影响线上稳定性,暂未使用。