面试---基础性能篇

— 转载自极客时间《Android开发高手课 》,作者张绍文。
面试笔记用,更多请看张绍文专栏。

包大小

1. 为什么要优化包体积

在 2018 年的 Google I/O,Google 透露了 Google Play 上安装包体积与下载转化率的关系图。
在这里插入图片描述
从这张图上看,大体来说,安装包越小,转化率越高这个结论依然成立。而包体积对应用的影响,主要有下面几点:

  • 下载转化率。一个 100MB 的应用,用户即使点了下载,也可能因为网络速度慢、突然反悔下载失败。对于一个 10MB 的应用,用户点了下载之后,在犹豫要不要下的时候已经下载完了。但是正如上图的数据,安装包大小与转化率的关系是非常微妙的。10MB 跟 15MB 可能差距不大,但是 10MB 跟 40MB 的差距还是非常明显的。
  • 推广成本。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。
  • 应用市场。苹果的 App Store 强制超过 150MB 的应用只能使用 WiFi 网络下载,Google Play 要求超过 100MB 的应用只能使用APK 扩展文件方式上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。

目前成熟的超级 App 越来越多,很多产品也希望自己成为下一个超级 App,希望功能可以包罗万象,满足用户的一切需求。但这同样也导致安装包不断变大,其实很多用户只使用到很少一部分功能。

下面我们就来看看微信、QQ、支付宝以及淘宝这几款超级 App 这几年安装包增长的情况。
在这里插入图片描述
我还记得在 15 年的时候,为了让微信 6.2 版本小于 30MB,我使用了各种各样的手段,把体积从 34MB 降到 29.85MB,资源混淆工具 AndResGuard 也就是在那个优化专项中写的。几年过去了,微信包体积已经涨到 100MB 了,淘宝似乎也不容乐观。相比之下,QQ 和支付宝相对还比较节制。

2. 包体积与应用性能

React Native 5MB、Flutter 4MB、浏览器内核 20MB、Chromium 网络库 2MB…现在第三方开发框架和扩展库越来越多,很多的应用包体积都已经几十是 MB 起步了。那包体积除了转化率的影响,它对我们应用性能还有哪些影响呢?

  • 安装时间。文件拷贝、Library 解压、编译 ODEX、签名校验,特别对于 Android 5.0 和 6.0 系统来说(Android 7.0 之后有了混合编译),微信 13 个 Dex 光是编译 ODEX 的时间可能就要 5 分钟。
  • 运行内存。在内存优化的时候我们就说过,Resource 资源、Library 以及 Dex 类加载这些都会占用不少的内存。
  • ROM 空间。100MB 的安装包,启动解压之后很有可能就超过 200MB 了。对低端机用户来说,也会有很大的压力。在“I/O 优化”中我们讨论过,如果闪存空间不足,非常容易出现写入放大的情况。

对于大部分一两年前的“千元机”,淘宝和微信都已经玩不转了。“技术短期内被高估,长期会被低估”,特别在业务高速发展的时候,性能往往就被排到后面。

包体积对技术人员来说应该是非常重要的技术指标,我们不能放任它的增长,它对我们还有不少意义。

业务梳理。删除无用或者低价值的业务,永远都是最有效的性能优化方式。我们需要经常回顾过去的业务,不能只顾着往前冲,适时地还一些“技术债务”。
开发模式升级。如果所有的功能都不能移除,那可能需要倒逼开发模式的转变,更多地采用小程序、H5 这样开发模式。

包体积优化

国内地开发者都非常羡慕海外的应用,因为海外有统一的 Google Play 市场。它可以根据用户的 ABI、density 和 language 发布,还有在 2018 年最新推出的App Bundle
在这里插入图片描述
事实上安装包中无非就是 Dex、Resource、Assets、Library 以及签名信息这五部分,接下来我们就来看看对于国内应用来说,还有什么高级“秘籍”。

1. 代码

对于大部分应用来说,Dex 都是包体积中的大头。看一下上面表格中微信、QQ、支付宝和淘宝的数据,它们的 Dex 数量从 1 个增长到 10 多个,我们的代码量真的增长了那么多倍吗?
而且 Dex 的数量对用户安装时间也是一个非常大的挑战,在不砍功能的前提下,我们看看有哪些方法可以减少这部分空间。

ProGuard
“十个 ProGuard 配置九个坑”,特别是各种第三方 SDK。我们需要仔细检查最终合并的 ProGuard 配置文件,是不是存在过度 keep 的现象。

你可以通过下面的方法输出 ProGuard 的最终配置,尤其需要注意各种的 keep *,很多情况下我们只需要 keep 其中的某个包、某个方法,或者是类名就可以了。

-printconfiguration  configuration.txt

那还有没有哪些方法可以进一步加大混淆力度呢?这时我们可能要向四大组件和 View 下手了。一般来说,应用都会 keep 住四大组件以及 View 的部分方法,这样是为了在代码以及 XML 布局中可以引用到它们。

-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.view.View

事实上,我们完全可以把非 exported的四大组件以及 View 混淆,但是需要完成下面几个工作:

  • XML 替换。在代码混淆之后,需要同时修改 AndroidManifest 以及资源 XML 中引用的名称。
  • 代码替换。需要遍历其他已经混淆好的代码,将变量或者方法体中定义的字符串也同时修改。需要注意的是,代码中不能出现经过运算得到的类名,这种情况会导致替换失败
// 情况一:变量
public String activityName = "com.sample.TestActivity";
// 情况二:方法体
startActivity(new Intent(this, "com.sample.TestActivity"));
// 情况三:通过运算得到,不支持
startActivity(new Intent(this, "com.sample" + ".TestActivity"));

代码替换的方法,我推荐使用 ASM。不熟悉 ASM 的同学也不用着急,后面我会专门讲它的原理和用法。饿了么曾经开源过一个可以实现四大组件和 View 混淆的组件Mess,不过似乎已经没在维护了,可供你参考。

Android Studio 3.0 推出了新 Dex 编译器 D8 与新混淆工具 R8,目前 D8 已经正式 Release,大约可以减少 3% 的 Dex 体积。但是计划用于取代 ProGuard 的R8依然处于实验室阶段,期待它在未来能有更好的表现。

去掉 Debug 信息或者去掉行号
某个应用通过相同的 ProGuard 规则生成一个 Debug 包和 Release 包,其中 Debug 包的大小是 4MB,Release 包只有 3.5MB。
既然它们 ProGuard 的混淆与优化的规则是一样的,那它们之间的差异在哪里呢?那就是 DebugItem。
在这里插入图片描述
DebugItem 里面主要包含两种信息:

  • 调试的信息。函数的参数变量和所有的局部变量。
  • 排查问题的信息。所有的指令集行号和源文件行号的对应关系。
    事实上,在 ProGuard 配置中一般我们也会通过下面的方式保留行号信息。
-keepattributes SourceFile, LineNumberTable

对于去除 debuginfo 以及行号信息更详细的分析,推荐你认真看一下支付宝的一篇文章《Android 包大小极致压缩》。通过这个方法,我们可以实现既保留行号,但是又可以减少大约 5% 的 Dex 体积。

