1. 启动优化
[toc]
最新开源库:AnchorTask
相关博客:
Android 启动优化(二) - 拓扑排序的原理以及解题思路
Android 启动优化(三) - AnchorTask 使用说明
1.1. 一、冷启动
从点击应用图标到UI界面完全显示且用户可操作的全部过程。
特点:耗时最多,衡量标准
启动流程:Click Event -> IPC -> Process.start -> ActivityThread -> bindApplication -> LifeCycle -> ViewRootImpl
1.1.1. 1.1 视觉优化
应用程序启动有三种状态,每种状态都会影响应用程序对用户可见所需的时间:冷启动,热启动和温启动。
在冷启动时,应用程序从头开始。在其他状态下,系统需要将正在运行的应用程序从后台运行到前台。我们建议您始终根据冷启动的假设进行优化。这样做也可以改善热启动和温启动的性能。
在冷启动开始时,系统有三个任务。这些任务是:
- 加载并启动应用程序。
- 启动后立即显示应用程序空白的启动窗口。
- 创建应用程序进程。
一旦系统创建应用程序进程,应用程序进程就会负责下一阶段。这些阶段是:
- 创建app对象.
- 启动主线程(main thread).
- 创建应用入口的Activity对象.
- 填充加载布局Views
- 在屏幕上执行View的绘制过程.measure -> layout -> draw
应用程序进程完成第一次绘制后,系统进程会交换当前显示的背景窗口,将其替换为主活动。此时,用户可以开始使用该应用程序。
因为App应用进程的创建过程是由手机的软硬件决定的,所以我们只能在这个创建过程中视觉优化。
启动主题优化
冷启动阶段 :
- 加载并启动应用程序。
- 启动后立即显示应用程序空白的启动窗口。
- 创建应用程序进程。 所谓的主题优化,就是应用程序在冷启动的时候(1~2阶段),设置启动窗口的主题。
因为现在 App 应用启动都会先进入一个闪屏页(LaunchActivity) 来展示应用信息。
- 默认情况
如果我们对App没有做处理(设置了默认主题),并且在 Application 初始化了其它第三方的服务(假设需要加载2000ms),那么冷启动过程就会如下图 :
系统默认会在启动应用程序的时候启动空白窗口,直到 App 应用程序的入口 Activity 创建成功,视图绘制完毕。( 大概是onWindowFocusChanged方法回调的时候 )
- 透明主题优化
为了解决启动窗口白屏问题,许多开发者使用透明主题来解决这个问题,但是治标不治本。 虽然解决了上面这个问题,但是仍然有些不足。
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowIsTranslucent">true</item>
</style>
(无白屏,不过从点击到App仍然存在视觉延迟~)
- 设置闪屏图片主题
为了更顺滑无缝衔接我们的闪屏页,可以在启动 Activity 的 Theme中设置闪屏页图片,这样启动窗口的图片就会是闪屏页图片,而不是白屏。
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@drawable/lunch</item> //闪屏页图片
<item name="android:windowFullscreen">true</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item><!--显示虚拟按键,并腾出空间-->
</style>
这样设置的话,就会在冷启动的时候,展示闪屏页的图片,等App进程初始化加载入口 Activity (也是闪屏页) 就可以无缝衔接。
其实这种方式并没有真正的加速应用进程的启动速度,而只是通过用户视觉效果带来的优化体验。
1.1.2. 1.2 代码优化
当然上面使用设置主题的方式优化用户体验效果治标不治本,关键还在于对代码的优化。
首先统计一下应用冷启动的时间。
1.2.1 冷启动耗时统计
- adb 命令统计
adb命令 :adb shell am start -S -W 包名/启动类的全限定名
, -S 表示重启当前应用
C:\Android\Demo>adb shell am start -S -W com.example.moneyqian.demo/com.example.moneyqian.demo.MainActivity
Stopping: com.example.moneyqian.demo
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.moneyqian.demo/.MainActivity }
Status: ok
Activity: com.example.moneyqian.demo/.MainActivity
ThisTime: 2247
TotalTime: 2247
WaitTime: 2278
Complete
ThisTime : 最后一个 Activity 的启动耗时(例如从 LaunchActivity - >MainActivity「adb命令输入的Activity」 , 只统计 MainActivity 的启动耗时)
TotalTime : 启动一连串的 Activity 总耗时.(有几个Activity 就统计几个)
WaitTime : 应用进程的创建过程 + TotalTime .
在第①个时间段内,AMS 创建 ActivityRecord 记录块和选择合理的 Task、将当前Resume 的 Activity 进行 pause.
在第②个时间段内,启动进程、调用无界面 Activity 的 onCreate() 等、 pause/finish 无界面的 Activity.
在第③个时间段内,调用有界面 Activity 的 onCreate、onResume.
//ActivityRecord
private void reportLaunchTimeLocked(final long curTime) {
final long thisTime = curTime - displayStartTime;
final long totalTime = stack.mLaunchStartTime != 0 ? (curTime - stack.mLaunchStartTime) : thisTime;
}
```
如果需要统计从点击桌面图标到 Activity 启动完毕,可以用WaitTime作为标准,但是系统的启动时间优化不了,所以优化冷启动只要在意ThisTime即可。
- 系统日志统计
也可以根据系统日志来统计启动耗时,在Android Studio中查找已用时间,必须在logcat视图中禁用过滤器(No Filters)。因为这个是系统的日志输出,而不是应用程序的。你也可以查看其它应用程序的启动耗时。
过滤displayed
输出的启动日志.
根据上面启动时间的输出统计,就可以先记录优化前的冷启动耗时,然后再对比优化之后的启动时间。
1.2.2 Application 优化
Application 作为 应用程序的整个初始化配置入口,时常担负着它不应该有的负担
有很多第三方组件(包括App应用本身)都在 Application 中抢占先机,完成初始化操作。
但是在 Application 中完成繁重的初始化操作和复杂的逻辑就会影响到应用的启动性能
通常,有机会优化这些工作以实现性能改进,这些常见问题包括:
- 复杂繁琐的布局初始化
- 阻塞主线程 UI 绘制的操作,如 I/O 读写或者是网络访问.
- Bitmap 大图片或者 VectorDrawable加载
- 其它占用主线程的操作
我们可以根据这些组件的轻重缓急之分,对初始化做一下分类 :
- 必要的组件一定要在主线程中立即初始化(入口 Activity 可能立即会用到)
- 组件一定要在主线程中初始化,但是可以延迟初始化。
- 组件可以在子线程中初始化。
放在子线程的组件初始化建议延迟初始化,这样就可以了解是否会对项目造成影响!
所以对于上面的分析,可以在项目中 Application 的加载组件进行如下优化 :
- 将Bugly,x5内核初始化,SP的读写,友盟等组件放到子线程中初始化。(子线程初始化不能影响到组件的使用)
```java
new Thread(new Runnable() {
@Override
public void run() {
//设置线程的优先级,不与主线程抢资源
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
//子线程初始化第三方组件
Thread.sleep(5000);//建议延迟初始化,可以发现是否影响其它功能,或者是崩溃!
}
}).start();
- 将需要在主线程中初始化但是可以不用立即完成的动作延迟加载(原本是想在入口 Activity 中进行此项操作,不过组件的初始化放在 Application 中统一管理为妙.)
handler.postDelayed(new Runnable() {
@Override
public void run() {
//延迟初始化组件
}
}, 3000);
1.2.3 闪屏页业务优化
最后还剩下那些为数不多的组件在主线程初始化动作,例如埋点,点击流,数据库初始化等,不过这些消耗的时间可以在其它地方相抵。
需求背景: 应用App通常会设置一个固定的闪屏页展示时间,例如2000ms,所以我们可以根据用户手机的运行速度,对展示时间做出调整,但是总时间仍然为 2000ms。
闪屏页政展示总时间 = 组件初始化时间 + 剩余展示时间。
也就是2000ms的总时间,组件初始化了800ms,那么就再展示1200ms即可。
先了解一下 Application的启动过程 虽然这个以下图片的源码并不是最新源码(5.0源码),不过不影响整体流程。(7.0,8.0方法名会有所改变)。
冷启动的过程中系统会初始化应用程序进程,创建Application等任务,这时候会展示一个启动窗口 Starting Window,如果没有优化主题的话,那么就是白屏。
分析源码后,我们可以知道 Application 初始化后会调用attachBaseContext()
方法,再调用 Application 的onCreate()
,再到入口 Activity的创建和执行onCreate()
方法。所以我们就可以在 Application 中记录启动时间。
//Application
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
SPUtil.putLong("application_attach_time", System.currentTimeMillis());//记录Application初始化时间
}
有了启动时间,我们得知道入口的 Acitivty 显示给用户的时间(View绘制完毕),在onWindowFocusChanged()
的回调时机中表示可以获取用户的触摸时间和View的流程绘制完毕,所以可以在这个方法里记录显示时间。
//入口Activity
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
long appAttachTime = SPUtil.getLong("application_attach_time");
long diffTime = System.currentTimeMillis() - appAttachTime;//从application到入口Acitity的时间
//所以闪屏页展示的时间为 2000ms - diffTime.
}
所以就可以动态的设置应用闪屏的显示时间,尽量让每一部手机展示的时间一致,这样就不会让手机配置较低的用户感觉漫长难熬的闪屏页时间(例如初始化了2000ms,又要展示2000ms的闪屏页时间.),优化用户体验。
1.2.4 广告页优化
闪屏页过后就要展示金主爸爸们的广告页了。
因为项目中广告页图片有可能是大图,APng动态图片,所以需要将这些图片下载到本地文件,下载完成后再显示,这个过程往往会遇到以下两个问题 :
- 广告页的下载,由于这个是一个异步过程,所以往往不知道加载到页面的合适时机。
- 广告页的保存,因为保存是 I/O 流操作,很有可能被用户中断,下次拿到破损的图片。
因为不清楚用户的网络环境,有些用户下载广告页可能需要一段时间,这时候又不可能无限的等候。所以针对这个问题可以开启IntentService
用来下载广告页图片。
- 在入口 Acitivity 中开启IntentService来下载广告页。 或者是其它异步下载操作。
- 在广告页图片文件流完全写入后记录图片大小,或者记录一个标识。
在下次的广告页加载中可以判断是否已经下载好了广告页图片以及图片是否完整,否则删除并且再次下载图片。
另外因为在闪屏页中仍然有剩余展示时间,所以在这个时间段里如果用户已经下载好了图片并且图片完整,就可以显示广告页。否则进入主 Activity , 因为IntentService
仍然在后台继续默默的下载并保存图片~
1.1.3. 1.3 优化效果
优化前 : (小米6)
Displayed | LaunchActivity | MainActivity |
---|---|---|
+2s526ms | +1s583ms | |
+2s603ms | +1s533ms | |
+2s372ms | +1s556ms |
优化后 : (小米6)
Displayed | LaunchActivity | MainActivity |
---|---|---|
+995ms | +1s191ms | |
+911ms | +1s101ms | |
+903ms | +1s187ms |
通过手上 小米6,小米 mix2s,还有小米 2s的启动测试,发现优化后App冷启动的启动速度均提升了 60% !!! ,并且可以再看一下手机冷启动时候的内存情况 :
优化前 : 伴随着大量对象的创建回收,15s内系统GC 5次。内存使用波澜荡漾。
优化后 : 趋于平稳上升状态创建对象,15s内系统GC 2次。(后期业务拓展加入新功能,所以代码量增加。)之后总内存使用平缓下降。
- Other:应用使用的系统不确定如何分类的内存。
- Code:应用用于处理代码和资源(如 dex 字节码、已优化或已编译的 dex 码、.so 库和字体)的内存。
- Stack: 应用中的原生堆栈和 Java 堆栈使用的内存。 这通常与您的应用运行多少线程有关。
- Graphics:图形缓冲区队列向屏幕显示像素(包括 GL 表面、GL 纹理等等)所使用的内存。 (请注意,这是与 CPU 共享的内存,不是 GPU 专用内存。)
- Native:从 C 或 C++ 代码分配的对象内存。即使应用中不使用 C++,也可能会看到此处使用的一些原生内存,因为 Android 框架使用原生内存代表处理各种任务,如处理图像资源和其他图形时,即使编写的代码采用 Java 或 Kotlin 语言。
- Java:从 Java 或 Kotlin 代码分配的对象内存。
- Allocated:应用分配的 Java/Kotlin 对象数。 它没有计入 C 或 C++ 中分配的对象。
1.1.4. 1.4 启动窗口
优化完代码后,分析一下启动窗口的源码。基于 android-25 (7.1.1)
启动窗口是由 WindowManagerService
统一管理的 Window
窗口,一般作为冷启动页入口 Activity 的预览窗口,启动窗口由 ActivityManagerService
来决定是否显示的,并不是每一个 Activity 的启动和跳转都会显示这个窗口。
WindowManagerService
通过窗口管理策略类 PhoneWindowManager
来创建启动窗口。
AMS启动Activity流程
在 ActivityStarter
的 startActivityUnchecked()
方法中,调用了 ActivityStack
(Activity 状态管理)的 startActivityLocked()
方法。此时Activity 还在启动过程中,窗口并未显示。
启动窗口的显示过程
首先,由 Activity 状态管理者 ActivityStack
开始执行显示启动窗口的流程。
//ActivityStack
final void startActivityLocked(ActivityRecord r, boolean newTask, boolean keepCurTransition,
ActivityOptions options) {
if (!isHomeStack() || numActivities() > 0) {//HOME_STACK表示Launcher桌面所在的Stack
// 1.首先当前启动栈不在Launcher的桌面栈里,并且当前系统已经有激活过Activity
// We want to show the starting preview window if we are
// switching to a new task, or the next activity's process is
// not currently running.
boolean doShow = true;
if (newTask) {
// 2.要将该Activity组件放在一个新的任务栈中启动
// Even though this activity is starting fresh, we still need
// to reset it to make sure we apply affinities to move any
// existing activities from other tasks in to it.
if ((r.intent.getFlags() & Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) != 0) {
resetTaskIfNeededLocked(r, r);
doShow = topRunningNonDelayedActivityLocked(null) == r;
}
} else if (options != null && options.getAnimationType()
== ActivityOptions.ANIM_SCENE_TRANSITION) {
doShow = false;
}
if (r.mLaunchTaskBehind) {
//3. 热启动,不需要启动窗口
// Don't do a starting window for mLaunchTaskBehind. More importantly make sure we
// tell WindowManager that r is visible even though it is at the back of the stack.
mWindowManager.setAppVisibility(r.appToken, true);
ensureActivitiesVisibleLocked(null, 0, !PRESERVE_WINDOWS);
} else if (SHOW_APP_STARTING_PREVIEW && doShow) {
``````
//4. 显示启动窗口
r.showStartingWindow(prev, showStartingIcon);
}
} else {
// 当前启动的是桌面Launcher (开机启动)
// If this is the first activity, don't do any fancy animations,
// because there is nothing for it to animate on top of.
``````
}
}
- 首先判断当前要启动的 Activity 不在Launcher栈里
- 要启动的 Activity 是否处于新的 Task 里,并且没有转场动画
- 如果是热/温启动则不需要启动窗口,直接设置App的Visibility
接下来调用 ActivityRecord
的 showStartingWindow()
方法来设置启动窗口并且改变当前窗口的状态。
如果 App 的应用进程创建完成,并且入口 Activity 准备就绪,就可以根据 mStartingWindowState
来判断是否需要关闭启动窗口。
//ActivityRecord
void showStartingWindow(ActivityRecord prev, boolean createIfNeeded) {
final CompatibilityInfo compatInfo =
service.compatibilityInfoForPackageLocked(info.applicationInfo);
final boolean shown = service.mWindowManager.setAppStartingWindow(
appToken, packageName, theme, compatInfo, nonLocalizedLabel, labelRes, icon,
logo, windowFlags, prev != null ? prev.appToken : null, createIfNeeded);
if (shown) {
mStartingWindowState = STARTING_WINDOW_SHOWN;
}
}
WindowManagerService 会对当前 Activity 的token和主题进行判断。
//WindowManagerService
@Override
public boolean setAppStartingWindow(IBinder token, String pkg,
int theme, CompatibilityInfo compatInfo,
CharSequence nonLocalizedLabel, int labelRes, int icon, int logo,
int windowFlags, IBinder transferFrom, boolean createIfNeeded) {
synchronized(mWindowMap) {
//1. 启动窗口也是需要token的
AppWindowToken wtoken = findAppWindowToken(token);
//2. 如果已经设置过启动窗口了,不继续处理
if (wtoken.startingData != null) {
return false;
}
// If this is a translucent window, then don't
// show a starting window -- the current effect (a full-screen
// opaque starting window that fades away to the real contents
// when it is ready) does not work for this.
if (theme != 0) {
AttributeCache.Entry ent = AttributeCache.instance().get(pkg, theme,
com.android.internal.R.styleable.Window, mCurrentUserId);
//3. 一堆代码对主题判断,不符合要求则不显示启动窗口(如透明主题)
if (windowIsTranslucent) {
return false;
}
if (windowIsFloating || windowDisableStarting) {
return false;
}
}
//4. 创建StartingData,并且通过Handler发送消息
wtoken.startingData = new StartingData(pkg, theme, compatInfo, nonLocalizedLabel,
labelRes, icon, logo, windowFlags);
Message m = mH.obtainMessage(H.ADD_STARTING, wtoken);
// Note: we really want to do sendMessageAtFrontOfQueue() because we
// want to process the message ASAP, before any other queued
// messages.
mH.sendMessageAtFrontOfQueue(m);
}
return true;
}
```
- 启动窗口也需要和 Activity 拥有同样令牌 token ,虽然启动窗口可能是白屏,或者一张图片,但是仍然需要走绘制流程已经通过WMS显示窗口。
- StartingData对象用来表示启动窗口的相关数据,描述了启动窗口的视图信息。
- 如果当前 Activity 是透明主题或者是浮动窗口等,那么就不需要启动窗口来过渡启动过程,所以在上面视觉优化中的设置透明主题就没有显示白色的启动窗口。
- 显示启动窗口也是一件心急火燎的事情,WMS的内部类H (handler) 处于主线程处理消息,所以需要将当前Message放置队列头部。
为什么需要通过 Handler 发送消息 ?
你可以在各大服务Service中见到 Handler 的身影,并且它们可能都有一个很吊的命名 H
,因为可能调用这个服务的某个执行方法处于子线程中,所以 Handler 的职责就是将它们切换到主线程中,并且也可以统一管理调度。
//WindowManagerService --> H
public void handleMessage(Message msg) {
switch (msg.what) {
case ADD_STARTING: {
final AppWindowToken wtoken = (AppWindowToken)msg.obj;
final StartingData sd = wtoken.startingData;
View view = null;
try {
final Configuration overrideConfig = wtoken != null && wtoken.mTask != null
? wtoken.mTask.mOverrideConfig : null;
view = mPolicy.addStartingWindow(wtoken.token, sd.pkg, sd.theme,
sd.compatInfo, sd.nonLocalizedLabel, sd.labelRes, sd.icon, sd.logo,
sd.windowFlags, overrideConfig);
} catch (Exception e) {
Slog.w(TAG_WM, "Exception when adding starting window", e);
}
} break;
}
```
在当前的 handleMessage
方法中,会处于主线程处理消息,拿到token和StartingData启动数据后,便通过 mPolicy.addStartingWindow()
方法将启动窗口添加到WIndow上。
mPolicy
为 PhoneWindowManager
,控制着启动窗口的添加删除和修改。
在PhoneWindowManager对启动窗口进行配置,获取当前Activity设置的主题和资源信息,设置到启动窗口中。
//PhoneWindowManager
@Override
public View addStartingWindow(IBinder appToken, String packageName, int theme,
CompatibilityInfo compatInfo, CharSequence nonLocalizedLabel, int labelRes,
int icon, int logo, int windowFlags, Configuration overrideConfig) {
//可以通过SHOW_STARTING_ANIMATIONS设置不显示启动窗口
if (!SHOW_STARTING_ANIMATIONS) {
return null;
}
WindowManager wm = null;
View view = null;
try {
//1. 获取上下文Context和主题theme以及标题
Context context = mContext;
if (theme != context.getThemeResId() || labelRes != 0) {
try {
context = context.createPackageContext(packageName, 0);
context.setTheme(theme);
} catch (PackageManager.NameNotFoundException e) {
// Ignore
}
}
//2. 创建PhoneWindow 用来显示
final PhoneWindow win = new PhoneWindow(context);
win.setIsStartingWindow(true);
//3. 设置当前窗口type和flag,源码注释中描述的很清晰...
win.setType(
WindowManager.LayoutParams.TYPE_APPLICATION_STARTING);
// Force the window flags: this is a fake window, so it is not really
// touchable or focusable by the user. We also add in the ALT_FOCUSABLE_IM
// flag because we do know that the next window will take input
// focus, so we want to get the IME window up on top of us right away.
win.setFlags(
windowFlags|
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE|
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
windowFlags|
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE|
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
win.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
view = win.getDecorView();
//4. WindowManager的绘制流程
wm.addView(view, params);
return view.getParent() != null ? view : null;
} catch (WindowManager.BadTokenException e) {
// ignore
} catch (RuntimeException e) {
// don't crash if something else bad happens, for example a
// failure loading resources because we are loading from an app
// on external storage that has been unmounted.
Log.w(TAG, appToken + " failed creating starting window", e);
}
return null;
}
```
- 如果theme和labelRes的值不为0,那么说明开发者指定了启动窗口的主题和标题,那么就需要从当前要启动的Activity中获取这些信息,并设置到启动窗口中。
- 和其它窗口一样,启动窗口也需要通过PhoneWindow来设置布局信息DecorView。所以在上面视觉优化中的设置闪屏图片主题的启动窗口显示的就是图片内容。
- 启动窗口和普通窗口的不同之处在于它是 fake window ,不需要触摸事件
- 最后通过WindowManger走View的绘制流程(measure-layout-draw)将启动窗口显示出来,最后会请求WindowManagerService为启动窗口添加一个WindowState对象,真正的将启动窗口显示给用户,并且可以对启动窗口进行管理。
1.2. 二、热启动
因为会从已有的应用进程启动,所以不会再创建和初始化Application,只会重新创建并初始化Activity。
特点:耗时较少
启动流程:LifeCycle -> ViewRootImpl
1.3. 三、启动耗时检测
1.3.1. 3.1 查看Logcat
在Android Studio Logcat中过滤关键字“Displayed”,可以看到对应的冷启动耗时日志。
1.3.2. 3.2 adb shell
使用adb shell获取应用的启动时间
// 其中的AppstartActivity全路径可以省略前面的packageName
adb shell am start -W [packageName]/[AppstartActivity全路径]
执行后会得到三个时间:ThisTime、TotalTime和WaitTime,详情如下:
- ThisTime:最后一个Activity启动耗时。
- TotalTime:所有Activity启动耗时。
- WaitTime:AMS启动Activity的总耗时。
一般查看得到的TotalTime,即应用的启动时间,包括创建进程 + Application初始化 + Activity初始化到界面显示的过程。
特点
- 线下使用方便,不能带到线上。
- 非严谨、精确时间。
1.3.3. 3.3 代码打点(函数插桩)
可以写一个统计耗时的工具类来记录整个过程的耗时情况。
其中需要注意的有:
- 在上传数据到服务器时建议根据用户ID的尾号来抽样上报。
- 在项目中核心基类的关键回调函数和核心方法中加入打点。
代码如下:
/**
* 耗时监视器对象,记录整个过程的耗时情况,可以用在很多需要统计的地方,比如Activity的启动耗时和Fragment的启动耗时。
*/
public class TimeMonitor {
private final String TAG = TimeMonitor.class.getSimpleName();
private int mMonitord = -1;
// 保存一个耗时统计模块的各种耗时,tag对应某一个阶段的时间
private HashMap<String, Long> mTimeTag = new HashMap<>();
private long mStartTime = 0;
public TimeMonitor(int mMonitorId) {
Log.d(TAG, "init TimeMonitor id: " + mMonitorId);
this.mMonitorId = mMonitorId;
}
public int getMonitorId() {
return mMonitorId;
}
public void startMonitor() {
// 每次重新启动都把前面的数据清除,避免统计错误的数据
if (mTimeTag.size() > 0) {
mTimeTag.clear();
}
mStartTime = System.currentTimeMillis();
}
/**
* 每打一次点,记录某个tag的耗时
*/
public void recordingTimeTag(String tag) {
// 若保存过相同的tag,先清除
if (mTimeTag.get(tag) != null) {
mTimeTag.remove(tag);
}
long time = System.currentTimeMillis() - mStartTime;
Log.d(TAG, tag + ": " + time);
mTimeTag.put(tag, time);
}
public void end(String tag, boolean writeLog) {
recordingTimeTag(tag);
end(writeLog);
}
public void end(boolean writeLog) {
if (writeLog) {
//写入到本地文件
}
}
public HashMap<String, Long> getTimeTags() {
return mTimeTag;
}
}
为了使代码更好管理,定义一个打点配置类:
/**
* 打点配置类,用于统计各阶段的耗时,便于代码的维护和管理。
*/
public final class TimeMonitorConfig {
// 应用启动耗时
public static final int TIME_MONITOR_ID_APPLICATION_START = 1;
}
因为,耗时统计可能会在多个模块和类中需要打点,所以需要一个单例类来管理各个耗时统计的数据:
/**
* 采用单例管理各个耗时统计的数据。
*/
public class TimeMonitorManager {
private static TimeMonitorManager mTimeMonitorManager = null;
private HashMap<Integer, TimeMonitor> mTimeMonitorMap = null;
public synchronized static TimeMonitorManager getInstance() {
if (mTimeMonitorManager == null) {
mTimeMonitorManager = new TimeMonitorManager();
}
return mTimeMonitorManager;
}
public TimeMonitorManager() {
this.mTimeMonitorMap = new HashMap<Integer, TimeMonitor>();
}
/**
* 初始化打点模块
*/
public void resetTimeMonitor(int id) {
if (mTimeMonitorMap.get(id) != null) {
mTimeMonitorMap.remove(id);
}
getTimeMonitor(id);
}
/**
* 获取打点器
*/
public TimeMonitor getTimeMonitor(int id) {
TimeMonitor monitor = mTimeMonitorMap.get(id);
if (monitor == null) {
monitor = new TimeMonitor(id);
mTimeMonitorMap.put(id, monitor);
}
return monitor;
}
}
主要在以下几个方面需要打点:
- 应用程序的生命周期节点。
- 启动时需要初始化的重要方法,如数据库初始化,读取本地的一些数据。
- 其他耗时的一些算法。
例如,启动时在Application和第一个Activity加入打点统计:
Application
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
TimeMonitorManager.getInstance().resetTimeMonitor(TimeMonitorConfig.TIME_MONITOR_ID_APPLICATION_START);
}
@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
TimeMonitorManager.getInstance().getTimeMonitor(TimeMonitorConfig.TIME_MONITOR_ID_APPLICATION_START).recordingTimeTag("Application-onCreate");
}
第一个Activity:
@Override
protected void onCreate(Bundle savedInstanceState) {
TimeMonitorManager.getInstance().getTimeMonitor(TimeMonitorConfig.TIME_MONITOR_ID_APPLICATION_START).recordingTimeTag("SplashActivity-onCreate");
super.onCreate(savedInstanceState);
initData();
TimeMonitorManager.getInstance().getTimeMonitor(TimeMonitorConfig.TIME_MONITOR_ID_APPLICATION_START).recordingTimeTag("SplashActivity-onCreate-Over");
}
@Override
protected void onStart() {
super.onStart();
TimeMonitorManager.getInstance().getTimeMonitor(TimeMonitorConfig.TIME_MONITOR_ID_APPLICATION_START).end("SplashActivity-onStart", false);
}
特点:精确,可带到线上,推荐使用。
注意:
- 在上传数据到服务器时建议根据用户ID的尾号来抽样上报。
- onWindowFocusChanged只是首帧时间,App启动完成的结束点应该是真实数据展示出来的时候,如列表第一条数据展示,记得使用getViewTreeObserver().addOnPreDrawListener(),它会把任务延迟到列表显示后再执行。
AOP(Aspect Oriented Programming)打点
面向切面编程,通过预编译和运行期动态代理实现程序功能统一维护的一种技术。
作用:利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合性降低,提高程序的可重用性,同时大大提高了开发效率。
AOP核心概念
1、横切关注点:对哪些方法进行拦截,拦截后怎么处理。
2、切面(Aspect):类是对物体特征的抽象,切面就是对横切关注点的抽象。
3、连接点(JoinPoint):被拦截到的点(方法、字段、构造器)。
4、切入点(PointCut):对JoinPoint进行拦截的定义。
5、通知(Advice):拦截到JoinPoint后要执行的代码,分为前置、后置、环绕三种类型。
准备:
首先,为了在Android使用AOP埋点需要引入AspectJ,在项目根目录的build.gradle下加入:
classpath 'com.hujiang.aspectjx:gradle-android-plugin- aspectjx:2.0.0'
然后,在app目录下的build.gradle下加入:
apply plugin: 'android-aspectjx'
implement 'org.aspectj:aspectjrt:1.8.+'
AOP埋点实战
JoinPoint一般定位在如下位置
- 函数调用
- 获取、设置变量
- 类初始化
使用PointCut对我们指定的连接点进行拦截,通过Advice,就可以拦截到JoinPoint后要执行的代码。Advice通常有以下几种类型:
- Before:PointCut之前执行
- After:PointCut之后执行
- Around:PointCut之前、之后分别执行
首先,我们举一个小栗子:
@Before("execution(* android.app.Activity.on**(..))")
public void onActivityCalled(JoinPoint joinPoint) throws Throwable {
...
}
在execution中的是一个匹配规则,第一个*代表匹配任意的方法返回值,后面的语法代码匹配所有Activity中on开头的方法。
处理Join Point的类型:
- call:插入在函数体里面
- execution:插入在函数体外面
如何统计Application中的所有方法耗时?
@Aspect
public class ApplicationAop {
@Around("call (* com.json.chao.application.BaseApplication.**(..))")
public void getTime(ProceedingJoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
String name = signature.toShortString();
long time = System.currentTimeMillis();
try {
joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
Log.i(TAG, name + " cost" + (System.currentTimeMillis() - time));
}
}
注意:
当Action为Before、After时,方法入参为JoinPoint。 当Action为Around时,方法入参为ProceedingPoint。
Around和Before、After的最大区别:
ProceedingPoint不同于JoinPoint,其提供了proceed方法执行目标方法。
总结AOP特性:
- 无侵入性
- 修改方便
1.4. 四、启动速度分析工具 — TraceView
1.4.1. 4.1 使用方式:
1、代码中添加:Debug.startMethodTracing()、检测方法、Debug.stopMethodTracing()。(需要使用adb pull将生成的**.trace文件导出到电脑,然后使用Android Studio的Profiler加载)
2、打开Profiler -> CPU -> 点击 Record -> 点击 Stop -> 查看Profiler下方Top Down/Bottom Up 区域找出耗时的热点方法。
1.4.2. 4.2 Profile CPU
4.2.1 Trace types
Trace Java Methods:会记录每个方法的时间、CPU信息。对运行时性能影响较大。
Sample Java Methods:相比于Trace Java Methods会记录每个方法的时间、CPU信息,它会在应用的Java代码执行期间频繁捕获应用的调用堆栈,对运行时性能的影响比较小,能够记录更大的数据区域。
Sample C/C++ Functions:需部署到Android 8.0及以上设备,内部使用simpleperf跟踪应用的native代码,也可以命令行使用simpleperf。
Trace System Calls
- 检查应用与系统资源的交互情况。
- 查看所有核心的CPU瓶。
- 内部采用systrace,也可以使用systrace命令。
4.2.2 Event timeline
显示应用程序中在其生命周期中转换不同状态的活动,如用户交互、屏幕旋转事件等。
4.2.3 CPU timeline
显示应用程序实时CPU使用率、其它进程实时CPU使用率、应用程序使用的线程总数。
4.2.4 Thread activity timeline
列出应用程序进程中的每个线程,并使用了不同的颜色在其时间轴上指示其活动。
- 绿色:线程处于活动状态或准备好使用CPU。
- 黄色:线程正等待IO操作。(重要)
- 灰色:线程正在睡眠,不消耗CPU时间。
1.4.3. 4.3 Profile提供的检查跟踪数据窗口有四种
4.3.1 Call Chart
提供函数跟踪数据的图形表示形式。
- 水平轴:表示调用的时间段和时间。
- 垂直轴:显示被调用方。
- 橙色:系统API。
- 绿色:应用自有方法
- 蓝色:第三方API(包括Java API)
提示:右键点击Jump to source跳转至指定函数。
4.3.2 Flame Chart
将具有相同调用方顺序的完全相同的方法收集起来。
- 水平轴:执行每个方法的相对时间量。
- 垂直轴:显示被调用方。
注意:看顶层的哪个函数占据的宽度最大(平顶),可能存在性能问题。
4.3.3 Top Down
- 递归调用列表,提供self、children、total时间和比率来表示被调用的函数信息。
- Flame Chart是Top Down列表数据的图形化。
4.3.4 Bottom Up
- 展开函数会显示其调用方。
- 按照消耗CPU时间由多到少的顺序对函数排序。
注意点:
- Wall Clock Time:程序执行时间。
- Thread Time:CPU执行的时间。
1.4.4. 4.4 TraceView小结
4.4.1 特点
1、图形的形式展示执行时间、调用栈等。
2、信息全面,包含所有线程。
3、运行时开销严重,整体都会变慢,得出的结果并不真实。
4.4.2 作用
主要做热点分析,得到两种数据:
- 单次执行最耗时的方法。
- 执行次数最多的方法。
1.5. 五、启动速度分析工具 — Systrace
1.5.1. 5.1 使用方式:
代码插桩
定义Trace静态工厂类,将Trace.begainSection(),Trace.endSection()封装成i、o方法,然后再在想要分析的方法前后进行插桩即可。
在命令行下执行systrace.py脚本:
python /Users/quchao/Library/Android/sdk/platform-tools/systrace/systrace.py -t 20 sched gfx view wm am app webview -a "com.wanandroid.json.chao" -o ~/Documents/open-project/systrace_data/wanandroid_start_1.html
具体参数含义如下:
- -t:指定统计时间为20s。
- shced:cpu调度信息。
- gfx:图形信息。
- view:视图。
- wm:窗口管理。
- am:活动管理。
- app:应用信息。
- webview:webview信息。
- -a:指定目标应用程序的包名。
- -o:生成的systrace.html文件。
如何查看数据?
在UIThread一栏可以看到核心的系统方法时间区域和我们自己使用代码插桩捕获的方法时间区域。
1.5.2. 5.2 Systrace原理
- 在系统的一些关键链路(如SystemServcie、虚拟机、Binder驱动)插入一些信息(Label);
- 通过Label的开始和结束来确定某个核心过程的执行时间; 把这些Label信息收集起来得到系统关键路径的运行时间信息,最后得到整个系统的运行性能信息;
- Android Framework里面一些重要的模块都插入了label信息,用户App中可以添加自定义的Lable。
1.5.3. 5.3 Systrace小结
5.3.1 特性
- 结合Android内核的数据,生成Html报告。
- 系统版本越高,Android Framework中添加的系统可用Label就越多,能够支持和分析的系统模块也就越多。
- 必须手动缩小范围,会帮助你加速收敛问题的分析过程,进而快速地定位和解决问题。
5.3.2 作用
- 主要用于分析绘制性能方面的问题。
- 分析系统关键方法和应用方法耗时。
1.6. 六、启动监控
1.6.1. 6.1 实验室监控:视频录制
- 80%绘制
- 图像识别
注意:覆盖高中低端机型不同的场景。
1.6.2. 6.2 线上监控:需要准确地统计启动耗时。
启动结束的统计时机:是否是使用界面显示且用户真正可以操作的时间作为启动结束时间。
启动时间扣除逻辑:闪屏、广告和新手引导这些时间都应该从启动时间里扣除。
启动排除逻辑:Broadcast、Server拉起,启动过程进入后台都需要排除统计。
使用什么指标来衡量启动速度的快慢?平均启动时间的问题,一些体验很差的用户很可能被平均了。
建议的指标
- 快开慢开比:如2s快开比,5s慢开比,可以看到有多少比例的用户体验好,多少比例的用户比较糟糕。
- 90%用户的启动时间:如果90%用户的启动时间都小于5s,那么90%区间的启动耗时就是5s。
启动的类型有哪几种?
- 首次安装启动
- 覆盖安装启动
- 冷启动(指标)
- 热启动(反映程序的活跃或保活能力)
借鉴Facebook的profilo工具原理,对启动整个流程的耗时监控,在后台对不同的版本做自动化对比,监控新版本是否有新增耗时的函数。
1.7. 七、启动优化常规方案
1.7.1. 7.1 启动过程中的常见问题
- 点击图标很久都不响应:预览窗口被禁用或设置为透明。
- 首页显示太慢:初始化任务太多。
- 首页显示后无法进行操作:太多延迟初始化任务占用主线程CPU时间片。
1.7.2. 7.2 优化区域
Application、Activity创建以及回调等过程。
常规方案,省略了一些常规方案细节,感兴趣可以查看原文。
1、主题切换
2、第三方库懒加载
3、异步初始化
4、延迟初始化
5、Multidex预加载优化
6、预加载SharedPreferences
7、类预加载优化:在Application中提前异步加载初始化耗时较长的类。
如何找到耗时较长的类:替换系统的ClassLoader,打印类加载的时间,按需选取需要异步加载的类。
注意:
- Class.forName()只加载类本身及其静态变量的引用类。
- new 类实例 可以额外加载类成员变量的引用类。
8、WebView启动优化
- WebView首次创建比较耗时,需要预先创建WebView提前将其内核初始化。
- 使用WebView缓存池,用到WebView的时候都从缓存池中拿,注意内存泄漏问题。
- 本地离线包,即预置静态页面资源。
9、页面数据预加载:在主页空闲时,将其它页面的数据加载好保存到内存或数据库,等到打开该页面时,判断已经预加载过,就直接从内存或数据库取数据并显示。
10、启动阶段不启动子进程:子进程会共享CPU资源,导致主进程CPU紧张。此外,在多进程情况下一定要可以在onCreate中去区分进程做一些初始化工作。
注意启动顺序:App onCreate之前是ContentProvider初始化。
11、闪屏页与主页的绘制优化
- 布局优化。
- 过渡绘制优化。
关于绘制优化可以参考Android性能优化之绘制优化。
1.8. 八、启动优化黑科技
1.8.1. 8.1 启动阶段抑制GC
启动时CG抑制,允许堆一直增长,直到手动或OOM停止GC抑制。(空间换时间)
前提条件
- 设备厂商没有加密内存中的Dalvik库文件。
- 设备厂商没有改动Google的Dalvik源码。
实现原理
- 在源码级别找到抑制GC的修改方法,例如改变跳转分支。
- 在二进制代码里找到 A 分支条件跳转的”指令指纹”,以及用于改变分支的二进制代码,假设为 override_A。
- 应用启动后扫描内存中的 libdvm.so,根据”指令指纹”定位到修改位置,然后用 override_A 覆盖。
缺点:白名单覆盖所有设备,但维护成本高。
1.8.2. 8.2 CPU锁频
在Android系统中,CPU相关的信息存储在/sys/devices/system/cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改。
缺点:暴力拉伸CPU频率,导致耗电量增加。
1.8.3. 8.3 数据重排
Dex文件用到的类和APK里面各种资源文件都比较小,读取频繁,且磁盘地址分布范围比较广。我们可以利用Linux文件IO流程中的page cache机制将它们按照读取顺序重新排列在一起,以减少真实的磁盘IO次数。
类重排:使用Facebook的ReDex的Interdex调整类在Dex中的排列顺序。
资源文件重排
- 最佳方案是修改内核源码,实现统计、度量、自动化。
- 其次可以使用Hook框架进行统计得出资源加载顺序列表。
- 最后,调整apk文件列表需要修改7zip源码以支持传入文件列表顺序。
技术视野:
- 所谓的创新,不一定是要创造前所未有的东西,也可以将已有的方案移植到新的平台,并结合该平台的特性落地,就是一个很大的创新。
- 当我们足够熟悉底层的知识时,可以利用系统的特性去做更加深层次的优化。
1.8.4. 8.4 类加载优化(Dalvik)
类预加载原理
对象第一次创建的时候,JVM首先检查对应的Class对象是否已经加载。如果没有加载,JVM会根据类名查找.class文件,将其Class对象载入。同一个类第二次new的时候就不需要加载类对象,而是直接实例化,创建时间就缩短了。
类加载优化过程
- 在Dalvik VM加载类的时候会有一个类校验过程,它需要校验方法的每一个指令。
- 通过Hook去掉verify步骤 -> 几十ms的优化
- 最大优化场景在于首次安装和覆盖安装时,在Dalvik平台上,一个2MB的Dex正常需要350ms,将classVerifyMode设为VERIFY_MODE_NONE后,只需150ms,节省超过50%时间。
ART比较复杂,Hook需要兼容几个版本。而且在安装时,大部分Dex已经优化好了,去掉ART平台的verify只会对动态加载的Dex带来一些好处。所以暂时不建议在ART平台使用。
1.9. 九、总结
1.9.1. 9.1 优化总方针
- 异步、延迟、懒加载
- 技术、业务相结合
1.9.2. 9.2 注意事项
cpu time和wall time
- wall time(代码执行时间)与cpu time(代码消耗CPU时间),锁冲突会造成两者时间差距过大。
- cpu time才是优化方向,应尽力按照systrace的cpu time和wall time跑满cpu。
监控的完善
- 线上监控多阶段时间(App、Activity、生命周期间隔时间)。
- 处理聚合看趋势。
- 收敛启动代码修改权限。
- 结合CI修改启动代码需要Review通知。
1.9.3. 9.3 常见问题
启动优化是怎么做的?
- 分析现状、确认问题
- 针对性优化(先概括,引导其深入)
- 长期保持优化效果
是怎么异步的,异步遇到问题没有?
- 体现演进过程
- 详细介绍启动器
启动优化有哪些容易忽略的注意点?
- cpu time与wall time
- 注意延迟初始化的优化
- 介绍下黑科技
版本迭代导致的启动变慢有好的解决方式吗?
- 启动器
- 结合CI
- 监控完善