抖音一直显示此应用正在加速优化中,请稍后重试。打不开怎么办?

启动性能是 APP 使用体验的门面,启动过程耗时较长很可能导致用户使用 APP 的兴趣骤减,抖音通过对启动性能做劣化的 AB 实验也验证了其对于业务指标有影响显著。抖音拥有数亿的用户,启动耗时几百毫秒的增长就可能带来成千上万用户的留存缩减,因此,启动性能的优化成为了抖音 Android 基础技术团队在体验优化方向上的重中之重。

本文基于过往对抖音 Android 客户端做启动性能优化的实战经验总结提炼出普适性的方法论,并将该过程中沉淀的工具加以分享,希望能给大家带来一些新的思考。

抖音 Android 性能优化系列往期文章回顾:

假如你要负责优化抖音的启动性能,你会怎样去规划整体的优化方案?你可能会一下子想到很多方面的细节点,比如:要优化主线程耗时、要减少布局层级、要对某些启动任务做按需加载或预加载、要避免主线程 IO、要对线程使用进行优化、还要有分析工具帮助定位性能问题等……

然而,该如何系统性地把这些细碎点组织起来并按照一定的章法来落地启动优化呢?此时,需要我们在具体细节点之上有进一步的问题分解与深入思考,最终形成一套完整的方法论,不仅能覆盖所有细节点,还能切实指导在实战中达成启动优化的效果。切实有效的方法论必然是从实战中经过千锤百炼才能形成的,而抖音庞大的用户基数又进一步保障了方法论的可行性与普适性。那么接下来让我们带着前述问题来看抖音的启动优化方法论是怎样的又是如何应用于实战之中的。

抖音的启动性能优化方法论分为五部分,分别是:理论分析、现状分析、启动性能优化、线上验证与防劣化。

这五部分间存在明显的先后顺序,又能闭环达成可持续的启动性能优化,下面将对这五部分做详细阐述:

理论分析放在最先是为了从一开始就避免让视野受到限制,很多同学往往一开始接手启动优化就容易陷入对各种现状细节的分析,拘泥于片面的潜在可优化点,这样就难以做到对全局和优先级的把控,所以,我们应该首先跳出现状,从更加全局的视角来思考整体优化的目标和策略。这里可以利用特斯拉创始人——埃隆·马斯克所推崇的“第一性原理”思考法:

“通过第一性原理,把事情升华到最根本的真理,然后从最核心处开始推理。”

基于此,我们在做启动优化的理论分析时可以从更本源的角度出发做到全局思考,比如抖音会做从进程创建到页面展示的全启动路径分阶段耗时分析、还会按照消耗的系统资源类型做耗时成因分析,通过这种极致的耗时分析可以带来极致的优化策略,此外,从全路径出发还能够发现容易忽视的问题、探索优化的极限。

在完成理论分析后,我们基本具备了全局的视角,并且也大致清楚了整体的优化目标和策略,接下来就要基于此来做现状分析从而明晰实现目标的具体路径:

  • 首先使用 profile 工具对可优化点进行摸底:其实不合理的高耗时点就是潜在的优化点,并能按照前述的理论分析归入一个或多个耗时成因中;

  • 然后结合线上的指标数据确定最终优化方向:线下摸底的潜在优化点要结合其线上打点确认是否为普遍耗时,再根据耗时成因明确大致的优化思路、实施成本和预估收益。

在这部分需要尤其注意三点:优质的 profile 工具(这里推荐使用同样来自基础技术团队的“”)、线下 trace 结合线上监控综合分析根据投入产出比评估实施优先级,这三点是保障切实有效取得启动优化收益的关键。

