启动速度影响的除了用户的体验和留存,还会影响商业化数据的曝光营收数据,本文通过对启动原理、工具使用、优化实践的系统总结梳理,加深对启动优化的认识,书写更符合性能规范的高质量代码。
一、理论基础
1、定义与分类
应用有三种启动状态:冷启动、温启动、热启动。
冷启动
冷启动是指应用从头开始启动:系统进程在冷启动后才创建应用进程。发生冷启动的情况包括系统终止应用后首次启动或者打开子进程的组件。例如,通过任务列表手动杀掉应用进程后,又重新启动应用。
热启动
热启动比冷启动简单得多,开销也更低。在热启动中,系统的所有工作就是将您的 Activity 带到前台。只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行进程、应用、activity的创建。例如,按home键到桌面,然后又点图标启动应用。
温启动
温启动包含了在冷启动期间发生的部分操作;同时,它的开销要比热启动高。有许多潜在状态可视为温启动。例如:用户按返回键退出应用后又重新启动应用。这时进程已在运行,但应用必须通过调用 onCreate() 从头开始重新创建 Activity。
2、启动原理
启动全路径梳理的目的是对整体流程有基本认识,同时发现各个阶段的潜在耗时点,帮助我们系统性地将各个耗时点归因,从而引导我们找寻优化思路。
大多数 APP启动路径都是类似的,整体分为两大阶段和两个间隙,它们按时间顺序排布为:Application 阶段-handle message 间隙-Activity 阶段-数据加载间隙。
大概的流程和耗时点如下:
2.1 Application启动流程
桌面点击图标启动一个应用的组件如Activity时,如果Activity所在的进程不存在,就会创建并启动进程。
zgote阶段
Android系统中一 般应用进程的创建都是统一由zygote进程fork创建的,AMS在需要创建应用进程时,会通过socket连接并通知到zygote进程,其中socket服务端是在开机阶段就创建好的,然后由zygote进程fork创建出应用进程。
整体流程如下:
这个流程其实我们从常见的UI线程报错的堆栈中也能看到部分,细心分析一下这个崩溃的链路,也可以帮助我们理解进程启动流程,如下:
ActivityThread阶段
zygote创建应用进程后,会调用ActivityThread的main方法,开启主线程Looper循环,同时创建了ActivityThread对象,并调用了它的attach方法。 在attach方法中远程调用AMS的attachApplication方法,该方法中又远程调用PMS的queryContentProviders方法获取应用注册的Provider信息, 然后调用ApplicationThread的bindApplication方法将Provider信息传递过去。ApplicationThread在handleBindApplication方法中通过makeApplication生成Application 对象,然后调用 installContentProviders 方法初始化并加载ContentProvider,然后调用Application对象的onCreate方法,应用就这样跑起来了 。整体流程如下:
2.2 Activity启动流程
AMS进程启动完成后,接着会在attachApplication方法的后半段继续执行启动Activity的逻辑,这里的逻辑和普通打开Activity的逻辑是差不多的,也是通过ApplicationThread将指令转到目标Activity所在的应用进程执行。
Activity创建过程:
-
创建Activity的Context;
-
通过反射创建Activity对象;
-
执行Activity的attach动作,其中会创建应用窗口的PhoneWindow对象并设置WindowManager;
-
执行应用Activity的onCreate生命周期函数,并在setContentView中创建窗口的DecorView对象;
-
PhoneWindow.setContentView中首先会调用installDecor,内部调用generateDecor进行DecorView的创建+调用generateLayout(decor)初始化内容DecorView的子布局,具体加载哪个默认的layout作为DecorView的子布局,是通过Theme来决定(也是要在setContentView前要设置theme的原因),最后从子布局中找到mContentParent。
-
最后使用LayoutInflater加载setContentView传入的layoutResID添加到mContentParent中。
Activity.View添加过程:
-
执行应用Activity的onResume生命周期函数;
-
执行WindowManager的addView开启视图绘制逻辑;
-
创建Activity的ViewRootImpl对象;
-
执行ViewRootImpl的setView开启UI界面绘制动作以及请求WMS分配窗口;
整体流程如下:
看上述流程,进入启动Activity流程之前会先利用PhoneWindowManager.addView添加一个类型为TYPE_APPLICATION_STARTING的预览窗口,用于添加显示目标Activity在主题中设置的启动图片,也就是主题属性"windowSplashscreenContent",这也就是我们常说的黑白屏优化的根本原理,因为从这里到真正目标View可见还有一段时间,这样可以让用户先看到预览内容。
2.3 View绘制流程
Activity在Resume生命周期之后会通过WindowManager执行添加DecorView的操作,最终绘调用到ViewRootImpl的setView,这里会调用requestLayout触发第一次重绘请求,这里会有checkThread线程检测,也就是我们常说的子线程不能更新UI的报错源头,当然也有一些方法可以躲避这个线程检查实现在子线程更新UI,这个我们日后再讲,然后会通过WindowSession请求WMS分配添加承载这个DecorView的窗口,等待编舞者Choreographer的vsync信号回来后,执行measure、layout、draw流程。
**注意:**这里执行measure之前会先调用到dispatchAttachedToWindow,首先执行通过View.post存储起来的任务,然后递归执行我们常用的View.onAttachedToWindow方法,因此也有一个常见的错误就是在View的onAttachedToWindow中获取测量宽高,其实是获取不到的,那还有一个问题就是为啥在onCreate里面通过view.post可以获取到测量宽高呢,他是执行在measure之前吗?其实不是的,这个问题的原理大家可以自行查看HandlerActionQueue的源码就知晓了。
View的整体流程如下:
二、工具介绍
性能优化中除了基础、原理、规范等,最关键的就是如何用好性能分析工具来发现问题,同样在启动分析中,我们首先也着重看一下工具的使用。
启动中每个阶段的耗时可以通过多种工具、方式来定位,每种方式都有自己的优势和劣势,可根据启动场景配合选择使用。
1、耗时统计工具
用日志过滤查看耗时
在 logcat 中过滤 Displayed 字段也可以看到启动时间
bash
2023-05-15 11:09:39.932 3059-3124/system_process I/ActivityTaskManager:
Displayed com.example.myapplicationalltest/com.ui.recyclerview.multiadapter.MultiAdapterMainActivity: +477ms
见到了App第一页就意味着启动好了吗?显然并不是这样的,有的时候,还需要从网络上拉取一些数据,这些数据加载好了,才意味着 App 的真正启动完成,所以还有一个完全显示所用时间。
对应的是图中的 reportFullyDraw 方法,注意!这个方法是需要手动调用的,因为系统也不知道我们应用什么时候算完全显示成功。
在我们认为界面真正渲染完成的时候调用 Activity.reportFullyDrawn()。
例如:
当我们调用过这个方法以后,会出现下面的日志:
yaml
2023-11-13 10:45:25.346 1483-1976 ActivityTaskManager system_server I
Fully drawn com.elam.demo.tools/com.xx.xx.xx.xx.xxActivity: +3s221ms
用命令行启动查看耗时
makefile
adb shell am start -W com.example.myapplicationalltest/com.ui.recyclerview.multiadapter.MultiAdapterMainActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.myapplicationalltest/com.ui.recyclerview.multiadapter.MultiAdapterMainActivity }
Status: ok
LaunchState: COLD
Activity: com.example.myapplicationalltest/com.ui.recyclerview.multiadapter.MultiAdapterMainActivity
TotalTime: 477
WaitTime: 480
Complete
自定义打点查看耗时
启动时埋点,启动后结束埋点,二者差值即为启动时间。
比如选取从Application的attachBaseContext()为起点,ViewTreeObserver.OnDrawListener()第一次回调为终点。 实现方式可以是手动修改代码进行埋点也可以是通过AOP自动埋点,方案有很多,这里就不过多介绍。
截取关键帧查看耗时
这是从用户角度进行测量耗时,也是测试最常用的测量手法,通过会多次测量取平均值。
MTN工具有这个能力,通过录屏然后手动选取开始和结束帧来计算启动耗时,为了方便识别,建议打开开发者模式-显示触摸操作。
MTN是我们公司内部工具,市面上应该有差不多能力的工具,知道的还麻烦底部评论分享一下。
界面如下:
2、耗时分析工具
用Profiler跟踪
1.手动记录
进入Profiler的cpu界面后选择采样方式-点击开始记录-操作手机触发对应的场景-结束记录即可
2.自动记录
冷启动Activity的场景来不及点击开始,可以使用这种自动开始记录的方式,不过也仅仅适用于申明为启动Activity类型的启动。
进入RUN->Edit Configurations勾选启动记录-选择采样方式,运行时候点击Profile图标即可自动抓取启动trace
不管是自动抓取还是手动抓取,打开后trace都如下图:
接下来的分析方法有几种
1.找到抓取的对应线程,通过快捷键WSAD进行缩放移动上下左右逐行查看函数调用耗时情况
2.Flame Chart模式查看
火焰图将具有相同调用方顺序的完全相同的方法或函数收集起来,并在火焰图中将它们表示为一个较长的横条,优点是可以直观反映整体执行过程中哪一部分函数耗时最严重,缺点是并不能完全按照时间线来表示耗时,也就是说一个函数在火焰图中表现出耗时,但可能单次并不耗时,是由于具有相同的调用路径调用了多次导致,这种情况在RecyclerView.onCreateViewHolder方法中最常见。
3.Top Down模式查看
根据占用CPU的百分比自顶向下排查,可以清醒看到函数调用顺序、耗时等。优点是比较容易突出重点目标,缺点是一般排查深度较深。
建议:线程一定要有自己的名字,方便在Trace中找到对应的片段分析。
用Debug工具类跟踪
Debug.startMethodTracing是通过应用插桩来生成跟踪日志,做到对方法的跟踪,不仅适用于UI线程也适用于其他子线程。
使用代码插桩的方法进行跟踪是最稳定发现应用层代码性能问题的方法,不依赖于操作手法,比如冷启动场景我们无法快速启动Profiler手动跟踪就可以使用这种方式,但是需要我们改动源码。而且启用剖析功能后,应用的运行速度会减慢,所以我们不要过度剖析数据的绝对时间,它最大的作用是用在对比上,可以对比之前,或者对比周围函数,找到相对耗时的方法。
使用步骤如下:
1.代码插入
rust
Debug.startMethodTracing("file_path/xx_trace");
......
要跟踪的方法或者代码段
......
Debug.stopMethodTracing();
2.获取trace分析
触发执行之后会在对应目录生成trace文件,输出的文件使用adb pull命令导出或者直接使用AS的Device Explorer找到该文件双击打开,打开后和Profiler抓取到的分析方法就是一样的了 。
依赖Profiler+Debug类基本能定位哪些函数导致了启动速度慢,但是这些函数可能并非自己耗时严重,也许是会因为调度或者锁的原因导致慢,这个时候perfetto/systrace会提供更多帮助。
注意:默认存储文件大小限制8MB,但是我们抓取的日志一般是超过这个大小的,可以通过第二个参数扩大内存。
用perfetto/systrace跟踪
利用perfetto/systrace来查看可以从全局和系统调度方面进行分析启动过程中瓶颈。
虽然无法具体到每个方法,但可以提供全局性能概览,可以更快定位问题范围,Debug工具类收集对于性能有些影响,而perfetto/systrace借助系统本身log,可以降低自身带来的影响。
抓取trace有很多方式,这里简单介绍我最常用的一种:
1.进入设置页开发者选项-进入开发者选项-选择-系统跟踪-打开 显示快捷设置图块
2.使用UI方式抓取
3.拉取文件
bash
adb pull /data/local/traces
4.使用网页打开
首先就能直观的看到那些阶段的耗时比较严重,然后定向分析即可,将时间段收缩,放大观察。
比如能观察到
-
发现inflate耗时过长问题。
-
发现锁等待问题,比如UI线程因为没有获取锁进入了睡眠,从而导致长时间的空白,之后被另一个线程唤起了,同样在渲染阶段有些异常的睡眠也是类似问题,基本都是异常、频繁调用些可能阻塞的耗时任务。
-
发现CPU调度和繁忙情况等。
-
发现启动时频繁触发GC导致启动缓慢的问题。
应用启动的Trace情况如下:
当然也可以通过这个查看启动的整体耗时。
需要注意的是Android App Startups显示的时间是应用的TTID - 初始显示时间:
还有一个技巧就是可以通过自动分析得出大概的启动耗时结论,但这里对抓取的trace应该有要求,我暂时还未深入使用,需要进一步补充。
三、优化实践
1、业务背景
本次启动速度优化包含XX首页、XX二级页。(这里我们简称该功能模块叫XX)
XX首页:是滑入应用后底部常驻悬浮的一个功能模块,咱们记录开始滑入到XX首页刷新网络数据成功展示的时间为启动耗时时间。
XX二级页:运行在子进程,启动耗时时间包含点击Item后跳转进入二级页,子进程XX业务SDK初始化,页面渲染成功的耗时。
咱们的目标就是优化这两个场景的启动耗时,由于篇幅,咱们挑XX二级页的启动流程图介绍一下:
2、测试手法
1、后台杀掉应用。
2、桌面滑动进入应用。
3、首页:滑入之前的手势记录为起始帧,滑入后XX功能内容加载平稳为结束帧。二级页:点击Item进入XX二级页,记录手势触发的关键帧为起始帧,二级页加载完成为结束帧。通过关键帧工具查看时延。
3、计算10次去掉最大和最小值结果的平均值作为最后的结果。
3、耗时分析和对应措施
由于咱们这个启动场景是有涉及到子进程的冷启动场景,因此我是采用的Debug工具类进行代码埋点跟踪,对上述的各个流程进行抓取trace。
抓取方法代码示例如下:
导入Profiler分析发现如下耗时问题。
SDK初始化耗时
通过梳理不同的SDK具体在哪些业务中使用,根据需要进行初始化,减少不必要的SDK初始化耗时和内存占用。
比如不需要视频播放能力的进程就不会再初始化播放器,只在需要的进程初始化。
视图添加过晚
滑入主应用的时候才开始执行addView将XX功能模块添加到父布局,导致视图加载链路过慢,我们增加了视图预加载能力,在进程拉起后未滑入主应用之前就执行addView,将View提前attach到父布局。
注意:这种场景使用的数据则会数据库缓存的数据,因为如果使用拉取网络数据,则会因为后台同时请求的并发量过大导致服务端成本增加,因为进程拉起的策略一般都是批量的,比如凌晨重启等。
次要View加载耗时
由于样式支持单双排切换,用户首先看到的是双排,之前为了交互优化,加载双排的同时会加载单排,但其实单排的View在用户第一时间并不关心的,这里的优化方法为针对次要View的加载放到IdleHandler中闲时执行。
布局加载Inflate耗时
存在多个布局inflate耗时,这里挑选部分View分析
布局加载耗时的原因主要是需要通过IO将XML布局文件加载到内存中并进行解析以及通过反射创建View,如果层级较深,加载耗时问题更为严重,解决这个问题有两种思路:
-
一种是不用IO和反射,比如简单View直接new对象以及通过addView的方式进行View的构建,或者通过x2c框架辅助转换。
-
一种是将耗时的加载操作放到子线程中。
这里由于视图结构较为复杂,我采用的是基于AsyncLayoutInflater异步布局加载框架进行优化,然后进行布局预加载。由于源码较简单,我直接把文件贴出来
kotlin
class AsyncLayoutInflateExt private constructor(context: Context) {
companion object {
private const val TAG = "AsyncLayoutInflateExt"
private const val TIME_MAX_WAIT = 300L
@Volatile
private var instance: AsyncLayoutInflateExt? = null
fun getInstance(context: Context): AsyncLayoutInflateExt {
return instance ?: synchronized(AsyncLayoutInflateExt::class.java) {
instance ?: AsyncLayoutInflateExt(context).also {
instance = it
}
}
}
}
private val mPreLayoutPool = ConcurrentHashMap<Int, ArrayBlockingQueue<View?>>()
private val mRequestPool = SynchronizedPool<InflateRequest>(10)
private var mInflater: LayoutInflater = BasicInflater(context)
private var mDispatcher: Dispatcher = Dispatcher()
private var mHandler: Handler = Handler(Looper.getMainLooper()) { msg ->
val request = msg.obj as InflateRequest
request.callback?.onInflateFinished(
request.view, request.resid, request.parent
)
releaseRequest(request)
true
}
/**
* 预加载
* 结果不缓存,使用回调给到调用方
*/
@UiThread
fun inflate(
@LayoutRes resid: Int, parent: ViewGroup?, callback: OnInflateFinishedListener?
) {
Log.d(TAG, "inflate.resid:$resid")
val request = obtainRequest()
request.inflater = this
request.resid = resid
request.parent = parent
request.callback = callback
mDispatcher.enqueue(request)
}
/**
* 预加载
* 通过layoutId创建View,需要将加载结果缓存起来
*/
fun preInflate(@LayoutRes resid: Int) {
Log.d(TAG, "preInflate.resid:$resid")
val request = obtainRequest()
request.inflater = this
request.resid = resid
request.callback = PreInflateListener()
mPreLayoutPool[resid] = ArrayBlockingQueue<View?>(1)
mDispatcher.enqueue(request)
}
/**
* 预加载
* 通过传入的表达式来自定义创建View,因为有些View是new出来的,并不都是通过inflate
*/
fun preInflateByInvoke(identity: Int, creater: () -> View) {
Log.d(TAG, "preInflateByCreater.identity:$identity")
val request = obtainRequest()
request.inflater = this
request.resid = identity
request.callback = PreInflateListener()
request.viewCreater = creater
mPreLayoutPool[identity] = ArrayBlockingQueue<View?>(1)
mDispatcher.enqueue(request)
}
fun getLayout(@LayoutRes resId: Int): View? {
val inflatedView = mPreLayoutPool[resId]?.poll(TIME_MAX_WAIT, TimeUnit.MILLISECONDS)
Log.d(TAG, "getLayout.inflatedView:$inflatedView")
mPreLayoutPool.remove(resId) //防止下次来取同样redId的导致一直需要等待
return inflatedView
}
interface OnInflateFinishedListener {
fun onInflateFinished(
view: View?, @LayoutRes resid: Int, parent: ViewGroup?
) {
}
fun onPreInflateFinished(
view: View?, @LayoutRes resid: Int, parent: ViewGroup?, request: InflateRequest
) {
}
}
inner class PreInflateListener: OnInflateFinishedListener {
override fun onPreInflateFinished(
view: View?, resid: Int, parent: ViewGroup?, request: InflateRequest
) {
val offerRes = mPreLayoutPool[resid]?.offer(view)
Log.d(TAG, "Thread:"+(Thread.currentThread().name)+" onInflateFinished.resid:$resid offerRes:$offerRes")
releaseRequest(request)
}
}
class InflateRequest internal constructor() {
var inflater: AsyncLayoutInflateExt? = null
var parent: ViewGroup? = null
var resid = 0
var view: View? = null
var viewCreater: (() -> View)? = null
var callback: OnInflateFinishedListener? = null
}
class Dispatcher {
fun enqueue(request: InflateRequest) {
THREAD_POOL_EXECUTOR.execute(InflateRunnable(request))
}
companion object {
//获得当前CPU的核心数
private val CPU_COUNT = Runtime.getRuntime().availableProcessors()
//设置线程池的核心线程数2-4之间,但是取决于CPU核数
private val CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4))
//设置线程池的最大线程数为 CPU核数 * 2 + 1
private val MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1
//设置线程池空闲线程存活时间30s
private const val KEEP_ALIVE_SECONDS = 30
private val sThreadFactory: ThreadFactory = object : ThreadFactory {
private val mCount = AtomicInteger(1)
override fun newThread(r: Runnable): Thread {
return Thread(r, TAG+"#" + mCount.getAndIncrement())
}
}
//LinkedBlockingQueue 默认构造器,队列容量是Integer.MAX_VALUE
private val sPoolWorkQueue: BlockingQueue<Runnable> = LinkedBlockingQueue()
/**
* An [Executor] that can be used to execute tasks in parallel.
*/
var THREAD_POOL_EXECUTOR: ThreadPoolExecutor
init {
Log.i(
TAG,
"static initializer: " + " CPU_COUNT = " + CPU_COUNT + " CORE_POOL_SIZE = " + CORE_POOL_SIZE + " MAXIMUM_POOL_SIZE = " + MAXIMUM_POOL_SIZE
)
val threadPoolExecutor = ThreadPoolExecutor(
CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE,
KEEP_ALIVE_SECONDS.toLong(),
TimeUnit.SECONDS,
sPoolWorkQueue,
sThreadFactory
)
threadPoolExecutor.allowCoreThreadTimeOut(true)
THREAD_POOL_EXECUTOR = threadPoolExecutor
}
}
}
private class BasicInflater internal constructor(context: Context?) : LayoutInflater(context) {
init {
if (context is AppCompatActivity) {
// 手动setFactory2,兼容AppCompatTextView等控件
val appCompatDelegate = context.delegate
if (appCompatDelegate is Factory2) {
LayoutInflaterCompat.setFactory2(this, (appCompatDelegate as Factory2))
}
}
}
override fun cloneInContext(newContext: Context): LayoutInflater {
return BasicInflater(newContext)
}
@Throws(ClassNotFoundException::class)
override fun onCreateView(name: String, attrs: AttributeSet): View {
for (prefix in sClassPrefixList) {
try {
val view = createView(name, prefix, attrs)
if (view != null) {
return view
}
} catch (e: ClassNotFoundException) {
// In this case we want to let the base class take a crack
// at it.
}
}
return super.onCreateView(name, attrs)
}
companion object {
private val sClassPrefixList = arrayOf(
"android.widget.", "android.webkit.", "android.app."
)
}
}
private class InflateRunnable(private val request: InflateRequest) : Runnable {
var isRunning = false
private set
override fun run() {
isRunning = true
try {
request.view = request.viewCreater?.run {
this.invoke()
} ?: request.inflater?.mInflater?.run {
inflate(
request.resid, request.parent, false
)
}
} catch (ex: RuntimeException) {
// Probably a Looper failure, retry on the UI thread
Log.w(
TAG,
"Failed to inflate resource in the background! Retrying on the UI" + " thread",
ex
)
}
if (request.callback is PreInflateListener) {
request.view?.run {
(request.callback as? PreInflateListener)?.onPreInflateFinished(
request.view, request.resid, request.parent, request
)
}
} else {
Message.obtain(request.inflater!!.mHandler, 0, request).sendToTarget()
}
}
}
private fun obtainRequest(): InflateRequest {
var obj = mRequestPool.acquire()
if (obj == null) {
obj = InflateRequest()
}
return obj
}
private fun releaseRequest(obj: InflateRequest) {
obj.callback = null
obj.inflater = null
obj.parent = null
obj.resid = 0
obj.view = null
mRequestPool.release(obj)
}
fun cancel() {
mHandler.removeCallbacksAndMessages(null)
}
}
相比原生AsyncLayoutInflater有如下优化:
1.引入线程池,减少单线程等待
2.手动设置setFactory2
3.支持View缓存,用于预加载场景
4.支持阻塞等待超时获取预加载View
5.支持非inflate方式的view预加载
执行预加载
使用预加载的布局
优化后再抓取trace进行对比发现最耗时的inflate问题已经解决。
注意:预加载布局也遇到几个问题
1、inflate是有3个参数的,由于预加载不能传入Parent,因此无法使用Parent的生成LayoutParam设置给目标View,因此如果要使用LayoutParam则会崩溃。
2、无法独立加载ViewStub结合merge修饰的子布局,这种问题的解决办法是直接加载根布局,先不管子布局如何,报错的原因我们从LayoutInflater的源码可以看到:
3、主题属性无法获取问题,因为传入的Context一般如果在Activitg加载的话是传入的Activity,而声明Activity的时候一般会带主题,而如果预加载的话我们传入的是全部Context,因此如果xml中有对应主题相关的属性,则会崩溃。这种问题的解决办法我是通过传入一个默认主题进行预加载。
4、Handler创建失败
由于我们是在子线程创建的View,因此如果在View中有使用Handler,子线程创建Handler则会没有Looper循环可用的报错,因此我们这里改成懒加载进行规避。
比如创建了手势检测的同时内部就会创建Handler导致崩溃:
懒加载进行规避:
Koin依赖注册耗时
Koin是我们应用用于跨模块通信的依赖注入框架,注册时机是在Application.attachBaseContext中,又由于多进程这里会多次执行,因此导致了在不需要注册的进程中也执行了这部分逻辑,我们需要在注册的时候进行进程判断,只在需要的进程执行注册即可。
日志打印耗时
1.修改不必要的i级别日志
2.d级别的日志使用函数表达式在确定要进行输出的时候再进行字符串的拼接,因为d级别的日志用户可以控制开关,如果关闭了就没必要提前进行耗时拼接。
SP+系统属性+网络状态读取耗时
在启动流程的生命周期函数中有访问系统属性、读取SharedPreferences文件、读取网络连接状态,都是比较存在耗时的隐患的,比如SP在未初始化完成的时候去取值则会阻塞,这里的优化方法有两个,一个是异步预读取,一个是懒加载。
1.提前到更早的时机在子线程执行预读取,并使用CountDownLatch保证有效读取
2.将不立即使用的业务依赖项放到需要的时候再初始化,避免在构造时就初始化
跨进程耗时
跨进程的问题有两个,一个是方法调用耗时,一个是方法回调耗时。
1.跨进程方法调用
跨进程方法调用耗时是不可控的,优化方法是涉及到跨进程的操作统一开线程异步处理,当然也可以声明AIDL接口为oneway使能串行+异步的特性来解决,但那样就无法接收返回值。
2.跨进程多次回调
由于此回调其实只有一个进程关心,其他进程其实根本不关系,优化方法则是根据进程按需注册即可。
数据库插入耗时
拉取网络数据后给到UI前,会执行数据库的插入,优化方法则是将数据库的插入从串行改为并行即可
线程切换耗时
本来就在子线程执行的逻辑,如果没必要切换到主线程,就不需要切到主线程再切回子线程,可以直接在子线程继续执行,减少线程切换开销。
网络数据获取耗时
正式数据展示一般都是依赖网络数据返回的,这里通用的优化方法则是对网络数据执行预加载,保证服务端并发压力的前提下将网络数据获取的时机提前到恰当的时机,以便渲染的时候直接使用。
1、比如我这次优化的其中有一个场景就是在第一帧正式数据展示之前需要获取网络数据,更多的网络数据是在列表页面的底部Footer加载后才发起请求,优化方法则是将网络请求的时机提前,不依赖于Footer加载。
2、另一个场景也是如此,通过提早网络获取的时机到开始滑动来异步获取进行优化,因为滑动手势从SCROLL状态变为IDLE状态的时延还挺长的,我们此前是在IDLE后才开始获取数据。
广播和服务注册绑定耗时
优化方法为使用异步注册和绑定服务即可
冗余埋点业务耗时
通过梳理业务发现流程中有技术跟踪的埋点频繁上报,经过和产品沟通,新增产研配置默认关闭该部分埋点能力,需要的时候再打开。
延时任务导致界面显示延迟
通过梳理业务流程发现在界面显示之前会先隐藏预览图,但是此前这里有延时的逻辑,通过测试取消延时也没有影响,因此取消延时将内容显示时机提前。
4、优化效果
通过以上优化,使用关键帧抓取测试10次去除最大最小值取平均值,优化效果还不错,XX首页场景优化35%左右,XX二级页场景优化20%左右。数据如下:
XX首页场景:
XX二级页场景:
四、优化总结
结合上述的内容,我们再来按照分类扩展总结梳理一下启动优化具体应该从哪些方面入手?
回顾一下启动流程,点击桌面图标后要尽快的显示第一个页面,并且能够进行交互。 根据启动流程的分析,显示页面能和用户交互,这是主线程做的事情。那么就要求 我们不能再主线程做耗时的操作。启动中的系统任务我们无法干预,能干预的就是在创建Application应用和创建 Activity 的过程中可能会出现的性能问题。
这一过程具体就是:
1.Application的attachBaseContext
2.ContentProvider的onCreate
3.Application的onCreate
4.activity的onCreate
5.首帧View的加载
6.activity的onStart
7.activity的onResume
8.首帧View的绘制
9.数据加载
10.真实View的绘制
具体措施整理成一句话其实就是对上诉的各个流程的中耗时操作通过减法、异步、延迟、预加载等主要手段进行优化。
除了套用一些常用的优化方法以外,最重要的还是对业务流程的梳理,梳理清楚启动过程中的每一个功能,哪些是一定需要的,那些是可以砍掉,那些是可以懒加载的,通常,业务梳理带来的启动速度优化效果才是大头。
1.启动窗口优化
根据前面分析的Activity启动流程中有介绍到在Activity启动前会展示一个名字叫StartingWindow的window,这个window的背景是取要启动Activity的Theme中配置的WindowBackground,默认是白色背景图。从点击启动到真正目标View可见还有一段时间,使用StartingWindow可以让用户先看到预览内容,避免点击后一段时间没有反应,给用户误解,这就是应用启动白屏的原因。
通常按照以下操作即可:
1.不要禁止系统默认的启动窗口:即不要在主题里面设置android:windowDisablePreview
2.定制启动窗口的内容主题属性,最开始是设置windowBackground后来是新增了Splash Screen API变成了设置android:windowSplashscreenContent主题属性
xml
<style name="xx" parent="xx">
<item name="android:windowBackground">@drawable/xx</item>
</style>
<style name="AppTheme" parent="BaseTheme">
<item name="android:windowSplashscreenContent">@drawable/window_splash_screen_content</item>
</style>
通过设置一个优先展示给用户的内容图片来来优化启动的体验,这个也被叫做启动优化中的视觉优化方案,实际上启动速度并没有变快,只是视觉上体验比白屏好很多。
2.减法
对业务尽量做减法,能不做的尽量不做!通过梳理启动流程中业务,减少耗时业务,这是最有效的优化方式,实在不行就使用下面的懒加载和闲时加载进行补充。
按需加载其实也是一种减法,比如只加载当前进程或者业务必须要用的,非必要的不加载。
举几个场景:
1.Debug包可以加日志打印和部分统计,但Release能不加的就不加
2.Koin按需注册,不注册当前进程不需要的module
3.按需加载三方SDK,例如非媒体进程不要初始化媒体播放依赖的SDK
3.异步
异步也是线程优化的一种,将耗时任务尽量异步处理,充分利用CPU多核心处理的优势,并行处理任务,通过回调来观察处理结果。
当然如果启动过程中有太多的线程一起启动,会给 CPU 带来非常大的压力,尤其是比较低端的机器,可以使用线程池控制线程数量并且进行统一调度 。
还有就是异步在某些场景下可能UI会出现闪动,UI体验会差,因此需要合理运用。
如果异步初始化的多个任务有依赖关系,可以考虑CountdownLatch或者使用LaunchStarter,这里就不过多介绍,这里还需要注意一下StartUP虽然也能解决关联依赖问题,但是不能处理异步。
举几个场景:
1.异步完成广播注册或者服务绑定
2.异步进行跨进程调用
3.异步进行网路、文件IO
4.降低埋点线程的优先级让出CPU调度权优先处理核心业务
4.延迟加载
延迟加载也叫懒加载,适用于在使用时再加载的任务,避免扎堆到启动流程中进行初始化影响启动速度,当然这其实也是开机内存优化常用的手段。
尽量避免类构造的时候就初始化具体业务相关的类和属性,可以使用到再初始化,比如有些业务以来用户行为其实才需要加载。Kotlin中可以使用by lazy,无法用by lazy 的我们模仿该逻辑自己实现按需加载。
举几个场景:
1.等用户实际操作后再加载用户负反馈的依赖项,因为用户可能并不会产生任何点击。
2.Provider中的业务逻辑改为在使用到的时候再进行初始化而不是跟随进程起来就全部准备好。
3.使用Koin框架注入的话减少使用get获取实例,使用inject懒加载方式代替。
5.闲时调用
IdleHandler闲时调用主要是用于解决那些不能异步执行,而且必须在主线程执行,但是优先级不是很高的任务在主线程空闲的时候执行,这也是解决界面卡顿的好方法。
举几个场景:
1.可以将一些埋点任务放到闲时上报。
2.可以把一些不重要的 View 的加载和数据绑定放到 IdleHandler 中执行。
用法如下:
typescript
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
return false;//返回值决定是否循环执行
}
});
原理可以看MessageQueue
6.预加载
通过将必须的业务加载时机提前,保证在使用到具体逻辑的时候该依赖项已经准备好,避免上述启动过程中的任何环节被阻塞。也可以适当调整业务的执行顺序,比如网络数据在不影响服务端突刺的前提下,可以提前到尽早的时机并行拉取。
举几个场景:
1.布局预加载(AsyncLayoutInflater)
2.提前渲染,例如打开ViewPager的离屏预加载,或者手动提前addView
3.网络状态预加载
4.SP文件读取预加载
5.网络数据预加载,解决首帧正式View渲染的数据依赖
7.页面布局优化
页面布局优化可以有效减少布局的加载时间和渲染时间,这个比较简单,就简单介绍一下:
1.使用更合适的布局容器,减少层级嵌套,尽量保持层级的扁平化
2.使用merge标签减少层级
3.使用ViewStub按需加载布局
8.其他手段
还有一些其他启动速度优化的手段,有些已经用过有些还未来得及学习,我这里也罗列记录一下:
1.CPU和GPU升频+大小核调度 (大多依赖于系统接口用于场景识别)
2.线程优先级调整
3.GC抑制
4.类重排
5.类加载优化
6.主线程消息调度优化
五、未来展望
上文已经介绍了很多了理论和实际措施,再进一步思考其实还有一些要注意的。
通常我们优化只包含如下三部分:
1、分析现状确认问题 2、针对性优化 3、评估优化效果
借鉴抖音的启动性能优化方法论,它们分为五部分,分别是:
1、理论分析 2、现状分析 3、启动性能优化 4、线上验证 5、防劣化
其中的线上验证和防劣化部分是很容易忽略的一点,我们来学习一下:
线上验证
在完成了具体的优化项施工后,就来到了线上验证大盘收益的阶段。这个阶段有三点需要注意:
1.线下的优化一定要有线上的指标反馈,线下的优化项因为设备或操作习惯差异往往难以评估是否具备普遍影响,只有当相应的线上指标取得正面反馈后才能验证拿到了有效的优化收益;
2.线上指标需要结合均值与分位值综合来评估,只关注启动耗时的均值往往会掩盖低分位设备的现状,这部分设备可能占比不高,对均值影响有限,但抖音庞大的用户基数乘以该比例仍旧是不小的数量,为了保障该部分用户的启动性能体验,一般会分 50%、70%、90%三个分位值来评估指标;
3.在验证收益时通过 AB 实验达成,这样做不仅能控制变量确保优化项的严格有效,还能借此来观察性能优化所带来的业务指标收益,这些都可以作为规划后续启动优化方向的参考指导。
防劣化
在线上验证优化措施取得切实收益后,并不是万事大吉了,持续保持住优化效果才算完整达成了启动性能优化的目的。其实不仅是启动优化,整个性能优化领域都是围绕着"攻"和"守"来展开的,"攻"即为前述的分析与优化,而"守"则是防止劣化,在防劣化方面大家往往不会像优化的方面那么重视,但实际上能防止劣化是可持续取得优化效果的前提(否则新的优化效果会用于弥补劣化甚至入不敷出),并且防劣化相比于优化是更能持久有益的。
通过线上监控工具对启动路径全量插桩并且设置告警,然后线上线下相结合的分析启动耗时,为启动优化提供优化方向指导,能有效及时避免启动速度的劣化,当然这种工程化的建设也是一个艰难且持续的课题。
最后,启动速度优化只是性能优化中的其中一环,甚至优化措施都是相辅相成的,我们必须保持全局思维,持续迭代,用有限的资源实现用户体验的极致化。
六、参考资料
【抖音 Android 性能优化系列:启动优化之理论和工具篇】juejin.cn/post/705808...
【抖音 Android 性能优化系列:启动优化实践】juejin.cn/post/708006...
【Android性能优化系列篇(二):启动优化】zhuanlan.zhihu.com/p/573370831...