事实上,支付宝参考的是 Facebook 的一个开源编译工具ReDex。ReDex 除了没有文档之外,绝对是客户端领域非常硬核的一个开源库,非常值得你去认真研究。

ReDex 这个库里面的好东西实在是太多了,后面我们还会反复讲到,其中去除 Debug 信息是通过 StripDebugInfoPass 完成。


{
  "redex" : {
    "passes" : [
      "StripDebugInfoPass"
    ]
  },
  "StripDebugInfoPass" : {
    "drop_all_dbg_info" : "0",     // 去除所有的debug信息,0表示不去除
    "drop_local_variables" : "1",  // 去除所有局部变量,1表示去除
    "drop_line_numbers" : "0",     // 去除行号,0表示不去除
    "drop_src_files" : "0",        
    "use_whitelist" : "0",
    "drop_prologue_end" : "1",
    "drop_epilogue_begin" : "1",
    "drop_all_dbg_info_if_empty" : "1"
  }
}

Dex 分包
当我们在 Android Studio 查看一个 APK 的时候,不知道你是否知道下图中“defines 19272 methods”和“references 40229 methods”的区别。
在这里插入图片描述
关于 Dex 的格式以及各个字段的定义,你可以参考《Dex 文件格式详解》。为了加深对 Dex 格式的理解,推荐你使用 010Editor。
在这里插入图片描述
“define classes and methods”是指真正在这个 Dex 中定义的类以及它们的方法。而“reference methods”指的是 define methods 以及 define methods 引用到的方法。

简单来说,如下图所示如果将 Class A 与 Class B 分别编译到不同的 Dex 中,由于 method a 调用了 method b,所以在 classes2.dex 中也需要加上 method b 的 id。
在这里插入图片描述因为跨 Dex 调用造成的这些冗余信息,它对我们 Dex 的大小会造成哪些影响呢?

  • method id 爆表。我们都知道每个 Dex 的 method id 需要小于 65536,因为 method id 的大量冗余导致每个 Dex 真正可以放的 Class 变少,这是造成最终编译的Dex 数量增多。
  • 信息冗余。因为我们需要记录跨 Dex 调用的方法的详细信息,所以在 classes2.dex 我们还需要记录 Class B 以及 method b 的定义,造成 string_ids、type_ids、proto_ids 这几部分信息的冗余。

事实上,我自己定义了一个 Dex 信息有效率的指标,希望保证 Dex 有效率应该在 80% 以上。同时,为了进一步减少 Dex 的数量,我们希望每个 Dex 的方法数都是满的,即分配了 65536 个方法。

Dex信息有效率 = define methods数量/reference methods数量

那如何实现 Dex 信息有效率提升呢?关键在于我们需要将有调用关系的类和方法分配到同一个 Dex 中,即减少跨 Dex 的调用的情况。但是由于类的调用关系非常复杂,我们不太可能可以计算出最优解,只能得到局部的最优解。

为了提高 Dex 信息有效率,我在微信时曾参与写过一个依赖分析的工具 Builder。但在微信最新的 7.0 版本,你可以看到上面表中 Dex 的数量和大小都增大了很多,这是因为他们不小心把这个工具搞失效了。Dex 数量的增多,对于 Tinker 热修复时间、用户安装时间都有很大影响。如果把这个问题修复,微信 7.0 版本的 Dex 数量应该可以从 13 个降到 6 个左右,包体积可以减少 10MB 左右。

但是我在研究 ReDex 的时候,发现它也提供了这个优化,而且实现得比微信的更好。ReDex 在分析类调用关系后,使用的是贪心算法计算局部最优值,具体算法可查看CrossDexDefMinimizer

为什么我们不能计算到最优解?因为我们需要在编译速度和效果之间找一个平衡点,在 ReDex 中使用这个优化的配置如下:

{
  "redex" : {
    "passes" : [
      "InterDexPass"
    ]
  },
  "InterDexPass" : {
    "minimize_cross_dex_refs": true,
    "minimize_cross_dex_refs_method_ref_weight": 100,
    "minimize_cross_dex_refs_field_ref_weight": 90,
    "minimize_cross_dex_refs_type_ref_weight": 100,
    "minimize_cross_dex_refs_string_ref_weight": 90
  }
}

那么通过 Dex 分包可以对包体积优化多少呢?因为 Android 默认的分包方式做得实在不好,如果你的应用有 4 个以上的 Dex,我相信这个优化至少有 10% 的效果。

Dex 压缩
我曾经在逆向 Facebook 的 App 时惊奇地发现,它怎么可能只有一个 700 多 KB 的 Dex。Google Play 是不允许动态下发代码的,那它的代码都放到哪里了呢?
在这里插入图片描述
事实上,Facebook App 的 classes.dex 只是一个壳,真正的代码都放到 assets 下面。它们把所有的 Dex 都合并成同一个 secondary.dex.jar.xzs 文件,并通过 XZ 压缩。
在这里插入图片描述
XZ 压缩算法和 7-Zip 一样,内部使用的都是 LZMA 算法。对于 Dex 格式来说,XZ 的压缩率可以比 Zip 高 30% 左右。但是不知道你有没有注意到,这套方案似乎存在一些问题:

  • 首次启动解压。应用首次启动的时候,需要将 secondary.dex.jar.xzs 解压缩,根据上图的配置信息,应该一共有 11 个 Dex。Facebook 使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要 3~5 秒。这里为什么不采用 Zstandard 或者 Brotli 呢?主要是压缩率与解压速度的权衡。
  • ODEX 文件生成。前面我就讲过,当 Dex 非常多的时候会增加应用的安装时间。对于 Facebook 的这个做法,首次生成 ODEX 的时间可能就会达到分钟级别。Facebook 为了解决这个问题,使用了 ReDex 另外一个超级硬核的方法,那就是oatmeal

oatmeal 的原理非常简单,就是根据 ODEX 文件的格式,自己生成一个 ODEX 文件。它生成的结果跟解释执行的 ODEX 一样,内部是没有机器码的。
在这里插入图片描述
如上图所示,对于正常的流程,我们需要 fork 进程来生成 dex2oat,这个耗时一般都比较大。通过 oatmeal,我们直接在本进程生成 ODEX 文件。一个 10MB 的 Dex,如果在 Android 5.0 生成一个 ODEX 的耗时大约在 10 秒以上,在 Android 8.0 使用 speed 模式大约在 1 秒左右,而通过 oatmeal 这个耗时大约在 100 毫秒左右。

我一直都很想把 oatmeal 引入进 Tinker,但是比较担心兼容性的问题。因为每个版本 ODEX 格式都有一些差异,oatmeal 是需要分版本适配的。

2. Native Library

现在音视频、美颜、AI、VR 这些功能在应用越来越普遍,但这些库一般都是使用 C 或者 C++ 写的,也就是说,我们的 APK 中 Native Library 的体积越来越大了。
对于 Native Library,传统的优化方法可能就是去除 Debug 信息、使用 c++_shared 这些。那我们还有没有更好的优化方法呢?
Library 压缩跟 Dex 压缩一样,Library 优化最有效果的方法也是使用 XZ 或者 7-Zip 压缩。
在这里插入图片描述
在默认的 lib 目录,我们只需要加载少数启动过程相关的 Library,其他的 Library 我们都在首次启动时解压。对于 Library 格式来说,压缩率同样可以比 Zip 高 30% 左右,效果十分惊人。

