启动优化

491 阅读14分钟

一:概览

启动优化的文章网上实在是很多,最近也做了相关的启动优化,正好记录下。

文章主要包含两点:

  1. 优化手段和排查方法
  2. 具体优化

二:优化手段和排查方法

1. 日志记录

相信一些巨型App都有启动耗时的相关日志或者记录,记录应用冷启动时间,从Application的attachBaseContext到App首页的耗时。这里要说明下,日志的结束点,有些App拿页面绘制的第一帧(onWindowFocusChanged)作为结束,也有一些App拿获取数据并且展示后的第一帧作为结束,也有一些App拿Activity.onResume作为结束。当然个人从技术角度上出发,拿onWindowFocusChanged作为结束比较合理,从用户体验上拿数据展示后的第一帧作为结束比较合理(如果数据从网络请求,那么时间会更长)。

在线上也会有相应的监控去观察日志,如果发现某个版本的启动耗时增加了,如果日志只记录了一个总耗时的话,很难定位到是哪个阶段哪个代码的修改导致的。所以启动日志要记录的更加详细,像一些主要的函数都要记录,譬如Application和Activity的生命周期函数,这些生命周期函数,我们又会分发到各个业务的监听器里去初始化他们的业务代码,因此也要记录各个业务的监听器耗时(算了下我们总共记录了快20个时间)。这样线上一出现异常情况,直接通过前后版本日志对比,即可发现问题所在。

  • 进程保活 另外需要说明的是,要注意进程保活。我们日志判断冷起的方式是看App首页是走onCreate还是onNewIntent,如果是onCreate就认为是冷起。但这存在一个问题就是如果保活情况下,Application已经初始化了,这个时候如果过了1分钟再点开App的话,也算为冷起,那么耗时就很长了。因此我们日志里直接减去了Application.onCreate结束到SplashActivity.onCreate之间的时间。当然也有其他方式判断冷起:

i.记录Application.onCreate到SplashActivity.onCreate的时间差,若小于X毫秒,则认为是冷起,只是X不好定。

ii.Application生命周期执行时,如果此时ActivityThread.H里已经有处理Activity的Message,则认为是冷起(这种判断有点麻烦,具体可看这篇文章)。

  • provider耗时 另外还需要注意Provider的初始化耗时,在attachBaseContext和onCreate之间,会进行provider的初始化,但刚开始我们对这个时间并没有日志记录。后来由于接入一个厂商第三方库(oppo厂商提供),里面要求在manifest里面声明一个provider(没有做机型区分,导致其他厂商也执行了这个provider),且本身这个provider就耗时,厂商也没有优化方案,导致了整体耗时,后续我们在这个provider中加了厂商判断,避免了其他厂商数据耗时。

2. 腾讯matrix开源库

腾讯开源的性能监控框架Matrix,可以扫描出指定时间段内主线程执行耗时排名靠前的方法以及触发的堆栈,这对我们来说就很方便,直接告诉我们哪个方法耗时,改起来也轻松,这个可以帮助我们优化性能比较差的手机,间接提升启动时间。

  • matrix扫描慢方法原理 主要是利用了ASM插桩,在打包编译过程中,为每个方法分配一个method id(long类型,在打包结束后,会把methodid和方法对应关系生成一个文件methodMapping.txt放在打包目录下),插件在每个方法的方法第一句和最后一句分别插入了AppMethodBeat.i(long methodId)和AppMethodBeat.o(long methodId)。 在运行期间,用一个long类型数组记录所有方法的i执行时间点和o执行时间点,开发可以指定分析哪一段时间,将这段时间内所有的方法时间和调用堆栈都整理起来。

当然方法会很多,你可以指定筛选出排名前60(开发可以修改该个数)的方法,筛选逻辑是,先把耗时为0毫秒的筛除,然后看剩余方法数是不是小于60,如果不是,继续筛除耗时小于5毫秒的,以此类推筛除10毫秒,15毫秒的,直到当前剩余方法数小于等于60,然后将堆栈返回。

