Android App冷启动优化的一些经验

  关于Android App的优化,百度,Google已经收录了不计其数的文章。常规套路我相信大家基本上应该都可以如数家珍了吧。尤其是国内的技术环境,大众化的,易于理解和实践的优化方式和方法,被粘贴过来,复制过去。无非也就是使用系统提供的工具,例如Traceview, Hierarchyviewer,DDMS,MAT等等,去分析方法的调用,View的层级,内存的占用和一些其他的影响App性能表现的Key。随便查阅四五篇,也就了然于胸了。相比而言,Google搜到的外文技术文章,还有一些App优化的独到见解和处理方式方法,这个会在下文中有所提及。

  所以呢,常规套路在这里我就不准备写了,由于最近半年断断续续的都在做Sogou浏览器的冷启动优化,就分享一下在浏览器冷启动优化过程中的一些经验和一些接下来怎么去进一步优化整个App的想法吧。

说明:这里所说的冷启动,是指非第一次安装的冷启动。由于Android 方法数的限制,Google引入了MultiDex,也正是因为安装包内多了几个Dex文件,导致在第一次安装启动的过程中,多个Dex要做合并编译的优化处理,所以在Sogou浏览器引入的Moudle越来越多的现实状况下,第一次安装启动的时间被拉的越来越长。这一块的优化涉及的技术比较专深,就让负责优化的同事来讲吧。:P

Sogou浏览器的冷启动优化

  冷启动时间:从用户点击桌面的ICON到,App启动并展示在前台,可以与用户产生交互的这段时间。冷启动的优化,说的直白一些,就是尽量让App启动的时间变短。假如用户从点击icon到App启动耗费的时间都够用户思考人生了,99%的用户会毫不犹豫的卸载掉咱么的应用,用户的留存率会直线下降。
  因此,冷启动的优化,贯穿浏览器的迭代中,每一版在不停的添加模块,添加功能的同时,在灰度上线的前夕专门预留时间来做冷启动的优化,以保证功能增加,时间不加长。
每一版的首页架构不同,因此优化的方法也不尽相同,尤其是4.x版本到5.x版本的优化方式,更改较大。今天就着重介绍一下从浏览器5.0版之后的一处优化小细节:

0,衡量标准:

衡量冷启动优化的效果,总要有一个标准,QA的同学是通过摄像头采点的方法来获取冷启动的时间信息的。我们则可以通过adb的命令得到接近的效果,虽然完全启动用户可见的时间点可能会较QA的测试有所不同,但是作为纵向对比工具已经足够了。

"adb命令"

以下摘自其他人的博客:

“adb shell am start -W ”的实现在frameworks\base\cmds\am\src\com\android\commands\am\Am.java文件中。其实就是跨Binder调用ActivityManagerService.startActivityAndWait()接口,然后等待返回结果,结束本次启动过程并将统计数据打印出来。(后面将ActivityManagerService简称为AMS),

+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

result中的thisTime,totalTime时间的计算,是在:frameworks\base\services\core\java\com\android\server\am\ActivityRecord.java中:

curTime表示该函数调用的时间点.
displayStartTime表示一连串启动Activity中的最后一个Activity的启动时间点.
mLaunchStartTime表示一连串启动Activity中第一个Activity的启动时间点.

正常情况下点击桌面图标只启动一个有界面的Activity,此时displayStartTime与mLaunchStartTime便指向同一时间点,此时ThisTime=TotalTime。另一种情况是点击桌面图标应用会先启动一个无界面的Activity做逻辑处理,接着又启动一个有界面的Activity,在这种启动一连串Activity的情况下,displayStartTime便指向最后一个Activity的开始启动时间点,mLaunchStartTime指向第一个无界面Activity的开始启动时间点,此时ThisTime!=TotalTime。这两种情况如下图:"TotalTime/ThisTime的关系"
在上面的图中,我用①②③分别标注了三个时间段,在这三个时间段内分别干了什么事呢?

  1. 在第①个时间段内,AMS创建ActivityRecord记录块和选择合理的Task、将当前Resume的Activity进行pause;
  2. 在第②个时间段内,启动进程、调用无界面Activity的onCreate()等、pause/finish无界面的Activity;
  3. 在第③个时间段内,调用有界面Activity的onCreate、onResume;