Facebook 有一个 So 加载的开源库SoLoader,它可以跟这套方案配合使用。和 Dex 压缩一样,压缩方案的主要缺点在于首次启动的时间,毕竟对于低端机来说,多线程的意义并不大,因此我们要在包体积和用户体验之间做好平衡。

Library 合并与裁剪
对于 Native Library,Facebook 中的编译构建工具Buck也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在源码中。

  • Library 合并。在 Android 4.3 之前,进程加载的 Library 数量是有限制的。在编译过程,我们可以自动将部分 Library 合并成一个。具体思路你可以参考文章《Android native library merging》以及Demo。
  • Library 裁剪。Buck 里面有一个relinker的功能,原理就是分析代码中 JNI 方法以及不同 Library 的方法调用,找到没有无用的导出 symbol,将它们删掉。这样 linker 在编译的时候也会把对应的无用代码同时删掉,这个方法相当于实现了 Library 的 ProGuard Shrinking 功能。
    在这里插入图片描述

包体积监控

关于包体积,如果一直放任不管,几个版本之后就会给你很大的“惊喜”。我了解到一些应用对包体积卡得很紧,任何超过 100KB 的功能都需要审批。

对于包体积的监控,通常有下面几种:

  • 大小监控。这个非常好理解,每个版本跟上一个版本包体积的对比情况。如果某个版本体积增长过大,需要分析具体原因,是否有优化空间。
  • 依赖监控。每一版本我们都需要监控依赖,这里包括新增 JAR 以及 AAR 依赖。这是因为很多开发者非常不细心,经常会不小心把一些超大的开源库引进来。
  • 规则监控。如果发现某个版本包体积增长很大,我们需要分析原因。规则监控也就是将包体积的监控抽象为规则,例如无用资源、大文件、重复文件、R 文件等。比如我在微信的时候,使用ApkChecker实现包体积的规则监控。
    在这里插入图片描述
    包体积的监控最好可以实现自动化与平台化,作为发布流程的其中一个环节。不然通过人工的方式,很难持续坚持下去。

总结

今天我们一起分析了实现难度比较大的包体积优化方法,可能有人会想这些方法实现难度那么大,真的有价值吗?根据我的理解,现在我们已经到了移动优化的“深水区”,网上那些千篇一律的文章已经无法满足需求。也就是说,简单的方法我们都掌握了,而且也都已经在做了,需要考虑接下来应该如何进一步优化。
这时候就需要静下心来,学会思考与钻研,再往底层走走。我们要去研究 APK 的文件格式,进一步还要研究内部 Dex、Library 以及 Resource 的文件格式。同时思考整个编译流程,才能找到那些可以突破的地方。
在实现 AndResGuard 的时候,我就对 resources.arsc 格式以及 Android 加载资源的流程有非常深入的研究。几年过去了,对于资源的优化又有哪些新的秘籍呢?我们下一期就会讨论“资源优化”这个主题。
从 Buck 和 ReDex 看出来,Facebook 比国内的研究真的要高深很多,希望他们可以补充一些文档,让我们学习起来更轻松一些。

资源优化的进阶实践

上一期我们聊了 Dex 与 Native Library 的优化,是不是还有点意犹未尽的感觉呢?那安装包还有哪些可以优化的地方呢?
在这里插入图片描述
请看上面这张图,Assets、Resource 以及签名 metadata 都是安装包中的“资源”部分,今天我们就一起来看看如何进一步优化资源的体积。

AndResGuard 工具

在美团的一篇文章《Android App 包瘦身优化实践》中,也讲到了很多资源优化相关的方法,例如 WebP 和 SVG、R 文件、无用资源、资源混淆以及语言压缩等。
在我们的安装包中,资源相关的文件具体有下面这几个,它们都是我们需要优化的目标文件。
在这里插入图片描述
想使用好AndResGuard工具,需要对安装包格式以及 Android 资源编译的原理有很深地理解,它主要有两个功能,一个是资源混淆,一个是资源的极限压缩。

接下来我们先来复习一下这个工具的核心实现,然后再进一步思考还有哪些地方需要继续优化。

1. 资源混淆

ProGuard 的核心优化主要有三个:Shrink、Optimize 和 Obfuscate,也就是裁剪、优化和混淆。当初我在写 AndResGuard 的时候,希望实现的就是 ProGuard 中的混淆功能。
资源混淆的思路其实非常简单,就是把资源和文件的名字混淆成短路径:

Proguard          -> Resource Proguard
R.string.name     -> R.string.a   
res/drawable/icon -> res/s/a

那么这样的实现究竟对哪些资源文件有优化作用呢?

  • resources.arsc。因为资源索引文件 resources.arsc 需要记录资源文件的名称与路径,使用混淆后的短路径 res/s/a,可以减少整个文件的大小。
  • metadata 签名文件签名文件 MF 与 SF都需要记录所有文件的路径以及它们的哈希值,使用短路径可以减少这两个文件的大小。
    在这里插入图片描述
  • ZIP 文件索引。ZIP 文件格式里面也需要记录每个文件 Entry 的路径、压缩算法、CRC、文件大小等信息。使用短路径,本身就可以减少记录文件路径的字符串大小。
    在这里插入图片描述
    资源文件有一个非常大的特点,那就是文件数量特别多。以微信 7.0 为例,安装包中就有 7000 多个资源文件。所以说,资源混淆工具仅仅通过短路径的优化,就可以达到减少 resources.arsc、签名文件以及 ZIP 文件大小的目的。

既然移动优化已经到了“深水区”,正如 Dex 和 Library 优化一样,我们需要对它们的格式以及特性有非常深入的研究,才能找到优化的思路。而我们要做的资源优化也是如此,要对 resources.arsc、签名文件以及 ZIP 格式需要有非常深入的研究与思考。

2. 极限压缩

AndResGuard 的另外一个优化就是极限压缩,它的极限压缩功能体现在两个方面:

  • 更高的压缩率。虽然我们使用的还是 Zip 算法,但是利用了 7-Zip 的大字典优化,APK 的整体压缩率可以提升 3% 左右。
  • 压缩更多的文件。Android 编译过程中,下面这些格式的文件会指定不压缩;在 AndResGuard 中,我们支持针对 resources.arsc、PNG、JPG 以及 GIF 等文件的强制压缩。
/* these formats are already compressed, or don't compress well */
static const char* kNoCompressExt[] = {
    ".jpg", ".jpeg", ".png", ".gif",
    ".wav", ".mp2", ".mp3", ".ogg", ".aac",
    ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet",
    ".rtttl", ".imy", ".xmf", ".mp4", ".m4a",
    ".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2",
    ".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"
};

这里可能会有一个疑问,为什么 Android 系统会专门选择不去压缩这些文件呢?

  • 压缩效果并不明显。这些格式的文件大部分本身已经压缩过,重新做 Zip 压缩效果并不明显。例如 PNG 和 JPG 格式,重新压缩只有 3%~5% 的收益,并不是十分明显。
  • 读取时间与内存的考虑。如果文件是没有压缩的,系统可以利用 mmap 的方式直接读取,而不需要一次性解压并放在内存中。