返回的堆栈格式如下:

第一位数字代表是堆栈深度(深度为0的方法调用了深度为1的方法,深度为1的方法调用了深度为2的方法)
第二位是method id,第三位是调用次数,第四位是耗时时间(毫秒)
0,81850,1,205
1,81851,1,131
2,63486,1,73
3,65790,1,52
0,45579,1,51
0,81846,1,1278
1,81847,1,59
2,63510,1,59
3,63422,1,59
1,81849,1,1203
2,72836,1,1203

还需要用刚刚生成的methodMapping.txt文件将method id对应成方法,如果是release包的数据,还得根据proguard文件一起对照着看。给大家看下转化后的结果:

0,com.XX.XX.XX  XXMethod(android.content.Context),1,205
1,com.XX.XX.XX  xxmethod(java.lang.String),1,131
2,com.XX.XX.XX.XX.XX  XX(android.content.Context,java.lang.String,int),1,73
3,com.XX.XX.XX.XX  XX(),1,52

0,com.XX.XX.XX.XX.XX.XX  onCreate(),1,51

0,com.XX.XX.XX  onCreate(),1,1278
1,com.XX.XX.XX  xxx(),1,59
2,com.XX.XX.XX.XX.XX  xx(java.lang.String,boolean),1,59
3,com.XX.XX.XX.XX.XX  xx(java.lang.String,boolean),1,59


1,com.XX.XX.XX  xx(),1,1203
2,com.XX.XX.XX.XX  XX(android.content.Context),1,1203

正是通过matrix,我们才发现刚刚提到的provider耗时(因为原来没监控这块)。

  • 缺点

matrix只能帮我们发现表面的东西,例如告诉我们该方法耗时,却难以发现耗时本质(读文件耗时、锁等待、cpu分配等等)。

3. systrace、perfetto

刚刚提到matrix并不能帮我们发现一些本质的问题,而systraceperfetto可以。

有时候我们一个方法执行慢,可能是因为锁等待导致阻塞,也可能是因为CPU调度给其他进程或者其他线程使用所导致,通过这两个命令,我们可以发现这些问题。这两个命令生成的文件可以通过google官方提供的界面打开分析。但是记住,要用Release包,Debug本身性能就不如Release包!!!

systrace命令参数:

options解释
-o <FILE>输出的目标文件
-t N, –time=N执行时间,默认5s
-b N, –buf-size=Nbuffer大小(单位kB),用于限制trace总大小,默认无上限
-k <KFUNCS>,–ktrace=<KFUNCS>追踪kernel函数,用逗号分隔
-a <APP_NAME>,–app=<APP_NAME>追踪应用包名,用逗号分隔
–from-file=<FROM_FILE>从文件中创建互动的systrace
-e <DEVICE_SERIAL>,–serial=<DEVICE_SERIAL>指定设备
-l, –list-categories列举可用的tags

category

category解释
gfxGraphics
inputInput
viewView System
webviewWebView
wmWindow Manager
amActivity Manager
smSync Manager
audioAudio
videoVideo
cameraCamera
halHardware Modules
appApplication
resResource Loading
dalvikDalvik VM
rsRenderScript
bionicBionic C Library
powerPower Management
schedCPU Scheduling
irqIRQ Events
freqCPU Frequency
idleCPU Idle
diskDisk I/O
mmceMMC commands
loadCPU Load
syncSynchronization
workqKernel Workqueues
memreclaimKernel Memory Reclaim
regulatorsVoltage and Current Regulators

例如:

python systrace.py -b 32768 -t 5 -o mytrace.html gfx input view webview wm am sm audio video camera hal app res dalvik rs bionic power sched irq freq idle disk mmc load sync workq memreclaim regulators
  • 锁等待例子

锁等待.png

锁等待2.png