看到这里应该清楚 ThisTime、TotalTime、WaitTime三个时间的关系了吧。WaitTime就是总的耗时,包括前一个应用Activity pause的时间和新应用启动的时间;ThisTime表示一连串启动Activity的最后一个Activity的启动耗时;TotalTime表示新应用启动的耗时,包括新进程的启动和Activity的启动,但不包括前一个应用Activity pause的耗时。也就是说,开发者一般只要关心TotalTime即可,这个时间才是自己应用真正启动的耗时。

Event log中TAG=am_activity_launch_time中的两个值分表表示ThisTime、TotalTime,跟通过“adb shell am start -W ”得到的值是一致的。

最后再说下系统根据什么来判断应用启动结束。我们知道应用启动包括进程启动、走Activity生命周期onCreate/onResume等。在第一次onResume时添加窗口到WMS(WindowManagerService)中,然后measure/layout/draw,窗口绘制完成后通知WMS,WMS在合适的时机控制界面开始显示(夹杂了界面切换动画逻辑)。记住是窗口界面显示出来后,WMS才调用reportLaunchTimeLocked()通知AMS Activity启动完成。

1,应用层面的分析:

当用户点击Launcher的Sogou Icon之后的动作,从一个App开发者角度看,要经历一下过程,才能展示在用户眼前:


Launcher Icon —> BrowserApp(Sogou Application) —> BrowserActivity.onCreate()(Sogou Main Activity) —> BrowserActivity.onResume() —> showing


以上就是搜狗浏览器在启动过程中的必经之路,从BrowserApp开始就是我们可以控制的启动过程了。尽量缩短从Launcher Icon —> Showing的时间是我们的目的。
将BrowserApp和BrowserActivity.onCreate()和BrowserActivity.onResume()非必要代码移到启动过程之后,精简主页面的布局结构,去掉非必要的层级嵌套。之后进行测试,也确实缩短了一些时间,但是不是太明显。再将不必要的层级,不必要的代码减无可减的情况下,启动时间的提升却很有限。我曾经尝试过把主页面的内容全部置空,确实启动时间有了质的提升,遂想先启动开BrowserActivity,然后在BrowserActivity.onResume()之后再去延迟渲染加载真实的主页面,但这样用户体验就会极差,用户会看到短暂的空白页,然后才能看到真实的主页面,优化冷启动速度的目的本是提高加载速度,以便让用户等待最短的时间就可以与APP进行交互。如果主页面延迟显示,不仅没有提升用户体验,反而让用户更能明显的感知到APP缓慢的启动过程,有点顾此失彼,本末倒置。

观察竞品QQ浏览器:


Q浏览器新版的更改较大,稍后再说。但是在较早之前的版本,他们的页面如图所示,也不简单,但是他们能做到秒开,右图为QA提供的当时的测试数据直方图。

在显示布局边界选项开启,反复启动QQ浏览器,你会发现一些现象:在启动后可见的极短时间内,整个界面的布局边界不会如上图所示,而是就显示了一个View的布局边界。而且,在这段时间内,浏览器是不响应任何事件的。所以,我们大胆的推测,QQ浏览器在启动的过程中,不是按部就班,老老实实的展示真实的页面的,而是先展示了一直图片,然后再使用真实的页面内容将其替换掉。
假设完,接下来就是要去求证。在同事通过编译Android ROM,在关键节点打印信息,通过分析,证实了我们的猜测,并且转存了他们截图,并替换成其他图片,QQ浏览器在接下来的启动过程中也确实显示的是我们偷梁换柱的图片。