Android 6.0 之后 AndroidManifest 支持不压缩 Library 文件,这样安装 APK 的时候也不需要把 Library 文件解压出来,系统可以直接 mmap 安装包中的 Library 文件。

android:extractNativeLibs=“true”

简单来说,我们在启动性能、内存和安装包体积之间又做了一个抉择。在上一期中我就讲过对于 Dex 和 Library 来说,最有效果的方法是使用 XZ 或者 7-Zip 压缩,对于资源来说也是如此,一些比较大的资源文件我们也可以考虑使用 XZ 压缩,但是在首次启动时需要解压出来。

进阶的优化方法

学习完 AndResGuard 工具的混淆和压缩功能的实现原理后,可以帮助我们加深对安装包格式以及 Android 资源编译的原理的认识。

但 AndResGuard 毕竟是几年前的产物,那现在又有哪些新的进阶优化方法呢?

1. 资源合并

在资源混淆方案中,我们发现资源文件的路径对于 resources.arsc、签名信息以及 ZIP 文件信息都会有影响。而且因为资源文件数量非常非常多,导致这部分的体积非常可观。
那我们能不能把所有的资源文件都合并成同一个大文件,这样做肯定会比资源混淆方案效果更好。
1. 资源合并
在资源混淆方案中,我们发现资源文件的路径对于 resources.arsc、签名信息以及 ZIP 文件信息都会有影响。而且因为资源文件数量非常非常多,导致这部分的体积非常可观。
那我们能不能把所有的资源文件都合并成同一个大文件,这样做肯定会比资源混淆方案效果更好。
在这里插入图片描述
事实上,大部分的换肤方案也是采用这个思路,这个大资源文件就相当于一套皮肤。因此我们完全可以把这套方案推广开来,但是实现起来还是需要解决不少问题的。

资源的解析。我们需要模拟系统实现资源文件的解析,例如把 PNG、JPG 以及 XML 文件转换为 Bitmap 或者 Drawable,这样获取资源的方法需要改成我们自定义的方法。

// 系统默认的方式
Drawable drawable = getResouces().getDrawable(R.drawable.loading);

// 新的获取方式
Drawable drawable = CustomResManager.getDrawable(R.drawable.loading);

那为什么我们不像 SVG 那样,直接把这些解析完的所有 Drawable 全部丢到系统的缓存中呢?这样代码就无需做太多修改?之所以没这么做主要是考虑对内存的影响,如果我们把全部的资源文件一次性全部解析,并且丢到系统的缓存中,这部分会占用非常大的内存。

  • 资源的管理。考虑到内存和启动时间,所有的资源也是用时加载,我们只需要使用 mmap 来加载“Big resource File”。同时我们还要实现自己的资源缓存池 ResourceCache,释放不再使用的资源文件,这部分内容你可以参考类似 Glide 图片库的实现。

我在逆向 Facebook 的 App 的时候也发现,它们的资源和多语言基本走的完全是自己的流程。在“UI 优化”时我就说过,我们先在系统的框架下尝试做了很多的优化,但是渐渐发现这样的方式依然要受系统的各种制约,这时就要考虑去突破系统的限制,把所有的流程都接管过来。

当然我们也需要在性能和效率之间寻找平衡点,要看自己的应用当前更重视性能提升还是开发效率。

2. 无用资源
AndResGuard 中的资源混淆实现的是 ProGuard 的 Obfuscate,那我们是否可以同样实现资源的 Shrink,也就是裁剪功能呢?应用通过长时间的迭代,总会有一些无用的资源,尽管它们在程序运行过程不会被使用,但是依然占据着安装包的体积。

事实上,Android 官方早就考虑到这种情况了,下面我们一起来看看无用资源优化方案的演进过程。
第一阶段:Lint
从 Eclipse 时代开始,我们就开始使用Lint这个静态代码扫描工具,它里面就支持 Unused Resources 扫描。

然后我们直接选择“Remove All Unused Resources”,就可以轻松删除所有的无用资源了。既然它是第一阶段的方案,那 Lint 方案扫描具体的缺点是什么呢?

第二阶段:shrinkResources
所以 Android 在第二阶段增加了“shrinkResources”资源压缩功能,它需要配合 ProGurad 的“minifyEnabled”功能同时使用。

如果 ProGuard 把部分无用代码移除,这些代码所引用的资源也会被标记为无用资源,然后通过资源压缩功能将它们移除。

android {
    ...
    buildTypes {
        release {
            shrinkResources true
            minifyEnabled true
        }
    }
}

是不是看起来很完美,但是目前的 shrinkResources 实现起来还有几个缺陷。

  • 没有处理 resources.arsc 文件。这样导致大量无用的 String、ID、Attr、Dimen 等资源并没有被删除。
    在这里插入图片描述
  • 没有真正删除资源文件。对于 Drawable、Layout 这些无用资源,shrinkResources 也没有真正把它们删掉,而是仅仅替换为一个空文件。为什么不能删除呢?主要还是因为 resources.arsc 里面还有这些文件的路径,具体你可以查看这个issues

所以尽管我们的应用有大量的无用资源,但是系统目前的做法并没有真正减少文件数量。这样 resources.arsc、签名信息以及 ZIP 文件信息这几个“大头”依然没有任何改善。

那为什么 Studio 不把这些资源真正删掉呢?事实上 Android 也知道有这个问题,在它的核心实现ResourceUsageAnalyzer中的注释也写得非常清楚,并尝试解决这个问题提供了两种思路。
在这里插入图片描述
如果想解答系统为什么不能直接把这些资源删除,我们需要先回过头来重温一下 Android 的编译流程。
在这里插入图片描述

  • 由于 Java 代码需要用到资源的 R.java 文件,所以我们就需要把 R.java 提前准备好。
  • 在编译 Java 代码过程,已经根据 R.java 文件,直接将代码中资源的引用替换为常量,例如将 R.String.sample 替换为 0x7f0c0003。
  • .ap_ 资源文件的同步编译,例如 resources.arsc、XML 文件的处理等。

如果我们在这个过程强行把无用资源文件删除,resources.arsc 和 R.java 文件的资源 ID 都会改变(因为默认都是连续的),这个时候代码中已经替换过的 0x7f0c0003 就会出现资源错乱或者找不到的情况。

因此系统为了避免发生这种情况,采用了折中的方法,并没有二次处理 resources.arsc 文件,只是仅仅把无用的 Drawable 和 Layout 文件替换为空文件。

第三阶段:realShrinkResources
那怎么样才能真正实现无用资源的删除功能呢?ResourceUsageAnalyzer 的注释中就提供了一个思路,我们可以利用 resources.arsc 中 Public ID 的机制,实现非连续的资源 ID。

简单来说,就是 keep 住保留资源的 ID,保证已经编译完的代码可以正常找到对应的资源。
在这里插入图片描述
但是重写 resources.arsc 的方法会比资源混淆更加复杂,我们既要从这个文件中抹去所有的无用资源相关信息,还要 keep 住所有保留资源的 ID,相当于把整个文件都重写了。

