二 Android性能优化系列篇:启动优化

强调一下:性能优化的开发文档跟之前的面试文档一样 , 想要的跟作者直接要 。
二、启动优化2.1 我们为什么要做启动优化?
用户希望应用能够快速打开 。启动时间过长的应用不能满足这个期望 , 并且可能会令用户失望 。轻则鄙视你,重则直接卸载你的应用 。
用户不会在乎你的项目是不是过大,里面是不是有很多初始化的逻辑 。他只在乎你-慢了 。
所以咱们这篇文章有两个目的:
今天咱们就来了解一下应用启动内部机制和启动速度优化 。
2.2 启动内部机制
应用有三种启动状态:
2.2.1 冷启动
冷启动是指应用从头开始:冷启动发生在设备启动后第一次启动应用程序 (>fork>app)  , 或系统关闭应用程序后 。
在冷启动开始时 , 系统有三个任务 。这些任务是:
一旦系统创建了应用程序进程 , 应用程序进程就负责接下来的阶段:
如下图:
注意:在创建和创建期间可能会出现性能问题 。
创建
当应用程序启动时,空白启动页面保留在屏幕上,直到系统首次完成应用程序的绘制 。
如果你重写了.(),系统将调用 上的()方法 。之后,应用程序生成主线程 , 也称为UI线程java简单实现一个阻塞队列 , 并将创建主的任务交给它 。
创建
应用进程创建你的后,会执行以下操作:
注意:() 方法对加载时间的影响最大,因为它执行开销最高的工作:加载UI的布局和渲染,以及初始化运行所需的对象 。
2.2.2 热启动
热启动时,系统将应用从后台拉回前台 , 应用程序的在内存中没有被销毁,那么应用程序可以避免重复对象初始化,UI的布局和渲染 。
如果被销毁则需要重新创建 。
和冷启动的区别: 不需要创建。
2.2.3 温启动
温启动介于冷启动和热启动中间吧 。例如:
咱们看看他们共同消耗多长时间 。
2.3 查询的启动时间2.3.1 初始显示时间(Time to)
在4.4(API 级别 19)及更高版本中,包含一个输出行,其中包含一个名为的值 。此值表示启动流程和完成在屏幕上绘制相应活动之间经过的时间量 。经过的时间包含以下事件序列:
注意这里查看日志需要如下操作:
报告的日志行类,如下图:
//冷启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +1s355ms//温启动(进程被杀死)I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +1s46ms//热启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +289msI/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +253ms
图例讲解:
第一个时间,冷启动时间:+ 。
然后我们在后台杀死进程,再次启动应用;
第二个时间 , 温启动时间:+ 。
这里咱们在后台杀死进程所以:应用进程和需要重新启动 。
第三个时间:热启动时间:+289ms 和 +253ms
按返回键,仅退出 。所以耗时比较短 。
当然整体看这个应用开启时间并不长,因为 Demo 的和都没有进行太多的操作 。
2.3.2 完全显示时间(Time to full )
你可以使用 () 方法来测量应用程序启动和所有资源和视图层次结构的完整显示之间经过的时间 。在应用程序执行延迟加载的情况下,这可能很有价值 。在延迟加载中,应用程序不会阻止窗口的初始绘制,而是异步加载资源并更新视图层次结构 。
这里我在.()中加了个工作线程 。并在里面调用() 方法 。代码如下:
@Overridepublic void onCreate(@Nullable Bundle savedInstanceState) {super.onCreate(savedInstanceState);Log.e(this.getClass().getName(), "onCreate");setContentView(R.layout.activity_main);...new Thread(new Runnable() {@Overridepublic void run() {try {Thread.sleep(3000);reportFullyDrawn();} catch (InterruptedException e) {e.printStackTrace();}}}).start();}
报告的日志行类,如下图:
I/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s970msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s836msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s107msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s149ms
图例讲解:
然后你会发现界面出来好一会才打这个日志 。看到这里我觉得好多人已经知道怎么去优化启动速度了 。
2.4 性能迟缓分析
看到上面的实验其实三种启动情况,受我们影响的方面在于和。
2.4.1 繁琐的 初始化
当你的代码覆盖对象并在初始化该对象时执行繁重的工作或复杂的逻辑时 , 启动性能可能会受到影响 。产生的原因包括:
解决方案
无论问题在于不必要的初始化还是磁盘I/O,解决方案都是延迟初始化 。换句话说 , 你应该只初始化立即需要的对象 。不要创建全局静态对象,而是转向单例模式,应用程序只在第一次需要时初始化对象 。
此外,考虑使用依赖注入框架(如Hilt)
2.4.2 繁琐的初始化
活动创建通常需要大量高开销工作 。通常,有机会优化这项工作以实现性能改进 。
产生的原因包括:
解决方案如下 。
布局优化
具体内容请看后文布局优化 。
代码优化
具体内容请看后文代码优化 。
2.5 阻塞实验
2.5.1阻塞 2秒,阻塞 2秒 。
.class
public class SccApp extends Application {@RequiresApi(api = Build.VERSION_CODES.P)@Overridepublic void onCreate() {super.onCreate();String name = getProcessName();MLog.e("ProcessName:"+name);getProcessName("com.scc.demo");try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}}}
.class
publicclass MainActivity extends ActivityBase implements View.OnClickListener {@Overridepublic void onCreate(@Nullable Bundle savedInstanceState) {super.onCreate(savedInstanceState);Log.e(this.getClass().getName(), "onCreate");setContentView(R.layout.activity_main);...try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}new Thread(new Runnable() {@Overridepublic void run() {try {Thread.sleep(3000);reportFullyDrawn();} catch (InterruptedException e) {e.printStackTrace();}}}).start();}}
报告的日志,如下:
//冷启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +5s458msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +8s121ms//温启动(进程被杀死)I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +5s227msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +7s935ms//热启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +2s304msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +5s189msI/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +2s322msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +5s169ms
将和阻塞的2秒都放在工作线程去操作
这个就是把代码放在如下代码中执行即可,就不全部贴出来了 。
new Thread(new Runnable() {@Overridepublic void run() {...}}).start();
【二 Android性能优化系列篇:启动优化】运行结果如下:
//冷启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +1s227msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s957ms//温启动(进程被杀死)I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +1s83msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s828ms//热启动I/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +324msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s169msI/ActivityTaskManager: Displayed com.scc.demo/.actvitiy.MainActivity: +358msI/ActivityTaskManager: Fully drawn com.scc.demo/.actvitiy.MainActivity: +3s207ms
2.6 APP 启动黑/白屏
应用启动时java简单实现一个阻塞队列 , 尤其是大型应用, 经常出现几秒钟的黑屏或白屏,黑屏或白屏取决于主界面的主题风格 。
2.6.1 优雅的解决黑白屛
应用启动时很多大型应用都会有一个广告(图片及视频)页或闪屏页(2-3S) 。这并不是开发者想要放上去的 , 而是为了避免上述启动白屏导致用户体很差 。当然你可以珍惜这2-3秒做一个异步加载或者请求 。
写到这里 。应用启动模式、启动时间、启动速度优化算是完事了 。当然后面如果有更好的优化方案还会继续补充 。
2.7 启动阶段抑制GC
启动时CG抑制,允许堆一直增长,直到手动或OOM停止GC抑制 。(空间换时间)
前提条件实现原理缺点
需要白名单覆盖所有设备,但维护成本高 。
2.8 CPU锁频
一个设备的CPU通常都是4核或者8核,但是应用在一般情况下对CPU的利用率并不高,可能只有30%或者50%,如果我们在启动速度暴力拉伸CPU频率 , 以此提高CPU的利用率,那么,应用的启动速度会提升不少 。
在系统中,CPU相关的信息存储在/sys///cpu目录的文件中,通过对该目录下的特定文件进行写值,实现对CPU频率等状态信息的更改 。
缺点
暴力拉伸CPU频率,导致耗电量增加 。
CPU工作模式CPU的工作频率范围
对应的文件有:
2.9 IO优化
这里需要注意的是 , 需要考虑重度用户的使用场景 。
补充加油站:Linux IO知识
1、磁盘高速缓存技术
利用内存中的存储空间来暂存从磁盘中读出的一系列盘块中的信息 。因此,磁盘高速缓存在逻辑上属于磁盘,物理上则是驻留在内存中的盘块 。
其内存中分为两种形式:
2、分页
3、高速缓存/缓冲器
4、linux同步IO:sync、fsync、msync、
为什么要使用同步IO?
当数据写入文件时,内核通常先将该数据复制到缓冲区高速缓存或页面缓存中,如果该缓冲区尚未写满,则不会将其排入输入队列,而是等待其写满或内核需要重用该缓冲区以便存放其他磁盘块数据时 , 再将该缓冲排入输出队列,最后等待其到达队首时,才进行实际的IO操作—延迟写 。
延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度 , 可能会造成文件更新内容的丢失 。为了保证数据一致性,则需使用同步IO 。
sync
fsync
msync
如果当前硬盘的平均寻道时间是3-15ms,硬盘的平均旋转延迟大约为4ms,因此一次IO操作的耗时大约为10ms 。
如果使用内存映射文件的方式进行文件IO(mmap),将文件的page cache直接映射到进程的地址空间,这时需要使用msync系统调用确保修改的内容完全同步到硬盘之上 。
日志文件都是追加性的 , 文件尺寸一致在增大,如何利用好减少日志文件的同步开销?
创建每个log文件时先写文件的最后一个page,将log文件扩展为10MB大?。?这样便可以使用,每写10MB只有一次同步的开销 。

