除了稳定性以外,对于性能纬度来说,哪个方面的性能是最重要的呢?毫无疑问,就是应用的启动速度。下面,就让我们继续逐步深入地探索Android启动速度优化的奥秘。
启动时CG抑制,允许堆一直增长,直到手动或OOM停止GC抑制。(空间换时间)
需要白名单覆盖所有设备,但维护成本高。
一个设备的CPU通常都是4核或者8核,但是应用在一般情况下对CPU的利用率并不高,可能只有30%或者50%,如果我们在启动速度暴力拉伸CPU频率,以此提高CPU的利用率,那么,应用的启动速度会提升不少。
在Android系统中,CPU相关的信息存储在/sys/devices/system/cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改。
暴力拉伸CPU频率,导致耗电量增加。
对应的文件有:
这里需要注意的是,需要考虑重度用户的使用场景。
利用内存中的存储空间来暂存从磁盘中读出的一系列盘块中的信息。因此,磁盘高速缓存在逻辑上属于磁盘,物理上则是驻留在内存中的盘块。
其内存中分为两种形式:
当数据写入文件时,内核通常先将该数据复制到缓冲区高速缓存或页面缓存中,如果该缓冲区尚未写满,则不会将其排入输入队列,而是等待其写满或内核需要重用该缓冲区以便存放其他磁盘块数据时,再将该缓冲排入输出队列,最后等待其到达队首时,才进行实际的IO操作—延迟写。
延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,可能会造成文件更新内容的丢失。为了保证数据一致性,则需使用同步IO。
如果当前硬盘的平均寻道时间是3-15ms,7200RPM硬盘的平均旋转延迟大约为4ms,因此一次IO操作的耗时大约为10ms。
如果使用内存映射文件的方式进行文件IO(mmap),将文件的page cache直接映射到进程的地址空间,这时需要使用msync系统调用确保修改的内容完全同步到硬盘之上。
创建每个log文件时先写文件的最后一个page,将log文件扩展为10MB大小,这样便可以使用fdatasync,每写10MB只有一次同步metadata的开销。
标准IO,大多数文件系统默认的IO操作。
优点
缺点
DMA方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存中写回到磁盘,而不能在应用程序地址空间和磁盘之间进行数据传输,这样,数据在传输过程中需要在应用程序地址空间(用户空间)和缓存(内核空间)中进行多次数据拷贝操作,这带来的CPU以及内存开销是非常大的。
磁盘IO主要的延时(15000RPM硬盘为例)
机械转动延时(平均2ms)+ 寻址延时(2~3ms)+ 块传输延时(0.1ms左右)=> 平均5ms
网络IO主要延时
服务器响应延时 + 带宽限制 + 网络延时 + 跳转路由延时 + 本地接收延时(一般为几十毫秒到几千毫秒,受环境影响极大)
很早之前,磁盘和内存之间的数据传输是需要CPU控制的,也就是读取磁盘文件到内存中时,数据会经过CPU存储转发,这种方式称为PIO。
应用程序直接访问磁盘数据,而不经过内核缓冲区。以减少从内核缓冲区到用户数据缓存的数据复制。
当访问数据的线程发出请求后,线程会接着去处理其它事情,而不是阻塞等待。
可以为访问文件系统的系统调用提供一个统一的抽象接口。
Dex文件用到的类和APK里面各种资源文件都比较小,读取频繁,且磁盘地址分布范围比较广。我们可以利用Linux文件IO流程中的page cache机制将它们按照读取顺序重新排列在一起,以减少真实的磁盘IO次数。
使用Facebook的
ReDex github.com/facebook/re…
的Interdex调整类在Dex中的排列顺序。
一个可以不修改APK就影响程序运行的Hook框架。
用自身实现的app_process替换掉系统/system/bin/app_process,加载一个额外的XposedBridge的jar包,用于将入口osZygoteInit.main()替换成XposedBridge.main()。之后,创建的Zygote进程和其子进程都是Hook过的了。
使用具体细节参见
Xposed教程 blog.csdn.net/coder_pig/a…
对象第一次创建的时候,JVM首先检查对应的Class对象是否已经加载。如果没有加载,JVM会根据类名查找.class文件,将其Class对象载入。同一个类第二次new的时候就不需要加载类对象,而是直接实例化,创建时间就缩短了。
ART比较复杂,Hook需要兼容几个版本。而且在安装时,大部分Dex已经优化好了,去掉ART平台的verify只会对动态加载的Dex带来一些好处。所以暂时不建议在ART平台使用。
它们在设计上都存在大量的Hook和私有API调用,共同的缺点有如下两类问题。
由于厂商的兼容性、安装失败、ART加载时dex2oat失败等原因,还是会有一些代码和资源的异常。Android P推出的non-sdk-interface调用限制,以后适配只会越来越难,成本越来越高。
用到一些黑科技导致底层Runtime的优化享受不到。如Tinker加载补丁后,启动速度会降低5%~10%。
Android官方使用热补丁技术实现InstantRun。
构建 -> 部署 -> 安装 -> 重启app -> 重启activity
尽可能多的剔除不必要的步骤,然后提升必要步骤的速度。
1、HotSwap
增量构建 -> 改变部署
场景:
适用于多数简单的改变(包括一些方法实现的修改,或者变量值修改)。
2、Warm Swap
增量构建 -> 改变部署 -> activity重启
场景:
一般是修改了resources。
3、Cold Swap
增量构建 -> 改变部署 -> 应用重启 -> activity重启
场景:
涉及结构性变化,如修改了继承规则或方法签名。
Android Studio monitors 运行着Gradle任务来生成增量.dex文件(dex对应着开发中的修改类),AS会提取这些.dex文件发送到App Server,然后部署到App。因为原来版本的类都装载在运行中的程序了,Gradle会解释更新好这些.dex文件,发送到App Server的时候,交给自定义的类加载器来加载.dex文件。 App Server会不断地监听是否需要重写类文件,如果需要,任务会被立马执行,新的更改便能立即被响应。
需要注意的是,此时InstantRun是不能回退的,必须重启应用响应修改。
因为资源文件是在Activity创建时加载,所以必须重启Activity加载资源文件。
注意:AndroidManifest的值是在APK安装的时候被读取的,所以需要触发一个完整的应用构建和部署。
应用部署的时候,会把工程拆分成十个部分,每个部分都拥有自己的.dex文件,然后所有的类会根据包名被分配给相应的.dex文件。当ColdSwap开启时,修改过的类所对应的的.dex文件,会重组生成新的.dex文件,然后再部署到设备上。
注意:应用多进程会被降级为ColdSwap。
manifest文件合并、打包,和res一起被AAPT合并到APK中,同时项目代码被编译成字节码,然后转换成.dex文件,也被合并到APK中。
在回答这个问题之前,我们需要先了解下内存对齐(DSA,Data Structure Alignment):
各种类型的数据按照一定的规则在内存空间上排列,这就是对齐。
内存对齐的优势在于能够以空间换时间,减少数据存取指令周期,提升程序运行时的速度。
zipalign优化的最根本目的是帮助操作系统更高效地根据请求索引资源,使用resource-handling code统一将DSA限定为4byte。
利用build-tools文件夹下对应Android版本中的zipalign工具:
zipalign -v 4 source.apk androidres.apk
检查当前APK是否已经执行过Align优化:
zipalign -c -v 4 androidres.apk
其中:
native hook -> dalvik_repleaceMethod -> 无法支持新增或删除filed的情况 -> 需修复特定问题
它是一个基于Android Dex分包方案。它将多个dex文件放入到app的classloader中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当用到这个重复的类时,系统会选择哪个类进行加载呢?
一个ClassLoader可以包含多个dex文件,每个dex文件是一个Elements,多个dex文件排列成有序的dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找到则返回,如果找不到从下一个dex文件继续查找。
所以,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类。
Qzone热补丁方案就是把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面。
1、当其它dex文件中的类引用了patch.dex中的类时,会出现校验错误。拆分dex的很多类都不是在同一个dex内的,怎么没有问题?
因为这个校验有个前提,当引用类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。
2、CLASS_ISPREVERIFIED标志是什么时候被打上去的?
有两步验证:
1、验证clazz -> directMethods方法,其包含以下方法:
2、clazz -> virtualMethods
如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFED标志。
为了解决补丁方案中遇到的问题,所以必须从这些方法中入手,防止类被打上CLASS_ISPREVERIFIED标志。空间的方案是往所有类的构造函数里面插入一段代码:
If (ClassVerifier.PREVENT_VERIFY) {
System.out.println(AntilazyLoad.class);
}
其中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex中的类都会引用一个在不同dex中的AntilazyLoad类,这样就防止类被打上了CLASS_ISPREVERIFILED标志,只要没被打上这个标志的类都可以进行打补丁操作。
注意:
为什么要选择构造函数?
因为他不增加方法数,一个类即使没有显示的构造函数,也有一个隐式的默认构造函数。
可以使用ASM/javaassist库在编译期间将相应的字节码插入Class文件中。
Art采用了新的方式,插桩对代码的执行效率没有影响。但是补丁中的类出现修改类变量或者方法,可能会导致出现内存地址错乱的情况。
原因:
dex2oat时fast*已经将类能确定的各个地址写死。如果运行时补丁包的地址出现改变,原始类去调用时就会出现地址错乱。
解决方法:
将其父类以及调用类的所有类都加入到补丁包中。
为了提高性能。
由于现在很多App都使用了MultiDex分包方案,这导致了很多类都没有被打上这个标志,所以此时禁用所有类打上CLASS_ISPREVERIFIED标志对性能的影响不是很大。
在补丁包大小与性能损耗上有一定的局限性。
插桩就是将一段代码插入或者替换原本的代码。 字节码插桩就是在我们的代码编译成字节码(Class)后,在Android下生成dex之前修改Class文件,修改或者增强原有代码逻辑的操作。
除了AspectJ、Javassist框架外,还有一个应用更为广泛的ASM框架同样也是字节码操作框架,Instant Run包括Javassist就是借助ASM来实现各自的功能。
可以这样理解Class字节码与ASM之间的联系,即JSON对于GSON就类似于字节码Class对于Javassist/ASM。
Android 1.5.0版本以后提供了Transform API,允许第三方Plugin在打包dex文件之前的编译过程中操作.class文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成后对文件进行替换。
大致的流程如下所示:
1、自动埋点追踪,遍历所有文件更换字节码
AutoTransform -> transform -> inputs.each {TransformInput input -> input.jarInput.each { JarInput jarInput -> … } input.directoryInputs.each { DirectoryInput directoryInput -> … }}
2、Gradle插件实现
PluginEntry -> apply -> def android = project.extensions.getByType(AppExtension)
registerTransform(android) -> AutoTransform transform = new AutoTransform
android.registerTransform(transform)
3、使用ASM进行字节码编写
ASM框架核心类
1、visit -> 在ClassVisitor中根据判断是否是实现View$OnClickListener接口的类,只有满足条件的类才会遍历其中的方法进行操作。
2、在MethodVisitor中对该方法进行修改
visitAnnotation -> onMethodEnter -> onMethodExit
3、先在java文件中编写要插入的代码,然后使用ASM插件查看对应的字节码,根据其用ASM提供的Api一一对应地把代码填进来即可。
DexDiff的粒度是Dex格式的每一项,BsDiff的粒度是文件,AndFix/Qzone的粒度为class。
若不care性能损耗与补丁包大小,Qzone是最简单且成功率最高的方案。
负责将补丁包交付给用户,包括特定用户和全量用户。
1、pull通道
在登录/24小时等时机,通过pull方式查询后台是否有对应的补丁包更新。
2、指定版本的push通道
在紧急情况下,我们可以在一个小时内向所有用户下发补丁包更新。
3、指定特定用户的push通道
对特定用户或用户组做远程调试。
快速上线,管理历史记录,以及监控补丁的运行情况。
构建了App与系统(ROM)之间可靠的通信框架,让系统知道App的需求。
平均10%~30%。
一种优化资源调度的技术。
让应用程序与系统资源实现实时"双向对话"。当来自应用和游戏程序的不同场景和用户行为被Hyper Boost识别后,手机会智能地匹配到合理的系统资源,让手机SoC的CPU、GPU、ISP、DSP提供的运算资源更加合理地利用,从而让用户使用手机更加流畅。
在某一个版本之后呢,我们会发现这个启动速度变得特别慢,同时用户给我们的反馈也越来越多,所以,我们开始考虑对应用的启动速度来进行优化。然后,我们就对启动的代码进行了代码层面的梳理,我们发现应用的启动流程已经非常复杂,接着,我们通过一系列的工具来确认是否在主线程中执行了太多的耗时操作。
我们经过了细查代码之后,发现应用主线程中的任务太多,我们就想了一个方案去针对性地解决,也就是进行异步初始化。(引导=>第2题) 然后,我们还发现了另外一个问题,也可以进行针对性的优化,就是在我们的初始化代码当中有些的优先级并不是那么高,它可以不放在Application的onCreate中执行,而完全可以放在之后延迟执行的,因为我们对这些代码进行了延迟初始化,最后,我们还结合了idealHandler做了一个更优的延迟初始化的方案,利用它可以在主线程的空闲时间进行初始化,以减少启动耗时导致的卡顿现象。做完这些之后,我们的启动速度就变得很快了。
最后,我简单说下我们是怎么长期来保持启动优化的效果的。首先,我们做了我们的启动器,并且结合了我们的CI,在线上加上了很多方面的监控。(引导=> 第4题)
我们最初是采用的普通的一个异步的方案,即new Thread + 设置线程优先级为后台线程的方式在Application的onCreate方法中进行异步初始化,后来,我们使用了线程池、IntentService的方式,但是,在我们应用的演进过程当中,发现代码会变得不够优雅,并且有些场景非常不好处理,比如说多个初始化任务直接的依赖关系,比如说某一个初始化任务需要在某一个特定的生命周期中初始化完成,这些都是使用线程池、IntentService无法实现的。所以说,我们就开始思考一个新的解决方案,它能够完美地解决我们刚刚所遇到的这些问题。
这个方案就是我们目前所使用的启动器,在启动器的概念中,我们将每一个初始化代码抽象成了一个Task,然后,对它们进行了一个排序,根据它们之间的依赖关系排了一个有向无环图,接着,使用一个异步队列进行执行,并且这个异步队列它和CPU的核心数是强烈相关的,它能够最大程度地保证我们的主线程和别的线程都能够执行我们的任务,也就是大家几乎都可以同时完成。
首先,在CPU Profiler和Systrace中有两个很重要的指标,即cpu time与wall time,我们必须清楚cpu time与wall time之间的区别,wall time指的是代码执行的时间,而cpu time指的是代码消耗CPU的时间,锁冲突会造成两者时间差距过大。我们需要以cpu time来作为我们优化的一个方向。
其次,我们不仅只追求启动速度上的一个提升,也需要注意延迟初始化的一个优化,对于延迟初始化,通常的做法是在界面显示之后才去进行加载,但是如果此时界面需要进行滑动等与用户交互的一系列操作,就会有很严重的卡顿现象,因此我们使用了idealHandler来实现cpu空闲时间来执行耗时任务,这极大地提升了用户的体验,避免了因启动耗时任务而导致的页面卡顿现象。
最后,对于启动优化,还有一些黑科技,首先,就是我们采用了类预先加载的方式,我们在MultiDex.install方法之后起了一个线程,然后用Class.forName的方式来预先触发类的加载,然后当我们这个类真正被使用的时候,就不用再进行类加载的过程了。同时,我们再看Systrace图的时候,有一部分手机其实并没有给我们应用去跑满cpu,比如说它有8核,但是却只给了我们4核等这些情况,然后,有些应用对此做了一些黑科技,它会将cpu的核心数以及cpu的频率在启动的时候去进行一个暴力的提升。
这种问题其实我们之前也遇到过,这的确非常难以解决。但是,我们后面对此进行了反复的思考与尝试,终于找到了一个比较好的解决方式。
首先,我们使用了启动器去管理每一个初始化任务,并且启动器中每一个任务的执行都是被其自动进行分配的,也就是说这些自动分配的task我们会尽量保证它会平均分配在我们每一个线程当中的,这和我们普通的异步是不一样的,它可以很好地缓解我们应用的启动变慢。
其次,我们还结合了CI,比如说,我们现在限制了一些类,如Application,如果有人修改了它,我们不会让这部分代码合并到主干分支或者是修改之后会有一些内部的工具如邮件的形式发送到我,然后,我就会和他确认他加的这些代码到底是耗时多少,能否异步初始化,不能异步的话就考虑延迟初始化,如果初始化时间太长,则可以考虑是否能进行懒加载,等用到的时候再去使用等等。
然后,我们会将问题尽可能地暴露在上线之前。同时,我们真正已经到了线上的一个环境下时,我们进行了监控的一个完善,我们不仅是监控了App的整个的启动时间,同时呢,我们也将每一个生命周期都进行了一个监控。比如说Application的onCreate与onAttachBaseContext方法的耗时,以及这两个生命周期之间间隔的时间,我们都进行了一个监控,如果说下一次我们发现了这个启动速度变慢了,我们就可以去查找到底是哪一个环节变慢了,我们会和以前的版本进行对比,对比完成之后呢,我们就可以来找这一段新加的代码。
至此,探索Android启动速度优化的旅途也应该告一段落了,如果你耐心读到最后的话,会发现要想极致地提升App的性能,需要有一定的技术广度,如我们引入了始于后端的AOP编程来实现无侵入式的函数插桩,也需要有一定的深度,从前面的探索之旅来看,我们先后涉及了Framework层、Native层、Dalvik虚拟机、甚至是Linux IO和文件系统相关的原理。因此,我想说,Android开发并不简单,即使是App层面的性能优化这一知识体系,也是需要我们不断地加深自身知识的深度和广度。
1、Android开发高手课之启动优化
2、支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
3、支付宝 App 构建优化解析:通过安装包重排布优化 Android 端启动性能
4、Facebook Redex字节码优化工具
5、微信Android热补丁实践演进之路
6、安卓App热补丁动态修复技术介绍
7、Dalvik Optimization and Verification With dexopt
8、微信在Github开源了Hardcoder,对Android开发者有什么影响?
9、历时三年研发,OPPO 的 Hyper Boost 引擎如何对系统、游戏和应用实现加速?
10、抱歉,Xposed真的可以为所欲为
11、墙上时钟时间 ,用户cpu时间 ,系统cpu时间的理解
12、《Android应用性能优化最佳实践》
13、必知必会 | Android 性能优化的方面方面都在这儿
14、极客时间之Top团队大牛带你玩转Android性能分析与优化
15、启动器源码
16、MultiDex优化源码
17、使用gradle自动化增加Trace Tag
转载:https://juejin.cn/post/6870457006784774152