本文作者:九心 ,原文发布于:九心说 。 前言 第一篇《Android启动这些事儿,你都拎得清吗?》从源码的角度分析了启动流程。 第二篇《进阶应用启动分析,这一篇就够了!》讲了如何使用工具测量启动流程。 今天我将结合自己的过往工作经验,分享一下常见的启动优化和一些黑科技的实操。
因为在实际的分析过程,一定是我们懂得了自己应用的启动阶段的各个耗时点,然后对这些流程分析,最终做出针对性的优化策略。 最简单来讲,我们自己的应用的启动时长怎么定义的,启动的开始点在哪里,结束点在哪里。举个例子,我们App之前定义的两个点: 开始点 :拦截的 ActivityThread 里面的消息机制 Application 创建的点。 结束点 :第一个 Activity 的 onWindowsFocusChanged 方法。 我们知道, onWindowsFocusChanged 回调发生在 Activity#onCreate 之后,又在第一帧vSync之前,也就是途中的的 Displayed Time 和 reportFullyDrawn 之间。所以对于我来说,就可以知道我们的应用的优化范围在 Application#onCreate 和闪屏页,后面就可以持续的对这一块儿做优化。 另外一个重点就是关于启动工具的选择。对于启动流程的分析,我强烈建议使用 Android Studio Profiler 工具,使用其中的 TraceSystemCall 功能,优点如下: 分析各种系统资源 :CPU使用情况、显示(Vsync信号、卡顿市场)、一些核心函数的耗时。 函数插桩 :对于想关注的其他方法耗时,可以通过函数插装来实现。 可以看到,系统资源的展示是比较全面的。
1、梳理冗余逻辑 梳理冗余逻辑这个词看着比较简单,实际做起来也是不难,主要是各种业务的权衡与取舍。 1.1 去除历史包袱 做启动优化的第一步是梳理启动业务流程,如果我们开发的是一个中大型应用,那么其中的很多流程是把握不准的,因此我之前的策略是和同事在周会上review代码,将启动过程的业务一个个的过,标记下不用的业务,然后在后续开发中下线。 对于更大型的团队,如大众点评,遇到相关的不熟的业务,不仅要和团队内沟通,还需要和团队外的其他业务方进行沟通,了解相关的启动业务的使用情况。 1.2 了解业务使用时机 梳理完启动业务的流程以后,我们需要对启动的业务的使用有一定的了解。对于时间偏长的任务,我们去思考一下,这个任务真的有必要在启动中去使用吗?是否可以使用懒加载,这可能需要和相关的业务方进行Argue。 2、启动框架 我想各位同学一定知道,在启动过程中,如果遇到耗时任务,可以根据情况放到异步线程,但是异步线程也会有一些特殊情况: 时机保证 :有一些重要的异步线程任务,如何保证在在启动结束后,能够及及时使用到对应的功能。 任务时机 :有一些任务是有依赖关系的,如何保证任务的执行顺序。 这也是我们使用启动框架原因,利用多核的CPU + 多线程,高效率、并行、有序、按时执行启动任务。 2.1 基础框架 我之前使用过的启动框架有:android-startup。 主线程重要的任务 : Application#onCreate 结束前主线程执行完成 子线程重要任务 :交给线程池执行,也会在 Application#onCreate 结束前执行完成 任务排序 :很好的处理了任务依赖关系,如果发生了循环依赖,可以在任务的拓扑排序阶段,就对外抛出异常 记录任务耗时 :统计好各个任务的时长,记录下来,后期有需要,可以上传到埋点 主线程不重要任务 :我们可能还有主线程不太重要的任务,这个时候可以交给idleHandler执行 更多的执行时机 :这个启动框架主要针对的时机是 Application#onCreate ,我们可以有更多的时机,比如首页初始化、首页空闲时、次级页面打开时,通过划分更多的时机,可以缓解CPU、线程池和内存的压力,从而降低启动时长。 2.2 动态调整启动任务的执行顺序 通常启动框架去执行启动任务的时候,顺序都是确定的。有时,我们会对外进行广告投放,想给用户一个比较有吸引力的落地页,比如我在腾讯体育看到一个京东的目的商品的广告投放页,像这样: 这个时候落地页可能是一个活动页,那这个活动页的技术栈和首页的技术栈大概率是不一样的,那么我们是否可以针对活动页的技术栈调整一下启动任务顺序,从而降低启动时长。 加入标记 :在对外投放的Deeplink中,加入相关的标记。 识别标记 :在Android或者iOS启动过程中,可以通过Hook的方式或者其他方式,在启动的早期,拿到相关的参数。 改变任务执行优先级 :可以在系统中静态注册相关标记下的另外一套任务执行级顺序,或者动态下发也可以。 核心的想法就是跟落地页相关的技术栈的任务时机往前挪,不相关的任务往后挪,从而保证启动时长的最低。 3、线程梳理 在启动过程中,如果线程资源不加以限制,线程数量可能就有几百个,这会有什么问题呢? 资源消耗过高 :每个线程都需要一定的系统资源,包括内存、CPU时间等。 上下文切换开销 :操作系统需要在多个线程之间进行上下文切换,以便让每个线程都有机会执行。频繁的上下文切换会带来额外的开销,影响应用程序的整体性能。 线程的数量可以在性能分析工具中查看。具体的治理策略有: 对于一些可以替换线程池的第三方库,替换成内部使用的线程池。 将第三方SDK中开源库,在核心线程空闲的时候,也能够进行释放。 先讲一下第一点,如果项目团队不大,开发的人员都在一个项目中开发,那么我们使用全局搜索就可以定位new Thread的位置,但对于第三方库中的创建却无从定位。那如果是大的项目,每个团队都有自己的开发模块,这种怎么定位呢? Booster有给我们具体的解决方案,在字节码Transform的时候,将线程的调用方,然后传递给线程,运行的时候给它打印出来。 再简单讲一下第三点吧,可以看Booster框架,它提供了一些思路,也是在Transform的时候,将第三方的线程池的 allowCoreThreadTimeOut 设置为true,让它可以在空闲的时候能够进行释放,除此以外,还可以: 4、闪屏页优化 有对应业务的闪屏 :比如说可以自定义闪屏页、或者承接开屏广告的工作,如起点读书,B站。 4.1 纯闪屏页 对于纯闪屏的应用,给人的感觉就是启动速度非常快,因为启动第一个Activity可能就是我们的首页。 这里有一个优化措施就是利用StartWindow机制,简单介绍一下,在Android的启动过程中,在第一个Activity真正显示之前,系统会会提供一个页面来进行过渡,我们称之为StartWindow。StartWindow会在应用的第一个Activity绘制完成以后被移除。 默认情况下根据主题而定,白色或者黑色,我们也可以设置成自定义的颜色或者图片。 启动的时候,为首个Activity提供一个带闪屏页的主题。 在进入Activity以后,在 onCreate 方法中设置透明主题。 通过在onCreate中设置透明主题,我们可以减少绘制一层背景,通常我们在首页中,也不需要带背景。 4.2 携带业务的闪屏页 对于承接业务的闪屏页,这类应用启动的第一个页面一般就不是首页,它就是一个单独的闪屏页Activity,里面会有一些广告处理的逻辑。 这里也有一些具体的优化措施,除了上述的StartWindow机制以外,还有: 一般这类的闪屏页的元素也比较简单,将xml布局改成动态创建View,可以减少读取xml文件和反射创建View的时间,在中低端的效果还是比较明显的。 5、系统资源处理 系统资源指的是锁竞争、IO治理、CPU治理,通过监控这些数据,然后分析一下其中的不合理之处,这个其实是一个细活,需要通过Perfetto和Profiler工具查看。 6、Baseline Profile 早期的Android虚拟机采用的是Dalvik,为了提高Java执行的效率,在虚拟机中采用JIT(Just in time)技术,在运行的时候将高频的方法编译成机器码,但是JIT编译的机器码是存在内存中的,下次冷启动,这些数据会丢失,对于类似服务端长期运行的Java应用来讲,提效明显。对于Android应用来讲,应用可能需要经常重启,显然就不是那么友好了。 AOT(Ahead of time)是一种预编译机制,可以将Apk中的字节码编译成二进制的机器码,减少运行时间。Android 7.0以前,在安装的时候,会将全部的字节码编译成机器码,但这会有两个问题: Android 7.0以后支持JIT和AOT并存的编译模式,其中AOT中有两种编译策略值得关注: quicken :应用安装时的编译模式,相对编译速度较快,占用空间合理。 speed-profile :系统后台触发的编译模式,按照用户的习惯进行特定的优化。 所谓的Baseline Profile,指的是提前扫描我们的热点代码,生成配置文件,然后在安装的时候,对这些热点代码做AOT,可以看一下谷歌官方给的流程图: 这个是借助Google Play实现的,所以针对国内的应用,可行吗? 根据网易云音乐得出来的结果,AOT其实有两种场景: 安装时AOT :在安装或者更新过程中,提前对这些热点代码aot,从而降低我们的启动时长,国内厂商对这一块儿支持的比较少,仅少数厂商支持。 还有一种场景就是启动后对Profile文件进行aot,流程如下图: 从网易云优化的结果来看,第一种方案提升明显,可以降低30%的启动时长(应该是安装或者更新后的首次启动时长),第二种仅有5%。
我们再来聊聊黑科技,其中的一些策略需要投入比较长的时间,一个人还是比较难搞定的。
1、Apk资源重排 1.1 背景 Android底层运行着Linux系统,当App启动时,需要通过Linux系统从磁盘中加载很多文件到内存中,比如代码、资源文件(Manifest文件、布局、图片),把这些文件加载到内存中。 两种文件加载方式都会把文件内容加载到pagecache中,如果读取文件已经在pagecache中,就不会发生真正的磁盘IO,而是直接从pagecache中读取,这就大大提升读的速度。 1.2 内部优化策略 为了提升磁盘读取效率,Linux采取了预读机制。简单来说: 单个文件的第一次读取,系统读入所请求页面的后面几个页面作为缓存。 如果下次要读取的页面不在缓存中,则表明此次的文件访问不是顺序访问,系统会采用之前的同步预读方式。 如果读的页面命中,系统会把之前预读的页面扩大一倍,但是这个过程是异步的。 如果我们启动过程中,读取的apk文件按实际加载顺序排列,就能充分的利用Linux预读机制,减少启动过程中的磁盘IO,从而降低启动时间。 1.3 技术策略 那么我们能做的就是统计这些资源文件的命中率,代码文件其实受AOT影响,拿到启动资源文件的顺序以后,重新打包,中间涉及的流程还是挺复杂的。 可以参考:《支付宝 App 构建优化解析:通过安装包重排布优化 Android 端启动性能》 2、dex2aot触发 dex2aot指的就是我们在Baseline Profile方案中说的aot,aot的时机有很多种,常见的有: 这些其实是系统帮我们触发的,并且也具有不确定性,我们是否可以让应用处在后台的时候主动触发这些流程吗? 答案是肯定的,查看源码的时候发现,aot的流程都是由 PackageManagerService 触发的,其中的函数 pefromdexOpt 可以通过一些手段被我们主动触发。 可以参考:《Android ART dex2oat 浅析》。 3、启动阶段抑制GC 在启动过程中,我们希望合理的使用CPU,避免启动过程中CPU被一些任务长时间的占用。下图是通过使用字节的Btrace结合Perfetto分析得出来启动流程中的HeapTaskDaemon执行情况: 我们可以发现了HeapTaskDaemon线程占用了比较高CPU时间片,这个线程实际上是虚拟机执行GC操作的。 简单介绍一下,HeapTaskDaemon是一个守护线程,随着Zygote线程一起启动,HeapTaskDaemon做的就是无限从执行GC的HeapTask集合里面取任务执行,对于需要延时的任务,会阻塞到目标执行。 那么我们可以通过获取系统的HeapTask,并让这个HeapTask休眠,同样能达到抑制HeapTaskDaemon线程执行的目的。这个过程比较复杂,可以参考: 《速度优化:GC抑制》。 需要指出的是,在 Android 8.0 以后,在应用启动的时候,会默认执行 TriggerPostForkCCGcTask ,该任务可以将GC延后2秒执行,所以我们看到,上面的HeapTaskDaemon并不是一开始就执行的。所以我们需要分析一下,在启动还没完成的场景下,就GC的场景是不是很多。 4、保活 现在大部分包活策略,都不太行的通了,但是之前看过某个开源项目,安装以后,即使用户手动点击强行停止,该软件也能重新启动。 https://github.com/fgkeepalive/AndroidKeepAlive
TechMerger里面的一篇文章也有介绍,原因如下: 地址:https://mp.weixin.qq.com/s/E038lXvQwMCn0Neeb4RV7Q 里面的作者反编译后得出的结论是,文中的流氓软件主要做了: 被杀后重启:通过高优先级的native进程进行监听。 可以看到,流氓软件还是做了很多东西,有兴趣的读者可以看一下原文。
本文中涉及到的很多内容都没有深入讲解,只是提供了一个思路和一些策略,希望做一个抛砖引玉。如果你有更好的想法,欢迎评论区留言。
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读 :
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!