主线程等待pool-5-thread-5占有的锁,相同时间点下,发现pool-5-thread-5正在等待pool-5-thread-1持有的锁,结合代码分析,即可发现锁等待链,从而优化代码。

  • CPU调度

上述第一张图中,可以看到当前线程CPU的分配情况,除了锁等待这种代码问题,也有可能是因为CPU真的被其他线程进程调度占有(例子)。譬如我们主进程启动时,也可能会启动子进程,导致CPU被占用,所以还得看是否可以延迟子进程的创建。

  • 插桩+systrace

systrace主要是用系统的Trace.beginSection和endSection的日志数据来分析,而这个系统API原来只是记录系统代码的相关时间,为了将systrace利用的更彻底一些,我们可以在方法前后都调用Trace.beginSection和endSection(传入这个方法的class + name),然后在Application里再将Trace.setAppTracingAllowed设置为true,再重新跑systrace时,就能把整个启动里的所有方法以及耗时都列出来。

try {
    cTrace = Class.forName("android.os.Trace");
    cTrace.getDeclaredMethod("setAppTracingAllowed", Boolean.TYPE).invoke(null, Boolean.TRUE);
} catch (Throwable th) {
    th.printStackTrace();
}

systraceAsm.png

  • Debug.startMethodTracing

刚刚ASM + systrace是针对Release,那如果是Debug包想看这种数据呢?可以直接使用系统提供的Debug.startMethodTracing和Debug.endMethodTracing。

生成文件使用Android studio的profiler查看:

Debug.png

但是官方同时也说明了,Debug包和Release包本身存在性能差异,不能拿Debug包数据就认为是Release数据,只能两个Debug包数据比较,查看某个方法耗时是不是增加。而且Debug.startMethodTracing为了抓取到完整的trace,开启该功能时,虚拟机会将目标应用设置成仅解释执行,禁用掉机器码执行和JIT,这样会严重影响运行性能,得到的数据也会失真。

4. 线程监控

在启动阶段,我们可能会创建很多线程,不同业务使用自己的线程池,或者直接通过new Thread或者handlerThread来执行任务。如何监听启动过程中所有创建的线程呢?有以下几种办法:

1、adb shell ps -t [pid],可以过滤pid进程下的所有线程。
2、Android studio - profile - CPU可以查看线程(巨卡,有时候甚至电脑都无响应了)

但是这两种都不太行,只能看到目前还活着的线程。要知道线程(Thread)执行完任务就直接销毁,如果执行时间很短,在你打印命令前,他可能就已经挂了,你也看不到他存在的痕迹。如果是HandlerThread,执行完一个Message,还会阻塞等待,你还能看到他的身影。我过往公司项目中,曾经有一个bug,使用Okhttp发起网络请求,却写错代码,每个请求就创建一个okhttp,每个okhttp里又有一个线程池,这导致了每个请求都创建线程,用完的线程又不能给接下来的接口用(因为根本就不是一个线程池),导致我们一起启动就创建了100个线程,又快速销毁,线上因为pthread create导致的OOM可不少。但如果你用上面两种方法都不一定能发现这个问题,因为线程执行太快都销毁了。

  • 线程创建和耗时监控

当然还是利用ASM插桩,但是插哪里?我也想直接插到Thread类里,这样我们就能改一处,但你要知道,我们是无法插系统代码的(动态ASM可以,例如epic、DXposed)。而且像线程池这种,我们使用它都是传入一个runnable,线程创建代码都在系统代码内部,你也无法插。

具体实现可以看我另外一篇文章:ASM插桩--多线程运行监测,大概讲述了原理。

  • 线程重命名

参考滴滴的Booster开源库,里面有一个线程优化和线程重命名

线程优化指的是,虽然我们现在都习惯用线程池来管理线程,但是还是存在不同业务方、第三方库都使用自己的线程池,有时候核心线程还不一定会设置成超时销毁。当我们刚启动时,所有线程池创建的线程加起来的总数也会过多。因此booster打包插件在发现代码里有创建线程池或者创建线程的地方,都修改ASM指令,返回滴滴优化后的线程池。