正因为异常复杂,所以目前 Android 还没有提供这套方案的完整实现。我最近也正在按照这个思路来实现这套方案,希望完成后可以尽快开源出来。

总结

今天我们回顾了 AndResGuard 工具的实现原理,也学习了两种资源优化的进阶方式。特别是无用资源的优化,你可以看到尽管是无所不能的 Google,也并没有把方案做到最好,依然存在一些妥协的地方。

其实这种不完美的地方还有很多很多,也正是有了这些不完美的地方,才会出现各种各样优秀的开源方案。也因此我们才会不断思考如何突破系统的限制,去实现更多、更底层的优化。

UI 优化

CPU 与 GPU

除了屏幕,UI 渲染还依赖两个核心的硬件:CPU 与 GPU。UI 组件在绘制到屏幕之前,都需要经过 Rasterization(栅格化)操作,而栅格化操作又是一个非常耗时的操作。GPU(Graphic Processing Unit )也就是图形处理器,它主要用于处理图形运算,可以帮助我们加快栅格化操作。

在这里插入图片描述
你可以从图上看到,软件绘制使用的是 Skia 库,它是一款能在低端设备如手机上呈现高质量的 2D 跨平台图形框架,类似 Chrome、Flutter 内部使用的都是 Skia 库。

Android View绘制 和 vsync 同步

窗口被激活,viewroot通过调用requestLayout然后scheduleTraversals和performTraversals,如果这个绘制时间和vsync的时间这两个值不一致,不论View绘制的快慢,都会导致丢帧,那android是如何保持这两者时间同步的呢?

最近刚好又研究了下这一块,没有研究各位那么深入,对于底层的SurfaceFlinger还没有看,主要集中在ViewRootImpl和Choreographer
以下是我的理解:
1、ViewRootImplViewRootImpl怎来的,大家可以看看ActivityThread里面的handleResumeActivity,抛开anr这些系统的窗口等,理论上一个APP应该是一个ViewRootImpl
在WindowManagerGlobal的addView会

root = new ViewRootImpl(view.getContext(), display);
root.setView(view, wparams, panelParentView);

也就是说, ViewRootImpl的mView其实是DecorView.
而DecorView是一个activity中最外层的view了,其实是继承的FrameLayout。
而对于View不论是Invalidate还是requestlayout,这些重绘开始的地方都是ViewRootImpl的scheduleTraversals,在scheduleTraversals中,
mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
有这样一行代码,这个mTraversalRunnable就是调用了View measure,layout,draw的非常经典的performTraversals方法。
而postCallback中做的事情主要就是根据时间看这个啥时候执行,如果要立即执行了就添加到一个callback队列中然后去请求VSYNC(这里只考虑有VSYNC的场景)。
如果不需要立即执行就sendmessage做一个队列排队去。Choreographer是对VSYNC做了监听的,当有VSYNC消息的时候会执行onVsync,最终走到Choreographer的doFrame,在这里会将从callback队列中取出runnable进行绘制。
所以对于当用户手动去invalidate,或者requestlayout的情况,还是会通过Choreographer对这些请求做排队处理,所有绘制的地方都会等待VSYNC的消息回调再去做相应的绘制。所以就不会出现,用户要求去绘制,但是这个时机和VSYNC不同步的问题了。

框架中为了更快的响应UI刷新事件在 ViewRootImpl.scheduleTraversals 中使用了同步屏障
最后,当然要移除同步屏障的时候,调用ViewRootImpl#unscheduleTraversals

VSYNC、 Choreographer 起源
屏幕刷新频率保持同步
Android屏幕刷新机制

1. Create View 优化

观察渲染的流水线时,有没有同学发现缺少一个非常重要的环节,那就是 View 创建的耗时。请不要忘记,View 的创建也是在 UI 线程里,对于一些非常复杂的界面,这部分的耗时不容忽视。
在优化之前我们先来分解一下 View 创建的耗时,可能会包括各种 XML 的随机读的 I/O 时间、解析 XML 的时间、生成对象的时间(Framework 会大量使用到反射)。
相应的,我们来看看这个阶段有哪些优化方式。

使用代码创建

使用 XML 进行 UI 编写可以说是十分方便,可以在 Android Studio 中实时预览到界面。如果我们要对一个界面进行极致优化,就可以使用代码进行编写界面。

但是这种方式对开发效率来说简直是灾难,因此我们可以使用一些开源的 XML 转换为 Java 代码的工具,例如X2C。但坦白说,还是有不少情况是不支持直接转换的。

所以我们需要兼容性能与开发效率,我建议只在对性能要求非常高,但修改又不非常频繁的场景才使用这个方式。

异步创建

那我们能不能在线程提前创建 View,实现 UI 的预加载吗?尝试过的同学都会发现系统会抛出下面这个异常:

java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()      
  at android.os.Handler.<init>(Handler.java:121)

事实上,我们可以通过又一个非常取巧的方式来实现。在使用线程创建 UI 的时候,先把线程的 Looper 的 MessageQueue 替换成 UI 线程 Looper 的 Queue。

不过需要注意的是,在创建完 View 后我们需要把线程的 Looper 恢复成原来的。

View 重用
正常来说,View 会随着 Activity 的销毁而同时销毁。ListView、RecycleView 通过 View 的缓存与重用大大地提升渲染性能。因此我们可以参考它们的思想,实现一套可以在不同 Activity 或者 Fragment 使用的 View 缓存机制。

但是这里需要保证所有进入缓存池的 View 都已经“净身出户”,不会保留之前的状态。微信曾经就因为这个缓存,导致出现不同的用户聊天记录错乱。

在这里插入图片描述

2. measure/layout 优化

渲染流程中 measure 和 layout 也是需要 CPU 在主线程执行的,对于这块内容网上有很多优化的文章,一般的常规方法有:

  • 减少 UI 布局层次。例如尽量扁平化,使用<ViewStub> <Merge>等优化。
  • 优化 layout 的开销。尽量不使用 RelativeLayout 或者基于 weighted LinearLayout,它们 layout 的开销非常巨大。这里我推荐使用 ConstraintLayout 替代 RelativeLayout 或者 weighted LinearLayout。
  • 背景优化。尽量不要重复去设置背景,这里需要注意的是主题背景(theme), theme 默认会是一个纯色背景,如果我们自定义了界面的背景,那么主题的背景我们来说是无用的。但是由于主题背景是设置在 DecorView 中,所以这里会带来重复绘制,也会带来绘制性能损耗。

对于 measure 和 layout,我们能不能像 Create View 一样实现线程的预布局呢?这样可以大大地提升首次显示的性能。

Textview 是系统控件中非常强大也非常重要的一个控件,强大的背后就代表着需要做很多计算。在 2018 年的 Google I/O 大会,发布了PrecomputedText并已经集成在 Jetpack 中,它给我们提供了接口,可以异步进行 measure 和 layout,不必在主线程中执行。

3.UI 优化的进阶手段

那对于其他的控件我们是不是也可以采用相同的方式?接下来我们一起来看看近两年新框架的做法,我来介绍一下 Facebook 的一个开源库 Litho 以及 Google 开源的 Flutter。

1. Litho:异步布局

Litho是 Facebook 开源的声明式 Android UI 渲染框架,它是基于另外一个 Facebook 开源的布局引擎Yoga开发的。