2,我们怎么做?:

  取证、验证完了,证明QQ浏览器就是这么做的,接下来要做的事情就是比着葫芦画瓢了。主页面截图,开机启动展示截图,并替换成真实的页面信息。这些都很easy。但是,关键点在于:截图和真实内容替换的时机,到底在什么时机完成狸猫换太子呢???
  刚开始的想法:在BrowserActivity.onCreate()的开始,将截图贴上去,然后在BrowserActivity.onResume()通过Handler delay一定时间后再用真实页面去替换掉截图(之所以延时,是因为onResume距离页面可见还有一段时间间隔)。这样做确实是可以的,但是在delay多少时间的确定上就遇到了大麻烦,因为从onResume()到页面展示,中间的时长是和手机性能密切相关的,在高端机上需要0.5s,在低端机上就有可能需要5s,相差很大。我们不可能跟割韭菜一样,齐刷刷的一刀切,这样或许在低端机上对于冷启动速度有所提升,但是在高端机上,就等于人为的拉长加载时间。再说手机的配置,日新月异,整体Android市场的手机配置正在往高端靠拢,咱们不可能为了优化低端机上的表现,把所有高端机的性能拉低。
  后来也想过保持delay的方案不变,通过动态的去配置delay的时间来保证高中低端机都有合适的delay时长。但是,仔细想就放弃了,android市场和ios市场不同,市面上的手机,千差万别,配置更是五花八门,我们不可能为所有的手机去指定delay时间,即使按照cpu,内存等参数,去指定粗略的delay时长也是非常耗时,耗人力的,遂放弃。

恰好彼时正在改版首页面的快链模块,由于要做快速链接的大改版,整体的效果跟Android Launcher差不多,就是可以跨越多个屏幕进行icon的排序,整理,删除等功能。所以就参考了一些系统launcher的源代码,看到在LauncherModel中有许多地方用到MessageQueue中提供的IdleHandler,从字面理解就是空闲处理,源码关于这一块的处理如下:

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
- public class Looper {
- ......
-
- public static final void loop() {
- Looper me = myLooper();
- MessageQueue queue = me.mQueue;
-
- ......
-
- while (true) {
- Message msg = queue.next(); // might block
- ......
-
- if (msg != null) {
- if (msg.target == null) {
- // No target is a magic identifier for the quit message.
- return;
- }
-
- ......
-
- msg.target.dispatchMessage(msg);
-
- ......
-
- msg.recycle();
- }
- }
- }
-
- ......
- }

Looper中是一个死循环,一直从MessageQueue中取Message,然后交给Handler去处理,当没有任何数据可以取得时候,循环会阻塞在MessageQueue.next()方法上,直到有新的数据,在CPP底层,通过Binder去通知上层,解除阻塞,继续循环取Message得操作。
而在MessageQueue中提供Message的next()方法内部实现如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
- public class MessageQueue {
- ......
-
- final Message next() {
- int pendingIdleHandlerCount = -1; // -1 only during first iteration
- int nextPollTimeoutMillis = 0;
-
- for (;;) {
- if (nextPollTimeoutMillis != 0) {
- Binder.flushPendingCommands();
- }
- nativePollOnce(mPtr, nextPollTimeoutMillis);
-
- synchronized (this) {
- // Try to retrieve the next message. Return if found.
- final long now = SystemClock.uptimeMillis();
- final Message msg = mMessages;
- if (msg != null) {
- final long when = msg.when;
- if (now >= when) {
- mBlocked = false;
- mMessages = msg.next;
- msg.next = null;
- if (Config.LOGV) Log.v("MessageQueue", "Returning message: " + msg);
- return msg;
- } else {
- nextPollTimeoutMillis = (int) Math.min(when - now, Integer.MAX_VALUE);
- }
- } else {
- nextPollTimeoutMillis = -1;
- }
-
- // If first time, then get the number of idlers to run.
- if (pendingIdleHandlerCount < 0) {
- pendingIdleHandlerCount = mIdleHandlers.size();
- }
- if (pendingIdleHandlerCount == 0) {
- // No idle handlers to run. Loop and wait some more.
- mBlocked = true;
- continue;
- }
-
- if (mPendingIdleHandlers == null) {
- mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
- }
- mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
- }
-
- // Run the idle handlers.
- // We only ever reach this code block during the first iteration.
- for (int i = 0; i < pendingIdleHandlerCount; i++) {
- final IdleHandler idler = mPendingIdleHandlers[i];
- mPendingIdleHandlers[i] = null; // release the reference to the handler
-
- boolean keep = false;
- try {
- keep = idler.queueIdle();
- } catch (Throwable t) {
- Log.wtf("MessageQueue", "IdleHandler threw exception", t);
- }
-
- if (!keep) {
- synchronized (this) {
- mIdleHandlers.remove(idler);
- }
- }
- }
-
- // Reset the idle handler count to 0 so we do not run them again.
- pendingIdleHandlerCount = 0;
-
- // While calling an idle handler, a new message could have been delivered
- // so go back and look again for a pending message without waiting.
- nextPollTimeoutMillis = 0;
- }
- }
-
- ......
- }

