深入探索Android启动速度的奥秘~(几乎包含市面上所有启动优
一、启动优化的意义
如果我们去一家餐厅吃饭,在点餐的时候等了半天都没有服务人员过来,可能就没有耐心等待直接走了。
对于App来说,也是同样如此,如果用户点击App后,App半天都打不开,用户就可能失去耐心卸载应用。
启动速度是用户对我们App的第一体验,打开应用后才能去使用其中提供的强大功能,就算我们应用的内部界面设计的再精美,功能再强大,如果启动速度过慢,用户第一印象就会很差。
因此,拯救App的启动速度,迫在眉睫。
下面,我们来逐步深入探索提升Android App启动速度的奥秘。
二、应用启动流程
1 、应用启动的类型
冷启动
从点击应用图标到UI界面完全显示且用户可操作的全部过程。
特点
耗时最多,衡量标准
启动流程
Click Event -> IPC -> Process.start -> ActivityThread -> bindApplication -> LifeCycle -> ViewRootImpl
热启动
因为会从已有的应用进程启动,所以不会再创建和初始化Application,只会重新创建并初始化Activity。
特点
耗时较少
启动流程
LifeCycle -> ViewRootImpl
ViewRootImpl
ViewRoot是GUI管理系统与GUI呈现系统之间的桥梁。每一个ViewRootImpl关联一个Window,
ViewRootImpl最终会通过它的setView方法绑定Window所对应的View,并通过其performTraversals方法对View进行布局、测量和绘制。
三、启动耗时检测
1、查看Logcat
在Android Studio Logcat中过滤关键字“Displayed”,可以看到对应的冷启动耗时日志。
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、线下使用方便,不能带到线上。
2、非严谨、精确时间。
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一般定位在如下位置
1、函数调用
2、获取、设置变量
3、类初始化
使用PointCut对我们指定的连接点进行拦截,通过Advice,就可以拦截到JoinPoint后要执行的代码。Advice通常有以下几种类型:
1、Before:PointCut之前执行
2、After:PointCut之后执行
3、Around:PointCut之前、之后分别执行
首先,我们举一个小栗子:
@Before("execution(* android.app.Activity.on**(..))")
public void onActivityCalled(JoinPoint joinPoint) throws Throwable {
...
}
在execution中的是一个匹配规则,第一个*代表匹配任意的方法返回值,后面的语法代码匹配所有Activity中on开头的方法。
处理Join Point的类型:
1、call:插入在函数体里面
2、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、无侵入性
2、修改方便
4、启动速度分析工具 — TraceView
使用方式
1、代码中添加:Debug.startMethodTracing()、检测方法、Debug.stopMethodTracing()。(需要使用adb pull将生成的**.trace文件导出到电脑,然后使用Android Studio的Profiler加载)
2、打开Profiler -> CPU -> 点击 Record -> 点击 Stop -> 查看Profiler下方Top Down/Bottom Up 区域找出耗时的热点方法。
Profile CPU
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命令。
2、Event timeline
显示应用程序中在其生命周期中转换不同状态的活动,如用户交互、屏幕旋转事件等。
3、CPU timeline
显示应用程序实时CPU使用率、其它进程实时CPU使用率、应用程序使用的线程总数。
4、Thread activity timeline
列出应用程序进程中的每个线程,并使用了不同的颜色在其时间轴上指示其活动。
绿色:线程处于活动状态或准备好使用CPU。
黄色:线程正等待IO操作。(重要)
灰色:线程正在睡眠,不消耗CPU时间。
Profile提供的检查跟踪数据窗口有四种
1、Call Chart
提供函数跟踪数据的图形表示形式。
- 水平轴:表示调用的时间段和时间。
- 垂直轴:显示被调用方。
- 橙色:系统API。
- 绿色:应用自有方法
- 蓝色:第三方API(包括Java API)
提示:右键点击Jump to source跳转至指定函数。
2、Flame Chart
将具有相同调用方顺序的完全相同的方法收集起来。
- 水平轴:执行每个方法的相对时间量。
- 垂直轴:显示被调用方。
注意:看顶层的哪个函数占据的宽度最大(平顶),可能存在性能问题。
3、Top Down
- 递归调用列表,提供self、children、total时间和比率来表示被调用的函数信息。
- Flame Chart是Top Down列表数据的图形化。
4、Bottom Up
- 展开函数会显示其调用方。
- 按照消耗CPU时间由多到少的顺序对函数排序。
注意点:
- Wall Clock Time:程序执行时间。
- Thread Time:CPU执行的时间。
TraceView小结
特点
1、图形的形式展示执行时间、调用栈等。
2、信息全面,包含所有线程。
3、运行时开销严重,整体都会变慢,得出的结果并不真实。
作用
主要做热点分析,得到两种数据:
单次执行最耗时的方法。
执行次数最多的方法。
5、启动速度分析工具 — Systrace
使用方式:
代码插桩
定义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一栏可以看到核心的系统方法时间区域和我们自己使用代码插桩捕获的方法时间区域。
Systrace原理
- 在系统的一些关键链路(如SystemServcie、虚拟机、Binder驱动)插入一些信息(Label);
- 通过Label的开始和结束来确定某个核心过程的执行时间;
把这些Label信息收集起来得到系统关键路径的运行时间信息,最后得到整个系统的运行性能信息; - Android Framework里面一些重要的模块都插入了label信息,用户App中可以添加自定义的Lable。
Systrace小结
特性
- 结合Android内核的数据,生成Html报告。
- 系统版本越高,Android Framework中添加的系统可用Label就越多,能够支持和分析的系统模块也就越多。
- 必须手动缩小范围,会帮助你加速收敛问题的分析过程,进而快速地定位和解决问题。
作用
- 主要用于分析绘制性能方面的问题。
- 分析系统关键方法和应用方法耗时。
6、启动监控
1、实验室监控:视频录制
- 80%绘制
- 图像识别
注意
覆盖高中低端机型不同的场景。
2、线上监控
需要准确地统计启动耗时。
1、启动结束的统计时机
是否是使用界面显示且用户真正可以操作的时间作为启动结束时间。
2、启动时间扣除逻辑
闪屏、广告和新手引导这些时间都应该从启动时间里扣除。
3、启动排除逻辑
Broadcast、Server拉起,启动过程进入后台都需要排除统计。
4、使用什么指标来衡量启动速度的快慢?
平均启动时间的问题
一些体验很差的用户很可能被平均了。
建议的指标
1、快开慢开比
如2s快开比,5s慢开比,可以看到有多少比例的用户体验好,多少比例的用户比较糟糕。
2、90%用户的启动时间
如果90%用户的启动时间都小于5s,那么90%区间的启动耗时就是5s。
5、启动的类型有哪几种?
- 首次安装启动
- 覆盖安装启动
- 冷启动(指标)
- 热启动(反映程序的活跃或保活能力)
借鉴Facebook的profilo工具原理,对启动整个流程的耗时监控,在后台对不同的版本做自动化对比,监控新版本是否有新增耗时的函数。
四、启动优化常规方案
启动过程中的常见问题
- 点击图标很久都不响应:预览窗口被禁用或设置为透明。
- 首页显示太慢:初始化任务太多。
*首页显示后无法进行操作:太多延迟初始化任务占用主线程CPU时间片。
优化区域
Application、Activity创建以及回调等过程。
1、主题切换
2、第三方库懒加载
3、异步初始化
4、延迟初始化
5、Multidex预加载优化
6、预加载SharedPreferences
7、类预加载优化
在Application中提前异步加载初始化耗时较长的类。
如何找到耗时较长的类?
替换系统的ClassLoader,打印类加载的时间,按需选取需要异步加载的类。
注意:
- Class.forName()只加载类本身及其静态变量的引用类。
- new 类实例 可以额外加载类成员变量的引用类。
8、WebView启动优化
1、WebView首次创建比较耗时,需要预先创建WebView提前将其内核初始化。
2、使用WebView缓存池,用到WebView的时候都从缓存池中拿,注意内存泄漏问题。
3、本地离线包,即预置静态页面资源。
9、页面数据预加载
在主页空闲时,将其它页面的数据加载好保存到内存或数据库,等到打开该页面时,判断已经预加载过,就直接从内存或数据库取数据并显示。
10、启动阶段不启动子进程
子进程会共享CPU资源,导致主进程CPU紧张。此外,在多进程情况下一定要可以在onCreate中去区分进程做一些初始化工作。
注意启动顺序:
App onCreate之前是ContentProvider初始化。
11、闪屏页与主页的绘制优化
1、布局优化。
2、过渡绘制优化。
关于绘制优化可以参考Android性能优化之绘制优化。
五、启动优化黑科技
1、启动阶段抑制GC
启动时CG抑制,允许堆一直增长,直到手动或OOM停止GC抑制。(空间换时间)
前提条件
1、设备厂商没有加密内存中的Dalvik库文件。
2、设备厂商没有改动Google的Dalvik源码。
实现原理
在源码级别找到抑制GC的修改方法,例如改变跳转分支。
在二进制代码里找到 A 分支条件跳转的”指令指纹”,以及用于改变分支的二进制代码,假设为 override_A。
应用启动后扫描内存中的 libdvm.so,根据”指令指纹”定位到修改位置,然后用 override_A 覆盖。
缺点
白名单覆盖所有设备,但维护成本高。
2、CPU锁频
在Android系统中,CPU相关的信息存储在/sys/devices/system/cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改。
缺点
暴力拉伸CPU频率,导致耗电量增加。
3、数据重排
Dex文件用到的类和APK里面各种资源文件都比较小,读取频繁,且磁盘地址分布范围比较广。我们可以利用Linux文件IO流程中的page cache机制将它们按照读取顺序重新排列在一起,以减少真实的磁盘IO次数。
1、类重排
使用Facebook的ReDex的Interdex调整类在Dex中的排列顺序。
2、资源文件重排
最佳方案是修改内核源码,实现统计、度量、自动化。
其次可以使用Hook框架进行统计得出资源加载顺序列表。
最后,调整apk文件列表需要修改7zip源码以支持传入文件列表顺序。
技术视野:
所谓的创新,不一定是要创造前所未有的东西,也可以将已有的方案移植到新的平台,并结合该平台的特性落地,就是一个很大的创新。
当我们足够熟悉底层的知识时,可以利用系统的特性去做更加深层次的优化。
4、类加载优化(Dalvik)
1、类预加载原理
对象第一次创建的时候,JVM首先检查对应的Class对象是否已经加载。如果没有加载,JVM会根据类名查找.class文件,将其Class对象载入。同一个类第二次new的时候就不需要加载类对象,而是直接实例化,创建时间就缩短了。
2、类加载优化过程
在Dalvik VM加载类的时候会有一个类校验过程,它需要校验方法的每一个指令。
通过Hook去掉verify步骤 -> 几十ms的优化
最大优化场景在于首次安装和覆盖安装时,在Dalvik平台上,一个2MB的Dex正常需要350ms,将classVerifyMode设为VERIFY_MODE_NONE后,只需150ms,节省超过50%时间。
ART比较复杂,Hook需要兼容几个版本。而且在安装时,大部分Dex已经优化好了,去掉ART平台的verify只会对动态加载的Dex带来一些好处。所以暂时不建议在ART平台使用。
六、总结
1、优化总方针
- 异步、延迟、懒加载
- 技术、业务相结合
2、注意事项
1、cpu time和wall time
- wall time(代码执行时间)与cpu time(代码消耗CPU时间),锁冲突会造成两者时间差距过大。
- cpu time才是优化方向,应尽力按照systrace的cpu time和wall time跑满cpu。
2、监控的完善
- 线上监控多阶段时间(App、Activity、生命周期间隔时间)。
- 处理聚合看趋势。
- 收敛启动代码修改权限。
- 结合CI修改启动代码需要Review通知。
3、常见问题
1、启动优化是怎么做的?
- 分析现状、确认问题
- 针对性优化(先概括,引导其深入)
- 长期保持优化效果
2、是怎么异步的,异步遇到问题没有?
- 体现演进过程
- 详细介绍启动器
3、启动优化有哪些容易忽略的注意点?
- cpu time与wall time
- 注意延迟初始化的优化
- 介绍下黑科技
4、版本迭代导致的启动变慢有好的解决方式吗?
- 启动器
- 结合CI
- 监控完善
至此,探索Android启动速度优化的旅途也应该告一段落了,如果你耐心读到最后的话,会发现要想极致地提升App的性能,需要有一定的广度,如我们引入了始于后端的AOP编程来实现无侵入式的函数插桩,也需要有一定的深度,从前面的探索之旅来看,我们先后涉及了Framework层、Native层、Dalvik虚拟机、甚至是Linux IO和文件系统相关的原理。
因此,我想说,Android开发并不简单,即使是App层面的性能优化这一知识体系,也是需要我们不断地加深自身知识的深度和广度。
最后
其实对于程序员来说,要学习的知识内容、技术有太多太多,要想不被环境淘汰就只有不断提升自己,从来都是我们去适应环境,而不是环境来适应我们!
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。