Litho 本身非常强大,内部做了很多非常不错的优化。下面我来简单介绍一下它是如何优化 UI 的。

异步布局
一般来说的 Android 所有的控件绘制都要遵守 measure -> layout -> draw 的流水线,并且这些都发生在主线程中。
在这里插入图片描述
Litho 如我前面提到的 PrecomputedText 一样,把 measure 和 layout 都放到了后台线程,只留下了必须要在主线程完成的 draw,这大大降低了 UI 线程的负载。它的渲染流水线如下:
在这里插入图片描述
界面扁平化
前面也提到过,降低 UI 的层级是一个非常通用的优化方法。你肯定会想,有没有一种方法可以直接降低 UI 的层级,而不通过代码的改变呢?Litho 就给了我们一种方案,由于 Litho 使用了自有的布局引擎(Yoga),在布局阶段就可以检测不必要的层级、减少 ViewGroups,来实现 UI 扁平化。比如下面这样图,上半部分是我们一般编写这个界面的方法,下半部分是 Litho 编写的界面,可以看到只有一层层级。在这里插入图片描述
优化 RecyclerView
Litho 还优化了 RecyclerView 中 UI 组件的缓存和回收方法。原生的 RecyclerView 或者 ListView 是按照 viewType 来进行缓存和回收,但如果一个 RecyclerView/ListView 中出现 viewType 过多,会使缓存形同虚设。但 Litho 是按照 text、image 和 video 独立回收的,这可以提高缓存命中率、降低内存使用率、提高滚动帧率。
在这里插入图片描述
Litho 虽然强大,但也有自己的缺点。它为了实现 measure/layout 异步化,使用了类似 react 单向数据流设计,这一定程度上加大了 UI 开发的复杂性。并且 Litho 的 UI 代码是使用 Java/Kotlin 来进行编写,无法做到在 AS 中预览。

如果你没有计划完全迁移到 Litho,我建议可以优先使用 Litho 中的 RecyclerCollectionComponent 和 Sections 来优化自己的 RecyelerView 的性能。

2. Flutter:自己的布局 + 渲染引擎

如下图所示,Litho 虽然通过使用自己的布局引擎 Yoga,一定程度上突破了系统的一些限制,但是在 draw 之后依然走的系统的渲染机制。
在这里插入图片描述
那我们能不能再往底层深入,把系统的渲染也同时接管过来?Flutter 正是这样的框架,它也是最近十分火爆的一个新框架,这里我也简单介绍一下。

Flutter是 Google 推出并开源的移动应用开发框架,开发者可以通过 Dart 语言开发 App,一套代码同时运行在 iOS 和 Android 平台。

我们先整体看一下 Flutter 的架构,在 Android 上 Flutter 完全没有基于系统的渲染引擎,而是把 Skia 引擎直接集成进了 App 中,这使得 Flutter App 就像一个游戏 App。并且直接使用了 Dart 虚拟机,可以说是一套跳脱出 Android 的方案,所以 Flutter 也可以很容易实现跨平台。

在这里插入图片描述
开发 Flutter 应用总的来说简化了线程模型,框架给我们抽象出各司其职的 Runner,包括 UI、GPU、I/O、Platform Runner。Android 平台上面每一个引擎实例启动的时候会为 UI Runner、GPU Runner、I/O Runner 各自创建一个新的线程,所有 Engine 实例共享同一个 Platform Runner 和线程。

由于本期我们主要讨论 UI 渲染相关的内容,我来着重分析一下 Flutter 的渲染步骤,相关的具体知识你可以阅读《Flutter 原理与实践》

  • 首先 UI Runner 会执行 root isolate(可以简单理解为 main 函数。需要简单解释一下 isolate 的概念,isolate 是 Dart 虚拟机中一种执行并发代码实现,Dart 虚拟机实现了 Actor 的并发模型,与大名鼎鼎的 Erlang 使用了类似的并发模型。如果不太了解 Actor 的同学,可以简单认为 isolate 就是 Dart 虚拟机的“线程”,Root isolate 会通知引擎有帧要渲染)。
  • Flutter 引擎得到通知后,会告知系统我们要同步 VSYNC。
  • 得到 GPU 的 VSYNC 信号后,对 UI Widgets 进行 Layout 并生成一个 Layer Tree。
  • 然后 Layer Tree 会交给 GPU Runner 进行合成和栅格化。
  • GPU Runner 使用 Skia 库绘制相关图形。
    在这里插入图片描述
    Flutter 也采用了类似 Litho、React 属性不可变,单向数据流的方案。这已经成为现代 UI 渲染引擎的标配。这样做的好处是可以将视图与数据分离。

总体来说 Flutter 吸取和各个优秀前端框架的精华,还“加持”了强大的 Dart 虚拟机和 Skia 渲染引擎,可以说是一个非常优秀的框架,闲鱼、今日头条等很多应用部分功能已经使用 Flutter 开发。结合 Google 最新的 Fuchsia 操作系统,它会不会是一个颠覆 Android 的开发框架?我们在专栏后面会单独详细讨论 Flutter。

3. RenderThread 与 RenderScript

在 Android 5.0,系统增加了 RenderThread,对于 ViewPropertyAnimator 和 CircularReveal 动画,我们可以使用RenderThead 实现动画的异步渲染。当主线程阻塞的时候,普通动画会出现明显的丢帧卡顿,而使用 RenderThread 渲染的动画即使阻塞了主线程仍不受影响。

现在越来越多的应用会使用一些高级图片或者视频编辑功能,例如图片的高斯模糊、放大、锐化等。拿日常我们使用最多的“扫一扫”这个场景来看,这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。

图片的变换涉及大量的计算任务,而根据我们上一期的学习,这个时候使用 GPU 是更好的选择。那如何进一步压榨系统 GPU 的性能呢?

我们可以通过RenderScript,它是 Android 操作系统上的一套 API。它基于异构计算思想,专门用于密集型计算。RenderScript 提供了三个基本工具:一个硬件无关的通用计算 API;一个类似于 CUDA、OpenCL 和 GLSL 的计算 API;一个类C99的脚本语言。允许开发者以较少的代码实现功能复杂且性能优越的应用程序。

如何将它们应用到我们的项目中?你可以参考下面的一些实践方案:

总结

回顾一下 UI 优化的所有手段,我们会发现它存在这样一个脉络:

1. 在系统的框架下优化。布局优化、使用代码创建、View 缓存等都是这个思路,我们希望减少甚至省下渲染流水线里某个阶段的耗时。

2. 利用系统新的特性。使用硬件加速、RenderThread、RenderScript 都是这个思路,通过系统一些新的特性,最大限度压榨出性能。

3. 突破系统的限制。由于 Android 系统碎片化非常严重,很多好的特性可能低版本系统并不支持。而且系统需要支持所有的场景,在一些特定场景下它无法实现最优解。这个时候,我们希望可以突破系统的条条框框,例如 Litho 突破了布局,Flutter 则更进一步,把渲染也接管过来了。

回顾一下过去所有的 UI 优化,第一阶段的优化我们在系统的束缚下也可以达到非常不错的效果。不过越到后面越容易出现瓶颈,这个时候我们就需要进一步往底层走,可以对整个架构有更大的掌控力,需要造自己的“轮子”。