该方法在消息队列中没有消息或者消息的执行时间还没有到,都会使其进入线程等待,而在进入等待状态前会去处理mPendingIdleHandlers中的数据,而其中的数据,就是通过MessageQueue.addIdleHandler将其放入到mPendingIdleHandlers中去的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- public class MessageQueue {
- ......
-
- /**
- * Callback interface for discovering when a thread is going to block
- * waiting for more messages.
- */
- public static interface IdleHandler {
- /**
- * Called when the message queue has run out of messages and will now
- * wait for more. Return true to keep your idle handler active, false
- * to have it removed. This may be called if there are still messages
- * pending in the queue, but they are all scheduled to be dispatched
- * after the current time.
- */
- boolean queueIdle();
- }
-
- ......
- }

ok,整理以上内容:Handler通过轮询去处理MessageQueue中取出的数据,MessageQueue没有数据需要处理时,会进入wait状态,而在进入wait之前,会处理IdleHandler的东西。

我们的想法就是,手机性能各有不同,所以,就让手机去执行该执行的任务,在任务完成,进入wait的时机,把我们的截图替换上去,然后手机还是去执行必要的加载任务(完成View的绘制),然后在下一个wait到来的时机,再将截图去替换成真是的Views。

上述想法需要一个充分必要条件:App启动过程中的确有使主线程Handler进入wait的时间点,且这个点,到来的比较快,之前不会做太多App自己的动作。应用程序的启动过程比较复杂,且冗长。有兴趣可以看一下:http://blog.csdn.net/luoshengyang/article/details/6747696。这里只截取关键的几个点:

ActivityManagerService 通过调用ApplicationThread中的scheduleLaunchActivity去加载App默认的Activity,而这个scheduleLaunchActivity是在ActivityThread中实现的:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
- public final class ActivityThread {
-
- final H mH = new H();
- public static final void main(String[] args) {
- ......
-
- Looper.prepareMainLooper();
- ActivityThread thread = new ActivityThread();
- thread.attach(false);
- if(sMainThreadHandler == null){
- sMainThreadHandler = thread.getHandler();
- }
- ......
- }
- ......
- final Handler getHandler(){
- return mH;
- }
- private final class ApplicationThread extends ApplicationThreadNative {
-
- // we use token to identify this activity without having to send the
- // activity itself back to the activity manager. (matters more with ipc)
- public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,
- ActivityInfo info, Bundle state, List<ResultInfo> pendingResults,
- List<Intent> pendingNewIntents, boolean notResumed, boolean isForward) {
- ActivityClientRecord r = new ActivityClientRecord();
-
- r.token = token;
- r.ident = ident;
- r.intent = intent;
- r.activityInfo = info;
- r.state = state;
-
- r.pendingResults = pendingResults;
- r.pendingIntents = pendingNewIntents;
-
- r.startsNotResumed = notResumed;
- r.isForward = isForward;
-
- queueOrSendMessage(H.LAUNCH_ACTIVITY, r);
- }
- private final void queueOrSendMessage(int what, Object obj, int arg1, int arg2) {
- synchronized (this) {
- ......
- Message msg = Message.obtain();
- msg.what = what;
- msg.obj = obj;
- msg.arg1 = arg1;
- msg.arg2 = arg2;
- mH.sendMessage(msg);
- }
- }
- private final class H extends Handler {
-
- ......
-
- public void handleMessage(Message msg) {
- ......
- switch (msg.what) {
- ......
- }
-
- ......
-
- }
-
- }

这里首次出现了往主线程的Handler发送信息的queueOrSendMessage,其方法内部将ActivityClientRecord 封装成Message,然后发送给mH这个Handler。而这个mH就是ActivityThread的私有类,应用进程启动之后,就会加载ActivityThread的main方法,这时候就是创建ActivityThread的对象,并在此工程中创建mH的对象,接下来的步骤,比较复杂:
一般View往主线程发送Runnable的作法如下:

那么这个AttachInfo的mHandler就是主线程的Handler,就是上文中提到的mH。

Activity和View的交互,是通过WindowManager来实现的,WindowManager创建PhoneWindow负责View的添加、删除和View的各种其他声明周期方法,而这个WindowManager其实是一个SystemService,其是在Context的具体实现类ContextImpl中创建的:

这样就把ActivityThread创建的MainHandler传递给了WindowManager,然后WindowManager创建PhoneWindow,再然后PhoneView创建DecorView和ViewRootImpl,在再然后往DecorView添加子View的时候,MainHandler就会作为AttachInfo的变量,在View attachToWindow的时候传递给子View(这也就解释了为什么在添加完View就调用View的post方法会失败的原因)。
就是ActivityThread执行Application,和Activity的生命周期,其中也会用到这个mH,然后走到Activity的onCreate,然后将App的Layout attach to DecorView,如果Layout本身添加了动画,也会使用到mH。添加Layout之后,如果没有业务逻辑,此时,mH 就马上要进入wait状态了,这就是一个好的时机。

  所以,为了让App主线程的mH尽早的进入wait状态,要尽量保证,在Application生命周期,Activity启动的生命周期的各种回调不做耗时操作,尽量不使用mH去做事情;将Layout尽量简化,去掉不必要的动画。然后在此时,把IdleHandler塞给MessageQueue,让mH有机会去处理。

3,搜狗浏览器的实践:

  搜狗浏览器的做法就是:BrowserApp和BrowserActivity.onCreate中,减少或者延时耗时操作。不使用主线程的Handler,将BrowserActivity的根layout,开始时设置为一个空的FrameLayout,并将上一次退出时保存的首页截图mScreenShotImage add 到这个空的FrameLayout中,scaleType = “fitXY”。然后App启动回调到BrowserActivity.onCreate时,先将包含截图的空FrameLayout直接add 到 ViewRoot(id=android.R.id.content的根ViewGroup)。然后再把替换Views 的步骤包装成IdleHandler塞给Hanlder。紧接着在完成Views绘制之后,包装一个去掉截图空FrameLayout的IdleHandler到Handler中,让其执行用来完成图片的替换过程,至此完成整个启动。
"贴上一个空FrameLayout的同时,将真实UI初始化方法initUI()放到一个IdleHandler中,等待被主UI的Handler空闲时执行"

initUI(Bundle):

"initUI()方法完成的最后,将去掉空FrameLayout的步骤放到一个IdelHandler中,等待被主UI的Handler空闲时执行"

其中,MainHandler就是主线程的Handler,mBrowserFrameLayout就是那个空架子,mScreenShotImage就是上一次的App Home页面截图。
MainHandler中的waitForIdle实现如下:


Looper.getQueue()会报错是因为我们的应用要兼容低版本Android,而getQueue()这个方法是Api23以上才公开的方法,不过不用担心,因为追溯到Android2.3的手机上,Framework的层Looper实现中都是有getQueue()这个方法的,只是低版本都是private的。

关于截图的时机,我们现在是在App退出的回调去执行的。

关于横竖屏启动,截图使用可能错误:竖着启动,可能使用上次退出横屏的截图;反之也是类似的情况。对比了QQ浏览器,我们采用了相同的做法,只是用竖屏截图,横屏下退出不截图。然后如果用户横屏启动,要加一点额外的处理:

"横竖屏启动时,使用截图时的处理细节"

最后,还有一些细枝末节的处理,因为作为浏览器,会比普通App的调用场景更复杂多变一些。

最终结果还是比较令人满意的:Duang~~~

"冷启动优化测试直方图"

先就这么多吧。:P