线程重命名指的是,系统命名线程比较简单,都是Thread-X或者pool-x-thread-y,当线上出现问题时或者观察线程创建时,你都不知道这线程是被谁创建的,让开发怎么优化?而booster插件可以在打包时,遇到创建线程和线程池的指令时,创建线程或线程池的类名作为线程名字的前缀,例如

class Test{
    void test(){
        new Thread(new Runnable(){}).start();
}

原来该线程名字为Thread-1,但是插桩修改名字后,该线程名字叫com.exmaple.Test #Thread-1。这样我们就能快速定位出问题。

5. 代码梳理

就是比较麻烦笨拙但有效的方法,就是梳理启动期间的代码,看看哪些是可以懒加载或者移除的代码,甚至是历史陈年老逻辑。也可以将启动相关代码移至某个package下,日后进代码需要严格review,避免乱加代码无法控制。

三:具体优化

上面讲了那么多优化方法,下面讲讲具体代码的改动。

1、SP提早初始化

这个应该是老生常谈了,在主线程获取SP的某个key下的value值,如果在使用时才初始化,会导致阻塞主线程等待SP文件读完成,所以可以在Application启动后,创建子线程先对SP进行初始化,后续业务使用时,sp文件已经读取完毕。

2、Gson优化

有时候很难避免在主线程使用gson去解析字符串到一个对象里,如果没有自定义适配器去解析的话,那么会触发到Gson自带的ReflectiveTypeAdapterFactory。使用这个解析一个简单的字符串,有可能会非常耗时,具体原因可看这篇文章

  • 优化效果: 我们用一个json字符(5个字段),实体类XX.class,有近20个属性(有些属性是业务方自己加的,并不需要json解析),原来解析300ms,使用自定义adapter,解析只用2ms,优化效果明显。

3、布局inflate

从systrace里,我们发现布局inflate也是耗时点之一,尤其是布局文件过大层级过深,时间花费较久。可以将布局里网络错误View、空白数据View等用ViewStub,只有用到时才加载。

4、第三方库异步提早初始化

举个例子,业务代码中用到了Rxjava去处理数据,使用的也是简单的操作符,例如map、fitler,takeFirst等,但是第一次执行相同逻辑代码,耗时几十毫秒,第二次执行时,只用了1毫秒,怀疑是RxJava内部静态初始化耗时。因此在Application启动时,异步线程里简单调用了下RxJava相关操作符,提早初始化。

5、锁优化

平时我们写代码或者查bug时很难自己发现一些锁等待的问题,只能通过刚刚的systrace或者matrix提供的耗时自己分析代码,得出优化结论。这个例子涉及代码实现,不太好举例子。

6、异步任务耗时监控

启动耗时我们都是只关心主线程,为啥要监控异步任务的耗时?其实两者也是息息相关的,因为有些数据主线程也是等到异步返回后才能继续执行,或者像刚刚的锁等待问题(主线程等待异步线程的锁),如果异步能执行的快一些,那也能让主线程少等待一些。所以异步任务执行慢的也要优化。

举个例子:

threadPoolExecutor.execute(new Runnable(){
    @override
    void run(){
        Thread.sleep(3000);
        //业务代码
    }
})

这个异步任务最起码执行了3秒,优化方法可以直接改成将该任务delay三秒执行,让线程先执行其他任务。

7、腾讯HardCoder开源库

刚刚讲的都是应用内的优化,跳出应用层面,hardCoder就是向系统发送消息,通过CPU提频来达到加速App启动的目的。优化效果各个厂商不一样,具体效果开源库github上都有展示。

启动优化大概就是这些,有些地方描述不详细,只是列了个大概,具体大家可以自行去查,有错误地方欢迎指正。