对于 UI 优化的另一个思考是效率,目前 Android Studio 对设计并不友好,例如不支持 Sketch 插件和 AE 插件。Lottie是一个非常好的案例,它很大提升了开发人员写动画的效率。

“设计师和产品,你们长大了,要学会自己写 UI 了”。在未来,我们希望 UI 界面与适配可以实现自动化,或者干脆把它交还给设计师和产品。

AsyncLayoutInflater?

内存优化:内存优化这件事,应该从哪里着手?

内存优化探讨

那要进行内存优化,应该从哪里着手呢?我通常会从设备分级、Bitmap 优化和内存泄漏这三个方面入手。

1. 设备分级

相信你肯定遇到过,同一个应用在 4GB 内存的手机运行得非常流畅,但在 1GB 内存的手机就不一定可以做到,而且在系统空闲和繁忙的时候表现也不太一样。

内存优化首先需要根据设备环境来综合考虑,专栏上一期我提到过很多同学陷入的一个误区:“内存占用越少越好”。其实我们可以让高端设备使用更多的内存,做到针对设备性能的好坏使用不同的内存分配和回收策略。

当然这需要有一个良好的架构设计支撑,在架构设计时需要做到以下几点。

  • 设备分级。使用类似 device-year-class 的策略对设备分级,对于低端机用户可以关闭复杂的动画,或者是某些功能;使用 565 格式的图片,使用更小的缓存内存等。在现实环境下,不是每个用户的设备都跟我们的测试机一样高端,在开发过程我们要学会思考功能要不要对低端机开启、在系统资源吃紧的时候能不能做降级。

下面我举一个例子。我们知道 device-year-class 会根据手机的内存、CPU 核心数和频率等信息决定设备属于哪一个年份,这个示例表示对于 2013 年之后的设备可以使用复杂的动画,对于 2010 年之前的低端设备则不添加任何动画。


if (year >= 2013) {
    // Do advanced animation
} else if (year >= 2010) {
    // Do simple animation
} else {
    // Phone too slow, don't do any animations
}
  • 缓存管理。我们需要有一套统一的缓存管理机制,可以适当地使用内存;当“系统有难”时,也要义不容辞地归还。我们可以使用 OnTrimMemory 回调,根据不同的状态决定释放多少内存。对于大项目来说,可能存在几十上百个模块,统一缓存管理可以更好地监控每个模块的缓存大小。
  • 进程模型。一个空的进程也会占用 10MB 的内存,而有些应用启动就有十几个进程,甚至有些应用已经从双进程保活升级到四进程保活,所以减少应用启动的进程数、减少常驻进程、有节操的保活,对低端机内存优化非常重要。
  • 安装包大小。安装包中的代码、资源、图片以及 so 库的体积,跟它们占用的内存有很大的关系。一个 80MB 的应用很难在 512MB 内存的手机上流畅运行。这种情况我们需要考虑针对低端机用户推出 4MB 的轻量版本,例如 Facebook Lite、今日头条极速版都是这个思路。

安装包中的代码、图片、资源以及 so 库的大小跟内存究竟有哪些关系?你可以参考下面的这个表格。
在这里插入图片描述
在这里插入图片描述

2. Bitmap 优化

Bitmap 内存一般占应用总内存很大一部分,所以做内存优化永远无法避开图片内存这个“永恒主题”。
即使把所有的 Bitmap 都放到 Native 内存,并不代表图片内存问题就完全解决了,这样做只是提升了系统内存利用率,减少了 GC 带来的一些问题而已。
那我们回过头来看看,到底该如何优化图片内存呢?我给你介绍两种方法。

方法一,统一图片库。
图片内存优化的前提是收拢图片的调用,这样我们可以做整体的控制策略。例如低端机使用 565 格式、更加严格的缩放算法,可以使用 Glide、Fresco 或者采取自研都可以。而且需要进一步将所有 Bitmap.createBitmap、BitmapFactory 相关的接口也一并收拢。

方法二,统一监控。
在统一图片库后就非常容易监控 Bitmap 的使用情况了,这里主要有三点需要注意。

  • 大图片监控。我们需要注意某张图片内存占用是否过大,例如长宽远远大于 View 甚至是屏幕的长宽。在开发过程中,如果检测到不合规的图片使用,应该立即弹出对话框提示图片所在的 Activity 和堆栈,让开发同学更快发现并解决问题。在灰度和线上环境下可以将异常信息上报到后台,我们可以计算有多少比例的图片会超过屏幕的大小,也就是图片的**“超宽率”**。
  • 重复图片监控。重复图片指的是 Bitmap 的像素数据完全一致,但是有多个不同的对象存在。这个监控不需要太多的样本量,一般只在内部使用。**之前我实现过一个内存 Hprof 的分析工具,它可以自动将重复 Bitmap 的图片和引用链输出。**下图是一个简单的例子,你可以看到两张图片的内容完全一样,通过解决这张重复图片可以节省 1MB 内存。

在这里插入图片描述

  • 图片总内存。通过收拢图片使用,我们还可以统计应用所有图片占用的内存,这样在线上就可以按不同的系统、屏幕分辨率等维度去分析图片内存的占用情况。在 OOM 崩溃的时候,也可以把图片占用的总内存、Top N 图片的内存都写到崩溃日志中,帮助我们排查问题。

讲完设备分级和 Bitmap 优化,我们发现架构和监控需要两手抓,一个好的架构可以减少甚至避免我们犯错,而一个好的监控可以帮助我们及时发现问题。

3. 内存泄漏

内存泄漏简单来说就是没有回收不再使用的内存,排查和解决内存泄漏也是内存优化无法避开的工作之一。

内存泄漏主要分两种情况,一种是同一个对象泄漏,还有一种情况更加糟糕,就是每次都会泄漏新的对象,可能会出现几百上千个无用的对象。

很多内存泄漏都是框架设计不合理所导致,各种各样的单例满天飞,MVC 中 Controller 的生命周期远远大于 View。优秀的框架设计可以减少甚至避免程序员犯错,当然这不是一件容易的事情,所以我们还需要对内存泄漏建立持续的监控。

  • Java 内存泄漏。建立类似 LeakCanary 自动化检测方案,至少做到 Activity 和 Fragment 的泄漏检测。在开发过程,我们希望出现泄漏时可以弹出对话框,让开发者更加容易去发现和解决问题。内存泄漏监控放到线上并不容易,我们可以对生成的 Hprof 内存快照文件做一些优化,裁剪大部分图片对应的 byte 数组减少文件大小。比如一个 100MB 的文件裁剪后一般只剩下 30MB 左右,使用 7zip 压缩最后小于 10MB,增加了文件上传的成功率。
  • OOM 监控。美团有一个 Android 内存泄露自动化链路分析组件Probe,它在发生 OOM 的时候生成 Hprof 内存快照,然后通过单独进程对这个文件做进一步的分析。不过在线上使用这个工具风险还是比较大,在崩溃的时候生成内存快照有可能会导致二次崩溃,而且部分手机生成 Hprof 快照可能会耗时几分钟,这对用户造成的体验影响会比较大。另外,部分 OOM 是因为虚拟内存不足导致,这块需要具体问题具体分析。
  • Native 内存泄漏监控。上一期我讲到 Malloc 调试(Malloc Debug)和 Malloc 钩子(Malloc Hook)似乎还不是那么稳定。在 WeMobileDev 最近的一篇文章《微信 Android 终端内存优化实践》中,微信也做了一些其他方案上面的尝试。
  • 针对无法重编 so 的情况,使用了 PLT Hook 拦截库的内存分配函数,其中 PLT Hook 是 Native Hook 的一种方案,后面我们还会讲到。然后重定向到我们自己的实现后记录分配的内存地址、大小、来源 so 库路径等信息,定期扫描分配与释放是否配对,对于不配对的分配输出我们记录的信息。
  • 针对可重编的 so 情况,通过 GCC 的“-finstrument-functions”参数给所有函数插桩,桩中模拟调用栈入栈出栈操作;通过 ld 的“–wrap”参数拦截内存分配和释放函数,重定向到我们自己的实现后记录分配的内存地址、大小、来源 so 以及插桩记录的调用栈此刻的内容,定期扫描分配与释放是否配对,对于不配对的分配输出我们记录的信息。