二 Android性能优化系列篇:启动优化

文章插图
二 Android性能优化系列篇:启动优化

文章插图
2.10 磁盘IO与网络IO
磁盘IO(缓存IO)
标准IO,大多数文件系统默认的IO操作 。
优点
缺点
DMA方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存中写回到磁盘,而不能在应用程序地址空间和磁盘之间进行数据传输,这样,数据在传输过程中需要在应用程序地址空间(用户空间)和缓存(内核空间)中进行多次数据拷贝操作 , 这带来的CPU以及内存开销是非常大的 。
磁盘IO主要的延时(硬盘为例)
机械转动延时(平均2ms)+ 寻址延时(2~3ms)+ 块传输延时(0.1ms左右)=> 平均5ms
网络IO主要延时
服务器响应延时 + 带宽限制 + 网络延时 + 跳转路由延时 + 本地接收延时(一般为几十毫秒到几千毫秒,受环境影响极大)
2.11 PIO与DMA
PIO
很早之前,磁盘和内存之间的数据传输是需要CPU控制的,也就是读取磁盘文件到内存中时,数据会经过CPU存储转发,这种方式称为PIO 。
DMA(直接内存访问,)
2.12 直接IO与异步IO
直接IO
应用程序直接访问磁盘数据,而不经过内核缓冲区 。以减少从内核缓冲区到用户数据缓存的数据复制 。
异步IO
当访问数据的线程发出请求后,线程会接着去处理其它事情,而不是阻塞等待 。
2.13 数据重排
Dex文件用到的类和APK里面各种资源文件都比较?。?寥∑捣保?掖排痰刂贩植挤段П冉瞎?。我们可以利用Linux文件IO流程中的page cache机制将它们按照读取顺序重新排列在一起,以减少真实的磁盘IO次数 。
2.13.1 类重排
使用的
ReDex //re…
的调整类在Dex中的排列顺序 。
2.13.2 资源文件重排2.14 类加载优化()2.14.1 类预加载原理
对象第一次创建的时候 , JVM首先检查对应的Class对象是否已经加载 。如果没有加载,JVM会根据类名查找.class文件 , 将其Class对象载入 。同一个类第二次new的时候就不需要加载类对象 , 而是直接实例化 , 创建时间就缩短了 。
2.14.2 类加载优化过程
ART比较复杂 , Hook需要兼容几个版本 。而且在安装时 , 大部分Dex已经优化好了,去掉ART平台的只会对动态加载的Dex带来一些好处 。所以暂时不建议在ART平台使用 。
2.14.3 延伸:插件化和热修复
它们在设计上都存在大量的Hook和私有API调用,共同的缺点有如下两类问题 。
1、稳定性较差
由于厂商的兼容性、安装失败、ART加载时失败等原因,还是会有一些代码和资源的异常 。P推出的non-sdk-调用限制,以后适配只会越来越难,成本越来越高 。
2、性能问题
用到一些黑科技导致底层的优化享受不到 。如加载补丁后,启动速度会降低5%~10% 。
2.14.4 各项热补丁技术的优缺点
缺点
优点
2.14.5 实现机制
官方使用热补丁技术实现 。
应用构建流程
构建 -> 部署 -> 安装 -> 重启app -> 重启
实现目标
尽可能多的剔除不必要的步骤,然后提升必要步骤的速度 。
构建的三种方式
1、
增量构建 -> 改变部署
场景:
适用于多数简单的改变(包括一些方法实现的修改,或者变量值修改) 。
2、Warm Swap
增量构建 -> 改变部署 -> 重启
场景:
一般是修改了 。
3、Cold Swap
增量构建 -> 改变部署 -> 应用重启 -> 重启
场景:
涉及结构性变化,如修改了继承规则或方法签名 。
首次运行 Run,执行的操作
原理
运行着任务来生成增量.dex文件(dex对应着开发中的修改类),AS会提取这些.dex文件发送到App ,然后部署到App 。因为原来版本的类都装载在运行中的程序了 , 会解释更新好这些.dex文件,发送到App 的时候,交给自定义的类加载器来加载.dex文件 。App 会不断地监听是否需要重写类文件,如果需要,任务会被立马执行 , 新的更改便能立即被响应 。
需要注意的是 , 此时是不能回退的,必须重启应用响应修改 。
原理
因为资源文件是在创建时加载,所以必须重启加载资源文件 。
注意:的值是在APK安装的时候被读取的,所以需要触发一个完整的应用构建和部署 。
原理
应用部署的时候,会把工程拆分成十个部分 , 每个部分都拥有自己的.dex文件,然后所有的类会根据包名被分配给相应的.dex文件 。当开启时,修改过的类所对应的的.dex文件,会重组生成新的.dex文件,然后再部署到设备上 。
注意:应用多进程会被降级为 。
2.15 ASM字节码插桩
插桩就是将一段代码插入或者替换原本的代码 。字节码插桩就是在我们的代码编译成字节码(Class)后 , 在下生成dex之前修改Class文件,修改或者增强原有代码逻辑的操作 。
除了、框架外,还有一个应用更为广泛的ASM框架同样也是字节码操作框架,Run包括就是借助ASM来实现各自的功能 。
可以这样理解Class字节码与ASM之间的联系 , 即JSON对于GSON就类似于字节码Class对于/ASM 。
ASM自动埋点方案实践
1.5.0版本以后提供了 API,允许第三方在打包dex文件之前的编译过程中操作.class文件,我们做的就是实现进行.class文件遍历拿到所有方法,修改完成后对文件进行替换 。
大致的流程如下所示:
1、自动埋点追踪 , 遍历所有文件更换字节码
AutoTransform -> transform -> inputs.each {TransformInput input -> input.jarInput.each { JarInput jarInput -> … } input.directoryInputs.each { DirectoryInput directoryInput -> … }}
2、插件实现
PluginEntry -> apply -> def android = project.extensions.getByType(AppExtension)registerTransform(android) -> AutoTransform transform = new AutoTransform?android.registerTransform(transform)
3、使用ASM进行字节码编写
ASM框架核心类
1、visit -> 在中根据判断是否是实现View$接口的类 , 只有满足条件的类才会遍历其中的方法进行操作 。
2、在中对该方法进行修改
visitAnnotation -> onMethodEnter -> onMethodExit
3、先在java文件中编写要插入的代码,然后使用ASM插件查看对应的字节码,根据其用ASM提供的Api一一对应地把代码填进来即可 。
2.16
原理
的粒度是Dex格式的每一项 , 的粒度是文件 , /Qzone的粒度为class 。
缺点
热补丁方案对比
若不care性能损耗与补丁包大?。琎zone是最简单且成功率最高的方案 。
2.17 完善的热补丁系统构建
一、网络通道
负责将补丁包交付给用户 , 包括特定用户和全量用户 。
1、pull通道
在登录/24小时等时机,通过pull方式查询后台是否有对应的补丁包更新 。
2、指定版本的push通道
在紧急情况下,我们可以在一个小时内向所有用户下发补丁包更新 。
3、指定特定用户的push通道
对特定用户或用户组做远程调试 。
二、上线与管理平台
快速上线,管理历史记录,以及监控补丁的运行情况 。
2.18 启动优化的常见问题(重要?。。?、启动优化是怎么做的?
在某一个版本之后呢 , 我们会发现这个启动速度变得特别慢,同时用户给我们的反馈也越来越多,所以,我们开始考虑对应用的启动速度来进行优化 。然后,我们就对启动的代码进行了代码层面的梳理,我们发现应用的启动流程已经非常复杂,接着 , 我们通过一系列的工具来确认是否在主线程中执行了太多的耗时操作 。
我们经过了细查代码之后,发现应用主线程中的任务太多,我们就想了一个方案去针对性地解决,也就是进行异步初始化 。(引导=>第2题) 然后,我们还发现了另外一个问题,也可以进行针对性的优化,就是在我们的初始化代码当中有些的优先级并不是那么高 , 它可以不放在的中执行,而完全可以放在之后延迟执行的,因为我们对这些代码进行了延迟初始化,最后,我们还结合了做了一个更优的延迟初始化的方案,利用它可以在主线程的空闲时间进行初始化,以减少启动耗时导致的卡顿现象 。做完这些之后,我们的启动速度就变得很快了 。
最后,我简单说下我们是怎么长期来保持启动优化的效果的 。首先,我们做了我们的启动器,并且结合了我们的CI,在线上加上了很多方面的监控 。(引导=> 第4题)
2、是怎么异步的,异步遇到问题没有?
我们最初是采用的普通的一个异步的方案,即new+ 设置线程优先级为后台线程的方式在的方法中进行异步初始化,后来 , 我们使用了线程池、的方式,但是,在我们应用的演进过程当中,发现代码会变得不够优雅,并且有些场景非常不好处理,比如说多个初始化任务直接的依赖关系,比如说某一个初始化任务需要在某一个特定的生命周期中初始化完成,这些都是使用线程池、无法实现的 。所以说,我们就开始思考一个新的解决方案 , 它能够完美地解决我们刚刚所遇到的这些问题 。
这个方案就是我们目前所使用的启动器 , 在启动器的概念中,我们将每一个初始化代码抽象成了一个Task,然后,对它们进行了一个排序,根据它们之间的依赖关系排了一个有向无环图,接着,使用一个异步队列进行执行,并且这个异步队列它和CPU的核心数是强烈相关的 , 它能够最大程度地保证我们的主线程和别的线程都能够执行我们的任务,也就是大家几乎都可以同时完成 。
3、启动优化有哪些容易忽略的注意点?
首先 , 在CPU 和中有两个很重要的指标,即cpu time与wall time,我们必须清楚cpu time与wall time之间的区别,wall time指的是代码执行的时间,而cpu time指的是代码消耗CPU的时间,锁冲突会造成两者时间差距过大 。我们需要以cpu time来作为我们优化的一个方向 。
其次,我们不仅只追求启动速度上的一个提升,也需要注意延迟初始化的一个优化,对于延迟初始化,通常的做法是在界面显示之后才去进行加载,但是如果此时界面需要进行滑动等与用户交互的一系列操作 , 就会有很严重的卡顿现象,因此我们使用了来实现cpu空闲时间来执行耗时任务,这极大地提升了用户的体验,避免了因启动耗时任务而导致的页面卡顿现象 。
最后,对于启动优化,还有一些黑科技,首先,就是我们采用了类预先加载的方式,我们在.方法之后起了一个线程,然后用Class.的方式来预先触发类的加载,然后当我们这个类真正被使用的时候,就不用再进行类加载的过程了 。同时 , 我们再看图的时候,有一部分手机其实并没有给我们应用去跑满cpu,比如说它有8核,但是却只给了我们4核等这些情况,然后,有些应用对此做了一些黑科技,它会将cpu的核心数以及cpu的频率在启动的时候去进行一个暴力的提升 。
4、版本迭代导致的启动变慢有好的解决方式吗?
这种问题其实我们之前也遇到过,这的确非常难以解决 。但是,我们后面对此进行了反复的思考与尝试 , 终于找到了一个比较好的解决方式 。
首先,我们使用了启动器去管理每一个初始化任务,并且启动器中每一个任务的执行都是被其自动进行分配的 , 也就是说这些自动分配的task我们会尽量保证它会平均分配在我们每一个线程当中的,这和我们普通的异步是不一样的,它可以很好地缓解我们应用的启动变慢 。
其次,我们还结合了CI,比如说,我们现在限制了一些类,如,如果有人修改了它,我们不会让这部分代码合并到主干分支或者是修改之后会有一些内部的工具如邮件的形式发送到我 , 然后,我就会和他确认他加的这些代码到底是耗时多少,能否异步初始化 , 不能异步的话就考虑延迟初始化,如果初始化时间太长,则可以考虑是否能进行懒加载,等用到的时候再去使用等等 。
然后,我们会将问题尽可能地暴露在上线之前 。同时,我们真正已经到了线上的一个环境下时,我们进行了监控的一个完善,我们不仅是监控了App的整个的启动时间,同时呢 , 我们也将每一个生命周期都进行了一个监控 。比如说的与方法的耗时,以及这两个生命周期之间间隔的时间,我们都进行了一个监控 , 如果说下一次我们发现了这个启动速度变慢了,我们就可以去查找到底是哪一个环节变慢了,我们会和以前的版本进行对比,对比完成之后呢 , 我们就可以来找这一段新加的代码 。
本文到此结束,希望对大家有所帮助 。