在完成了理论和现状分析后,就可以根据规划的路径来实施具体的启动优化项了。在实施过程中,主要考虑主线程优化、后台线程优化和全局优化三个维度:

  1. 等,完成耗时归因后可以使用逐步升级的优化策略来逐个击破:对于首屏所必须的耗时逻辑做正面优化(可使用缩减耗时逻辑、异步并发、延迟加载等手段)、对于非首屏必须的耗时逻辑做按需加载(需要架构优化的基础)、对于优化后仍存在耗时的逻辑尝试做业务降级(大都有损需评估全局收益);

  2. 后台线程优化策略与主线程类似,在此基础上还可以实施后台任务缩减、线程收敛、开启多进程等优化措施;此外,主线程和后台线程均存在较多启动任务且彼此间可能存在关联,因此,可以对全局的启动任务做依赖关系梳理并实施精细化的任务重排,旨在减少依赖任务间的等待耗时;

  3. 全局优化主要是指业务无关的通用的全局优化策略,如虚拟机层面或 IO 层面的优化等。

在完成了具体的优化项施工后,就来到了线上验证大盘收益的阶段。这个阶段有三点需要注意:

  1. 线下的优化一定要有线上的指标反馈,线下的优化项因为设备或操作习惯差异往往难以评估是否具备普遍影响,只有当相应的线上指标取得正面反馈后才能验证拿到了有效的优化收益;

  2. 线上指标需要结合均值与分位值综合来评估,只关注启动耗时的均值往往会掩盖低分位设备的现状,这部分设备可能占比不高,对均值影响有限,但抖音庞大的用户基数乘以该比例仍旧是不小的数量,为了保障该部分用户的启动性能体验,抖音一般会分 50%、70%、90%三个分位值来评估指标;

  3. 在验证收益时通过 AB 实验达成,这样做不仅能控制变量确保优化项的严格有效,还能借此来观察性能优化所带来的业务指标收益,这些都可以作为规划后续启动优化方向的参考指导。

在线上验证优化措施取得切实收益后,并不是万事大吉了,持续保持住优化效果才算完整达成了启动性能优化的目的。其实不仅是启动优化,整个性能优化领域都是围绕着“”和“”来展开的,“攻”即为前述的分析与优化,而“守”则是防止劣化,在防劣化方面大家往往不会像优化的方面那么重视,但实际上能防止劣化是可持续取得优化效果的前提(否则新的优化效果会用于弥补劣化甚至入不敷出),并且防劣化相比于优化是更能持久有益的。

抖音启动性能防劣化的进程分为了三个时期,不同时期有不同的表现与应对手段,这很可能是大多数 APP 优化启动性能都要经历的,这里提炼出来以供参考:

  1. 快速下降期:此时一般位于启动优化的初始阶段,优化空间很大,伴随有小幅度的劣化但往往都能被更大幅度的优化抵消且还仍有收益,这时应该抓大放小,按照更高投入产出比的策略重点推进优化,同时也抽出少部分精力治理修复成本低的劣化。

  2. 瓶颈期:到了该时期绝大部分优化收益已经拿到,想进一步做到优化往往需要投入更多成本,且优化幅度有限,整体的投入产出比不高,同期还会伴随有中小幅的劣化,此时需要建立完善的线上线下监控体系,及时发现并修复劣化,此外还要通过架构改造从源头上限制劣化的发生,综合保障优化的收益不会被劣化抵消。

  3. 劣化期:这个时期往往出现在年关或重要节日期间,这类时间点往往有重要且紧急的活动项目上线,众多关联方面均要为其开绿灯,启动性能指标也不例外,为了保障活动效果可能要加入若干耗时的主线程启动任务,所带来的的劣化幅度往往比较大,此时需要对齐预期并在活动结束后及时修复。

启动优化方法论的应用实践

古人云“纸上得来终觉浅,绝知此事要躬行”,前述的方法论讲得再详细再透彻也会与实际的落地存在隔阂,为了做到真正的学以致用,下文将细致讲解如何将启动优化方法论应用于实践之中。

抖音在理论分析部分会对启动流程分别作全路径分析和耗时成因分析,前者用于发现全路径各个阶段的潜在耗时点避免疏漏,后者用于系统性地将各个耗时点归因从而引导我们找寻优化思路,关于这两部分的具体实践如下:

启动性能全路径分析:抖音的启动路径和大多数 APP 类似,整体分为两大阶段和两个间隙,它们按时间顺序排布为:Application 阶段、handle message 间隙、Activity 阶段和数据加载间隙,全路径各部分细分涵盖的内容如下图所示:

作为 Android 开发者,相信大家都碰到过 Java OOM 问题,导致 OOM 的原因可能是应用存在内存泄漏,也可能是因为手机的 heapsize 比较小不能满足复杂应用对内存资源的大量需求。对于 Java 内存泄漏治理,业界已经有比较成熟的方案,这里不做介绍,本文主要针对第二点尝试进行分析和优化。

举个例子:我们在监控平台查看稳定性数据,发现 heapsize=256M 的设备发生的 OOM 崩溃最多,而 heapsize=512M 的设备很少发生 OOM 崩溃。且除此之外,还有一个特点:OOM 崩溃绝大多数发生在 Android 8.0 之前的设备。

对于这种 heapsize 较小难以满足业务复杂度的情况,可能有以下几种方式来解决:

如果我们已经设置了 largeHeap,也就没有常规的提升 heapsize 的方式了;再想往前一步,可以尝试从虚拟机中突破这个限制,因为 heapsize 是虚拟机的配置,是否抛出 OOM 异常也是在虚拟机中决定的;修改虚拟机运行逻辑是有一定可能的,但是其难度和可行性与想要修改的内容相关性较大,修改方案的稳定性也需要非常深厚的功力才能保证,而如果运气不好,找不到好的切入点,甚至从理论上都无法保证其稳定性,那么达到上线的难度就更大了,本文不在这个方向深入。

2. 降低业务复杂度,裁剪应用功能

这个方案也不在我们的考虑范围之内,实际上很多应用都有推出极速版,但是功能都会有所裁剪,对于使用常规版本的用户,我们也不能推送极速版,因为使用体验会有很大变化。

3. 分析 Java Heap 里的内容都是什么,尝试发现主要矛盾进行优化,对症下药

实际上本文就是从这个方向经过调查后,找到了一个相对稳定的突破口。下面是结合 OOM 堆栈、android 版本、heapsize 维度对 OOM 整体概况的一个分析:

出现最多的堆栈就是 Bitmap 创建时内存不足从而 OOM 崩溃,那么是不是已使用的内存大多都是 Bitmap 呢 ?不能 100%确定,因为直接触发 OOM 崩溃的原因是最后一次内存分配失败,而真正的原因是 OOM 之前的内存分配;但是仍然有一定可能性,因为总是出现同一个堆栈可能并不是巧合,可以在一定程度上说明这个堆栈执行的比较频繁,而且 Bitmap 一般占用内存较大。

这里先做一个不 100%确认的初步推断:OOM 时 Java heap 中占用内存较多的对象是 Bitmap

继续对 OOM 数据做总结后发现了 OOM 的分布规律如下图:

第四象限的数据说明,即便在 heapsize 较小的情况下,在 android 8.0 之后的版本上也不容易发生 OOM,结合上面的初步推断信息“OOM 时 Java heap 中占用内存较多的对象是 Bitmap”,很容易想到,应该是 Bitmap 在 android 8.0 前后的实现变化导致了当前的 OOM 分布现象:

由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以在前面的推测 “OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。

至此,得到了确定的结论

根据上述结论,目标也就比较清晰了:

二、Bitmap 使用分析和方案调查

如下堆栈描述了 Bitmap 的创建:

这个信息可以作为一个切入点,在后面进行深入调查。

通过初步的分析,初步有两个思路可以先进行尝试:

这个思路看起来想要实现目标,做一下替换就可以了,但实际上没有这么简单,存在的问题如下:

  1. Bitmap 内存的申请和释放要有匹配的逻辑和合适的时机

所以这个思路基本可以断定不可行。

其中 External 方式存储 Bitmap 像素,在源码中没有看到相关使用,无法参考;Java 类型就是默认的 Bitmap 创建方式,像素内存分配的 Java 堆上;Ashmem 方式存储 Bitmap 像素的方式在源码中有使用,主要是在跨进程 Bitmap 传递时使用,对应的场景主要是 Notification 和截图场景:

但经过详细的源码分析以及实际验证,其可行性仍然很低,主要原因如下:

实际情况中,6.0 系统的 OOM 占了非常大一部分,如果这个方案可行,也可以解决一部分问题,所以不会因为这个原因阻碍对这种方案的尝试,还可以继续尝试

2 . ashmem 方式存储 Bitmap 像素,每个 Bitmap 需要对应一个 fd,应用的 Bitmap 使用数量是能够达到 1000+ 的,这样可能会导致 fd 资源使用耗尽,从而发生崩溃

这个问题基本是无解的,但如果方案可行,可以尝试只给一定数量的 Bitmap 使用 ashmem 方式申请像素内存,比如 500 个;所以方案还可以继续尝试

3 . 最终尝试后发现这种方式影响 Bitmap 正常功能(一些视频动图不能正常展示),经分析主要原因是使用 ashmem 申请的 Bitmap 无法进行 reconfigure :

方式创建的 Bitmap 没有从 Java 堆申请 mBuffer,所以一定是不支持 reconfigure 的。当然到这里之后还没有完全堵死这个方式,还可以继续尝试在 ashmem 方式申请 Bitmap 时给其一个假的 mBuffer 来绕过这个限制,但接下来要做的调查和改动势必很大,因为 ashmem 方式申请 Bitmap 本身不支持 mBuffer 的管理,新创建的 buffer 就难以找到合适的时机进行释放。

结合上述 3 个点综合判断,这个方案限制比较多,也有一定风险,所以暂时将当前的方案暂时挂起,作为备用方案。

上述的两种思路不成功其实有一定的必然性,毕竟对应代码的设计并不是为了给我们取巧做切换用的。既然没有办法这么容易实现,就深入调查清楚为 Bitmap 从 Java 堆申请内存的流程和这个内存的使用流程,再尝试从这些流程中找到切入点进行修改。

思路 3:剖析 Java 堆分配 Bitmap 内存的过程,再尝试找到方案

实际就是查找 hook 点的思路,先分析内存是如何分配的,分配出来的内存是如何使用的(主要指分配出内存后,指针或者对象的传递路径),尝试把从 Java 堆分配内存的关键点替换为使用 malloc/calloc 函数从 Native 堆上进行分配,并把分配出来的内存指针构造成原流程中使用的数据结构,并保证其能够正常运行。

上图为简化后的核心内存分配流程,框起来的部分就是为 Bitmap 从 Java heap 申请像素内存的代码。其中:

这里需要先说明一下 java byte array 的内存布局(对应代码在 ART 虚拟机中):

想要把 Bitmap 内存分配改为在 Native 层分配,就需要从分配这里入手, 所以必须要把 arrayObj 和 addr 使用梳理清晰,为后续替换和适配做好铺垫。arrayObj 和 addr 使用如下:

Bitmap 像素的内存地址,即 arrayObj 的元素地址 addr,其是作为指针类型数据来使用的。

小结:addr 指向的内存是在 java 堆上,其会在需要的时候被传递给 skia 用来处理 bitmap 像素数据。

skia 中并不会为 Bitmap 的像素数据分配内存,它把 Java heap 上 byte 数组的元素首地址转换为 void* 来使用;也就是说在当前实现中,Bitmap 像素内存不一定非得是在 Java heap 上分配,我们可以 malloc 一块内存传递给 skia 使用,并不需要再给 skia 做任何适配。

有了上面这些信息,把 android 8.0 之前的 Bitmap 像素内存改到在 Native 层分配目标就看到了希望,因为不需要在 skia 层适配,可以降低一定难度。

根据上面的分析,只需要找好 hook 的切入点,并完成 3 个关键点的替换即可,如下图:

Bitmap 的内存分配了),所以只能给个假的。

这种方式看起来好像不太稳定,但是可以通过校验来保证,比如我们在执行方案之前先尝试伪造一个 byte array 来进行验证,如下代码就是申请了 1 字节长度的 byte array,把它的长度伪造成 36,然后进行校验,校验失败则不再执行 NativeBitmap 方案。