开发过程中内存泄漏排查可以使用 Androd Profiler 和 MAT 工具配合使用,而日常监控关键是成体系化,做到及时发现问题。

坦白地说,除了 Java 泄漏检测方案,目前 OOM 监控和 Native 内存泄漏监控都只能做到实验室自动化测试的水平。微信的 Native 监控方案也遇到一些兼容性的问题,如果想达到灰度和线上部署,需要考虑的细节会非常多。Native 内存泄漏检测在 iOS 会简单一些,不过 Google 也在一直优化 Native 内存泄漏检测的性能和易用性,相信在未来的 Android 版本将会有很大改善。

应用崩溃了,你应该如何去分析?

解决崩溃跟破案一样需要经验,我们分析的问题越多越熟练,定位问题就会越快越准。当然这里也有很多套路,比如对于“案发现场”我们应该留意哪些信息?怎样找到更多的“证人”和“线索”?“侦查案件”的一般流程是什么?对不同类型的“案件”分别应该使用什么样的调查方式?

崩溃现场

崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。在这里我们挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。

操作系统是整个崩溃过程的“旁观者”,也是我们最重要的“证人”。一个好的崩溃捕获工具知道应该采集哪些系统信息,也知道在什么场景要深入挖掘哪些内容,从而可以更好地帮助我们解决问题。

崩溃分析

有了这么多现场信息之后,我们可以开始真正的“破案”之旅了。绝大部分的“案件”只要我们肯花功夫,最后都能真相大白。不要畏惧问题,经过耐心和细心地分析,总能敏锐地发现一些异常或关键点,并且还要敢于怀疑和验证。下面我重点给你介绍崩溃分析“三部曲”。

第一步:确定重点

确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。

1. 确认严重程度。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。我曾经有一次辛苦了几天解决了一个大的崩溃,但下个版本产品就把整个功能都删除了,这令我很崩溃。

2. 崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。

  • Java 崩溃。Java 崩溃类型比较明显,比如 NullPointerException 是空指针,OutOfMemoryError 是资源不足,这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。

  • Native 崩溃。需要观察 signal、code、fault addr 等内容,以及崩溃时 Java 的堆栈。关于各 signal 含义的介绍,你可以查看崩溃信号介绍。比较常见的是有 SIGSEGV 和 SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为 ANR 和调用 abort() 退出所导致。

  • ANR。我的经验是,先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。

3. Logcat。Logcat 一般会存在一些有价值的线索,日志级别是 Warning、Error 的需要特别注意。从 Logcat 中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR 时,会有“am_anr”;App 被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。

4. 各个资源情况。结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd 泄漏了。

无论是资源文件还是 Logcat,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。

第二步:查找共性
如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。
机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了 Xposed,是不是只出现在 x86 的手机,是不是只有三星这款机型,是不是只在 Android 5.0 的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。
找到了共性,可以对你下一步复现问题有更明确的指引。

第三步:尝试复现
如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。
“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。
回想当时在开发 Tinker 的时候,我们遇到了各种各样的奇葩问题。比如某个厂商改了底层实现、新的 Android 系统实现有所更改,都需要去 Google、翻源码,有时候还需要去抠厂商的 ROM 或手动刷 ROM。这个痛苦的经历告诉我,很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。

疑难问题:系统崩溃

系统崩溃常常令我们感到非常无助,它可能是某个 Android 版本的 bug,也可能是某个厂商修改 ROM 导致。这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。针对这种疑难问题,我来谈谈我的解决思路。

1. 查找可能的原因。通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。

2. 尝试规避。查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。

3. Hook 解决。这里分为 Java Hook 和 Native Hook。以我最近解决的一个系统崩溃为例,我们发现线上出现一个 Toast 相关的系统崩溃,它只出现在 Android 7.0 的系统中,看起来是在 Toast 显示的时候窗口的 token 已经无效了。这有可能出现在 Toast 需要显示时,窗口已经销毁了。


android.view.WindowManager$BadTokenException: 
  at android.view.ViewRootImpl.setView(ViewRootImpl.java)
  at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java)
  at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4)
  at android.widget.Toast$TN.handleShow(Toast.java)

为什么 Android 8.0 的系统不会有这个问题?在查看 Android 8.0 的源码后我们发现有以下修改:


try {
  mWM.addView(mView, mParams);
  trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
  /* ignore */
}

考虑再三,我们决定参考 Android 8.0 的做法,直接 catch 住这个异常。这里的关键在于寻找 Hook 点,这个案例算是相对比较简单的。Toast 里面有一个变量叫 mTN,它的类型为 handler,我们只需要代理它就可以实现捕获。

如果你做到了我上面说的这些,95% 以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此。
当然总有一些疑难问题需要依赖到用户的真实环境,我们希望具备类似动态跟踪和调试的能力。专栏后面还会讲到 xlog 日志、远程诊断、动态分析等高级手段,可以帮助我们进一步调试线上疑难问题,敬请期待。

崩溃攻防是一个长期的过程,我们希望尽可能地提前预防崩溃的发生,将它消灭在萌芽阶段。这可能涉及我们应用的整个流程,包括人员的培训、编译检查、静态扫描工作,还有规范的测试、灰度、发布流程等。
而崩溃优化也不是孤立的,它跟我们后面讲到的内存、卡顿、I/O 等内容都有关。可能等你学完整个课程后,再回头来看会有不同的理解。

总结

今天我们介绍了崩溃问题的一些分析方法、特殊技巧、以及疑难和常见问题的解决方法。当然崩溃分析要具体问题具体分析,不同类型的应用侧重点可能也有所不同,我们不能只局限在上面所说的一些方法。讲讲自己的一些心得体会,在解决崩溃特别是一些疑难问题时,总会觉得患得患失。有时候解了一个问题,发现其他问题也跟“开心消消乐”一样消失了。有时候有些问题“解不出来郁闷,解出来更郁闷”,可能只是一个小的代码疏忽,换来了一个月的青春和很多根白头发。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值