的距离。接下来需要完成 malloc 出来的 Bitmap 内存的释放逻辑。

  1. 给 Java Bitmap 使用的小 size 的 byte array 对象,这个对象仍然按照原生逻辑释放,无需再做其他变动
  2. malloc 出来的用以存放 bitmap 像素数据的内存,在 byte array 释放时进行 free,相当于附着于原生的内存释放逻辑,从而不会影响 Bitmap 的生命周期

实现释放有两个关键点:

1 . malloc 出来的指针需要与 mBuffer 关联,这样才能在 mBuffer 释放时找到对应的内存进行释放

上的方案理论上完全可以实现,且需要的改动不大,只需要在原生 Bitmap 的创建流程和释放流程中做好修改即可。

根据上述思路 3 的方案,最终实现如下:

改造前 Bitmap 的创建和内存申请流程:

改造后 Bitmap 的创建和内存申请流程:

改造后在 Bitmap 创建过程中做了两个 hook,对应上图中两条紫色箭头指向的代码:

在 addressOf 的代理函数中根据前 4 个字节数据是否是 magic number 来判断传入进来的 array 是否是被改造的 array,如果不是则调用原函数进行返回,如果是则继续进行下述步骤;

  • 把 bitmap 指针返回,由原生逻辑在后续传递给 skia 使用;

在后面释放 Bitmap 相关内存时会使用到 byte array 中填充的这些数据。

在前面提到过申请的 fakeArray 本身占用的内存就作为 Bitmap 内存转移到 Native 层的代价,到这里及可以计算一出 Bitmap 被转移到 Native 层需要付出的内存代价是多少 ?

上图中虚线上方为原生的释放流程,虚线下方是在原生流程上新添加的释放流程。其中右侧的代码就是新的逻辑下对 Bitmap 像素数据和辅助数据释放的关键代码。释放逻辑已经在第二大节中的 [新的释放逻辑] 中说明,这里不再复述。

对象,从而回收其对应的 Native 层像素内存。

这种情况可以通过在 native 内存申请和释放时通知到虚拟机,由虚拟机来判断是否达到 GC 条件,来进行 GC 的触发。实际上 android 8.0 之后 Bitmap 内存申请和释放就是使用的这个方式。

目前该方案支持到 android 5.1.x ~ 7.x 的系统。4.x~5.0 的系统较早,实现差异较大,待后续完善。

四、线下验证和线上效果

在测试代码中尝试把一个 bitmap 缓存 5001 次:

完成加载 5001 个 Bitmap,并且应用仍能够正常使用:

在使用中我们对 NativeBitmap 方案的使用做了限制,因为 Bitmap 内存转移到 Native 层之后会占用虚拟内存,而 32 位设备的虚拟内存可用上限为 3G~4G,为了减少对虚拟内存的使用,只在 heap size 较小的机型才开启 NativeBitmap。我们在持续的优化中发现 Android 5.1.x ~ 7.1.x 版本上,已经有很多设备是 64 位的,所以当用户安装了 64 位的产品时,就可以在 heap size 较大的机型上也开启 NativeBitmap,因为此时的虚拟内存基本无法耗尽。在 64 位产品上把开启 NativeBitmap 的 heap size 限制提升到 512M 之后,Java OOM 数据在优化的基础上又降低了 72%。

有两个问题做一下说明:

答:并不是,NativeBitmap 只是把应用内存使用的大头(即 Bitmap 的像素占用的内存)转移到 Native 堆,如果其他的 Java 对象使用不合理占用较多内存,仍然会发生 Java OOM

2 . 方案可能产生的影响?

Bitmap 的像素占用的内存转移到 Native 堆之后,会使得虚拟内存使用增多,当存在泄漏时,可能会导致 32 位应用的虚拟内存被耗尽(实际上这个表现和 Android8.0 之后系统的表现一致)。

所以,方案的目标实际是为了使老的 android 版本能够支持更复杂的应用设计,而不是为了解决内存泄漏。


我要回帖

更多关于 正在优化第一个应用 的文章

 

随机推荐