27-Android开发高手课

开篇词 | 焦虑的移动开发者该如何破局？

最近半年，常常有人问我两个问题：“现在学习移动开发还有前景吗？”“移动开发还有什么可以研究的？”。这两个问题其实对应着同一个现象，无论是准备入行或刚刚入行，还是比较资深的开发者，对于移动开发职业的未来，都有一些迷茫、一些焦虑。为什么会这样？

现在想想，移动互联网的发展不知不觉已经十多年了，Mobile First 也已经变成了 AI First。换句话说，我们已经不再是“风口上的猪”。移动开发的光环和溢价开始慢慢消失，并且正在向 AI、区块链等新的领域转移。移动开发的新鲜血液也已经变少，最明显的是国内应届生都纷纷涌向了 AI 方向。

可以说，国内移动互联网的红利期已经过去了，现在是增量下降、存量厮杀，从争夺用户到争夺时长。比较明显的是手机厂商纷纷互联网化，与传统互联网企业直接竞争。另外一方面，过去渠道的打法失灵，小程序、快应用等新兴渠道崛起，无论是手机厂商，还是各大 App 都把出海摆到了战略的位置。

从技术的角度来看，今年移动端的技术变革也有点缓慢。大前端的概念虽然说了很久也很多，但 React Native、PWA（Progressive Web App）的效果依然不尽人意。在插件化热潮之后，移动端的精品文章开始变少。去年“安利”完 Kotlin 之后，今年讲得比较多的还是 Flutter。

这么看来，移动开发的前景不明朗，再加上竞争激烈以及技术变革放缓，我们感到迷茫、焦虑似乎就不难理解了。

带着同样的焦虑，2017 年年底我选择离开了微信，目前在某知名互联网企业负责海外产品的技术工作。

回想一下自己的过去，经历了搜狗手机输入法用户从一千万到一亿，也见证了微信用户从一亿到十亿的增长。在快速成长的同时，让我感到更加快乐的是对社区还是做出了那么一点贡献。写了一些文章，建立并运营了 WeMobileDev 公众号，输出了一些技术干货和思考；拥抱了开源，Tinker、Mars、WCDB 相继开源，特别是 Tinker 作为腾讯在 GitHub 的第一个开源产品，让我体会到了开源的魅力。

在进入新工作环境的这大半年里，我遇到了一些困难，也接受了很多新的挑战。慢慢明白，或许是移动互联网这个大环境变了，推动我们不得不跟着转变。移动端的招聘量变少，但中高端的职位却多了起来，这说明行业只是变得成熟规范起来了。竞争激烈，但产品质量与留存变得更加重要，我们进入了技术赋能业务的时代。大前端正在跨平台，移动开发者的未来更可能是跨终端，产品、运营、数据分析、后端，技多不压身。

那企业需要什么样的移动开发人才？移动开发还可以做些什么？我希望通过这个专栏来回答你这两个问题。专栏里，我会结合业界的现状，讲讲国内外各大公司的尝试，以及他们的发展方向。我会尽量少放源码，侧重结合工作上的实践经验，分享一些疑难问题的解决思路。整体来说，主要包括以下三个部分 （注：60% 是 Android 相关的，40% 是可以跨平台的）。

1. 高质量开发。在如今的竞争态势下，保证产品的基础用户体验尤为重要。最近国内外各大公司，对 APM 性能监控系统也越来越重视。我挑选了崩溃、内存、卡顿、启动、I/O、存储、网络、耗电、渲染、安装包体积等比较常见的关键点，为你全面梳理性能优化的经验技巧与方向，帮你学会如何解决已知的存量问题，同时也能及时发现增量问题。

2. 高效开发。持续交付、DevOps 近年在国内非常火热，我们都在寻找内部突破，提升效率。一个应用从想法到成品，需要经历开发、编译 CI、测试、灰度、发布等多个阶段，那怎样提升各个阶段的效率，也是你我比较关心的话题。跨平台开发可能是解决开发阶段的一个答案，动态部署可能是发布阶段的一个答案。头条和快手如何做到数据驱动式的开发，怎样才是高效的运营，在这个模块，我将围绕这些内容一一展开。

3. 架构演进。“君有疾在腠理，不治将恐深”，对于一个应用来说，架构一定是核心中的核心。在这个模块，我会讲到 Google 的一些架构演进，例如 Android P、App Bundle、虚拟机、耗电等，也会讲到移动网络架构的一些选择，跨平台开发、动态化实践等热点知识。

在我看来，如今的移动开发开始冷下来了，或者有人说开始进入移动互联网的下半场了。其实，对于我们开发人员来说，不管是下半场还是上半场，我们重要的是要把技术做好做精做深。是的，现在移动开发已经不再是风口，但是，这并不是说移动开发已经被淘汰，而是说移动开发的发展进入了成熟期，就像 Web 技术一样。

如果说现在，你还只是能做好产品给过来的“需求”，我认为这是远远不够的。作为一个移动开发工程师，你我都需要深耕细作，都需要有工匠精神，把已有的事情从好做到更好。因为在迈向更好的过程中，你必然需要学习底层原理，你必然需要拓展知识面，你必然需要结合其他的技术，有了这么多必然，你也必然会变得更强。

所以，我在这里回答我一开始提出的那个让人焦虑的问题。首先，我认为，移动开发不等于 App 开发，所有新的技术浪潮其实都可以融入到移动开发的体系里，比如 IoT、音视频、边缘计算、VR/AR，我们要做的，只是打好基础，随时准备战斗。其次，从心态上，我觉得我们千万不要把时间浪费在纠结问题上，而是应该放在解决问题上。“王者荣耀”“吃鸡”并不能解决我们的焦虑，拥抱变化，才能拥有未来，让我们共勉。

最后，我和极客时间一起，为你准备了丰厚的学习大礼包，听说礼包里面有 GMTC 大会的门票哦。今天先卖个关子，我会在下一节“导读”里给你揭晓。

希望通过这个专栏，可以在这个节骨眼上，给你一些启发和帮助。

导读 | 如何打造高质量的应用？

今年年初，我去上海参加一个移动技术会议，问了很多开发者最近在忙啥。令我非常惊讶的是，大家讲的最多的还是用户体验和应用质量。特别是出海东南亚的同学，面对一堆 512MB 内存的设备、无处不在的弱网络流下了无助的眼泪。

除了内存优化、弱网络优化，想做一款高质量的应用还远远不止这些。一方面，我们面对的环境越来越复杂。过去的 iOS 开发者可能做梦也想不到，现在也要开始适配屏幕和双卡双待了，更不用说 Android 那多如繁星的机型、厂家和系统。如果你的应用也要出海，那么还要面对几十个国家不同的语言、环境。

另一方面，我们的代码跟业务也越来越复杂了。先不说大量“年久失修”的历史代码，业务越来越复杂，如何管理好几十上百个模块？还要面对 React Native、Flutter、TensorFlow 等各种语言跟框架堆积在一起的情况，再加上复杂的环境和庞大的系统，想想做一款高质量的应用真的不容易。

从应用交付的流程说起

既然打造一款高质量的应用那么困难，我们可以先从哪里入手做些什么呢？我的方法是把应用当成一件商品，想象一下商品在流水线生产的过程，那么怎样在每个步骤做好“质检”呢？这就要从应用交付的流程说起。

在我看来，一个应用至少会经过开发、编译 CI、测试、灰度和发布这几个阶段。每个阶段需要关注什么问题呢？

1. 开发阶段。在面试的时候，常常有人说自己熟练掌握各种开发工具。但是，我们真的懂吗？就拿我们比较熟悉的耗时分析工具 Traceview 来说，它背后的实现原理是什么？能不能做一个完全没有性能损耗的 Traceview？或者怎么样将它移植到线上使用？

2. 编译 CI 阶段。如何防止代码不断地恶化？怎样进一步优化性能？d8 与 ReDex 有什么神奇的黑科技？如何利用好 Coverity、Infer 这些静态分析工具？这部分可能需要一些编译原理的知识，你会发现移动开发也有很多值得深入研究的东西。

3. 测试阶段。我们常说敏捷开发，用户是最好的测试。遇到问题在线上反复试错，对自己、对用户都十分痛苦。我们希望可以做到测试“左移”，尽可能早地发现问题。但是很多时候我们不是不想测试，而是发现测不出什么问题。那么怎样提升实验室发现问题的能力呢？如何尽可能地模拟用户的操作路径？做好测试并不容易，自动化测试结合 AI 或许可以帮助我们解决一些痛点。

4. 灰度和发布阶段。动态部署流行起来之后，很多开发变得松懈起来。有问题发补丁，一个不行就两个，两个不行就十个。怎样去保证产品质量？很多线上问题概率很低，基本很难复现，比如对于一个印度的用户，我们希望有一个远程的听诊器，而不需要把用户拉到我们的手术台上。

对照应用的交付流程，我来介绍一下专栏的学习方法。专栏“高质量开发”模块主要对应的是开发阶段，你可以带着实践过程的困惑去深入学习开发需要的各种武器。专栏“高效开发”模块主要对应编译 CI、测试、灰度和发布阶段，你可以结合实际工作全面提升整个应用交付的效率。另外，我认为一个好的架构可以减少甚至避免团队出错，也是打造一款高质量应用非常重要的一环，因此我会在最后的“架构演进”模块和你聊聊如何设计一个好的架构，以及架构该如何选型。

移动 APM 质量平台

请你思考一下，在应用交付的这几个阶段中，我们对高质量的目标和实现方式是否一样呢？开发阶段有开发人员，可能希望采集尽可能多的数据；测试阶段有测试人员，可能更针对实验室环境或与竞品对比进行测试；灰度和发布阶段可能也有专门的运维人员，策略会相对保守一些。很明显，不同阶段我们对高质量的目标跟手段可能不太一样。

一个公司有多套质量系统，这在大公司是非常普遍的现象。我们希望有一个统一的平台，整合应用的人员和开发流程，这就是我们常说 APM 质量平台。

APM 的全称是“Application Performance Management”，即应用性能管理。据我了解，国内像阿里、腾讯、美团点评、饿了么、爱奇艺这些公司都在大力投入。Google 今年也发力 Android Vitals 监控，新增了耗电、权限管理模块。那么 APM 质量平台究竟有着什么样的魅力呢？

1. 统一管理。A 同学写了一个耗时监控工具，B 同学写了一个内存监控工具，它们在不同的仓库，上报格式可能不太一样，各自都搭了一个简单的页面。如果想评估一个应用的质量，总是要去几个系统汇总数据，想想都费劲。

2. 统一三端。一个公司可能有多个应用，一个应用也可能有 H5、iOS、Android 多个端。我们希望它们只是采集数据方式有所不同，上报、后台分析、展示、报警都是共用的。随着技术的发展，我们可能会增加 React Native、Flutter 这些新模块的监控，这个平台应该是统一演进的。当然我们非常希望业界有一套开源的方案，大家可以一起优化。

那这个质量平台需要关注哪些问题呢？这需要看我们用户关心什么问题。有的问题可能是致命的，像崩溃、卡死、白屏。另一大类问题就是性能问题，安装包大小、启动、耗时、内存、耗电、流量都是这一个范畴。在这个专栏里，我并不会教你如何从头搭建一个 APM 平台，我会更期待你掌握背后所需要的知识，它们主要包括：

由于 Android 版本的碎片化和国内 Android 生态的乱象，或者换句话说，“Android 开发者很苦，国内的 Android 开发者更苦”。在 11 月举办的 Android 绿色联盟开发者大会上推出的应用体验标准，有对应用的兼容性、稳定性、性能、功能和安全做了详细的定义。我贴张图，你可以看下。

在极致性能的同时，我们希望能更进一步地打造“绿色应用”。在这个过程中，一个全面而强大的 APM 质量平台会是我们坚实的后盾。当然对于大多数中小开发者来说，我们更建议选择成熟的第三方服务。但深入了解它们背后的原理，无论是对我们如何选择合适的服务，还是日常开发工作都会有很大的帮助。在学习完上面的这些内容之后，你也会觉得其实“性能优化”并不是那么“高不可攀”，我们也可以慢慢地迈向“性能优化专家”之路。

不过我们需要明确一点，虽然移动 APM 质量平台可以帮助我们快速发现和定位问题，但是监控并不能保证实现高质量，这里最关键的永远是人，而不是系统。为什么呢？我举两个小例子。

你在工作中可能总能遇到这样的场景，我管它叫反馈问题三连击：“是我的问题吗？”“能复现吗？”“你的测试靠谱吗？”。虽然通过 APM 质量平台可以减少推卸责任，但有些人的做法通常还是发现空指针加一个判空，发现并发问题加一个锁。这里的空指针真正原因是什么？这里判空了后面的逻辑是否还会运行正常？有没有更加好的方法或架构可以避免这个问题？我们真正应该反问的是这三个问题，把“质量观”深入骨髓，真正去想要得到个人成长，深挖背后的原因。

第二个例子是，我发现许多人都在问题无法忍受，或者说是老板无法忍受的时候才去开启各种优化专项，但事后又不了了之。我们很多时候都在用战术的勤奋掩盖战略的懒惰，性能优化的关键在于如何解决存量问题，同时快速发现增量问题。APM 质量平台只可以协助我们，并不能解决组织内部的心态问题。

总结

看到这里可能你会有这样的疑问，我们在小公司根本没有机会学习到这些东西呀？确实如此，个人与公司一起成长是最快速，也是非常难得的事情，但并不一定人人都会有这样的机会。从我自己的经验来看，在搜狗、微信会遇到各种各样的疑难问题，也可以有很多时间去研究，让我在解决问题的过程中获得成长。

幸运的是现在大家都更加乐于去分享，在专栏和技术会议中，我们可以看到很多成熟的解决问题的经验和思路，在 GitHub 我们可以找到很多优秀的源代码。在这个环境下，我们需要耐得住寂寞，多抠一些细节，多深入研究，多停下来总结。

我来分享一个我的故事，2013 年初我去面试微信，前面都不太理想，最后还是通过了。后来有一次跟面试官闲聊说起这个事情，他们认为有一件事情打动了他们。2012 年的时候，LeakCanary 还没开源，我在使用 MAT 做内存泄漏分析的时候总觉得很不爽。为什么不能做自动化？为什么看不到 Bitmap 的图片？我当时深入研究了内存文件 Hprof 的格式，做了几个小创新：

1. 实现内存的自动化测试。在自动化测试后回到首页，这个时候获取应用的内存快照。自动分析内存中 Activity 实例，正常情况应该只存在一个，其他都认为是泄漏。为了支持正式包，还做了通过 mapping 文件反混淆 Hprof 文件的功能。

2. 查看图片。自动将内存中重复的图片、比较大的图片转换成 PNG 格式输出到文件。

现在看起来这些功能并不是太难，但如果放到六年前，想到而且能做到的人相信并不多。讲这个故事还是希望你能在工作和实践中多停下来思考，多深入研究一些细节，很多看似不经意的思考和创新，可能在日后发挥更大的价值。

课后作业

“纸上得来终觉浅，绝知此事要躬行”，只有通过实践，运用到自己的项目里面，才会对知识有更深入地理解。

我为专栏的“课后作业”环节专门设计了可供你操作实践的 Sample，并在 GitHub 新建了专栏的Group，后面会陆续将这些 Sample 放到上面。课后作业主要是根据 Sample 进行操作，需要你理解实现原理，并在极客时间 App 专栏文章下面的留言区提交你的学习总结和思考。我和极客时间为认真提交作业的同学准备了丰厚的学习加油礼包，里面包含我推荐的经典图书、极客时间周历、专栏阅码等礼品。我还会选出坚持参与学习并分享心得的同学，在专栏模块一、模块三结束时，分别送出 2 张 2019 年 GMTC 大会的门票。

为了更好地给同学们答疑，我邀请了我的朋友孙鹏飞作为课程的“学习委员”，他将不定期讲解我们 Sample 的实现原理。鹏飞是 Android 资深开发工程师，现在负责某知名网约车平台司机端的业务开发、性能优化等工作。我们的学习委员对系统框架、虚拟机、性能优化等都有很深入的理解，实战经验也很丰富，相信鹏飞的加入可以更好地帮助你掌握课程的要点，并能解决实践中的问题。

今天是课程导读，也正式开启了我们接下来 3 个多月的学习之旅，所以最后特别邀请你在留言区也做个自我介绍，介绍下自己，聊聊目前的工作、学习情况，或者说说遇到的具体问题和困惑，也可以写写自己对这个课程的期待，让我们彼此有更多了解。

希望你能够积极参与进来，咱们教学相长，共同进步。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

01 | 崩溃优化（上）：关于“崩溃”那些事儿

在各种场合遇到其他产品的开发人员时，大家总忍不住想在技术上切磋两招。第一句问的通常都是“你们产品的崩溃率是多少？”

程序员 A 自豪地说： “百分之一。”

旁边的程序员 B 鄙视地看了一眼，然后喊到： “千分之一！”

“万分之一” ，程序员 C 说完之后全场变得安静起来。

崩溃率是衡量一个应用质量高低的基本指标，这一点是你我都比较认可的。不过你说的“万分之一”就一定要比我说的“百分之一” 更好吗？我觉得，这个问题其实并不仅仅是比较两个数值这么简单。

今天我们就来聊一聊有关“崩溃”的那些事，我会从 Android 的两种崩溃类型谈起，再和你进一步讨论到底该怎样客观地衡量崩溃这个指标，以及又该如何看待和崩溃相关的稳定性。

Android 的两种崩溃

我们都知道，Android 崩溃分为 Java 崩溃和 Native 崩溃。

简单来说，Java 崩溃就是在 Java 代码中，出现了未捕获异常，导致程序异常退出。那 Native 崩溃又是怎么产生的呢？一般都是因为在 Native 代码中访问非法地址，也可能是地址对齐出现了问题，或者发生了程序主动 abort，这些都会产生相应的 signal 信号，导致程序异常退出。

所以，“崩溃”就是程序出现异常，而一个产品的崩溃率，跟我们如何捕获、处理这些异常有比较大的关系。Java 崩溃的捕获比较简单，但是很多同学对于如何捕获 Native 崩溃还是一知半解，下面我就重点介绍 Native 崩溃的捕获流程和难点。

1.Native 崩溃的捕获流程

如果你对 Native 崩溃机制的一些基本知识还不是很熟悉，建议你阅读一下《Android 平台 Native 代码的崩溃捕获机制及实现》。这里我着重给你讲讲一个完整的 Native 崩溃从捕获到解析要经历哪些流程。

编译端。编译 C/C++ 代码时，需要将带符号信息的文件保留下来。

客户端。捕获到崩溃时候，将收集到尽可能多的有用信息写入日志文件，然后选择合适的时机上传到服务器。

服务端。读取客户端上报的日志文件，寻找适合的符号文件，生成可读的 C/C++ 调用栈。

2.Native 崩溃捕获的难点

Chromium 的Breakpad是目前 Native 崩溃捕获中最成熟的方案，但很多人都觉得 Breakpad 过于复杂。其实我认为 Native 崩溃捕获这个事情本来就不容易，跟当初设计 Tinker 的时候一样，如果只想在 90% 的情况可靠，那大部分的代码的确可以砍掉；但如果想达到 99%，在各种恶劣条件下依然可靠，后面付出的努力会远远高于前期。

所以在上面的三个流程中，最核心的是怎么样保证客户端在各种极端情况下依然可以生成崩溃日志。因为在崩溃时，程序会处于一个不安全的状态，如果处理不当，非常容易发生二次崩溃。

那么，生成崩溃日志时会有哪些比较棘手的情况呢？

情况一：文件句柄泄漏，导致创建日志文件失败，怎么办？

应对方式：我们需要提前申请文件句柄 fd 预留，防止出现这种情况。

情况二：因为栈溢出了，导致日志生成失败，怎么办？

应对方式：为了防止栈溢出导致进程没有空间创建调用栈执行处理函数，我们通常会使用常见的 signalstack。在一些特殊情况，我们可能还需要直接替换当前栈，所以这里也需要在堆中预留部分空间。

情况三：整个堆的内存都耗尽了，导致日志生成失败，怎么办？

应对方式：这个时候我们无法安全地分配内存，也不敢使用 stl 或者 libc 的函数，因为它们内部实现会分配堆内存。这个时候如果继续分配内存，会导致出现堆破坏或者二次崩溃的情况。Breakpad 做的比较彻底，重新封装了Linux Syscall Support，来避免直接调用 libc。

情况四：堆破坏或二次崩溃导致日志生成失败，怎么办？

应对方式：Breakpad 会从原进程 fork 出子进程去收集崩溃现场，此外涉及与 Java 相关的，一般也会用子进程去操作。这样即使出现二次崩溃，只是这部分的信息丢失，我们的父进程后面还可以继续获取其他的信息。在一些特殊的情况，我们还可能需要从子进程 fork 出孙进程。

当然 Breakpad 也存在着一些问题，例如生成的 minidump 文件是二进制格式的，包含了太多不重要的信息，导致文件很容易达到几 MB。但是 minidump 也不是毫无用处，它有一些比较高级的特性，比如使用 gdb 调试、可以看到传入参数等。Chromium 未来计划使用 Crashpad 全面替代 Breakpad，但目前来说还是 “too early to mobile”。

我们有时候想遵循 Android 的文本格式，并且添加更多我们认为重要的信息，这个时候就要去改造 Breakpad 的实现。比较常见的例如增加 Logcat 信息、Java 调用栈信息以及崩溃时的其他一些有用信息，在下一节我们会有更加详细的介绍。

如果想彻底弄清楚 Native 崩溃捕获，需要我们对虚拟机运行、汇编这些内功有一定造诣。做一个高可用的崩溃收集 SDK 真的不是那么容易，它需要经过多年的技术积累，要考虑的细节也非常多，每一个失败路径或者二次崩溃场景都要有应对措施或备用方案。

3. 选择合适的崩溃服务

对于很多中小型公司来说，我并不建议自己去实现一套如此复杂的系统，可以选择一些第三方的服务。目前各种平台也是百花齐放，包括腾讯的Bugly、阿里的啄木鸟平台、网易云捕、Google 的 Firebase 等等。

当然，在平台的选择方面，我认为，从产品化跟社区维护来说，Bugly 在国内做的最好；从技术深度跟捕获能力来说，阿里 UC 浏览器内核团队打造的啄木鸟平台最佳。

如何客观地衡量崩溃

对崩溃有了更多了解以后，我们怎样才能客观地衡量崩溃呢？

要衡量一个指标，首先要统一计算口径。如果想评估崩溃造成的用户影响范围，我们会先去看UV 崩溃率。

UV 崩溃率 = 发生崩溃的 UV / 登录 UV

只要用户出现过一次崩溃就会被计算到，所以 UV 崩溃率的高低会跟应用的使用时长有比较大的关系，这也是微信 UV 崩溃率在业界不算低的原因（强行甩锅）。当然这个时候，我们还可以去看应用 PV 崩溃率、启动崩溃率、重复崩溃率这些指标，计算方法都大同小异。

这里为什么要单独统计启动崩溃率呢？因为启动崩溃对用户带来的伤害最大，应用无法启动往往通过热修复也无法拯救。闪屏广告、运营活动，很多应用启动过程异常复杂，又涉及各种资源、配置下发，极其容易出现问题。微信读书、蘑菇街、淘宝、天猫这些“重运营”的应用都有使用一种叫作“安全模式”的技术来保障客户端的启动流程，在监控到客户端启动失败后，给用户自救的机会。

现在回到文章开头程序员“华山论剑”的小故事，我来揭秘他们解决崩溃率的“独家秘笈”。

程序员 B 对所有线程、任务都封装了一层 try catch，“消化”掉了所有 Java 崩溃。至于程序是否会出现其他异常表现，这是上帝要管的事情，反正我是实现了“千分之一”的目标。

程序员 C 认为 Native 崩溃太难解决，所以他想了一个“好方法”，就是不采集所有的 Native 崩溃，美滋滋地跟老板汇报“万分之一”的工作成果。

了解了美好数字产生的“秘笈”后，不知道你有何感想？其实程序员 B 和 C 都是真实的案例，而且他们的用户体量都还不算小。技术指标过于 KPI 化，是国内比较明显的一个现象。崩溃率只是一个数字，我们的出发点应该是让用户有更好的体验。

如何客观地衡量稳定性

到此，我们讨论了崩溃是怎么回事儿，以及怎么客观地衡量崩溃。那崩溃率是不是就能完全等价于应用的稳定性呢？答案是肯定不行。处理了崩溃，我们还会经常遇到 ANR（Application Not Responding，程序没有响应）这个问题。

出现 ANR 的时候，系统还会弹出对话框打断用户的操作，这是用户非常不能忍受的。这又带来另外一个问题，我们怎么去发现应用中的 ANR 异常呢？总结一下，通常有两种做法。

1. 使用 FileObserver 监听 /data/anr/traces.txt 的变化。非常不幸的是，很多高版本的 ROM，已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径，海外可以使用 Google Play 服务，而国内微信利用Hardcoder框架（HC 框架是一套独立于安卓系统实现的通信框架，它让 App 和厂商 ROM 能够实时“对话”了，目标就是充分调度系统资源来提升 App 的运行速度和画质，切实提高大家的手机使用体验）向厂商获取了更大的权限。

2. 监控消息队列的运行时间。这个方案无法准确地判断是否真正出现了 ANR 异常，也无法得到完整的 ANR 日志。在我看来，更应该放到卡顿的性能范畴。

回想我当时在设计 Tinker 的时候，为了保证热修复不会影响应用的启动，Tinker 在补丁的加载流程也设计了简单的“安全模式”，在启动时会检查上次应用的退出类型，如果检查连续三次异常退出，将会自动清除补丁。所以除了常见的崩溃，还有一些会导致应用异常退出的情况。

在讨论什么是异常退出之前，我们先看看都有哪些应用退出的情形。

主动自杀。Process.killProcess()、exit() 等。

崩溃。出现了 Java 或 Native 崩溃。

系统重启；系统出现异常、断电、用户主动重启等，我们可以通过比较应用开机运行时间是否比之前记录的值更小。

被系统杀死。被 low memory killer 杀掉、从系统的任务管理器中划掉等。

ANR。

我们可以在应用启动的时候设定一个标志，在主动自杀或崩溃后更新标志，这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。对应上面的五种退出场景，我们排除掉主动自杀和崩溃（崩溃会单独的统计）这两种场景，希望可以监控到剩下三种的异常退出，理论上这个异常捕获机制是可以达到 100% 覆盖的。

通过这个异常退出的检测，可以反映如 ANR、low memory killer、系统强杀、死机、断电等其他无法正常捕获到的问题。当然异常率会存在一些误报，比如用户从系统的任务管理器中划掉应用。对于线上的大数据来说，还是可以帮助我们发现代码中的一些隐藏问题。

所以就得到了一个新的指标来衡量应用的稳定性，即异常率。

UV 异常率 = 发生异常退出或崩溃的 UV / 登录 UV

前不久我们的一个应用灰度版本发现异常退出的比例增长不少，最后排查发现由于视频播放存在一个巨大 bug，会导致可能有用户手机卡死甚至重启，这是传统崩溃收集很难发现的问题。

根据应用的前后台状态，我们可以把异常退出分为前台异常退出和后台异常退出。“被系统杀死”是后台异常退出的主要原因，当然我们会更关注前台的异常退出的情况，这会跟 ANR、OOM 等异常情况有更大的关联。

通过异常率我们可以比较全面的评估应用的稳定性，对于线上监控还需要完善崩溃的报警机制。在微信我们可以做到 5 分钟级别的崩溃预警，确保能在第一时间发现线上重大问题，尽快决定是通过发版还是动态热修复解决问题。

总结

今天，我讲了 Android 的两种崩溃，重点介绍了 Native 崩溃的捕获流程和一些难点。做一个高可用的崩溃收集 SDK 并不容易，它背后涉及 Linux 信号处理以及内存分配、汇编等知识，当你内功修炼得越深厚，学习这些底层知识就越得心应手。

接着，我们讨论了崩溃率应该如何去计算，崩溃率的高低跟应用时长、复杂度、收集 SDK 有关。不仅仅是崩溃率，我们还学习了目前 ANR 采集的方式以及遇到的问题，最后提出了异常率这一个新的稳定性监控指标。

作为技术人员，我们不应该盲目追求崩溃率这一个数字，应该以用户体验为先，如果强行去掩盖一些问题往往更加适得其反。我们不应该随意使用 try catch 去隐藏真正的问题，要从源头入手，了解崩溃的本质原因，保证后面的运行流程。在解决崩溃的过程，也要做到由点到面，不能只针对这个崩溃去解决，而应该要考虑这一类崩溃怎么解决和预防。

崩溃的治理是一个长期的过程，在专栏下一期我会重点讲一些分析应用崩溃的方法论。另外，你如果细心的话，可以发现，在这篇文章里，我放了很多的超链接，后面的文章里也会有类似的情况。所以，这就要求你在读完文章之后，或者读的过程中，如果对相关的背景信息或者概念不理解，就需要花些时间阅读周边文章。当然，如果看完还是没有明白，你也可以在留言区给我留言。

课后作业

Breakpad是一个跨平台的开源项目，今天的课后作业是使用 Breakpad 来捕获一个 Native 崩溃，并在留言区写下你今天学习和练习后的总结与思考。

当然我在专栏 GitHub 的Group里也为你提供了一个Sample方便你练习，如果你没使用过 Breakpad 的话，只需要直接编译即可。希望你可以通过一个简单的 Native 崩溃捕获过程，完成 minidump 文件的生成和解析，在实践中加深对 Breakpad 工作机制的认识。

我要再次敲黑板划重点了，请你一定要坚持参与我们的课后练习，从最开始就养成学完后立马动手操作的好习惯，这样才能让学习效率最大化，一步步接近“成为高手”的目标。当然了，认真提交作业的同学还有机会获得学习加油礼包。接下来，就看你的了！

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

02 | 崩溃优化（下）：应用崩溃了，你应该如何去分析？

在侦探漫画《名侦探柯南》中，无论柯南走到哪里都会遇到新的“案件”，这也很像程序员的“日常”，我们每天工作也会遇到各种各样的疑难问题，“崩溃”就是其中比较常见的一种问题。

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

“真相永远只有一个”，崩溃也并不可怕。通过今天的学习，希望你能成为代码届的名侦探柯南。

崩溃现场

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

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

接下来我们具体来看看在崩溃现场应该采集哪些信息。

1. 崩溃信息

从崩溃的基本信息，我们可以对崩溃有初步的判断。

进程名、线程名。崩溃的进程是前台进程还是后台进程，崩溃是不是发生在 UI 线程。

崩溃堆栈和类型。崩溃是属于 Java 崩溃、Native 崩溃，还是 ANR，对于不同类型的崩溃我们关注的点也不太一样。特别需要看崩溃堆栈的栈顶，看具体崩溃在系统的代码，还是我们自己的代码里面。

Process Name: 'com.sample.crash'

Thread Name: 'MyThread'

java.lang.NullPointerException

    at ...TestsActivity.crashInJava(TestsActivity.java:275)

有时候我们除了崩溃的线程，还希望拿到其他关键的线程的日志。就像上面的例子，虽然是 MyThread 线程崩溃，但是我也希望可以知道主线程当前的调用栈。

2. 系统信息

系统的信息有时候会带有一些关键的线索，对我们解决问题有非常大的帮助。

Logcat。这里包括应用、系统的运行日志。由于系统权限问题，获取到的 Logcat 可能只包含与当前 App 相关的。其中系统的 event logcat 会记录 App 运行的一些基本情况，记录在文件 /system/etc/event-log-tags 中。

system logcat:

10-25 17:13:47.788 21430 21430 D dalvikvm: Trying to load lib ...

event logcat:

10-25 17:13:47.788 21430 21430 I am_on_resume_called: 生命周期

10-25 17:13:47.788 21430 21430 I am_low_memory: 系统内存不足

10-25 17:13:47.788 21430 21430 I am_destroy_activity: 销毁 Activty

10-25 17:13:47.888 21430 21430 I am_anr: ANR 以及原因

10-25 17:13:47.888 21430 21430 I am_kill: APP 被杀以及原因

机型、系统、厂商、CPU、ABI、Linux 版本等。我们会采集多达几十个维度，这对后面讲到寻找共性问题会很有帮助。

设备状态：是否 root、是否是模拟器。一些问题是由 Xposed 或多开软件造成，对这部分问题我们要区别对待。

3. 内存信息

OOM、ANR、虚拟内存耗尽等，很多崩溃都跟内存有直接关系。如果我们把用户的手机内存分为“2GB 以下”和“2GB 以上”两个桶，会发现“2GB 以下”用户的崩溃率是“2GB 以上”用户的几倍。

系统剩余内存。关于系统内存状态，可以直接读取文件 /proc/meminfo。当系统可用内存很小（低于 MemTotal 的 10%）时，OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。

应用使用内存。包括 Java 内存、RSS（Resident Set Size）、PSS（Proportional Set Size），我们可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap 计算，可以进一步得到例如 apk、dex、so 等更加详细的分类统计。

虚拟内存。虚拟内存可以通过 /proc/self/status 得到，通过 /proc/self/maps 文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存，但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的。

Name:     com.sample.name   // 进程名

FDSize:   800               // 当前进程申请的文件句柄个数

VmPeak:   3004628 kB        // 当前进程的虚拟内存峰值大小

VmSize:   2997032 kB        // 当前进程的虚拟内存大小

Threads:  600               // 当前进程包含的线程个数

一般来说，对于 32 位进程，如果是 32 位的 CPU，虚拟内存达到 3GB 就可能会引起内存申请失败的问题。如果是 64 位的 CPU，虚拟内存一般在 3～4GB 之间。当然如果我们支持 64 位进程，虚拟内存就不会成为问题。Google Play 要求 2019 年 8 月一定要支持 64 位，在国内虽然支持 64 位的设备已经在 90% 以上了，但是商店都不支持区分 CPU 架构类型发布，普及起来需要更长的时间。

4. 资源信息

有的时候我们会发现应用堆内存和设备内存都非常充足，还是会出现内存分配失败的情况，这跟资源泄漏可能有比较大的关系。

文件句柄 fd。文件句柄的限制可以通过 /proc/self/limits 获得，一般单个进程允许打开的最大文件句柄个数为 1024。但是如果文件句柄超过 800 个就比较危险，需要将所有的 fd 以及对应的文件名输出到日志中，进一步排查是否出现了有文件或者线程的泄漏。

opened files count 812:

0 -> /dev/null

1 -> /dev/log/main4

2 -> /dev/binder

3 -> /data/data/com.crash.sample/files/test.config

...

线程数。当前线程数大小可以通过上面的 status 文件得到，一个线程可能就占 2MB 的虚拟内存，过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说，如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中，进一步排查是否出现了线程相关的问题。

 threads count 412:

 1820 com.sample.crashsdk

 1844 ReferenceQueueD

 1869 FinalizerDaemon

 ...

JNI。使用 JNI 时，如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过 DumpReferenceTables 统计 JNI 的引用表，进一步分析是否出现了 JNI 泄漏等问题。

5. 应用信息

除了系统，其实我们的应用更懂自己，可以留下很多相关的信息。

崩溃场景。崩溃发生在哪个 Activity 或 Fragment，发生在哪个业务中。

关键操作路径。不同于开发过程详细的打点日志，我们可以记录关键的用户操作路径，这对我们复现崩溃会有比较大的帮助。

其他自定义信息。不同的应用关心的重点可能不太一样，比如网易云音乐会关注当前播放的音乐，QQ 浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。

除了上面这些通用的信息外，针对特定的一些崩溃，我们可能还需要获取类似磁盘空间、电量、网络使用等特定信息。所以说一个好的崩溃捕获工具，会根据场景为我们采集足够多的信息，让我们有更多的线索去分析和定位问题。当然数据的采集需要注意用户隐私，做到足够强度的加密和脱敏。

崩溃分析

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

第一步：确定重点

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

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 等内容都有关。可能等你学完整个课程后，再回头来看会有不同的理解。

总结

今天我们介绍了崩溃问题的一些分析方法、特殊技巧、以及疑难和常见问题的解决方法。当然崩溃分析要具体问题具体分析，不同类型的应用侧重点可能也有所不同，我们不能只局限在上面所说的一些方法。

讲讲自己的一些心得体会，在解决崩溃特别是一些疑难问题时，总会觉得患得患失。有时候解了一个问题，发现其他问题也跟“开心消消乐”一样消失了。有时候有些问题“解不出来郁闷，解出来更郁闷”，可能只是一个小的代码疏忽，换来了一个月的青春和很多根白头发。

课后作业

在崩溃的长期保卫战中，你肯定有一些经典的漂亮战役，希望可以拿出来跟其他同学分享。当然也会有一些百思不得其解的问题，今天的课后作业是分享你破解崩溃问题的思路和方法，总结一下通过 Sample 的练习有什么收获。

如果想向崩溃发起挑战，那么 Top 20 崩溃就是我们无法避免的对手。在这里面会有不少疑难的系统崩溃问题，TimeoutException 就是其中比较经典的一个。

java.util.concurrent.TimeoutException:

         android.os.BinderProxy.finalize() timed out after 10 seconds

at android.os.BinderProxy.destroy(Native Method)

at android.os.BinderProxy.finalize(Binder.java:459)

今天的Sample提供了一种“完全解决”TimeoutException 的方法，主要是希望你可以更好地学习解决系统崩溃的套路。

1. 通过源码分析。我们发现 TimeoutException 是由系统的 FinalizerWatchdogDaemon 抛出来的。

2. 寻找可以规避的方法。尝试调用了它的 Stop() 方法，但是线上发现在 Android 6.0 之前会有线程同步问题。

3. 寻找其他可以 Hook 的点。通过代码的依赖关系，发现一个取巧的 Hook 点。

最终代码你可以参考 Sample 的实现，但是建议只在灰度中使用。这里需要提的是，虽然有一些黑科技可以帮助我们解决某些问题，但对于黑科技的使用我们需要慎重，比如有的黑科技对保活进程频率没有做限制，可能会导致系统卡死。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

03 | 内存优化（上）：4GB内存时代，再谈内存优化

在写今天这篇文章前，我又翻了翻三年前我在 WeMobileDev 公众号写过的《Android 内存优化杂谈》，今天再看，对里面的一句话更有感触：“我们并不能将内存优化中用到的所有技巧都一一说明，而且随着 Android 版本的更替，可能很多方法都会变的过时”。

三年过去了，4GB 内存的手机都变成了主流。那内存优化是不是变得不重要了？如今有哪些技巧已经淘汰，而我们又要升级什么技能呢？

今天在 4GB 内存时代下，我就再来谈谈“内存优化”这个话题。

移动设备发展

Facebook 有一个叫device-year-class的开源库，它会用年份来区分设备的性能。可以看到，2008 年的手机只有可怜的 140MB 内存，而今年的华为 Mate 20 Pro 手机的内存已经达到了 8GB。

内存看起来好像是我们都非常熟悉的概念，那请问问自己，手机内存和 PC 内存有哪什么差异呢？8GB 内存是不是就一定会比 4GB 内存更好？我想可能很多人都不一定能回答正确。

手机运行内存（RAM）其实相当于我们的 PC 中的内存，是手机中作为 App 运行过程中临时性数据暂时存储的内存介质。不过考虑到体积和功耗，手机不使用 PC 的 DDR 内存，采用的是 LPDDR RAM，全称是“低功耗双倍数据速率内存”，其中 LP 就是“Lower Power”低功耗的意思。

以 LPDDR4 为例，带宽 = 时钟频率 × 内存总线位数 ÷ 8，即 1600 × 64 ÷ 8 = 12.8GB/s，因为是 DDR 内存是双倍速率，所以最后的带宽是 12.8 × 2 = 25.6GB/s。

目前市面上的手机，主流的运行内存有 LPDDR3、LPDDR4 以及 LPDDR4X。可以看出 LPDDR4 的性能要比 LPDDR3 高出一倍，而 LPDDR4X 相比 LPDDR4 工作电压更低，所以也比 LPDDR4 省电 20%～40%。当然图中的数据是标准数据，不同的生成厂商会有一些低频或者高频的版本，性能方面高频要好于低频。

那手机内存是否越大越好呢？

如果一个手机使用的是 4GB 的 LPDDR4X 内存，另外一个使用的是 6GB 的 LPDDR3 内存，那么无疑选择 4GB 的运行内存手机要更加实用一些。

但是内存并不是一个孤立的概念，它跟操作系统、应用生态这些因素都有关。同样是 1GB 内存，使用 Android 9.0 系统会比 Android 4.0 系统流畅，使用更加封闭、规范的 iOS 系统也会比“狂野”的 Android 系统更好。今年发布的 iPhone XR 和 iPhone XS 使用的都是 LPDDR4X 的内存，不过它们分别只有 3GB 和 4GB 的大小。

内存问题

在前面所讲的崩溃分析中，我提到过“内存优化”是崩溃优化工作中非常重要的一部分。类似 OOM，很多的“异常退出”其实都是由内存问题引起。那么内存究竟能引发什么样的问题呢？

1. 两个问题

内存造成的第一个问题是异常。在前面的崩溃分析我提到过“异常率”，异常包括 OOM、内存分配失败这些崩溃，也包括因为整体内存不足导致应用被杀死、设备重启等问题。不知道你平时是否在工作中注意过，如果我们把用户设备的内存分成 2GB 以下和 2GB 以上两部分，你可以试试分别计算他们的异常率或者崩溃率，看看差距会有多大。

内存造成的第二个问题是卡顿。Java 内存不足会导致频繁 GC，这个问题在 Dalvik 虚拟机会更加明显。而 ART 虚拟机在内存管理跟回收策略上都做大量优化，内存分配和 GC 效率相比提升了 5～10 倍。如果想具体测试 GC 的性能，例如暂停挂起时间、总耗时、GC 吞吐量，我们可以通过发送SIGQUIT 信号获得 ANR 日志。

adb shell kill -S QUIT PID

adb pull /data/anr/traces.txt

它包含一些 ANR 转储信息以及 GC 的详细性能信息。

sticky concurrent mark sweep paused:	Sum: 5.491ms 99% C.I. 1.464ms-2.133ms Avg: 1.830ms Max: 2.133ms     // GC 暂停时间

Total time spent in GC: 502.251ms     // GC 总耗时

Mean GC size throughput: 92MB/s       // GC 吞吐量

Mean GC object throughput: 1.54702e+06 objects/s

另外我们还可以使用 systrace 来观察 GC 的性能耗时，这部分内容在专栏后面会详细讲到。

除了频繁 GC 造成卡顿之外，物理内存不足时系统会触发 low memory killer 机制，系统负载过高是造成卡顿的另外一个原因。

2. 两个误区

除了内存引起的异常和卡顿，在日常做内存优化和架构设计时，很多同学还非常容易陷入两个误区之中。

误区一：内存占用越少越好

VSS、PSS、Java 堆内存不足都可能会引起异常和卡顿。有些同学认为内存是洪水猛兽，占用越少应用的性能越好，这种认识在具体的优化过程中很容易“用力过猛”。

应用是否占用了过多的内存，跟设备、系统和当时情况有关，而不是 300MB、400MB 这样一个绝对的数值。当系统内存充足的时候，我们可以多用一些获得更好的性能。当系统内存不足的时候，希望可以做到“用时分配，及时释放”，就像下面这张图一样，当系统内存出现压力时，能够迅速释放各种缓存来减少系统压力。

现在手机已经有 6GB 和 8GB 的内存出现了，Android 系统也希望去提升内存的利用率，因此我们有必要简单回顾一下 Android Bitmap 内存分配的变化。

在 Android 3.0 之前，Bitmap 对象放在 Java 堆，而像素数据是放在 Native 内存中。如果不手动调用 recycle，Bitmap Native 内存的回收完全依赖 finalize 函数回调，熟悉 Java 的同学应该知道，这个时机不太可控。

Android 3.0～Android 7.0 将 Bitmap 对象和像素数据统一放到 Java 堆中，这样就算我们不调用 recycle，Bitmap 内存也会随着对象一起被回收。不过 Bitmap 是内存消耗的大户，把它的内存放到 Java 堆中似乎不是那么美妙。即使是最新的华为 Mate 20，最大的 Java 堆限制也才到 512MB，可能我的物理内存还有 5GB，但是应用还是会因为 Java 堆内存不足导致 OOM。Bitmap 放到 Java 堆的另外一个问题会引起大量的 GC，对系统内存也没有完全利用起来。

有没有一种实现，可以将 Bitmap 内存放到 Native 中，也可以做到和对象一起快速释放，同时 GC 的时候也能考虑这些内存防止被滥用？NativeAllocationRegistry 可以一次满足你这三个要求，Android 8.0 正是使用这个辅助回收 Native 内存的机制，来实现像素数据放到 Native 内存中。Android 8.0 还新增了硬件位图 Hardware Bitmap，它可以减少图片内存并提升绘制效率。

误区二：Native 内存不用管

虽然 Android 8.0 重新将 Bitmap 内存放回到 Native 中，那么我们是不是就可以随心所欲地使用图片呢？

答案当然是否定的。正如前面所说当系统物理内存不足时，lmk 开始杀进程，从后台、桌面、服务、前台，直到手机重启。系统构想的场景就像下面这张图描述的一样，大家有条不絮的按照优先级排队等着被 kill。

low memory killer 的设计，是假定我们都遵守 Android 规范，但并没有考虑到中国国情。国内很多应用就像是打不死的小强，杀死一个拉起五个。频繁的杀死、拉起进程，又会导致 system server 卡死。当然在 Android 8.0 以后应用保活变得困难很多，但依然有一些方法可以突破。

既然讲到了将图片的内存放到 Native 中，我们比较熟悉的是 Fresco 图片库在 Dalvik 会把图片放到 Native 内存中。事实上在 Android 5.0～Android 7.0，也能做到相同的效果，只是流程相对复杂一些。

步骤一：通过直接调用 libandroid_runtime.so 中 Bitmap 的构造函数，可以得到一张空的 Bitmap 对象，而它的内存是放到 Native 堆中。但是不同 Android 版本的实现有那么一点差异，这里都需要适配。

步骤二：通过系统的方法创建一张普通的 Java Bitmap。

步骤三：将 Java Bitmap 的内容绘制到之前申请的空的 Native Bitmap 中。

步骤四：将申请的 Java Bitmap 释放，实现图片内存的“偷龙转凤”。

// 步骤一：申请一张空的 Native Bitmap

Bitmap nativeBitmap = nativeCreateBitmap(dstWidth, dstHeight, nativeConfig, 22);

// 步骤二：申请一张普通的 Java Bitmap

Bitmap srcBitmap = BitmapFactory.decodeResource(res, id);

// 步骤三：使用 Java Bitmap 将内容绘制到 Native Bitmap 中

mNativeCanvas.setBitmap(nativeBitmap);

mNativeCanvas.drawBitmap(srcBitmap, mSrcRect, mDstRect, mPaint);

// 步骤四：释放 Java Bitmap 内存

srcBitmap.recycle();

srcBitmap = null；

虽然最终图片的内存的确是放到 Native 中了，不过这个“黑科技”有两个主要问题，一个是兼容性问题，另外一个是频繁申请释放 Java Bitmap 容易导致内存抖动。

测量方法

在日常开发中，有时候我们需要去排查应用程序中的内存问题。对于系统内存和应用内存的使用情况，你可以参考 Android Developer 中 《调查 RAM 使用情况》。

adb shell dumpsys meminfo <package_name|pid> [-d]

1. Java 内存分配

有些时候我们希望跟踪 Java 堆内存的使用情况，这个时候最常用的有 Allocation Tracker 和 MAT 这两个工具。

在我曾经写过的《Android 内存申请分析》里，提到过 Allocation Tracker 的三个缺点。

获取的信息过于分散，中间夹杂着不少其他的信息，很多信息不是应用申请的，可能需要进行不少查找才能定位到具体的问题。

跟 Traceview 一样，无法做到自动化分析，每次都需要开发者手工开始 / 结束，这对于某些问题的分析可能会造成不便，而且对于批量分析来说也比较困难。

虽然在 Allocation Tracking 的时候，不会对手机本身的运行造成过多的性能影响，但是在停止的时候，直到把数据 dump 出来之前，经常会把手机完全卡死，如果时间过长甚至会直接 ANR。

因此我们希望可以做到脱离 Android Studio，实现一个自定义的“Allocation Tracker”，实现对象内存的自动化分析。通过这个工具可以获取所有对象的申请信息（大小、类型、堆栈等），可以找到一段时间内那些对象占用了大量的内存。

但是这个方法需要考虑的兼容性问题会比较多，在 Dalvik 和 ART 中，Allocation Tracker 的处理流程差异就非常大。下面是在 Dalvik 和 ART 中，Allocation Tacker 的开启方式。

// dalvik

bool dvmEnableAllocTracker()

// art

void setAllocTrackingEnabled()

我们可以用自定义的“Allocation Tracker”来监控 Java 内存的监控，也可以拓展成实时监控 Java 内存泄漏。这方面经验不多的同学也不用担心，我在今天的“课后作业”提供了一个自定义的“Allocation Tracker”供你参考。不过任何一个工具如果只需要做到线下自动化测试，实现起来会相对简单，但想要移植到线上使用，那就要更加关注兼容性、稳定性和性能，付出的努力要远远高于实验室方案。

在课后作业中我们会提供一个简单的例子，在熟悉 Android Studio 中 Profiler 各种工具的实现原理后，我们就可以做各种各样的自定义改造，在后面的文章中也会有大量的例子供你参考和练习。

2. Native 内存分配

Android 的 Native 内存分析是一直做得非常不好，当然 Google 在近几个版本也做了大量努力，让整个过程更加简单。

首先 Google 之前将 Valgrind 弃用，建议我们使用 Chromium 的AddressSanitize 。遵循“谁最痛，谁最需要，谁优化”，所以 Chromium 出品了一大堆 Native 相关的工具。Android 之前对 AddressSanitize 支持的不太好，需要 root 和一大堆的操作，但在 Android 8.0 之后，我们可以根据这个指南来使用 AddressSanitize。目前 AddressSanitize 内存泄漏检测只支持 x86_64 Linux 和 OS X 系统，不过相信 Google 很快就可以支持直接在 Android 上进行检测了。

那我们有没有类似 Allocation Tracker 那样的 Native 内存分配工具呢？在这方面，Android 目前的支持还不是太好，但 Android Developer 近来也补充了一些相关的文档，你可以参考《调试本地内存使用》。关于 Native 内存的问题，有两种方法，分别是Malloc 调试和Malloc 钩子。

Malloc 调试可以帮助我们去调试 Native 内存的一些使用问题，例如堆破坏、内存泄漏、非法地址等。Android 8.0 之后支持在非 root 的设备做 Native 内存调试，不过跟 AddressSanitize 一样，需要通过wrap.sh做包装。

adb shell setprop wrap.<APP> '"LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper"'

Malloc 钩子是在 Android P 之后，Android 的 libc 支持拦截在程序执行期间发生的所有分配 / 释放调用，这样我们就可以构建出自定义的内存检测工具。

adb shell setprop wrap.<APP> '"LIBC_HOOKS_ENABLE=1"'

但是在使用“Malloc 调试”时，感觉整个 App 都会变卡，有时候还会产生 ANR。如何在 Android 上对应用 Native 内存分配和泄漏做自动化分析，也是我最近想做的事情。据我了解，微信最近几个月在 Native 内存泄漏监控上也做了一些尝试，我会在专栏下一期具体讲讲。

总结

LPDDR5 将在明年进入量产阶段，移动内存一直向着更大容量、更低功耗、更高带宽的方向发展。伴随内存的发展，内存优化的挑战和解决方案也不断变化。而内存优化又是性能优化重要的一部分，今天我讲到了很多的异常和卡顿都是因为内存不足引起的，并在最后讲述了如何在日常开发中分析和测量内存的使用情况。

一个好的开发者并不满足于做完需求，我们在设计方案的时候，还需要考虑要使用多少的内存，应该怎么去管理这些内存。在需求完成之后，我们也应该去回归需求的内存情况，是否存在使用不当的地方，是否出现内存泄漏。

课后作业

内存优化是一个非常“古老”的话题，大家在工作中也会遇到各种各样内存相关的问题。今天的课后作业是分享一下你在工作中遇到的内存问题，总结一下通过 Sample 的练习有什么收获。

在今天文章里我提到，希望可以脱离 Android Studio 实现一个自定义的 Allocation Tracker，这样就可以将它用到自动化分析中。本期的Sample就提供了一个自定义的 Allocation Tracker 实现的示例，目前已经兼容到 Android 8.1。你可以用它练习实现自动化的内存分析，有哪些对象占用了大量内存，以及它们是如何导致 GC 等。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

04 | 内存优化（下）：内存优化这件事，应该从哪里着手？

在掌握内存相关的背景知识后，下一步你肯定想着手开始优化内存的问题了。不过在真正开始做内存优化之前，需要先评估内存对应用性能的影响，我们可以通过崩溃中“异常退出” 和 OOM 的比例进行评估。另一方面，低内存设备更容易出现内存不足引起的异常和卡顿，我们也可以通过查看应用中用户的手机内存在 2GB 以下所占的比例来评估。

所以在优化前要先定好自己的目标，这一点非常关键。比如针对 512MB 的设备和针对 2GB 以上的设备，完全是两种不同的优化思路。如果我们面向东南亚、非洲用户，那对内存优化的标准就要变得更苛刻一些。

铺垫了这么多，下面我们就来看看内存优化都有哪些方法吧。

内存优化探讨

那要进行内存优化，应该从哪里着手呢？我通常会从设备分级、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. 采集方式

用户在前台的时候，可以每 5 分钟采集一次 PSS、Java 堆、图片总内存。我建议通过采样只统计部分用户，需要注意的是要按照用户抽样，而不是按次抽样。简单来说一个用户如果命中采集，那么在一天内都要持续采集数据。

2. 计算指标

通过上面的数据，我们可以计算下面一些内存指标。

内存异常率：可以反映内存占用的异常情况，如果出现新的内存使用不当或内存泄漏的场景，这个指标会有所上涨。其中 PSS 的值可以通过 Debug.MemoryInfo 拿到。

内存 UV 异常率 = PSS 超过 400MB 的 UV / 采集 UV

触顶率：可以反映 Java 内存的使用情况，如果超过 85% 最大堆限制，GC 会变得更加频繁，容易造成 OOM 和卡顿。

内存 UV 触顶率 = Java 堆占用超过最大堆限制的 85% 的 UV / 采集 UV

其中是否触顶可以通过下面的方法计算得到。

long javaMax = runtime.maxMemory();

long javaTotal = runtime.totalMemory();

long javaUsed = javaTotal - runtime.freeMemory();

// Java 内存使用超过最大限制的 85%

float proportion = (float) javaUsed / javaMax;

一般客户端只上报数据，所有计算都在后台处理，这样可以做到灵活多变。后台还可以计算平均 PSS、平均 Java 内存、平均图片占用这些指标，它们可以反映内存的平均情况。通过平均内存和分区间内存占用这些指标，我们可以通过版本对比来监控有没有新增内存相关的问题。

因为上报了前台时间，我们还可以按照时间维度看应用内存的变化曲线。比如可以观察一下我们的应用是不是真正做到了“用时分配，及时释放”。如果需要，我们还可以实现按照场景来对比内存的占用。

3. GC 监控

在实验室或者内部试用环境，我们也可以通过 Debug.startAllocCounting 来监控 Java 内存分配和 GC 的情况，需要注意的是这个选项对性能有一定的影响，虽然目前还可以使用，但已经被 Android 标记为 deprecated。

通过监控，我们可以拿到内存分配的次数和大小，以及 GC 发起次数等信息。

long allocCount = Debug.getGlobalAllocCount();

long allocSize = Debug.getGlobalAllocSize();

long gcCount = Debug.getGlobalGcInvocationCount();

上面的这些信息似乎不太容易定位问题，在 Android 6.0 之后系统可以拿到更加精准的 GC 信息。

﻿﻿// 运行的 GC 次数

Debug.getRuntimeStat("art.gc.gc-count");

// GC 使用的总耗时，单位是毫秒

Debug.getRuntimeStat("art.gc.gc-time");

// 阻塞式 GC 的次数

Debug.getRuntimeStat("art.gc.blocking-gc-count");

// 阻塞式 GC 的总耗时

Debug.getRuntimeStat("art.gc.blocking-gc-time");

需要特别注意阻塞式 GC 的次数和耗时，因为它会暂停应用线程，可能导致应用发生卡顿。我们也可以更加细粒度地分应用场景统计，例如启动、进入朋友圈、进入聊天页面等关键场景。

总结

在具体进行内容优化前，我们首先要问清楚自己几个问题，比如我们要优化到什么目标、内存对我们造成了多少异常和卡顿。只有在明确了应用的现状和优化目标后，我们才能去进行下一步的操作。

在探讨了内存优化的思路时，针对不同的设备、设备不同的情况，我们希望可以给用户不同的体验。这里我主要讲到了关于 Bitmap 内存优化和内存泄漏排查、监控的一些方法。最后我提到了怎样在线上监控内存的异常情况，通常内存异常率、触顶率这些指标对我们很有帮助。

目前我们在 Native 泄漏分析上做的还不是那么完善，不过做优化工作的时候，我特别喜欢用演进的思路来看问题。用演进的思路来看，即使是 Google， 在时机不成熟时也会做一些权衡和妥协。换到我们个人身上，等到时机成熟或者我们的能力达到了，就需要及时去还这些“技术债务”。

课后作业

看完我分享的内存优化的方法后，相信你也肯定还有很多好的思路和方法，今天的课后作业是分享一下你的内存优化“必杀技”，在留言区分享一下今天学习、练习的收获与心得。

在文中我提到 Hprof 文件裁剪和重复图片监控，这是很多应用目前都没有做的，而这两个功能也是微信的 APM 框架 Matrix 中内存监控的一部分。Matrix 是我一年多前在微信负责的最后一个项目，也付出了不少心血，最近听说终于准备开源了。

那今天我们就先来练练手，尝试使用 HAHA 库快速判断内存中是否存在重复的图片，并且将这些重复图片的 PNG、堆栈等信息输出。最终的实现可以通过向Sample发送 Pull Request。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

05 | 卡顿优化（上）：你要掌握的卡顿分析方法

“我的后羿怎么动不了！”，在玩《王者荣耀》的时候最怕遇到团战时卡得跟幻灯片一样。对于应用也是这样，我们经常会听到用户抱怨：“这个应用启动怎么那么慢？”“滑动的时候怎么那么卡？”。

对用户来说，内存占用高、耗费电量、耗费流量可能不容易被发现，但是用户对卡顿特别敏感，很容易直观感受到。另一方面，对于开发者来说，卡顿问题又非常难以排查定位，产生的原因错综复杂，跟 CPU、内存、磁盘 I/O 都可能有关，跟用户当时的系统环境也有很大关系。

那到底该如何定义卡顿呢？在本地有哪些工具可以帮助我们更好地发现和排查问题呢？这些工具之间的差异又是什么呢？今天我来帮你解决这些困惑。

基础知识

在具体讲卡顿工具前，你需要了解一些基础知识，它们主要都和 CPU 相关。造成卡顿的原因可能有千百种，不过最终都会反映到CPU 时间上。我们可以把 CPU 时间分为两种：用户时间和系统时间。用户时间就是执行用户态应用程序代码所消耗的时间；系统时间就是执行内核态系统调用所消耗的时间，包括 I/O、锁、中断以及其他系统调用的时间。

1. CPU 性能

我们先来简单讲讲 CPU 的性能，考虑到功耗、体积这些因素，移动设备和 PC 的 CPU 会有不少的差异。但近年来，手机 CPU 的性能也在向 PC 快速靠拢，华为 Mate 20 的“麒麟 980”和 iPhone XS 的“A12”已经率先使用领先 PC 的 7 纳米工艺。

评价一个 CPU 的性能，需要看主频、核心数、缓存等参数，具体表现出来的是计算能力和指令执行能力，也就是每秒执行的浮点计算数和每秒执行的指令数。

当然还要考虑到架构问题， “麒麟 980”采用三级能效架构，2 个 2.6GHz 主频的 A76 超大核 + 2 个 1.92GHz 主频的 A76 大核 + 4 个 1.8GHz 主频的 A55 小核。相比之下，“A12”使用 2 个性能核心 + 4 个能效核心的架构，这样设计主要是为了在日常低负荷工作时，使用低频核心更加节省电量。在开发过程中，我们可以通过下面的方法获得设备的 CPU 信息。

// 获取 CPU 核心数

cat /sys/devices/system/cpu/possible

// 获取某个 CPU 的频率

cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq

随着机器学习的兴起，现代芯片不仅带有强大的 GPU，还配备了专门为神经网络计算打造的 NPU（Neural network Processing Unit）。“A12”就使用了八核心的 NPU，每秒可执行五万亿次运算。从 CPU 到 GPU 再到 AI 芯片，随着手机 CPU 整体性能的飞跃，医疗诊断、图像超清化等一些 AI 应用场景也可以在移动端更好地落地。最近边缘计算也越来越多的被提及，我们希望可以更大程度地利用移动端的计算能力来降低高昂的服务器成本。

也因此在开发过程中，我们需要根据设备 CPU 性能来“看菜下饭”，例如线程池使用线程数根据 CPU 的核心数，一些高级的 AI 功能只在主频比较高或者带有 NPU 的设备开启。

拓展了那么多再回到前面我讲的 CPU 时间，也就是用户时间和系统时间。当出现卡顿问题的时候，应该怎么去区分究竟是我们代码的问题，还是系统的问题？用户时间和系统时间可以给我们哪些线索？这里还要集合两个非常重要的指标，可以帮助我们做判断。

2. 卡顿问题分析指标

出现卡顿问题后，首先我们应该查看CPU 的使用率。怎么查呢？我们可以通过/proc/stat得到整个系统的 CPU 使用情况，通过/proc/[pid]/stat可以得到某个进程的 CPU 使用情况。

关于 stat 文件各个属性的含义和 CPU 使用率的计算，你可以阅读《Linux 环境下进程的 CPU 占用率》和Linux 文档。其中比较重要的字段有：

proc/self/stat:

  utime:       用户时间，反应用户代码执行的耗时

  stime:       系统时间，反应系统调用执行的耗时

  majorFaults：需要硬盘拷贝的缺页次数

  minorFaults：无需硬盘拷贝的缺页次数

如果 CPU 使用率长期大于 60% ，表示系统处于繁忙状态，就需要进一步分析用户时间和系统时间的比例。对于普通应用程序，系统时间不会长期高于 30%，如果超过这个值，我们就应该进一步检查是 I/O 过多，还是其他的系统调用问题。

Android 是站在 Linux 巨人的肩膀上，虽然做了不少修改也砍掉了一些工具，但还是保留了很多有用的工具可以协助我们更容易地排查问题，这里我给你介绍几个常用的命令。例如，top 命令可以帮助我们查看哪个进程是 CPU 的消耗大户；vmstat 命令可以实时动态监视操作系统的虚拟内存和 CPU 活动；strace 命令可以跟踪某个进程中所有的系统调用。

除了 CPU 的使用率，我们还需要查看CPU 饱和度。CPU 饱和度反映的是线程排队等待 CPU 的情况，也就是 CPU 的负载情况。

CPU 饱和度首先会跟应用的线程数有关，如果启动的线程过多，容易导致系统不断地切换执行的线程，把大量的时间浪费在上下文切换，我们知道每一次 CPU 上下文切换都需要刷新寄存器和计数器，至少需要几十纳秒的时间。

我们可以通过使用vmstat命令或者/proc/[pid]/schedstat文件来查看 CPU 上下文切换次数，这里特别需要注意nr_involuntary_switches被动切换的次数。

proc/self/sched:

  nr_voluntary_switches：

  主动上下文切换次数，因为线程无法获取所需资源导致上下文切换，最普遍的是 IO。

  nr_involuntary_switches：

  被动上下文切换次数，线程被系统强制调度导致上下文切换，例如大量线程在抢占 CPU。

  se.statistics.iowait_count：IO 等待的次数

  se.statistics.iowait_sum：  IO 等待的时间

此外也可以通过 uptime 命令可以检查 CPU 在 1 分钟、5 分钟和 15 分钟内的平均负载。比如一个 4 核的 CPU，如果当前平均负载是 8，这意味着每个 CPU 上有一个线程在运行，还有一个线程在等待。一般平均负载建议控制在“0.7 × 核数”以内。

00:02:39 up 7 days, 46 min,  0 users,

load average: 13.91, 14.70, 14.32

另外一个会影响 CPU 饱和度的是线程优先级，线程优先级会影响 Android 系统的调度策略，它主要由 nice 和 cgroup 类型共同决定。nice 值越低，抢占 CPU 时间片的能力越强。当 CPU 空闲时，线程的优先级对执行效率的影响并不会特别明显，但在 CPU 繁忙的时候，线程调度会对执行效率有非常大的影响。

关于线程优先级，你需要注意是否存在高优先级的线程空等低优先级线程，例如主线程等待某个后台线程的锁。从应用程序的角度来看，无论是用户时间、系统时间，还是等待 CPU 的调度，都是程序运行花费的时间。

Android 卡顿排查工具

可能你会觉得按照上面各种 Linux 命令组合来排查问题太麻烦了，有没有更简单的、图形化的操作界面呢？Traceview 和 systrace 都是我们比较熟悉的排查卡顿的工具，从实现上这些工具分为两个流派。

第一个流派是 instrument。获取一段时间内所有函数的调用过程，可以通过分析这段时间内的函数调用流程，再进一步分析待优化的点。

第二个流派是 sample。有选择性或者采用抽样的方式观察某些函数调用过程，可以通过这些有限的信息推测出流程中的可疑点，然后再继续细化分析。

这两种流派有什么差异？我们在什么场景应该选择哪种合适的工具呢？还有没有其他有用的工具可以使用呢？下面我们一一来看。

1. Traceview

Traceview是我第一个使用的性能分析工具，也是吐槽的比较多的工具。它利用 Android Runtime 函数调用的 event 事件，将函数运行的耗时和调用关系写入 trace 文件中。

由此可见，Traceview 属于 instrument 类型，它可以用来查看整个过程有哪些函数调用，但是工具本身带来的性能开销过大，有时无法反映真实的情况。比如一个函数本身的耗时是 1 秒，开启 Traceview 后可能会变成 5 秒，而且这些函数的耗时变化并不是成比例放大。

在 Android 5.0 之后，新增了startMethodTracingSampling方法，可以使用基于样本的方式进行分析，以减少分析对运行时的性能影响。新增了 sample 类型后，就需要我们在开销和信息丰富度之间做好权衡。

无论是哪种的 Traceview 对 release 包支持的都不太好，例如无法反混淆。其实 trace 文件的格式十分简单，之前曾经写个一个小工具，支持通过 mapping 文件反混淆 trace。

2. Nanoscope

那在 instrument 类型的性能分析工具里，有没有性能损耗比较小的呢？

答案是有的，Uber 开源的Nanoscope就能达到这个效果。它的实现原理是直接修改 Android 虚拟机源码，在ArtMethod执行入口和执行结束位置增加埋点代码，将所有的信息先写到内存，等到 trace 结束后才统一生成结果文件。

在使用过程可以明显感觉到应用不会因为开启 Nanoscope 而感到卡顿，但是 trace 结束生成结果文件这一步需要的时间比较长。另一方面它可以支持分析任意一个应用，可用于做竞品分析。

但是它也有不少限制：

需要自己刷 ROM，并且当前只支持 Nexus 6P，或者采用其提供的 x86 架构的模拟器。

默认只支持主线程采集，其他线程需要代码手动设置。考虑到内存大小的限制，每个线程的内存数组只能支持大约 20 秒左右的时间段。

Uber 写了一系列自动化脚本协助整个流程，使用起来还算简单。Nanoscope 作为基本没有性能损耗的 instrument 工具，它非常适合做启动耗时的自动化分析。

Nanoscope 生成的是符合 Chrome tracing 规范的 HTML 文件。我们可以通过脚本来实现两个功能：

第一个是反混淆。通过 mapping 自动反混淆结果文件。

第二个是自动化分析。传入相同的起点和终点，实现两个结果文件的 diff，自动分析差异点。

这样我们可以每天定期去跑自动化启动测试，查看是否存在新增的耗时点。我们有时候为了实现更多定制化功能或者拿到更加丰富的信息，这个时候不得不使用定制 ROM 的方式。而 Nanoscope 恰恰是一个很好的工具，可以让我们更方便地实现定制 ROM，在后面启动和 I/O 优化里我还会提到更多类似的案例。

3. systrace

systrace是 Android 4.1 新增的性能分析工具。我通常使用 systrace 跟踪系统的 I/O 操作、CPU 负载、Surface 渲染、GC 等事件。

systrace 利用了 Linux 的ftrace调试工具，相当于在系统各个关键位置都添加了一些性能探针，也就是在代码里加了一些性能监控的埋点。Android 在 ftrace 的基础上封装了atrace，并增加了更多特有的探针，例如 Graphics、Activity Manager、Dalvik VM、System Server 等。

systrace 工具只能监控特定系统调用的耗时情况，所以它是属于 sample 类型，而且性能开销非常低。但是它不支持应用程序代码的耗时分析，所以在使用时有一些局限性。

由于系统预留了Trace.beginSection接口来监听应用程序的调用耗时，那我们有没有办法在 systrace 上面自动增加应用程序的耗时分析呢？

划重点了，我们可以通过编译时给每个函数插桩的方式来实现，也就是在重要函数的入口和出口分别增加Trace.beginSection和Trace.endSection。当然出于性能的考虑，我们会过滤大部分指令数比较少的函数，这样就实现了在 systrace 基础上增加应用程序耗时的监控。通过这样方式的好处有：

可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用，例如渲染耗时、线程锁，GC 耗时等。

性能损耗可以接受。由于过滤了大部分的短函数，而且没有放大 I/O，所以整个运行耗时不到原来的两倍，基本可以反映真实情况。

systrace 生成的也是 HTML 格式的结果，我们利用跟 Nanoscope 相似方式实现对反混淆的支持。

4. Simpleperf

那如果我们想分析 Native 函数的调用，上面的三个工具都不能满足这个需求。

Android 5.0 新增了Simpleperf性能分析工具，它利用 CPU 的性能监控单元（PMU）提供的硬件 perf 事件。使用 Simpleperf 可以看到所有的 Native 代码的耗时，有时候一些 Android 系统库的调用对分析问题有比较大的帮助，例如加载 dex、verify class 的耗时等。

Simpleperf 同时封装了 systrace 的监控功能，通过 Android 几个版本的优化，现在 Simpleperf 比较友好地支持 Java 代码的性能分析。具体来说分几个阶段：

第一个阶段：在 Android M 和以前，Simpleperf 不支持 Java 代码分析。

第二个阶段：在 Android O 和以前，需要手动指定编译 OAT 文件。

第三个阶段：在 Android P 和以后，无需做任何事情，Simpleperf 就可以支持 Java 代码分析。

从这个过程可以看到 Google 还是比较看重这个功能，在 Android Studio 3.2 也在 Profiler 中直接支持 Simpleperf。

顾名思义，从名字就能看出 Simpleperf 是属于 sample 类型，它的性能开销非常低，使用火焰图展示分析结果。

目前除了 Nanoscope 之外的三个工具都只支持 debugable 的应用程序，如果想测试 release 包，需要将测试机器 root。对于这个限制，我们在实践中会专门打出 debugable 的测试包，然后自己实现针对 mapping 的反混淆功能。其中 Simpleperf 的反混淆比较难实现，因为在函数聚合后会抛弃参数，无法直接对生成的 HTML 文件做处理。当然我们也可以根据各个工具的实现思路，自己重新打造一套支持非 debugable 的自动化测试工具。

选择哪种工具，需要看具体的场景。我来汇总一下，如果需要分析 Native 代码的耗时，可以选择 Simpleperf；如果想分析系统调用，可以选择 systrace；如果想分析整个程序执行流程的耗时，可以选择 Traceview 或者插桩版本的 systrace。

可视化方法

随着 Android 版本的演进，Google 不仅提供了更多的性能分析工具，而且也在慢慢优化现有工具的体验，使功能更强大、使用门槛更低。而 Android Studio 则肩负另外一个重任，那就是让开发者使用起来更加简单的，图形界面也更加直观。

在 Android Studio 3.2 的 Profiler 中直接集成了几种性能分析工具，其中：

Sample Java Methods 的功能类似于 Traceview 的 sample 类型。

Trace Java Methods 的功能类似于 Traceview 的 instrument 类型。

Trace System Calls 的功能类似于 systrace。

SampleNative (API Level 26+) 的功能类似于 Simpleperf。

坦白来说，Profiler 界面在某些方面不如这些工具自带的界面，支持配置的参数也不如命令行，不过 Profiler 的确大大降低了开发者的使用门槛。

另外一个比较大的变化是分析结果的展示方式，这些分析工具都支持了 Call Chart 和 Flame Chart 两种展示方式。下面我来讲讲这两种展示方式适合的场景。

1. Call Chart

Call Chart 是 Traceview 和 systrace 默认使用的展示方式。它按照应用程序的函数执行顺序来展示，适合用于分析整个流程的调用。举一个最简单的例子，A 函数调用 B 函数，B 函数调用 C 函数，循环三次，就得到了下面的 Call Chart。

Call Chart 就像给应用程序做一个心电图，我们可以看到在这一段时间内，各个线程的具体工作，比如是否存在线程间的锁、主线程是否存在长时间的 I/O 操作、是否存在空闲等。

2. Flame Chart

Flame Chart 也就是大名鼎鼎的火焰图。它跟 Call Chart 不同的是，Flame Chart 以一个全局的视野来看待一段时间的调用分布，它就像给应用程序拍 X 光片，可以很自然地把时间和空间两个维度上的信息融合在一张图上。上面函数调用的例子，换成火焰图的展示结果如下。

当我们不想知道应用程序的整个调用流程，只想直观看出哪些代码路径花费的 CPU 时间较多时，火焰图就是一个非常好的选择。例如，之前我的一个反序列化实现非常耗时，通过火焰图发现耗时最多的是大量 Java 字符串的创建和拷贝，通过将核心实现转为 Native，最终使性能提升了很多倍。

火焰图还可以使用在各种各样的维度，例如内存、I/O 的分析。有些内存可能非常缓慢地泄漏，通过一个内存的火焰图，我们就知道哪些路径申请的内存最多，有了火焰图我们根本不需要分析源代码，也不需要分析整个流程。

最后我想说，每个工具都可以生成不同的展示方式，我们需要根据不同的使用场景选择合适的方式。

总结

在写今天的文章，也就是分析卡顿的基础知识和四种 Android 卡顿排查工具时，我越发觉得底层基础知识的重要性。Android 底层基于 Linux 内核，像 systrace、Simpleperf 也是利用 Linux 提供的机制实现，因此学习一些 Linux 的基础知识，对于理解这些工具的工作原理以及排查性能问题，都有很大帮助。

另一方面，虽然很多大厂有专门的性能优化团队，但我觉得鼓励和培养团队里的每一个人都去关注性能问题更加重要。我们在使用性能工具的同时，要学会思考，应该知道它们的原理和局限性。更进一步来说，你还可以尝试去为这些工具做一些优化，从而实现更加完善的方案。

课后作业

当发生 ANR 的时候，Android 系统会打印 CPU 相关的信息到日志中，使用的是ProcessCpuTracker.java。但是这样好像并没有权限可以拿到其他应用进程的 CPU 信息，那能不能换一个思路？

当发现应用的某个进程 CPU 使用率比较高的时候，可以通过下面几个文件检查该进程下各个线程的 CPU 使用率，继而统计出该进程各个线程的时间占比。

/proc/[pid]/stat             // 进程 CPU 使用情况

/proc/[pid]/task/[tid]/stat  // 进程下面各个线程的 CPU 使用情况

/proc/[pid]/sched            // 进程 CPU 调度相关

/proc/loadavg                // 系统平均负载，uptime 命令对应文件

如果线程销毁了，它的 CPU 运行信息也会被删除，所以我们一般只会计算某一段时间内 CPU 使用率。下面是计算 5 秒间隔内一个 Sample 进程的 CPU 使用示例。有的时候可能找不到耗时的线程，有可能是有大量生命周期很短的线程，这个时候可以把时间间隔缩短来看看。

usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):

System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle

CPU Core: 8

Load Average: 8.74 / 7.74 / 7.36

Process:com.sample.app

  50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965

Threads:

  43% 23493/singleThread(R): 6.5% user + 36% kernel faults：3094

  3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults：329

  0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults：6

  0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults：982

  ...

今天的课后作业是，请你在留言区解读一下上面的信息，分享一下你认为这个示例的瓶颈在什么地方。之后能不能更进一步，自己动手写一个工具，得到一段时间内上面的这些统计信息。同样最终的实现可以通过向Sample发送 Pull Request。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

06 | 卡顿优化（下）：如何监控应用卡顿？

“我在秒杀 iPhone XS 的支付页面卡了 3 秒，最后没抢到”，用户嘶声力竭地反馈了一个卡顿问题。

“莫慌莫慌”，等我打开 Android Studio， 用上一讲学到的几个工具分析一下就知道原因了。

“咦，在我这里整个支付过程丝滑般流畅”。这个经历让我明白，卡顿跟崩溃一样需要“现场信息”。因为卡顿的产生也是依赖很多因素，比如用户的系统版本、CPU 负载、网络环境、应用数据等。

脱离这个现场，我们本地难以复现，也就很难去解决问题。但是卡顿又非常影响用户体验的，特别是发生在启动、聊天、支付这些关键场景，那我们应该如何去监控线上的卡顿问题，并且保留足够多的现场信息协助我们排查解决问题呢？

卡顿监控

前面我讲过监控 ANR 的方法，不过也提到两个问题：一个是高版本的系统没有权限读取系统的 ANR 日志；另一个是 ANR 太依赖系统实现，我们无法灵活控制参数，例如我觉得主线程卡顿 3 秒用户已经不太能忍受，而默认参数只能监控至少 5 秒以上的卡顿。

所以现实情况就要求我们需要采用其他的方式来监控是否出现卡顿问题，并且针对特定场景还要监控其他特定的指标。

1. 消息队列

我设计的第一套监控卡顿的方案是基于消息队列实现，通过替换 Looper 的 Printer 实现。在 2013 年的时候，我写过一个名为 WxPerformanceTool 的性能监控工具，其中耗时监控就使用了这个方法。后面这个工具在腾讯公共组件做了内部开源，还获得了 2013 年的年度十佳组件。

还没庆祝完，很快就有同事跟我吐槽一个问题：线上开启了这个监控模块，快速滑动时平均帧率起码降低 5 帧。我通过 Traceview 一看，发现是因为上面图中所示的大量字符串拼接导致性能损耗严重。

后来很快又想到了另外一个方案，可以通过一个监控线程，每隔 1 秒向主线程消息队列的头部插入一条空消息。假设 1 秒后这个消息并没有被主线程消费掉，说明阻塞消息运行的时间在 0～1 秒之间。换句话说，如果我们需要监控 3 秒卡顿，那在第 4 次轮询中头部消息依然没有被消费的话，就可以确定主线程出现了一次 3 秒以上的卡顿。

这个方案也存在一定的误差，那就是发送空消息的间隔时间。但这个间隔时间也不能太小，因为监控线程和主线程处理空消息都会带来一些性能损耗，但基本影响不大。

2. 插桩

不过在使用了一段时间之后，我感觉还是有那么一点不爽。基于消息队列的卡顿监控并不准确，正在运行的函数有可能并不是真正耗时的函数。这是为什么呢？

我画张图解释起来就清楚了。我们假设一个消息循环里面顺序执行了 A、B、C 三个函数，当整个消息执行超过 3 秒时，因为函数 A 和 B 已经执行完毕，我们只能得到的正在执行的函数 C 的堆栈，事实上它可能并不耗时。

不过对于线上大数据来说，因为函数 A 和 B 相对比较耗时，所以抓取到它们的概率会更大一些，通过后台聚合后捕获到函数 A 和 B 的卡顿日志会更多一些。

这也是我们线上目前依然使用基于消息队列的方法，但是肯定希望可以做到跟 Traceview 一样，可以拿到整个卡顿过程所有运行函数的耗时，就像下面图中的结果，可以明确知道其实函数 A 和 B 才是造成卡顿的主要原因。

既然这样，那我们能否直接利用 Android Runtime 函数调用的回调事件，做一个自定义的 Traceview++ 呢？

答案是可以的，但是需要使用 Inline Hook 技术。我们可以实现类似 Nanoscope 先写内存的方案，但考虑到兼容性问题，这套方案并没有用到线上。

对于大体量的应用，稳定性是第一考虑因素。那如果在编译过程插桩，兼容性问题肯定是 OK 的。上一讲讲到 systrace 可以通过插桩自动生成 Trace Tag，我们一样也可以在函数入口和出口加入耗时监控的代码，但是需要考虑的细节有很多。

避免方法数暴增。在函数的入口和出口应该插入相同的函数，在编译时提前给代码中每个方法分配一个独立的 ID 作为参数。

过滤简单的函数。过滤一些类似直接 return、i++ 这样的简单函数，并且支持黑名单配置。对一些调用非常频繁的函数，需要添加到黑名单中来降低整个方案对性能的损耗。

基于性能的考虑，线上只会监控主线程的耗时。微信的 Matrix 使用的就是这个方案，因为做了大量的优化，所以最终安装包体积只增大 1～2%，平均帧率下降也在 2 帧以内。虽然插桩方案对性能的影响总体还可以接受，但只会在灰度包使用。

插桩方案看起来美好，它也有自己的短板，那就是只能监控应用内自身的函数耗时，无法监控系统的函数调用，整个堆栈看起来好像“缺失了”一部分。

3. Profilo

2018 年 3 月，Facebook 开源了一个叫Profilo的库，它收集了各大方案的优点，令我眼前一亮。具体来说有以下几点：

第一，集成 atrace 功能。ftrace 所有性能埋点数据都会通过 trace_marker 文件写入内核缓冲区，Profilo 通过 PLT Hook 拦截了写入操作，选择部分关心的事件做分析。这样所有 systrace 的探针我们都可以拿到，例如四大组件生命周期、锁等待时间、类校验、GC 时间等。

不过大部分的 atrace 事件都比较笼统，从事件“B|pid|activityStart”，我们并不知道具体是哪个 Activity 的创建。同样我们可以统计 GC 相关事件的耗时，但是也不知道为什么发生了这次 GC。

第二，快速获取 Java 堆栈。很多同学有一个误区，觉得在某个线程不断地获取主线程堆栈是不耗时的。但是事实上获取堆栈的代价是巨大的，它要暂停主线程的运行。

Profilo 的实现非常精妙，它实现类似 Native 崩溃捕捉的方式快速获取 Java 堆栈，通过间隔发送 SIGPROF 信号，整个过程如下图所示。

Signal Handler 捕获到信号后，拿取到当前正在执行的 Thread，通过 Thread 对象可以获取当前线程的 ManagedStack，ManagedStack 是一个单链表，它保存了当前的 ShadowFrame 或者 QuickFrame 栈指针，先依次遍历 ManagedStack 链表，然后遍历其内部的 ShadowFrame 或者 QuickFrame 还原一个可读的调用栈，从而 unwind 出当前的 Java 堆栈。通过这种方式，可以实现线程一边继续跑步，我们还可以帮它做检查，而且耗时基本忽略不计。代码可以参照：Profilo::unwind和StackVisitor::WalkStack。

不用插桩、性能基本没有影响、捕捉信息还全，那 Profilo 不就是完美的化身吗？当然由于它利用了大量的黑科技，兼容性是需要注意的问题。它内部实现有大量函数的 Hook，unwind 也需要强依赖 Android Runtime 实现。Facebook 已经将 Profilo 投入到线上使用，但由于目前 Profilo 快速获取堆栈功能依然不支持 Android 8.0 和 Android 9.0，鉴于稳定性问题，建议采取抽样部分用户的方式来开启该功能。

先小结一下，不管我们使用哪种卡顿监控方法，最后我们都可以得到卡顿时的堆栈和当时 CPU 运行的一些信息。大部分的卡顿问题都比较好定位，例如主线程执行一个耗时任务、读一个非常大的文件或者是执行网络请求等。

其他监控

除了主线程的耗时过长之外，我们还有哪些卡顿问题需要关注呢？

Android Vitals 是 Google Play 官方的性能监控服务，涉及卡顿相关的监控有 ANR、启动、帧率三个。尤其是 ANR 监控，我们应该经常的来看看，主要是 Google 自己是有权限可以准确监控和上报 ANR。

对于启动和帧率，Android Vitals 只是上报了应用的区间分布，但是不能归纳出问题。这也是我们做性能优化时比较迷惑的一点，即使发现整体的帧率比过去降低了 5 帧，也并不知道是哪里造成的，还是要花很大的力气去做二次排查。

能不能做到跟崩溃、卡顿一样，直接给我一个堆栈，告诉我就是因为这里写的不好导致帧率下降了 5 帧。退一步说，如果做不到直接告诉我堆栈，能不能告诉我是因为聊天这个页面导致的帧率下降，让我缩小二次排查的范围。

1. 帧率

业界都使用 Choreographer 来监控应用的帧率。跟卡顿不同的是，需要排除掉页面没有操作的情况，我们应该只在界面存在绘制的时候才做统计。

那么如何监听界面是否存在绘制行为呢？可以通过 addOnDrawListener 实现。

getWindow().getDecorView().getViewTreeObserver().addOnDrawListener

我们经常用平均帧率来衡量界面流畅度，但事实上电影的帧率才 24 帧，用户对于应用的平均帧率是 40 帧还是 50 帧并不一定可以感受出来。对于用户来说，感觉最明显的是连续丢帧情况，Android Vitals 将连续丢帧超过 700 毫秒定义为冻帧，也就是连续丢帧 42 帧以上。

因此，我们可以统计更有价值的冻帧率。冻帧率就是计算发生冻帧时间在所有时间的占比。出现丢帧的时候，我们可以获取当前的页面信息、View 信息和操作路径上报后台，降低二次排查的难度。

正如下图一样，我们还可以按照 Activity、Fragment 或者某个操作定义场景，通过细化不同场景的平均帧率和冻帧率，进一步细化问题排查的范围。

2. 生命周期监控

Activity、Service、Receiver 组件生命周期的耗时和调用次数也是我们重点关注的性能问题。例如 Activity 的 onCreate() 不应该超过 1 秒，不然会影响用户看到页面的时间。Service 和 Receiver 虽然是后台组件，不过它们生命周期也是占用主线程的，也是我们需要关注的问题。

对于组件生命周期我们应该采用更严格地监控，可以全量上报。在后台我们可以看到各个组件各个生命周期的启动时间和启动次数。

有一次我们发现有两个 Service 的启动次数是其他的 10 倍，经过排查发现是因为频繁的互相拉起导致。Receiver 也是这样，而且它们都需要经过 System Server。曾经有一个日志上报模块通过 Broadcast 来做跨进程通信，每秒发送几千次请求，导致系统 System Server 卡死。所以说每个组件各个生命周期的调用次数也是非常有参考价值的指标。

除了四大组件的生命周期，我们还需要监控各个进程生命周期的启动次数和耗时。通过下面的数据，我们可以看出某些进程是否频繁地拉起。

对于生命周期的监控实现，我们可以利用插件化技术 Hook 的方式。但是 Android P 之后，我还是不太推荐你使用这种方式。我更推荐使用编译时插桩的方式，后面我会讲到 Aspect、ASM 和 ReDex 三种插桩技术的实现，敬请期待。

3. 线程监控

Java 线程管理是很多应用非常头痛的事情，应用启动过程就已经创建了几十上百个线程。而且大部分的线程都没有经过线程池管理，都在自由自在地狂奔着。

另外一方面某些线程优先级或者活跃度比较高，占用了过多的 CPU。这会降低主线程 UI 响应能力，我们需要特别针对这些线程做重点的优化。

对于 Java 线程，总的来说我会监控以下两点。

线程数量。需要监控线程数量的多少，以及创建线程的方式。例如有没有使用我们特有的线程池，这块可以通过 got hook 线程的 nativeCreate() 函数。主要用于进行线程收敛，也就是减少线程数量。

线程时间。监控线程的用户时间 utime、系统时间 stime 和优先级。主要是看哪些线程 utime+stime 时间比较多，占用了过多的 CPU。正如上一期“每课一练”所提到的，可能有一些线程因为生命周期很短导致很难发现，这里我们需要结合线程创建监控。

看到这里可能有同学会比较困惑，卡顿优化的主题就是监控吗？导致卡顿的原因会有很多，比如函数非常耗时、I/O 非常慢、线程间的竞争或者锁等。其实很多时候卡顿问题并不难解决，相较解决来说，更困难的是如何快速发现这些卡顿点，以及通过更多的辅助信息找到真正的卡顿原因。

就跟在本地使用各种卡顿分析工具一样，卡顿优化的难点在于如何把它们移植到线上，以最少的性能代价获得更加丰富的卡顿信息。当然某些卡顿问题可能是 I/O、存储或者网络引发的，后面会还有专门的内容来讲这些问题的优化方法。

总结

今天我们学习了卡顿监控的几种方法。随着技术的深入，我们发现了旧方案的一些缺点，通过不断地迭代和演进，寻找更好的方案。

Facebook 的 Profilo 实现了快速获取 Java 堆栈，其实它参考的是 JVM 的 AsyncGetCallTrace 思路，然后适配 Android Runtime 的实现。systrace 使用的是 Linux 的 ftrace，Simpleperf 参考了 Linux 的 perf 工具。还是熟悉的配方，还是熟悉的味道，我们很多创新性的东西，其实还是基于 Java 和 Linux 十年前的产物。

还是回到我在专栏开篇词说过的，切记不要浮躁，多了解和学习一些底层的技术，对我们的成长会有很大帮助。日常开发中我们也不能只满足于完成需求就可以了，在实现上应该学会多去思考内存、卡顿这些影响性能的点，我们比别人多想一些、多做一些，自己的进步自然也会更快一些。

课后作业

看完我分享的卡顿优化的方法后，相信你也肯定还有很多好的思路和方法，今天的课后作业是分享一下你的卡顿优化的“必杀技”，在留言区分享一下今天学习、练习的收获与心得。

课后练习

我在上一期中提到过 Linux 的 ftrace 机制，而 systrace 正是利用这个系统机制实现的。而 Profilo 更是通过一些黑科技，实现了一个可以用于线上的“systrace”。那它究竟是怎么实现的呢？

通过今天这个Sample，你可以学习到它的实现思路。当你对这些底层机制足够熟悉的时候，可能就不局限在本地使用，而是可以将它们搬到线上了。

当然，为了能更好地理解这个 Sample，可能你还需要补充一些 ftrace 和 atrace 相关的背景知识。你会发现这些的确都是 Linux 十年前的一些知识，但时至今日它们依然非常有用。

1.ftrace 简介、ftrace 使用（上）、frace 使用（下）。

2.atrace 介绍、atrace 实现。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

06补充篇 | 卡顿优化：卡顿现场与卡顿分析

我们使用上一期所讲的插桩或者 Profilo 的方案，可以得到卡顿过程所有运行函数的耗时。在大部分情况下，这几种方案的确非常好用，可以让我们更加明确真正的卡顿点在哪里。

但是，你肯定还遇到过很多莫名其妙的卡顿，比如读取 1KB 的文件、读取很小的 asset 资源或者只是简单的创建一个目录。

为什么看起来这么简单的操作也会耗费那么长的时间呢？那我们如何通过收集更加丰富的卡顿现场信息，进一步定位并排查问题呢？

卡顿现场

我先来举一个线上曾经发现的卡顿例子，下面是它的具体耗时信息。

从图上看，Activity 的 onCreate 函数耗时达到 3 秒，而其中 Lottie 动画中openNonAsset函数耗时竟然将近 2 秒。尽管是读取一个 30KB 的资源文件，但是它的耗时真的会有那么长吗？

今天我们就一起来分析这个问题吧。

1. Java 实现

进一步分析 openNonAsset 相关源码的时候发现，AssetManager 内部有大量的 synchronized 锁。首先我怀疑还是锁的问题，接下来需要把卡顿时各个线程的状态以及堆栈收集起来做进一步分析。

步骤一：获得 Java 线程状态

通过 Thread 的 getState 方法可以获取线程状态，当时主线程果然是 BLOCKED 状态。

什么是 BLOCKED 状态呢？当线程无法获取下面代码中的 object 对象锁的时候，线程就会进入 BLOCKED 状态。

// 线程等待获取 object 对象锁

synchronized (object)  {

    dosomething();

}

WAITING、TIME_WAITING 和 BLOCKED 都是需要特别注意的状态。很多同学可能对 BLOCKED 和 WAITING 这两种状态感到比较困惑，BLOCKED 是指线程正在等待获取锁，对应的是下面代码中的情况一；WAITING 是指线程正在等待其他线程的“唤醒动作”，对应的是代码中的情况二。

synchronized (object)  {     // 情况一：在这里卡住 --> BLOCKED

    object.wait();           // 情况二：在这里卡住 --> WAITING

}

不过当一个线程进入 WAITING 状态时，它不仅会释放 CPU 资源，还会将持有的 object 锁也同时释放。对 Java 各个线程状态的定义以及转换等更多介绍，你可以参考Thread.State和《Java 线程 Dump 分析》。

步骤二：获得所有线程堆栈

接着我们在 Java 层通过 Thread.getAllStackTraces() 进一步拿所有线程的堆栈，希望知道具体是因为哪个线程导致主线程的 BLOCKED。

需要注意的是在 Android 7.0，getAllStackTraces 是不会返回主线程的堆栈的。通过分析收集上来的卡顿日志，我们发现跟 AssetManager 相关的线程有下面这个。

"BackgroundHandler"  RUNNABLE

  at  android.content.res.AssetManager.list

  at  com.sample.business.init.listZipFiles

通过查看AssetManager.list的确发现是使用了同一个 synchronized 锁，而 list 函数需要遍历整个目录，耗时会比较久。

public String[] list(String path) throws IOException {

  synchronized (this) {

    ensureValidLocked();

    return nativeList(mObject, path);

  }

}

另外一方面，“BackgroundHandler”线程属于低优先级后台线程，这也是我们前面文章提到的不良现象，也就是主线程等待低优先级的后台线程。

2. SIGQUIT 信号实现

Java 实现的方案看起来非常不错，也帮助我们发现了卡顿的原因。不过在我们印象中，似乎ANR 日志的信息更加丰富，那我们能不能直接用 ANR 日志呢？

比如下面的例子，它的信息的确非常全，所有线程的状态、CPU 时间片、优先级、堆栈和锁的信息应有尽有。其中 utm 代表 utime，HZ 代表 CPU 的时钟频率，将 utime 转换为毫秒的公式是“time * 1000/HZ”。例子中 utm=218，也就是 218*1000/100=2180 毫秒。

// 线程名称 ; 优先级 ; 线程 id; 线程状态

"main" prio=5 tid=1 Suspended

  // 线程组 ;  线程 suspend 计数 ; 线程 debug suspend 计数 ;

  | group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400

  // 线程 native id; 进程优先级 ; 调度者优先级 ;

  | sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec

  // native 线程状态 ; 调度者状态 ; 用户时间 utime; 系统时间 stime; 调度的 CPU

  | state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100

  // stack 相关信息

  | stack=0xff717000-0xff719000 stackSize=8MB

疑问一：Native 线程状态

细心的你可能会发现，为什么上面的 ANR 日志中“main”线程的状态是 Suspended？想了一下，Java 线程中的 6 种状态中并不存在 Suspended 状态啊。

事实上，Suspended 代表的是 Native 线程状态。怎么理解呢？在 Android 里面 Java 线程的运行都委托于一个 Linux 标准线程 pthread 来运行，而 Android 里运行的线程可以分成两种，一种是 Attach 到虚拟机的，一种是没有 Attach 到虚拟机的，在虚拟机管理的线程都是托管的线程，所以本质上 Java 线程的状态其实是 Native 线程的一种映射。

不同的 Android 版本 Native 线程的状态不太一样，例如 Android 9.0 就定义了 27 种线程状态，它能更加明确地区分线程当前所处的情况。关于 Java 线程状态、Native 线程状态转换，你可以参考thread_state.h和Thread_nativeGetStatus。

我们可以看到 Native 线程状态的确更加丰富，例如将 TIMED_WAITING 拆分成 TimedWaiting 和 Sleeping 两种场景，而 WAITING 更是细化到十几种场景等，这对我们分析特定场景问题的时候会有非常大的帮助。

疑问二：获得 ANR 日志

虽然 ANR 日志信息非常丰富，那问题又来了，如何拿到卡顿时的 ANR 日志呢？

我们可以利用系统 ANR 的生成机制，具体步骤是：

第一步：当监控到主线程卡顿时，主动向系统发送 SIGQUIT 信号。

第二步：等待 /data/anr/traces.txt 文件生成。

第三步：文件生成以后进行上报。

通过 ANR 日志，我们可以直接看到主线程的锁是由“BackgroundHandler”线程持有。相比之下通过 getAllStackTraces 方法，我们只能通过一个一个线程进行猜测。

  // 堆栈相关信息

  at android.content.res.AssetManager.open(AssetManager.java:311)

  - waiting to lock <0x41ddc798> (android.content.res.AssetManager) held by tid=66 (BackgroundHandler)

  at android.content.res.AssetManager.open(AssetManager.java:289)

线程间的死锁和热锁分析是一个非常有意思的话题，很多情况分析起来也比较困难，例如我们只能拿到 Java 代码中使用的锁，而且有部分类型锁的持有并不会表现在堆栈上面。对这部分内容感兴趣，想再深入一下的同学，可以认真看一下这两篇文章：《Java 线程 Dump 分析》、《手 Q Android 线程死锁监控与自动化分析实践》。

3. Hook 实现

用 SIGQUIT 信号量获取 ANR 日志，从而拿到所有线程的各种信息，这套方案看起来很美好。但事实上，它存在这几个问题：

可行性。正如我在崩溃分析所说的一样，很多高版本系统已经没有权限读取 /data/anr/traces.txt 文件。

性能。获取所有线程堆栈以及各种信息非常耗时，对于卡顿场景不一定合适，它可能会进一步加剧用户的卡顿。

那有什么方法既可以拿到 ANR 日志，整个过程又不会影响用户的体验呢？

再回想一下，在崩溃分析的时候我们就讲过一种获得所有线程堆栈的方法。它通过下面几个步骤实现。

通过libart.so、dlsym调用ThreadList::ForEach方法，拿到所有的 Native 线程对象。

遍历线程对象列表，调用Thread::DumpState方法。

它基本模拟了系统打印 ANR 日志的流程，但是因为整个过程使用了一些黑科技，可能会造成线上崩溃。

为了兼容性考虑，我们会通过 fork 子进程方式实现，这样即使子进程崩溃了也不会影响我们主进程的运行。这样还可以带来另外一个非常大的好处，获取所有线程堆栈这个过程可以做到完全不卡我们主进程。

但使用 fork 进程会导致进程号改变，源码中通过 /proc/self 方式获取的一些信息都会失败（错误的拿了子进程的信息，而子进程只有一个线程），例如 state、schedstat、utm、stm、core 等。不过问题也不大，这些信息可以通过指定 /proc/[父进程 id] 的方式重新获取。

"main" prio=7 tid=1 Native

  | group="" sCount=0 dsCount=0 obj=0x74e99000 self=0xb8811080

  | sysTid=23023 nice=-4 cgrp=default sched=0/0 handle=0xb6fccbec

  | state=? schedstat=( 0 0 0 ) utm=0 stm=0 core=0 HZ=100

  | stack=0xbe4dd000-0xbe4df000 stackSize=8MB

  | held mutexes=

总的来说，通过 Hook 方式我们实现了一套“无损”获取所有 Java 线程堆栈与详细信息的方法。为了降低上报数据量，只有主线程的 Java 线程状态是 WAITING、TIME_WAITING 或者 BLOCKED 的时候，才会进一步使用这个“大杀器”。

4. 现场信息

现在再来看，这样一份我们自己构造的“ANR 日志”是不是已经是收集崩溃现场信息的完全体了？它似乎缺少了我们常见的头部信息，例如进程 CPU 使用率、GC 相关的信息。

正如第 6 期文章开头所说的一样，卡顿跟崩溃一样是需要“现场信息”的。能不能进一步让卡顿的“现场信息”的比系统 ANR 日志更加丰富？我们可以进一步增加这些信息：

CPU 使用率和调度信息。参考第 5 期的课后练习，我们可以得到系统 CPU 使用率、负载、各线程的 CPU 使用率以及 I/O 调度等信息。

内存相关信息。我们可以添加系统总内存、可用内存以及应用各个进程的内存等信息。如果开启了 Debug.startAllocCounting 或者 atrace，还可以增加 GC 相关的信息。

I/O 和网络相关。我们还可以把卡顿期间所有的 I/O 和网络操作的详细信息也一并收集，这部分内容会在后面进一步展开。

在 Android 8.0 后，Android 虚拟机终于支持了 JVM 的JVMTI机制。Profiler 中内存采集等很多模块也切换到这个机制中实现，后面我会邀请“学习委员”鹏飞给你讲讲 JVMTI 机制与应用。使用它可以获得的信息非常丰富，包括内存申请、线程创建、类加载、GC 等，有大量的应用场景。

最后我们还可以利用崩溃分析中的一些思路，例如添加用户操作路径等信息，这样我们可以得到一份比系统 ANR 更加丰富的卡顿日志，这对我们解决某些疑难的卡顿问题会更有帮助。

卡顿分析

在客户端捕获卡顿之后，最后数据需要上传到后台统一分析。我们可以对数据做什么样的处理？应该关注哪些指标？

1. 卡顿率

如果把主线程卡顿超过 3 秒定义为一个卡顿问题，类似崩溃，我们会先评估卡顿问题的影响面，也就是 UV 卡顿率。

UV 卡顿率 = 发生过卡顿 UV / 开启卡顿采集 UV

因为卡顿问题一般都是抽样上报，采样规则跟内存相似，都应该按照人来抽样。一个用户如果命中采集，那么在一天内都会持续的采集数据。

UV 卡顿率可以评估卡顿的影响范围，但对于低端机器来说比较难去优化卡顿的问题。如果想评估卡顿的严重程度，我们可以使用 PV 卡顿率。

PV 卡顿率 = 发生过卡顿 PV / 启动采集 PV

需要注意的是，对于命中采集 PV 卡顿率的用户，每次启动都需要上报作为分母。

2. 卡顿树

发生卡顿时，我们会把 CPU 使用率和负载相关信息也添加到卡顿日志中。虽然采取了抽样策略，但每天的日志量还是达到十万级别。这么大的日志量，如果简单采用堆栈聚合日志，会发现有几百上千种卡顿类型，很难看出重点。

我们能不能实现卡顿的火焰图，在一张图里就可以看到卡顿的整体信息？

这里我非常推荐卡顿树的做法，对于超过 3 秒的卡顿，具体是 4 秒还是 10 秒，这涉及手机性能和当时的环境。我们决定抛弃具体的耗时，只按照相同堆栈出现的比例来聚合。这样我们从一棵树上面，就可以看到哪些堆栈出现的卡顿问题最多，它下面又存在的哪些分支。

我们的精力是有限的，一般会优先去解决 Top 的卡顿问题。采用卡顿树的聚合方式，可以从全盘的角度看到 Top 卡顿问题的各个分支情况，帮助我们快速找到关键的卡顿点。

总结

今天我们从一个简单的卡顿问题出发，一步一步演进出解决这个问题的三种思路。其中 Java 实现的方案是大部分同学首先想到的方案，它虽然简单稳定，不过存在信息不全、性能差等问题。

可能很多同学认为问题可以解决就算万事大吉了，但我并不这样认为。我们应该继续敲问自己，如果再出现类似的问题，我们是否也可以采用相同的方法去解决？这个方案的代价对用户会带来多大的影响，是否还有优化的空间？

只有这样，才会出现文中的方案二和方案三，解决方案才会一直向前演进，做得越来越好。也只有这样，我们才能在追求卓越的过程中快速进步。

课后作业

线程等待、死锁和热锁在应用中都是非常普遍的，今天的课后作业是分享一下你的产品中是否出现过这些问题，又是如何解决的？请你在留言区分享一下今天学习、练习的收获与心得。

我在评论中发现很多同学对监控 Thread 的创建比较感兴趣，今天我们的Sample是如何监控线程的创建。在实践前，给你一些可以参考的链接。

Android 线程的创建过程

java_lang_Thread.cc

thread.cc

编译脚本 Android.bp

对于 PLT Hook 和 Inline Hook 的具体实现原理与差别，我在后面会详细讲到。这里我们可以把它们先隐藏掉，直接利用开源的实现即可。通过这个 Sample 我希望你可以学会通过分析源码，寻找合理的 Hook 函数与具体的 so 库。我相信当你熟悉这些方法之后，一定会惊喜地发现实现起来其实真的不难。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

07 | 启动优化（上）：从启动过程看启动速度优化

在超市排队结账，扫码支付启动十几秒都还没完成，只能换一个工具支付？

想买本书充实一下，页面刷出来时候十几秒都不能操作，那就换一个应用购买？

用户如果想打开一个应用，就一定要经过“启动”这个步骤。启动时间的长短，不只是用户体验的问题，对于淘宝、京东这些应用来说，会直接影响留存和转化等核心数据。对研发人员来说，启动速度是我们的“门面”，它清清楚楚可以被所有人看到，我们都希望自己应用的启动速度可以秒杀所有竞争对手。

那启动过程究竟会出现哪些问题？我们应该怎么去优化和监控应用的启动速度呢？今天我们一起来看看这些问题该如何解决。

启动分析

在真正动手开始优化之前，我们应该先搞清楚从用户点击图标开始，整个启动过程经过哪几个关键阶段，又会给用户带来哪些体验问题。

1. 启动过程分析

我以微信为例，用户从桌面点击图标开始，会经过 4 个关键阶段。

T1 预览窗口显示。系统在拉起微信进程之前，会先根据微信的 Theme 属性创建预览窗口。当然如果我们禁用预览窗口或者将预览窗口指定为透明，用户在这段时间依然看到的是桌面。

T2 闪屏显示。在微信进程和闪屏窗口页面创建完毕，并且完成一系列 inflate view、onmeasure、onlayout 等准备工作后，用户终于可以看到熟悉的“小地球”。

T3 主页显示。在完成主窗口创建和页面显示的准备工作后，用户可以看到微信的主界面。

T4 界面可操作。在启动完成后，微信会有比较多的工作需要继续执行，例如聊天和朋友圈界面的预加载、小程序框架和进程的准备等。在这些工作完成后，用户才可以真正开始愉快地聊天。

2. 启动问题分析

从启动流程的 4 个关键阶段，我们可以推测出用户启动过程会遇到的 3 个问题。这 3 个问题其实也是大多数应用在启动时可能会遇到的。

问题 1：点击图标很久都不响应

如果我们禁用了预览窗口或者指定了透明的皮肤，那用户点击了图标之后，需要 T2 时间才能真正看到应用闪屏。对于用户体验来说，点击了图标，过了几秒还是停留在桌面，看起来就像没有点击成功，这在中低端机中更加明显。

问题 2：首页显示太慢

现在应用启动流程越来越复杂，闪屏广告、热修复框架、插件化框架、大前端框架，所有准备工作都需要集中在启动阶段完成。上面说的 T3 首页显示时间对于中低端机来说简直就是噩梦，经常会达到十几秒的时间。

问题 3：首页显示后无法操作。

既然首页显示那么慢，那我能不能把尽量多的工作都通过异步化延后执行呢？很多应用的确就是这么做的，但这会造成两种后果：要么首页会出现白屏，要么首页出来后用户根本无法操作。

很多应用把启动结束时间的统计放到首页刚出现的时候，这对用户是不负责任的。看到一个首页，但是停住十几秒都不能滑动，这对用户来说完全没有意义。启动优化不能过于 KPI 化，要从用户的真实体验出发，要着眼从点击图标到用户可操作的整个过程。

启动优化

启动速度优化的方法和卡顿优化基本相同，不过因为启动实在是太重要了，我们会更加“精打细算”。我们希望启动期间加载的每个功能和业务都是必须的，它们的实现都是经过“千锤百炼”的，特别是在中低端机上面的表现。

1. 优化工具

“工欲善其事必先利其器”，我们需要先找到一款适合做启动优化分析的工具。

你可以先回忆一下“卡顿优化”提到的几种工具。Traceview 性能损耗太大，得出的结果并不真实；Nanoscope 非常真实，不过暂时只支持 Nexus 6P 和 x86 模拟器，无法针对中低端机做测试；Simpleperf 的火焰图并不适合做启动流程分析；systrace 可以很方便地追踪关键系统调用的耗时情况，但是不支持应用程序代码的耗时分析。

综合来看，在卡顿优化中提到“systrace + 函数插桩”似乎是比较理想的方案，而且它还可以看到系统的一些关键事件，例如 GC、System Server、CPU 调度等。

我们可以通过下面的命令，可以查看手机支持哪些 systrace 类型。不同的系统支持的类型有所差别，其中 Dalvik、sched、ss、app 都是我们比较关心的。

python systrace.py --list-categories

通过插桩，我们可以看到应用主线程和其他线程的函数调用流程。它的实现原理非常简单，就是将下面的两个函数分别插入到每个方法的入口和出口。

class Trace {

  public static void i(String tag) {

    Trace.beginSection(name);

  }

  public static void o() {

      Trace.endSection();

  }

}

当然这里面有非常多的细节需要考虑，比如怎么样降低插桩对性能的影响、哪些函数需要被排除掉。最终改良版的 systrace 性能损耗在一倍以内，基本可以反映真实的启动流程。函数插桩后的效果如下，你也可以参考课后练习的 Sample。

class Test {

  public void test() {

    Trace.i("Test.test()");

    // 原来的工作

    Trace.o()；

  }

}

只有准确的数据评估才能指引优化的方向，这一步是非常非常重要的。我见过太多同学在没有充分评估或者评估使用了错误的方法，最终得到了错误的方向。辛辛苦苦一两个月，最后发现根本达不到预期的效果。

2. 优化方式

在拿到整个启动流程的全景图之后，我们可以清楚地看到这段时间内系统、应用各个进程和线程的运行情况，现在我们要开始真正开始“干活”了。

具体的优化方式，我把它们分为闪屏优化、业务梳理、业务优化、线程优化、GC 优化和系统调用优化。

闪屏优化

今日头条把预览窗口实现成闪屏的效果，这样用户只需要很短的时间就可以看到“预览闪屏”。这种完全“跟手”的感觉在高端机上体验非常好，但对于中低端机，会把总的的闪屏时间变得更长。

如果点击图标没有响应，用户主观上会认为是手机系统响应比较慢。所以我比较推荐的做法是，只在 Android 6.0 或者 Android 7.0 以上才启用“预览闪屏”方案，让手机性能好的用户可以有更好的体验。

微信做的另外一个优化是合并闪屏和主页面的 Activity，减少一个 Activity 会给线上带来 100 毫秒左右的优化。但是如果这样做的话，管理时会非常复杂，特别是有很多例如 PWA、扫一扫这样的第三方启动流程的时候。

业务梳理

我们首先需要梳理清楚当前启动过程正在运行的每一个模块，哪些是一定需要的、哪些可以砍掉、哪些可以懒加载。我们也可以根据业务场景来决定不同的启动模式，例如通过扫一扫启动只需要加载需要的几个模块即可。对于中低端机器，我们要学会降级，学会推动产品经理做一些功能取舍。但是需要注意的是，懒加载要防止集中化，否则容易出现首页显示后用户无法操作的情形。

业务优化

通过梳理之后，剩下的都是启动过程一定要用的模块。这个时候，我们只能硬着头皮去做进一步的优化。优化前期需要“抓大放小”，先看看主线程究竟慢在哪里。最理想是通过算法进行优化，例如一个数据解密操作需要 1 秒，通过算法优化之后变成 10 毫秒。退而求其次，我们要考虑这些任务是不是可以通过异步线程预加载实现，但需要注意的是过多的线程预加载会让我们的逻辑变得更加复杂。

业务优化做到后面，会发现一些架构和历史包袱会拖累我们前进的步伐。比较常见的是一些事件会被各个业务模块监听，大量的回调导致很多工作集中执行，部分框架初始化“太厚”，例如一些插件化框架，启动过程各种反射、各种 Hook，整个耗时至少几百毫秒。还有一些历史包袱又非常沉重，而且“牵一发动全身”，改动风险比较大。但是我想说，如果有合适的时机，我们依然需要勇敢去偿还这些“历史债务”。

线程优化

线程优化就像做填空题和解锁题，我们希望能把所有的时间片都利用上，因此主线程和各个线程都是一直满载的。当然我们也希望每个线程都开足马力向前跑，而不是作为接力棒。所以线程的优化主要在于减少 CPU 调度带来的波动，让应用的启动时间更加稳定。

从具体的做法来看，线程的优化一方面是控制线程数量，线程数量太多会相互竞争 CPU 资源，因此要有统一的线程池，并且根据机器性能来控制数量。

线程切换的数据我们可以通过卡顿优化中学到的 sched 文件查看，这里特别需要注意 nr_involuntary_switches 被动切换的次数。

proc/[pid]/sched:

  nr_voluntary_switches：

  主动上下文切换次数，因为线程无法获取所需资源导致上下文切换，最普遍的是 IO。

  nr_involuntary_switches：

  被动上下文切换次数，线程被系统强制调度导致上下文切换，例如大量线程在抢占 CPU。

另一方面是检查线程间的锁。为了提高启动过程任务执行的速度，有一次我们把主线程内的一个耗时任务放到线程中并发执行，但是发现这样做根本没起作用。仔细检查后发现线程内部会持有一个锁，主线程很快就有其他任务因为这个锁而等待。通过 systrace 可以看到锁等待的事件，我们需要排查这些等待是否可以优化，特别是防止主线程出现长时间的空转。

特别是现在有很多启动框架，会使用 Pipeline 机制，根据业务优先级规定业务初始化时机。比如微信内部使用的mmkernel、阿里最近开源的Alpha启动框架，它们为各个任务建立依赖关系，最终构成一个有向无环图。对于可以并发的任务，会通过线程池最大程度提升启动速度。如果任务的依赖关系没有配置好，很容易出现下图这种情况，即主线程会一直等待 taskC 结束，空转 2950 毫秒。

GC 优化

在启动过程，要尽量减少 GC 的次数，避免造成主线程长时间的卡顿，特别是对 Dalvik 来说，我们可以通过 systrace 单独查看整个启动过程 GC 的时间。

python systrace.py dalvik -b 90960 -a com.sample.gc

对于 GC 各个事件的具体含义，你可以参考《调查 RAM 使用情况》。

不知道你是否还记得我在“内存优化”中提到 Debug.startAllocCounting，我们也可以使用它来监控启动过程总 GC 的耗时情况，特别是阻塞式同步 GC 的总次数和耗时。

// GC 使用的总耗时，单位是毫秒

Debug.getRuntimeStat("art.gc.gc-time");

// 阻塞式 GC 的总耗时

Debug.getRuntimeStat("art.gc.blocking-gc-time");

如果我们发现主线程出现比较多的 GC 同步等待，那就需要通过 Allocation 工具做进一步的分析。启动过程避免进行大量的字符串操作，特别是序列化跟反序列化过程。一些频繁创建的对象，例如网络库和图片库中的 Byte 数组、Buffer 可以复用。如果一些模块实在需要频繁创建对象，可以考虑移到 Native 实现。

Java 对象的逃逸也很容易引起 GC 问题，我们在写代码的时候比较容易忽略这个点。我们应该保证对象生命周期尽量的短，在栈上就进行销毁。

系统调用优化

通过 systrace 的 System Service 类型，我们可以看到启动过程 System Server 的 CPU 工作情况。在启动过程，我们尽量不要做系统调用，例如 PackageManagerService 操作、Binder 调用等待。

在启动过程也不要过早地拉起应用的其他进程，System Server 和新的进程都会竞争 CPU 资源。特别是系统内存不足的时候，当我们拉起一个新的进程，可能会成为“压死骆驼的最后一根稻草”。它可能会触发系统的 low memory killer 机制，导致系统杀死和拉起（保活）大量的进程，从而影响前台进程的 CPU。

讲个实践的案例，之前我们的一个程序在启动过程会拉起下载和视频播放进程，改为按需拉起后，线上启动时间提高了 3%，对于 1GB 以下的低端机优化，整个启动时间可以优化 5%～8%，效果还是非常明显的。

总结

今天我们首先学习了启动的整个流程，其中比较关键的是 4 个阶段。在这 4 个阶段中，用户可能会出现“点击图标很久都不响应“”首页显示太慢“和”首页显示后无法操作“这 3 个问题。

接着我们学习了启动优化和监控的一些常规方法。针对不同的业务场景、不同性能的机器，需要采用不同的策略。有些知识点似乎比较“浅尝辄止”，我更希望你能够通过学习和实践将它们丰富起来。

我讲到的大部分内容都是跟业务相关，业务的梳理和优化也是最快出成果的。不过这个过程我们要学会取舍，你可能遇到过，很多产品经理为了提升自己负责的模块的数据，总会逼迫开发做各种各样的预加载。但是大家都想快，最后的结果就是代码一团糟，肯定都快不起来。

比如只有 1% 用户使用的功能，却让所有用户都做预加载。面对这种情况，我们要狠下心来，只留下那些真正不能删除的业务，或者通过场景化直接找到那 1% 的用户。跟产品经理 PK 可能不是那么容易，关键在于数据。我们需要证明启动优化带来整体留存、转化的正向价值，是大于某个业务取消预加载带来的负面影响。

启动优化是性能优化工作非常重要的一环，今天的课后作业是，在你过去的工作中，曾经针对启动做过哪些优化，最终效果又是怎样的呢？请你在留言区分享一下今天学习、练习的收获与心得。

课后练习

“工欲善其事必先利其器”，我多次提到“systrace + 函数插桩”是一个非常不错的卡顿排查工具，那么通过今天的Sample，我们一起来看一下它是如何实现的。需要注意的是 Sample 选择了 ASM 插桩的方式，感兴趣的同学可以课后学习一下它的使用方法，在后续我们也会有关于插桩的专门课程。

我们可以将 Sample 运用到自己的应用中，虽然它过滤了大部分的函数，但是我们还是需要注意白名单的配置。例如 log、加解密等在底层非常频繁调用的函数，都要在白名单中配置过滤掉，不然可能会出现类似下面这样大量的毛刺。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

08 | 启动优化（下）：优化启动速度的进阶方法

专栏上一期，我们一起梳理了应用启动的整个过程和问题，也讲了一些启动优化方法，可以说是完成了启动优化工作最难的一部分。还可以通过删掉或延后一些不必要的业务，来实现相关具体业务的优化。你学会了这些工具和方法，是不是觉得效果非常不错，然后美滋滋地向老大汇报工作成果：“启动速度提升 30%，秒杀所有竞品好几条街”。

“还有什么方法可以做进一步优化吗？怎么证明你秒杀所有的竞品？如何在线上衡量启动优化的效果？怎么保障和监控启动速度是否变慢？”，老大一口气问了四个问题。

面对这四个问题，你可不能一脸懵。我们的应用启动是不是真的已经做到了极致？如何保证启动优化成果是长期有效的？让我们通过今天的学习，一起来回答老大这些问题吧。

启动进阶方法

除了上期讲的常规的优化方法，我还有一些与业务无关的“压箱底”方法可以帮助加快应用的启动速度。当然有些方法会用到一些黑科技，它就像一把双刃剑，需要你做深入的评估和测试。

1. I/O 优化

在负载过高的时候，I/O 性能下降得会比较快。特别是对于低端机，同样的 I/O 操作耗时可能是高端机器的几十倍。启动过程不建议出现网络 I/O，相比之下，磁盘 I/O 是启动优化一定要抠的点。首先我们要清楚启动过程读了什么文件、多少个字节、Buffer 是多大、使用了多长时间、在什么线程等一系列信息。

那么如何实现 I/O 的监控呢？我今天先卖个关子，下一期我会详细和你聊聊 I/O 方面的知识。

通过上面的数据，我们发现 chat.db 的大小竟然达到 500MB。我们经常发现本地启动明明非常快，为什么线上有些用户就那么慢？这可能是一些用户本地积累了非常多的数据，我们也发现有些微信的重度用户，他的 DB 文件竟然会超过 1GB。所以，重度用户是启动优化一定要覆盖的群体，我们要做一些特殊的优化策略。

还有一个是数据结构的选择问题，我们在启动过程只需要读取 Setting.sp 的几项数据，不过 SharedPreference 在初始化的时候还是要全部数据一起解析。如果它的数据量超过 1000 条，启动过程解析时间可能就超过 100 毫秒。如果只解析启动过程用到的数据项则会很大程度减少解析时间，启动过程适合使用随机读写的数据结构。

可以将 ArrayMap 改造成支持随机读写、延时解析的数据存储方式。同样我们今天也不再展开这部分内容，这些知识会在存储优化的相关章节进一步展开。

2. 数据重排

在上面的表格里面，我们读取 test.io 文件中 1KB 数据，因为 Buffer 不小心写成了 1 byte，总共要读取 1000 次。那系统是不是真的会读 1000 次磁盘呢？

事实上 1000 次读操作只是我们发起的次数，并不是真正的磁盘 I/O 次数。你可以参考下面 Linux 文件 I/O 流程。

Linux 文件系统从磁盘读文件的时候，会以 block 为单位去磁盘读取，一般 block 大小是 4KB。也就是说一次磁盘读写大小至少是 4KB，然后会把 4KB 数据放到页缓存 Page Cache 中。如果下次读取文件数据已经在页缓存中，那就不会发生真实的磁盘 I/O，而是直接从页缓存中读取，大大提升了读的速度。所以上面的例子，我们虽然读了 1000 次，但事实上只会发生一次磁盘 I/O，其他的数据都会在页缓存中得到。

Dex 文件用的到的类和安装包 APK 里面各种资源文件一般都比较小，但是读取非常频繁。我们可以利用系统这个机制将它们按照读取顺序重新排列，减少真实的磁盘 I/O 次数。

类重排

启动过程类加载顺序可以通过复写 ClassLoader 得到。

class GetClassLoader extends PathClassLoader {

    public Class<?> findClass(String name) {

        // 将 name 记录到文件

        writeToFile(name，"coldstart_classes.txt");

        return super.findClass(name);

    }

}

然后通过 ReDex 的Interdex调整类在 Dex 中的排列顺序，最后可以利用 010 Editor 查看修改后的效果。

我多次提到的ReDex，是 Facebook 开源的 Dex 优化工具，它里面有非常多好用的东西，后续我们会有更详细的介绍。

资源文件重排

Facebook 在比较早的时候就使用“资源热图”来实现资源文件的重排，最近支付宝在《通过安装包重排布优化 Android 端启动性能》中也详细讲述了资源重排的原理和落地方法。

在实现上，它们都是通过修改 Kernel 源码，单独编译了一个特殊的 ROM。这样做的目的有三个：

统计。统计应用启动过程加载了安装包中哪些资源文件，比如 assets、drawable、layout 等。跟类重排一样，我们可以得到一个资源加载的顺序列表。

度量。在完成资源顺序重排后，我们需要确定是否真正生效。比如有哪些资源文件加载了，它是发生真实的磁盘 I/O，还是命中了 Page Cache。

自动化。任何代码提交都有可能改变启动过程中类和资源的加载顺序，如果完全依靠人工手动处理，这个事情很难持续下去。通过定制 ROM 的一些埋点和配合的工具，我们可以将它们放到自动化流程当中。

跟前面提到的 Nanoscope 耗时分析工具一样，当系统无法满足我们的优化需求时，就需要直接修改 ROM 的实现。Facebook“资源热图”相对比较完善，也建设了一些配套的 Dashboard 工具，希望后续可以开源出来。

事实上如果仅仅为了统计，我们也可以使用 Hook 的方式。下面是利用 Frida 实现获得 Android 资源加载顺序的方法，不过 Frida 还是相对小众，后面会替换其他更加成熟的 Hook 框架。

resourceImpl.loadXmlResourceParser.implementation=function(a,b,c,d){

   send('file:'+a)

   return this.loadXmlResourceParser(a,b,c,d)

}

resourceImpl.loadDrawableForCookie.implementation=function(a,b,c,d,e){

   send("file:"+a)

   return this.loadDrawableForCookie(a,b,c,d,e)

}

调整安装包文件排列需要修改 7zip 源码实现支持传入文件列表顺序，同样最后可以利用 010 Editor 查看修改后的效果。

这两个优化可能会带来 100～200 毫秒的提高，我们还可以大大减少启动过程 I/O 的时间波动。特别是对于中低端机器来说，经常发现启动时间波动非常大，这个波动跟 CPU 调度相关，但更多时候是跟 I/O 相关。

可能有同学会问，这些优化思路究竟是怎么样想出来的呢？其实利用文件系统和磁盘读取机制的优化思路，在服务端和 Windows 上早已经不是什么新鲜事。所谓的创新，不一定是创造前所未有的东西。我们将已有的方案移植到新的平台，并且很好地结合该平台的特性将其落地，就是一个很大的创新。

3. 类的加载

在 WeMobileDev 公众号发布的《微信 Android 热补丁实践演进之路》中，我提过在加载类的过程有一个 verify class 的步骤，它需要校验方法的每一个指令，是一个比较耗时的操作。

我们可以通过 Hook 来去掉 verify 这个步骤，这对启动速度有几十毫秒的优化。不过我想说，其实最大的优化场景在于首次和覆盖安装时。以 Dalvik 平台为例，一个 2MB 的 Dex 正常需要 350 毫秒，将 classVerifyMode 设为 VERIFY_MODE_NONE 后，只需要 150 毫秒，节省超过 50% 的时间。

// Dalvik Globals.h

gDvm.classVerifyMode = VERIFY_MODE_NONE;

// Art runtime.cc

verify_ = verifier::VerifyMode::kNone;

但是 ART 平台要复杂很多，Hook 需要兼容几个版本。而且在安装时大部分 Dex 已经优化好了，去掉 ART 平台的 verify 只会对动态加载的 Dex 带来一些好处。Atlas 中的dalvik_hack-3.0.0.5.jar可以通过下面的方法去掉 verify，但是当前没有支持 ART 平台。

AndroidRuntime runtime = AndroidRuntime.getInstance();

runtime.init(context);

runtime.setVerificationEnabled(false);

这个黑科技可以大大降低首次启动的速度，代价是对后续运行会产生轻微的影响。同时也要考虑兼容性问题，暂时不建议在 ART 平台使用。

4. 黑科技

第一，保活

讲到黑科技，你可能第一个想到的就是保活。保活可以减少 Application 创建跟初始化的时间，让冷启动变成温启动。不过在 Target 26 之后，保活的确变得越来越难。

对于大厂来说，可能需要寻求厂商合作的机会，例如微信的 Hardcoder 方案和 OPPO 推出的Hyper Boost方案。根据 OPPO 的数据，对于手机 QQ、淘宝、微信启动场景会直接有 20% 以上的优化。

有的时候你问为什么微信可以保活？为什么它可以运行的那么流畅？这里可能不仅仅是技术上的问题，当应用体量足够大，就可以倒逼厂商去专门为它们做优化。

第二，插件化和热修复

从 2012 年开始，淘宝、微信尝试做插件化的探索。到了 2015 年，淘宝的 Dexposed、支付宝的 AndFix 以及微信的 Tinker 等热修复技术开始“百花齐放”。

它们真的那么好吗？事实上大部分的框架在设计上都存在大量的 Hook 和私有 API 调用，带来的缺点主要有两个：

稳定性。虽然大家都号称兼容 100% 的机型，由于厂商的兼容性、安装失败、dex2oat 失败等原因，还是会有那么一些代码和资源的异常。Android P 推出的 non-sdk-interface 调用限制，以后适配只会越来越难，成本越来越高。

性能。Android Runtime 每个版本都有很多的优化，因为插件化和热修复用到的一些黑科技，导致底层 Runtime 的优化我们是享受不到的。Tinker 框架在加载补丁后，应用启动速度会降低 5%～10%。

应用加固对启动速度来说简直是灾难，有时候我们需要做一些权衡和选择。为了提升启动速度，支付宝也提出一种GC 抑制的方案。不过首先 Android 5.0 以下的系统占比已经不高，其次这也会带来一些兼容性问题。我们还是更希望通过手段可以真正优化整个耗时，而不是一些取巧的方式。

总的来说，对于黑科技我们需要慎重，当你足够了解它们内部的机制以后，可以选择性的使用。

启动监控

终于千辛万苦的优化好了，我们还要找一套合理、准确的方法来度量优化的成果。同时还要对它做全方位的监控，以免被人破坏劳动果实。

1. 实验室监控

如果想客观地反映启动的耗时，视频录制会是一个非常好的选择。特别是我们很难拿到竞品的线上数据，所以实验室监控也非常适合做竞品的对比测试。

它的难点在于如何让实验系统准确地找到启动结束的点，这里可以通过下面两种方式。

80% 绘制。当页面绘制超过 80% 的时候认为是启动完成，不过可能会把闪屏当成启动结束的点，不一定是我们所期望的。

图像识别。手动输入一张启动结束的图片，当实验系统认为当前截屏页面有 80% 以上相似度时，就认为是启动结束。这种方法更加灵活可控，但是实现难度会稍微高一点。

启动的实验室监控可以定期自动去跑，需要注意的是，我们应该覆盖高、中、低端机不同的场景。但是使用录屏的方式也有一个缺陷，就是出现问题时我们需要人工二次定位具体是什么代码所导致的。

2. 线上监控

实验室覆盖的场景和机型还是有限的，是驴是马我们还是要发布到线上进行验证。针对线上，启动监控会更加复杂一些。Android Vitals可以对应用冷启动、温启动时间做监控。

事实上，每个应用启动的流程都非常复杂，上面的图并不能真实反映每个应用的启动耗时。启动耗时的计算需要考虑非常多的细节，比如：

启动结束的统计时机。是否是使用用户真正可以操作的时间作为启动结束的时间。

启动时间扣除的逻辑。闪屏、广告和新手引导这些时间都应该从启动时间里扣除。

启动排除逻辑。Broadcast、Server 拉起，启动过程进入后台这些都需要排除出统计。

经过精密的扣除和排除逻辑，我们最终可以得到用户的线上启动耗时。正如我在上一期所说的，准确的启动耗时统计是非常重要的。有很多优化在实验室完成之后，还需要在线上灰度验证效果。这个前提是启动统计是准确的，整个效果评估是真实的。

那我们一般使用什么指标来衡量启动速度的快慢呢？

很多应用采用平均启动时间，不过这个指标其实并不太好，一些体验很差的用户很有可能是被平均了。我更建议使用类似下面的指标：

快开慢开比。例如 2 秒快开比、5 秒慢开比，我们可以看到有多少比例的用户体验非常好，多少比例的用户比较槽糕。

90% 用户的启动时间。如果 90% 的用户启动时间都小于 5 秒，那么我们 90% 区间启动耗时就是 5 秒。

此外我们还要区分启动的类型。这里要统计首次安装启动、覆盖安装启动、冷启动和温启动这些类型，一般我们都使用普通的冷启动时间作为指标。另一方面热启动的占比也可以反映出我们程序的活跃或保活能力。

除了指标的监控，启动的线上堆栈监控更加困难。Facebook 会利用 Profilo 工具对启动的整个流程耗时做监控，并且在后台直接对不同的版本做自动化对比，监控新版本是否有新增耗时的函数。

总结

今天我们学习了一些与业务无关的启动优化方法，可以进一步减少启动耗时，特别是减少磁盘 I/O 可能带来的波动。然后我们探讨了一些黑科技对启动的影响，对于黑科技我们需要两面看，在选择时也要慎重。最后我们探讨了如何在实验室和线上更好地测量和监控启动速度。

启动优化需要耐得住寂寞，把整个流程摸清摸透，一点点把时间抠出来，特别是对于低端机和系统繁忙的场景。而数据重排的优化，对我有非常大的启发，帮助我开发了一个新的方向。也让我明白了，当我们足够熟悉底层的知识时，可以利用系统的特性去做更加深层次的优化。

不管怎么说，你都需要谨记一点：对于启动优化要警惕 KPI 化，我们要解决的不是一个数字，而是用户真正的体验问题。

看完我分享的启动优化的方法后，相信你肯定也还有很多好的思路和方法。今天的课后作业是分享一下你“压箱底”的启动优化“秘籍”，在留言区分享一下今天学习、练习的收获与心得。

课后练习

今天我们的Sample是如何在 Dalvik 去掉 verify，你可以顺着这个思路尝试去分析 Dalvik 虚拟机加载 Dex 和类的流程。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

09 | I/O优化（上）：开发工程师必备的I/O优化知识

250GB 容量，512MB DDR4 缓存，连续读取不超过 550MB/s，连续写入不超过 520MB/s。

“双十一”在天猫看到一款固态硬盘有上面的这些介绍，这些数字分别代表了什么意思？

在专栏前面卡顿和启动优化里，我也经常提到 I/O 优化。可能很多同学觉得 I/O 优化不就是不在主线程读写大文件吗，真的只有这么简单吗？那你是否考虑过，从应用程序调用 read() 方法，内核和硬件会做什么样的处理，整个流程可能会出现什么问题？今天请你带着这些疑问，我们一起来看看 I/O 优化需要的知识。

I/O 的基本知识

在工作中，我发现很多工程师对 I/O 的认识其实比较模糊，认为 I/O 就是应用程序执行 read()、write() 这样的一些操作，并不清楚这些操作背后的整个流程是怎样的。

我画了一张简图，你可以看到整个文件 I/O 操作由应用程序、文件系统和磁盘共同完成。首先应用程序将 I/O 命令发送给文件系统，然后文件系统会在合适的时机把 I/O 操作发给磁盘。

这就好比 CPU、内存、磁盘三个小伙伴一起完成接力跑，最终跑完的时间很大程度上取决于最慢的小伙伴。我们知道，CPU 和内存相比磁盘是高速设备，整个流程的瓶颈在于磁盘 I/O 的性能。所以很多时候，文件系统性能比磁盘性能更加重要，为了降低磁盘对应用程序的影响，文件系统需要通过各种各样的手段进行优化。那么接下来，我们首先来看文件系统。

1. 文件系统

文件系统，简单来说就是存储和组织数据的方式。比如在 iOS 10.3 系统以后，苹果使用 APFS（Apple File System）替代之前旧的文件系统 HFS+。对于 Android 来说，现在普遍使用的是 Linux 常用的 ext4 文件系统。

关于文件系统还需要多说两句，华为在 EMUI 5.0 以后就使用 F2FS 取代 ext4，Google 也在最新的旗舰手机 Pixel 3 使用了 F2FS 文件系统。Flash-Friendly File System 是三星是专门为 NAND 闪存芯片开发的文件系统，也做了大量针对闪存的优化。根据华为的测试数据，F2FS 文件系统在小文件的随机读写方面比 ext4 更快，例如随机写可以优化 60%，不足之处在于可靠性方面出现过一些问题。我想说的是，随着 Google、华为的投入和规模化使用，F2FS 系统应该是未来 Android 的主流文件系统。

还是回到文件系统的 I/O。应用程序调用 read() 方法，系统会通过中断从用户空间进入内核处理流程，然后经过 VFS（Virtual File System，虚拟文件系统）、具体文件系统、页缓存 Page Cache。下面是 Linux 一个通用的 I/O 架构模型。

虚拟文件系统（VFS）。它主要用于实现屏蔽具体的文件系统，为应用程序的操作提供一个统一的接口。这样保证就算厂商把文件系统从 ext4 切换到 F2FS，应用程序也不用做任何修改。

文件系统（File System）。ext4、F2FS 都是具体文件系统实现，文件元数据如何组织、目录和索引结构如何设计、怎么分配和清理数据，这些都是设计一个文件系统必须要考虑的。每个文件系统都有适合自己的应用场景，我们不能说 F2FS 就一定比 ext4 要好。F2FS 在连续读取大文件上并没有优势，而且会占用更大的空间。只是对一般应用程序来说，随机 I/O 会更加频繁，特别是在启动的场景。你可以在 /proc/filesystems 看到系统可以识别的所有文件系统的列表。

页缓存（Page Cache）。在启动优化中我已经讲过 Page Cache 这个概念了，在读文件的时候会，先看它是不是已经在 Page Cache 中，如果命中就不会去读取磁盘。在 Linux 2.4.10 之前还有一个单独的 Buffer Cache，后来它也合并到 Page Cache 中的 Buffer Page 了。

具体来说，Page Cache 就像是我们经常使用的数据缓存，是文件系统对数据的缓存，目的是提升内存命中率。Buffer Cache 就像我们经常使用的 BufferInputStream，是磁盘对数据的缓存，目的是合并部分文件系统的 I/O 请求、降低磁盘 I/O 的次数。需要注意的是，它们既会用在读请求中，也会用到写请求中。

通过 /proc/meminfo 文件可以查看缓存的内存占用情况，当手机内存不足的时候，系统会回收它们的内存，这样整体 I/O 的性能就会有所降低。

MemTotal:    2866492 kB

MemFree:      72192 kB

Buffers:      62708 kB      // Buffer Cache

Cached:      652904 kB      // Page Cache

2. 磁盘

磁盘指的是系统的存储设备，就像小时候我们常听的 CD 或者电脑使用的机械硬盘，当然还有现在比较流行的 SSD 固态硬盘。

正如我上面所说，如果发现应用程序要 read() 的数据没有在页缓存中，这时候就需要真正向磁盘发起 I/O 请求。这个过程要先经过内核的通用块层、I/O 调度层、设备驱动层，最后才会交给具体的硬件设备处理。

通用块层。系统中能够随机访问固定大小数据块（block）的设备称为块设备，CD、硬盘和 SSD 这些都属于块设备。通用块层主要作用是接收上层发出的磁盘请求，并最终发出 I/O 请求。它跟 VFS 的作用类似，让上层不需要关心底层硬件设备的具体实现。

I/O 调度层。磁盘 I/O 那么慢，为了降低真正的磁盘 I/O，我们不能接收到磁盘请求就立刻交给驱动层处理。所以我们增加了 I/O 调度层，它会根据设置的调度算法对请求合并和排序。这里比较关键的参数有两个，一个是队列长度，一个是具体的调度算法。我们可以通过下面的文件可以查看对应块设备的队列长度和使用的调度算法。

/sys/block/[disk]/queue/nr_requests      // 队列长度，一般是 128。

/sys/block/[disk]/queue/scheduler        // 调度算法

块设备驱动层。块设备驱动层根据具体的物理设备，选择对应的驱动程序通过操控硬件设备完成最终的 I/O 请求。例如光盘是靠激光在表面烧录存储、闪存是靠电子擦写存储数据。

Android I/O

前面讲了 Linux I/O 相关的一些知识，现在我们再来讲讲 Android I/O 相关的一些知识。

1. Android 闪存

我们先来简单讲讲手机使用的存储设备，手机使用闪存作为存储设备，也就是我们常说的 ROM。

考虑到体积和功耗，我们肯定不能直接把 PC 的 SSD 方案用在手机上面。Android 手机前几年通常使用 eMMC 标准，近年来通常会采用性能更好的 UFS 2.0/2.1 标准，之前沸沸扬扬的某厂商“闪存门”事件就是因为使用 eMMC 闪存替换了宣传中的 UFS 闪存。而苹果依然坚持独立自主的道路，在 2015 年就在 iPhone 6s 上就引入了 MacBook 上备受好评的 NVMe 协议。

最近几年移动硬件的发展非常妖孽，手机存储也朝着体积更小、功耗更低、速度更快、容量更大的方向狂奔。iPhone XS 的容量已经达到 512GB，连续读取速度可以超过 1GB/s，已经比很多的 SSD 固态硬盘还要快，同时也大大缩小了和内存的速度差距。不过这些都是厂商提供的一些测试数据，特别是对于随机读写的性能相比内存还是差了很多。

上面的数字好像有点抽象，直白地说闪存的性能会影响我们打开微信、游戏加载以及连续自拍的速度。当然闪存性能不仅仅由硬件决定，它跟采用的标准、文件系统的实现也有很大的关系。

2. 两个疑问

看到这里可能有些同学会问，知道文件读写的流程、文件系统和磁盘这些基础知识，对我们实际开发有什么作用呢？下面我举两个简单的例子，可能你平时也思考过，不过如果不熟悉 I/O 的内部机制，你肯定是一知半解。

疑问一：文件为什么会损坏？

先说两个客观数据，微信聊天记录使用的 SQLite 数据库大概有几万分之一的损坏率，系统 SharedPreference 如果频繁跨进程读写也会有万分之一的损坏率。

在回答文件为什么会损坏前，首先需要先明确一下什么是文件损坏。一个文件的格式或者内容，如果没有按照应用程序写入时的结果都属于文件损坏。它不只是文件格式错误，文件内容丢失可能才是最常出现的，SharedPreference 跨进程读写就非常容易出现数据丢失的情况。

再来探讨文件为什么会损坏，我们可以从应用程序、文件系统和磁盘三个角度来审视这个问题。

应用程序。大部分的 I/O 方法都不是原子操作，文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符 fd 来操作文件，它们都有可能导致数据被覆盖或者删除。事实上，大部分的文件损坏都是因为应用程序代码设计考虑不当导致的，并不是文件系统或者磁盘的问题。

文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏，不过文件系统也做了很多的保护措施。例如 system 分区保证只读不可写，增加异常检查和恢复机制，ext4 的 fsck、f2fs 的 fsck.f2fs 和 checkpoint 机制等。

在文件系统这一层，更多是因为断电而导致的写入丢失。为了提升 I/O 性能，文件系统把数据写入到 Page Cache 中，然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过 fsync、msync 这些接口强制写入磁盘，在下一其我会详细介绍直接 I/O 和缓存 I/O。

磁盘。手机上使用的闪存是电子式的存储设备，所以在资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用 ECC、多级编码等多种方式增加数据的可靠性，一般来说出现这种情况的可能性也比较小。

闪存寿命也可能会导致数据错误，由于闪存的内部结构和特征，导致它写过的地址必须擦除才能再次写入，而每个块擦除又有次数限制，次数限制是根据采用的存储颗粒，从十万次到几千都有（SLC>MLC>TLC）。

下图是闪存（Flash Memory）的结构图，其中比较重要的是 FTL（Flash Translation Layer），它负责物理地址的分配和管理。它需要考虑到每个块的擦除寿命，将擦除次数均衡到所有块上去。当某个块空间不够的时候，它还要通过垃圾回收算法将数据迁移。FTL 决定了闪存的使用寿命、性能和可靠性，是闪存技术中最为重要的核心技术之一。

对于手机来说，假设我们的存储大小是 128GB，即使闪存的最大擦除次数只有 1000 次，那也可以写入 128TB，但一般来说比较难达到。

疑问二：I/O 有时候为什么会突然很慢？

手机厂商的数据通常都是出厂数据，我们在使用 Android 手机的时候也会发现，刚买的时候“如丝般顺滑”的手机，在使用一年之后就会变得卡顿无比。

这是为什么呢？在一些低端机上面，我发现大量跟 I/O 相关的卡顿。I/O 有时候为什么会突然变慢，可能有下面几个原因。

内存不足。当手机内存不足的时候，系统会回收 Page Cache 和 Buffer Cache 的内存，大部分的写操作会直接落盘，导致性能低下。

写入放大。上面我说到闪存重复写入需要先进行擦除操作，但这个擦除操作的基本单元是 block 块，一个 page 页的写入操作将会引起整个块数据的迁移，这就是典型的写入放大现象。低端机或者使用比较久的设备，由于磁盘碎片多、剩余空间少，非常容易出现写入放大的现象。具体来说，闪存读操作最快，在 20us 左右。写操作慢于读操作，在 200us 左右。而擦除操作非常耗时，在 1ms 左右的数量级。当出现写入放大时，因为涉及移动数据，这个时间会更长。

由于低端机的 CPU 和闪存的性能相对也较差，在高负载的情况下容易出现瓶颈。例如 eMMC 闪存不支持读写并发，当出现写入放大现象时，读操作也会受影响。

系统为了缓解磁盘碎片问题，可以引入 fstrim/TRIM 机制，在锁屏、充电等一些时机会触发磁盘碎片整理。

I/O 的性能评估

正如下图你所看到的，整个 I/O 的流程涉及的链路非常长。我们在应用程序中通过打点，发现一个文件读取需要 300ms。但是下面每一层可能都有自己的策略和调度算法，因此很难真正的得到每一层的耗时。

在前面的启动优化内容中，我讲过 Facebook 和支付宝采用编译单独 ROM 的方法来评估 I/O 性能。这是一个比较复杂但是有效的做法，我们可以通过定制源码，选择打开感兴趣的日志来追踪 I/O 的性能。

1. I/O 性能指标

I/O 性能评估中最为核心的指标是吞吐量和 IOPS。今天文章开头所说的，“连续读取不超过 550MB/s，连续写入不超过 520MB/s”，就指的是 I/O 吞吐量。

还有一个比较重要的指标是 IOPS，它指的是每秒可以读写的次数。对于随机读写频繁的应用，例如大量的小文件存储，IOPS 是关键的衡量指标。

2. I/O 测量

如果不采用定制源码的方式，还有哪些方法可以用来测量 I/O 的性能呢？

第一种方法：使用 proc。

总的来说，I/O 性能会跟很多因素有关，是读还是写、是否是连续、I/O 大小等。另外一个对 I/O 性能影响比较大的因素是负载，I/O 性能会随着负载的增加而降低，我们可以通过 I/O 的等待时间和次数来衡量。

proc/self/schedstat:

  se.statistics.iowait_count：IO 等待的次数

  se.statistics.iowait_sum：  IO 等待的时间

如果是 root 的机器，我们可以开启内核的 I/O 监控，将所有 block 读写 dump 到日志文件中，这样可以通过 dmesg 命令来查看。

echo 1 > /proc/sys/vm/block_dump

dmesg -c grep pid

.sample.io.test(7540): READ block 29262592 on dm-1 (256 sectors)

.sample.io.test(7540): READ block 29262848 on dm-1 (256 sectors)

第二种方法：使用 strace。

Linux 提供了 iostat、iotop 等一些相关的命令，不过大部分 Anroid 设备都不支持。我们可以通过 strace 来跟踪 I/O 相关的系统调用次数和耗时。

strace -ttT -f -p [pid]

read(53, "*****************"\.\.\., 1024) = 1024       <0.000447>

read(53, "*****************"\.\.\., 1024) = 1024       <0.000084>

read(53, "*****************"\.\.\., 1024) = 1024       <0.000059>

通过上面的日志，你可以看到应用程序在读取文件操作符为 53 的文件，每次读取 1024 个字节。第一次读取花了 447us，后面两次都使用了 100us 不到。这跟启动优化提到的“数据重排”是一个原因，文件系统每次读取以 block 为单位，而 block 的大小一般是 4KB，后面两次的读取是从页缓存得到。

我们也可以通过 strace 统计一段时间内所有系统调用的耗时概况。不过 strace 本身也会消耗不少资源，对执行时间也会产生影响。

strace -c -f -p [pid]

% time     seconds  usecs/call     calls    errors  syscall

------ ----------- ----------- --------- --------- ----------------

 97.56    0.041002          21      1987             read

  1.44    0.000605          55        11             write

从上面的信息你可以看到，读占了 97.56% 的时间，一共调用了 1987 次，耗时 0.04s，平均每次系统调用 21us。同样的道理，我们也可以计算应用程序某个任务 I/O 耗时的百分比。假设一个任务执行了 10s，I/O 花了 9s，那么 I/O 耗时百分比就是 90%。这种情况下，I/O 就是我们任务很大的瓶颈，需要去做进一步的优化。

第三种方法：使用 vmstat。

vmstat 的各个字段说明可以参考《vmstat 监视内存使用情况》，其中 Memory 中的 buff 和 cache，I/O 中的 bi 和 bo，System 中的 cs，以及 CPU 中的 sy 和 wa，这些字段的数值都与 I/O 行为有关。

我们可以配合dd 命令来配合测试，观察 vmstat 的输出数据变化。不过需要注意的是 Android 里面的 dd 命令似乎并不支持 conv 和 flag 参数。

// 清除 Buffer 和 Cache 内存缓存

echo 3 > /proc/sys/vm/drop_caches

// 每隔 1 秒输出 1 组 vmstat 数据

vmstat 1

// 测试写入速度，写入文件 /data/data/test，buffer 大小为 4K，次数为 1000 次

dd if=/dev/zero of=/data/data/test bs=4k count=1000

总结

在性能优化的过程中，我们关注最多的是 CPU 和内存，I/O 也是性能优化中比较重要的一部分。

今天我们学习 I/O 处理的整个流程，它包括应用程序、文件系统和磁盘三个部分。不过 I/O 这个话题真的很大，在课后需要花更多时间学习课后练习中的一些参考资料。

LPDDR5、UFS 3.0 很快就要在 2019 年面世，有些同学会想，随着硬件越来越牛，我们根本就不需要去做优化了。但是一方面考虑到成本的问题，在嵌入式、IoT 等一些场景的设备硬件不会太好；另一方面，我们对应用体验的要求也越来越高，沉浸体验（VR）、人工智能（AI）等新功能对硬件的要求也越来越高。所以，应用优化是永恒的，只是在不同的场景下有不同的要求。

课后练习

学习完今天的内容，可能大部分同学会感觉有点陌生、有点茫然。但是没有关系，我们可以在课后补充更多的基础知识，下面的链接是我推荐给你的参考资料。今天的课后作业是，通过今天的学习，在留言区写写你对 I/O 的理解，以及你都遇到过哪些 I/O 方面的问题。

1.磁盘 I/O 那些事

2.Linux 内核的文件 Cache 管理机制介绍

3.The Linux Kernel/Storage

4.选 eMMC、UFS 还是 NVMe？ 手机 ROM 存储传输协议解析

5.聊聊 Linux IO

6.采用 NAND Flash 设计存储设备的挑战在哪里?

“实践出真知”，你也可以尝试使用 strace 和 block_dump 来观察自己应用的 I/O 情况，不过有些实验会要求有 root 的机器。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

10 | I/O优化（中）：不同I/O方式的使用场景是什么？

今天是 2019 年的第一天，在开始今天的学习前，先要祝你新年快乐、工作顺利。

I/O 是一个非常大的话题，很难一次性将每个细节都讲清楚。对于服务器开发者来说，可以根据需要选择合适的文件系统和磁盘类型，也可以根据需要调整内核参数。但对于移动开发者来说，我们看起来好像做不了什么 I/O 方面的优化？

事实上并不是这样的，启动优化中“数据重排”就是一个例子。如果我们非常清楚文件系统和磁盘的工作机制，就能少走一些弯路，减少应用程序 I/O 引发的问题。

在上一期中，我不止一次的提到 Page Cache 机制，它很大程度上提升了磁盘 I/O 的性能，但是也有可能导致写入数据的丢失。那究竟有哪些 I/O 方式可以选择，又应该如何应用在我们的实际工作中呢？今天我们一起来看看不同 I/O 方式的使用场景。

I/O 的三种方式

请你先在脑海里回想一下上一期提到的 Linux 通用 I/O 架构模型，里面会包括应用程序、文件系统、Page Cache 和磁盘几个部分。细心的同学可能还会发现，在图中的最左侧跟右上方还有 Direct I/O 和 mmap 的这两种 I/O 方式。

那张图似乎有那么一点复杂，下面我为你重新画了一张简图。从图中可以看到标准 I/O、mmap、直接 I/O 这三种 I/O 方式在流程上的差异，接下来我详细讲一下不同 I/O 方式的关键点以及在实际应用中需要注意的地方。

1. 标准 I/O

我们应用程序平时用到 read/write 操作都属于标准 I/O，也就是缓存 I/O（Buffered I/O）。它的关键特性有：

对于读操作来说，当应用程序读取某块数据的时候，如果这块数据已经存放在页缓存中，那么这块数据就可以立即返回给应用程序，而不需要经过实际的物理读盘操作。

对于写操作来说，应用程序也会将数据先写到页缓存中去，数据是否被立即写到磁盘上去取决于应用程序所采用写操作的机制。默认系统采用的是延迟写机制，应用程序只需要将数据写到页缓存中去就可以了，完全不需要等数据全部被写回到磁盘，系统会负责定期地将放在页缓存中的数据刷到磁盘上。

从中可以看出来，缓存 I/O 可以很大程度减少真正读写磁盘的次数，从而提升性能。但是上一期我说过延迟写机制可能会导致数据丢失，那系统究竟会在什么时机真正把页缓存的数据写入磁盘呢？

Page Cache 中被修改的内存称为“脏页”，内核通过 flush 线程定期将数据写入磁盘。具体写入的条件我们可以通过 /proc/sys/vm 文件或者 sysctl -a | grep vm 命令得到。

// flush 每隔 5 秒执行一次

vm.dirty_writeback_centisecs = 500

// 内存中驻留 30 秒以上的脏数据将由 flush 在下一次执行时写入磁盘

vm.dirty_expire_centisecs = 3000

// 指示若脏页占总物理内存 10％以上，则触发 flush 把脏数据写回磁盘

vm.dirty_background_ratio = 10

// 系统所能拥有的最大脏页缓存的总大小

vm.dirty_ratio = 20

在实际应用中，如果某些数据我们觉得非常重要，是完全不允许有丢失风险的，这个时候我们应该采用同步写机制。在应用程序中使用 sync、fsync、msync 等系统调用时，内核都会立刻将相应的数据写回到磁盘。

上图中我以 read() 操作为例，它会导致数据先从磁盘拷贝到 Page Cache 中，然后再从 Page Cache 拷贝到应用程序的用户空间，这样就会多一次内存拷贝。系统这样设计主要是因为内存相对磁盘是高速设备，即使多拷贝 100 次，内存也比真正读一次硬盘要快。

2. 直接 I/O

很多数据库自己已经做了数据和索引的缓存管理，对页缓存的依赖反而没那么强烈。它们希望可以绕开页缓存机制，这样可以减少一次数据拷贝，这些数据也不会污染页缓存。

从图中你可以看到，直接 I/O 访问文件方式减少了一次数据拷贝和一些系统调用的耗时，很大程度降低了 CPU 的使用率以及内存的占用。

不过，直接 I/O 有时候也会对性能产生负面影响。

对于读操作来说，读数据操作会造成磁盘的同步读，导致进程需要较长的时间才能执行完。

对于写操作来说，使用直接 I/O 也需要同步执行，也会导致应用程序等待。

Android 并没有提供 Java 的 DirectByteBuffer，直接 I/O 需要在 open() 文件的时候需要指定 O_DIRECT 参数，更多的资料可以参考《Linux 中直接 I/O 机制的介绍》。在使用直接 I/O 之前，一定要对应用程序有一个很清醒的认识，只有在确定缓冲 I/O 的开销非常巨大的情况以后，才可以考虑使用直接 I/O。

3. mmap

Android 系统启动加载 Dex 的时候，不会把整个文件一次性读到内存中，而是采用 mmap 的方式。微信的高性能日志 xlog也是使用 mmap 来保证性能和可靠性。

mmap 究竟是何方神圣，它是不是真的可以做到不丢失数据、性能还非常好？其实，它是通过把文件映射到进程的地址空间，而网上很多文章都说 mmap 完全绕开了页缓存机制，其实这并不正确。我们最终映射的物理内存依然在页缓存中，它可以带来的好处有：

减少系统调用。我们只需要一次 mmap() 系统调用，后续所有的调用像操作内存一样，而不会出现大量的 read/write 系统调用。

减少数据拷贝。普通的 read() 调用，数据需要经过两次拷贝；而 mmap 只需要从磁盘拷贝一次就可以了，并且由于做过内存映射，也不需要再拷贝回用户空间。

可靠性高。mmap 把数据写入页缓存后，跟缓存 I/O 的延迟写机制一样，可以依靠内核线程定期写回磁盘。但是需要提的是，mmap 在内核崩溃、突然断电的情况下也一样有可能引起内容丢失，当然我们也可以使用 msync 来强制同步写。

从上面的图看来，我们使用 mmap 仅仅只需要一次数据拷贝。看起来 mmap 的确可以秒杀普通的文件读写，那我们为什么不全都使用 mmap 呢？事实上，它也存在一些缺点：

虚拟内存增大。mmap 会导致虚拟内存增大，我们的 APK、Dex、so 都是通过 mmap 读取。而目前大部分的应用还没支持 64 位，除去内核使用的地址空间，一般我们可以使用的虚拟内存空间只有 3GB 左右。如果 mmap 一个 1GB 的文件，应用很容易会出现虚拟内存不足所导致的 OOM。

磁盘延迟。mmap 通过缺页中断向磁盘发起真正的磁盘 I/O，所以如果我们当前的问题是在于磁盘 I/O 的高延迟，那么用 mmap() 消除小小的系统调用开销是杯水车薪的。启动优化中讲到的类重排技术，就是将 Dex 中的类按照启动顺序重新排列，主要为了减少缺页中断造成的磁盘 I/O 延迟。

在 Android 中可以将文件通过MemoryFile或者MappedByteBuffer映射到内存，然后进行读写，使用这种方式对于小文件和频繁读写操作的文件还是有一定优势的。我通过简单代码测试，测试结果如下。

从上面的数据看起来 mmap 好像的确跟写内存的性能差不多，但是这并不正确，因为我们并没有计算文件系统异步落盘的耗时。在低端机或者系统资源严重不足的时候，mmap 也一样会出现频繁写入磁盘，这个时候性能就会出现快速下降。

mmap 比较适合于对同一块区域频繁读写的情况，推荐也使用线程来操作。用户日志、数据上报都满足这种场景，另外需要跨进程同步的时候，mmap 也是一个不错的选择。Android 跨进程通信有自己独有的 Binder 机制，它内部也是使用 mmap 实现。

利用 mmap，Binder 在跨进程通信只需要一次数据拷贝，比传统的 Socket、管道等跨进程通信方式会少一次数据拷贝过程。

多线程阻塞 I/O 和 NIO

我在上一期说过，由于写入放大的现象，特别是在低端机中，有时候 I/O 操作可能会非常慢。

所以 I/O 操作应该尽量放到线程中，不过很多同学可能都有这样一个疑问：如果同时读 10 个文件，我们应该用单线程还是 10 个线程并发读？

1. 多线程阻塞 I/O

我们来做一个实验，使用 Nexus 6P 读取 30 个大小为 40MB 的文件，分别使用不同的线程数量做测试。

你可以发现多线程在 I/O 操作上收益并没有那么大，总时间从 3.6 秒减少到 1.1 秒。因为 CPU 的性能相比磁盘来说就是火箭，I/O 操作主要瓶颈在于磁盘带宽，30 条线程并不会有 30 倍的收益。而线程数太多甚至会导致耗时更长，表格中我们就发现 30 个线程所需要的时间比 10 个线程更长。但是在 CPU 繁忙的时候，更多的线程会让我们更有机会抢到时间片，这个时候多线程会比单线程有更大的收益。

总的来说文件读写受到 I/O 性能瓶颈的影响，在到达一定速度后整体性能就会受到明显的影响，过多的线程反而会导致应用整体性能的明显下降。

案例一:

CPU: 0.3% user, 3.1% kernel, 60.2% iowait, 36% idle\.\.\.

案例二：

CPU: 60.3% user, 20.1% kernel, 14.2% iowait, 4.6% idle\.\.\.

你可以再来看上面这两个案例。

案例一：当系统空闲（36% idle）时，如果没有其他线程需要调度，这个时候才会出现 I/O 等待（60.2% iowait）。

案例二：如果我们的系统繁忙起来，这个时候 CPU 不会“无所事事”，它会去看有没有其他线程需要调度，这个时候 I/O 等待会降低（14.2% iowait）。但是太多的线程阻塞会导致线程切换频繁，增大系统上下文切换的开销。

简单来说，iowait 高，I/O 一定有问题。但 iowait 低，I/O 不一定没有问题。这个时候我们还要看 CPU 的 idle 比例。从下图我们可以看到同步 I/O 的工作模式：

对应用程序来说，磁盘 I/O 阻塞线程的总时间会更加合理，它并不关心 CPU 是否真的在等待，还是去执行其他工作了。在实际开发工作中，大部分时候都是读一些比较小的文件，使用单独的 I/O 线程还是专门新开一个线程，其实差别不大。

2. NIO

多线程阻塞式 I/O 会增加系统开销，那我们是否可以使用异步 I/O 呢？当我们线程遇到 I/O 操作的时候，不再以阻塞的方式等待 I/O 操作的完成，而是将 I/O 请求发送给系统后，继续往下执行。这个过程你可以参考下面的图。

非阻塞的 NIO 将 I/O 以事件的方式通知，的确可以减少线程切换的开销。Chrome 网络库是一个使用 NIO 提升性能很好的例子，特别是在系统非常繁忙的时候。但是 NIO 的缺点也非常明显，应用程序的实现会变得更复杂，有的时候异步改造并不容易。

下面我们来看利用 NIO 的 FileChannel 来读写文件。FileChannel 需要使用 ByteBuffer 来读写文件，可以使用 ByteBuffer.allocate(int size) 分配空间，或者通过 ByteBuffer.wrap(byte[]) 包装 byte 数组直接生成。上面的示例使用 NIO 方式在 CPU 闲和 CPU 忙时耗时如下。

通过上面的数据你可以看到，我们发现使用 NIO 整体性能跟非 NIO 差别并不大。这其实也是可以理解的，在 CPU 闲的时候，无论我们的线程是否继续做其他的工作，当前瓶颈依然在磁盘，整体耗时不会太大。在 CPU 忙的时候，无论是否使用 NIO，单线程可以抢到的 CPU 时间片依然有限。

那 NIO 是不是完全没有作用呢？其实使用 NIO 的最大作用不是减少读取文件的耗时，而是最大化提升应用整体的 CPU 利用率。在 CPU 繁忙的时候，我们可以将线程等待磁盘 I/O 的时间来做部分 CPU 操作。非常推荐 Square 的Okio，它支持同步和异步 I/O，也做了比较多优化，你可以尝试使用。

小文件系统

对于文件系统来说，目录查找的性能是非常重要的。比如微信朋友圈图片可能有几万张，如果我们每张图片都是一个单独的文件，那目录下就会有几万个小文件，你想想这对 I/O 的性能会造成什么影响？

文件的读取需要先找到存储的位置，在文件系统上面我们使用 inode 来存储目录。读取一个文件的耗时可以拆分成下面两个部分。

文件读取的时间 = 找到文件的 inode 的时间 + 根据 inode 读取文件数据的时间

如果我们需要频繁读写几万个小文件，查找 inode 的时间会变得非常可观。这个时间跟文件系统的实现有关。

对于 FAT32 系统来说，FAT32 系统是历史久远的产物，在一些低端机的外置 SD 卡会使用这个系统。当目录文件数比较多的时候，需要线性去查找，一个 exist() 都非常容易出现 ANR。

对于 ext4 系统来说，ext4 系统使用目录 Hash 索引的方式查找，目录查找时间会大大缩短。但是如果需要频繁操作大量的小文件，查找和打开文件的耗时也不能忽视。

大量的小文件合并为大文件后，我们还可以将能连续访问的小文件合并存储，将原本小文件间的随机访问变为了顺序访问，可以大大提高性能。同时合并存储能够有效减少小文件存储时所产生的磁盘碎片问题，提高磁盘的利用率。

业界中 Google 的 GFS、淘宝开源的TFS、Facebook 的 Haystack 都是专门为海量小文件的存储和检索设计的文件系统。微信也开发了一套叫 SFS 的小文件管理系统，主要用在朋友圈图片的管理，用于解决当时外置 SD 卡使用 FAT32 的性能问题。

当然设计一个小文件系统也不是那么简单，需要支持 VFS 接口，这样上层的 I/O 操作代码并不需要改动。另外需要考虑文件的索引和校验机制，例如如何快速从一个大文件中找到对应的部分。还要考虑文件的分片，比如之前我们发现如果一个文件太大，非常容易被手机管家这些软件删除。

总结

在性能优化的过程中，我们通常关注最多的是 CPU 和内存，但其实 I/O 也是性能优化中比较重要的一部分。

今天我们首先学习了 I/O 整个流程，它包括应用程序、文件系统和磁盘三部分。接着我介绍了多线程同步 I/O、异步 I/O 和 mmap 这几种 I/O 方式的差异，以及它们在实际工作中适用的场景。

无论是文件系统还是磁盘，涉及的细节都非常多。而且随着技术的发展，有些设计就变得过时了，比如 FAT32 在设计的时候，当时认为单个文件不太可能超过 4GB。如果未来某一天，磁盘的性能可以追上内存，那时文件系统就真的不再需要各种缓存了。

课后练习

今天我们讲了几种不同的 I/O 方式的使用场景，在日常工作中，你是否使用过标准 I/O 以外的其他 I/O 方式？欢迎留言跟我和其他同学一起讨论。

在文中我也对不同的 I/O 方式做了简单性能测试，今天的课后练习是针对不同的场景，请你动手写一些测试用例，这样可以更好地理解不同 I/O 方式的使用场景。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

11 | I/O优化（下）：如何监控线上I/O操作？

通过前面的学习，相信你对 I/O 相关的基础知识有了一些认识，也了解了测量 I/O 性能的方法。

但是在实际应用中，你知道有哪些 I/O 操作是不合理的吗？我们应该如何发现代码中不合理的 I/O 操作呢？或者更进一步，我们能否在线上持续监控应用程序中 I/O 的使用呢？今天我们就一起来看看这些问题如何解决。

I/O 跟踪

在监控 I/O 操作之前，你需要先知道应用程序中究竟有哪些 I/O 操作。

我在专栏前面讲卡顿优化的中提到过，Facebook 的 Profilo 为了拿到 ftrace 的信息，使用了 PLT Hook 技术监听了“atrace_marker_fd”文件的写入。那么还有哪些方法可以实现 I/O 跟踪，而我们又应该跟踪哪些信息呢？

1. Java Hook

出于兼容性的考虑，你可能第一时间想到的方法就是插桩。但是插桩无法监控到所有的 I/O 操作，因为有大量的系统代码也同样存在 I/O 操作。

出于稳定性的考虑，我们退而求其次还可以尝试使用 Java Hook 方案。以 Android 6.0 的源码为例，FileInputStream 的整个调用流程如下。

java : FileInputStream -> IoBridge.open -> Libcore.os.open

-> BlockGuardOs.open -> Posix.open

在Libcore.java中可以找到一个挺不错的 Hook 点，那就是BlockGuardOs这一个静态变量。如何可以快速找到合适的 Hook 点呢？一方面需要靠经验，但是耐心查看和分析源码是必不可少的工作。

public static Os os = new BlockGuardOs(new Posix());

// 反射获得静态变量

Class<?> clibcore = Class.forName("libcore.io.Libcore");

Field fos = clibcore.getDeclaredField("os");

我们可以通过动态代理的方式，在所有 I/O 相关方法前后加入插桩代码，统计 I/O 操作相关的信息。事实上，BlockGuardOs 里面还有一些 Socket 相关的方法，我们也可以用来统计网络相关的请求。

// 动态代理对象

Proxy.newProxyInstance(cPosix.getClassLoader(), getAllInterfaces(cPosix), this);

beforeInvoke(method, args, throwable);

result = method.invoke(mPosixOs, args);

afterInvoke(method, args, result);

看起来这个方案好像挺不错的，但在实际使用中很快就发现这个方法有几个缺点。

性能极差。I/O 操作调用非常频繁，因为使用动态代理和 Java 的大量字符串操作，导致性能比较差，无法达到线上使用的标准。

无法监控 Native 代码。例如微信中有大量的 I/O 操作是在 Native 代码中，使用 Java Hook 方案无法监控到。

兼容性差。Java Hook 需要每个 Android 版本去兼容，特别是 Android P 增加对非公开 API 限制。

2. Native Hook

如果 Java Hook 不能满足需求，我们自然就会考虑 Native Hook 方案。Profilo 使用到是 PLT Hook 方案，它的性能比GOT Hook要稍好一些，不过 GOT Hook 的兼容性会更好一些。

关于几种 Native Hook 的实现方式与差异，我在后面会花篇幅专门介绍，今天就不展开了。最终是从 libc.so 中的这几个函数中选定 Hook 的目标函数。

int open(const char *pathname, int flags, mode_t mode);

ssize_t read(int fd, void *buf, size_t size);

ssize_t write(int fd, const void *buf, size_t size); write_cuk

int close(int fd);

因为使用的是 GOT Hook，我们需要选择一些有调用上面几个方法的 library。微信 Matrix 中选择的是libjavacore.so、libopenjdkjvm.so、libopenjdkjvm.so，可以覆盖到所有的 Java 层的 I/O 调用，具体可以参考io_canary_jni.cc。

不过我更推荐 Profilo 中atrace.cpp的做法，它直接遍历所有已经加载的 library，一并替换。

void hookLoadedLibs() {

  auto& functionHooks = getFunctionHooks();

  auto& seenLibs = getSeenLibs();

  facebook::profilo::hooks::hookLoadedLibs(functionHooks, seenLibs);

}

不同版本的 Android 系统实现有所不同，在 Android 7.0 之后，我们还需要替换下面这三个方法。

open64

__read_chk

__write_chk

3. 监控内容

在实现 I/O 跟踪后，我们需要进一步思考需要监控哪些 I/O 信息。假设读取一个文件，我们希望知道这个文件的名字、原始大小、打开文件的堆栈、使用了什么线程这些基本信息。

接着我们还希望得到这一次操作一共使用了多长时间，使用的 Buffer 是多大的。是一次连续读完的，还是随机的读取。通过上面 Hook 的四个接口，我们可以很容易的采集到这些信息。

下面是一次 I/O 操作的基本信息，在主线程对一个大小为 600KB 的“test.db”文件。

使用了 4KB 的 Buffer，连续读取 150 次，一次性把整个文件读完，整体的耗时是 10ms。因为连读读写时间和打开文件的总时间相同，我们可以判断出这次 read() 操作是一气呵成的，中间没有间断。

因为 I/O 操作真的非常频繁，采集如此多的信息，对应用程序的性能会造成多大的影响呢？我们可以看看是否使用 Native Hook 的耗时数据。

你可以看到采用 Native Hook 的监控方法性能损耗基本可以忽略，这套方案可以用于线上。

线上监控

通过 Native Hook 方式可以采集到所有的 I/O 相关的信息，但是采集到的信息非常多，我们不可能把所有信息都上报到后台进行分析。

对于 I/O 的线上监控，我们需要进一步抽象出规则，明确哪些情况可以定义为不良情况，需要上报到后台，进而推动开发去解决。

1. 主线程 I/O

我不止一次说过，有时候 I/O 的写入会突然放大，即使是几百 KB 的数据，还是尽量不要在主线程上操作。在线上也会经常发现一些 I/O 操作明明数据量不大，但是最后还是 ANR 了。

当然如果把所有的主线程 I/O 都收集上来，这个数据量会非常大，所以我会添加“连续读写时间超过 100 毫秒”这个条件。之所以使用连续读写时间，是因为发现有不少案例是打开了文件句柄，但不是一次读写完的。

在上报问题到后台时，为了能更好地定位解决问题，我通常还会把 CPU 使用率、其他线程的信息以及内存信息一并上报，辅助分析问题。

2. 读写 Buffer 过小

我们知道，对于文件系统是以 block 为单位读写，对于磁盘是以 page 为单位读写，看起来即使我们在应用程序上面使用很小的 Buffer，在底层应该差别不大。那是不是这样呢？

read(53, "*****************"\.\.\., 1024) = 1024       <0.000447>

read(53, "*****************"\.\.\., 1024) = 1024       <0.000084>

read(53, "*****************"\.\.\., 1024) = 1024       <0.000059>

虽然后面两次系统调用的时间的确会少一些，但是也会有一定的耗时。如果我们的 Buffer 太小，会导致多次无用的系统调用和内存拷贝，导致 read/write 的次数增多，从而影响了性能。

那应该选用多大的 Buffer 呢？我们可以跟据文件保存所挂载的目录的 block size 来确认 Buffer 大小，数据库中的pagesize就是这样确定的。

new StatFs("/data").getBlockSize()

所以我们最终选择的判断条件为：

buffer size 小于 block size，这里一般为 4KB。

read/write 的次数超过一定的阈值，例如 5 次，这主要是为了减少上报量。

buffer size 不应该小于 4KB，那它是不是越大越好呢？你可以通过下面的命令做一个简单的测试，读取测试应用的 iotest 文件，它的大小是 40M。其中 bs 就是 buffer size，bs 分别使用不同的值，然后观察耗时。

// 每次测试之前需要手动释放缓存

echo 3 > /proc/sys/vm/drop_caches

time dd if=/data/data/com.sample.io/files/iotest of=/dev/null bs=4096

通过上面的数据大致可以看出来，Buffer 的大小对文件读写的耗时有非常大的影响。耗时的减少主要得益于系统调用与内存拷贝的优化，Buffer 的大小一般我推荐使用 4KB 以上。

在实际应用中，ObjectOutputStream 和 ZipOutputStream 都是一个非常经典的例子，ObjectOutputStream 使用的 buffer size 非常小。而 ZipOutputStream 会稍微复杂一些，如果文件是 Stored 方式存储的，它会使用上层传入的 buffer size。如果文件是 Deflater 方式存储的，它会使用 DeflaterOutputStream 的 buffer size，这个大小默认是 512Byte。

你可以看到，如果使用 BufferInputStream 或者 ByteArrayOutputStream 后整体性能会有非常明显的提升。

正如我上一期所说的，准确评估磁盘真实的读写次数是比较难的。磁盘内部也会有很多的策略，例如预读。它可能发生超过你真正读的内容，预读在有大量顺序读取磁盘的时候，readahead 可以大幅提高性能。但是大量读取碎片小文件的时候，可能又会造成浪费。

你可以通过下面的这个文件查看预读的大小，一般是 128KB。

/sys/block/[disk]/queue/read_ahead_kb

一般来说，我们可以利用 /proc/sys/vm/block_dump 或者/proc/diskstats的信息统计真正的磁盘读写次数。

/proc/diskstats

块设备名字|读请求次数|读请求扇区数|读请求耗时总和\.\.\.\.

dm-0 23525 0 1901752 45366 0 0 0 0 0 33160 57393

dm-1 212077 0 6618604 430813 1123292 0 55006889 3373820 0 921023 3805823

3. 重复读

微信之前在做模块化改造的时候，因为模块间彻底解耦了，很多模块会分别去读一些公共的配置文件。

有同学可能会说，重复读的时候数据都是从 Page Cache 中拿到，不会发生真正的磁盘操作。但是它依然需要消耗系统调用和内存拷贝的时间，而且 Page Cache 的内存也很有可能被替换或者释放。

你也可以用下面这个命令模拟 Page Cache 的释放。

echo 3 > /proc/sys/vm/drop_caches

如果频繁地读取某个文件，并且这个文件一直没有被写入更新，我们可以通过缓存来提升性能。不过为了减少上报量，我会增加以下几个条件：

重复读取次数超过 3 次，并且读取的内容相同。

读取期间文件内容没有被更新，也就是没有发生过 write。

加一层内存 cache 是最直接有效的办法，比较典型的场景是配置文件等一些数据模块的加载，如果没有内存 cache，那么性能影响就比较大了。

public String readConfig() {

  if (Cache != null) {

     return cache;

  }

  cache = read("configFile");

  return cache;

}

4. 资源泄漏

在崩溃分析中，我说过有部分的 OOM 是由于文件句柄泄漏导致。资源泄漏是指打开资源包括文件、Cursor 等没有及时 close，从而引起泄露。这属于非常低级的编码错误，但却非常普遍存在。

如何有效的监控资源泄漏？这里我利用了 Android 框架中的 StrictMode，StrictMode 利用CloseGuard.java类在很多系统代码已经预置了埋点。

到了这里，接下来还是查看源码寻找可以利用的 Hook 点。这个过程非常简单，CloseGuard 中的 REPORTER 对象就是一个可以利用的点。具体步骤如下：

利用反射，把 CloseGuard 中的 ENABLED 值设为 true。

利用动态代理，把 REPORTER 替换成我们定义的 proxy。

虽然在 Android 源码中，StrictMode 已经预埋了很多的资源埋点。不过肯定还有埋点是没有的，比如 MediaPlayer、程序内部的一些资源模块。所以在程序中也写了一个 MyCloseGuard 类，对希望增加监控的资源，可以手动增加埋点代码。

I/O 与启动优化

通过 I/O 跟踪，可以拿到整个启动过程所有 I/O 操作的详细信息列表。我们需要更加的苛刻地检查每一处 I/O 调用，检查清楚是否每一处 I/O 调用都是必不可少的，特别是 write()。

当然主线程 I/O、读写 Buffer、重复读以及资源泄漏是首先需要解决的，特别是重复读，比如 cpuinfo、手机内存这些信息都应该缓存起来。

对于必不可少的 I/O 操作，我们需要思考是否有其他方式做进一步的优化。

对大文件使用 mmap 或者 NIO 方式。MappedByteBuffer就是 Java NIO 中的 mmap 封装，正如上一期所说，对于大文件的频繁读写会有比较大的优化。

安装包不压缩。对启动过程需要的文件，我们可以指定在安装包中不压缩，这样也会加快启动速度，但带来的影响是安装包体积增大。事实上 Google Play 非常希望我们不要去压缩 library、resource、resource.arsc 这些文件，这样对启动的内存和速度都会有很大帮助。而且不压缩文件带来只是安装包体积的增大，对于用户来说，Download size 并没有增大。

Buffer 复用。我们可以利用Okio开源库，它内部的 ByteString 和 Buffer 通过重用等技巧，很大程度上减少 CPU 和内存的消耗。

存储结构和算法的优化。是否可以通过算法或者数据结构的优化，让我们可以尽量的少 I/O 甚至完全没有 I/O。比如一些配置文件从启动完全解析，改成读取时才解析对应的项；替换掉 XML、JSON 这些格式比较冗余、性能比较较差的数据结构，当然在接下来我还会对数据存储这一块做更多的展开。

2013 年我在做 Multidex 优化的时候，发现代码中会先将 classes2.dex 从 APK 文件中解压出来，然后再压缩到 classes2.zip 文件中。classes2.dex 做了一次无用的解压和压缩，其实根本没有必要。

那个时候通过研究 ZIP 格式的源码，我发现只要能构造出一个符合 ZIP 格式的文件，那就可以直接将 classses2.dex 的压缩流搬到 classes2.zip 中。整个过程没有任何一次解压和压缩，这个技术也同样应用到Tinker 的资源合成中。

总结

今天我们学习了如何在应用层面监控 I/O 的使用情况，从实现上尝试了 Java Hook 和 Native Hook 两种方案，最终考虑到性能和兼容性，选择了 Native Hook 方案。

对于 Hook 方案的选择，在同等条件下我会优先选择 Java Hook 方案。但无论采用哪种 Hook 方案，我们都需要耐心地查看源码、分析调用流程，从而寻找可以利用的地方。

一套监控方案是只用在实验室自动化测试，还是直接交给用户线上使用，这两者的要求是不同的，后者需要 99.9% 稳定性，还要具备不影响用户体验的高性能才可以上线。从实验室到线上，需要大量的灰度测试以及反复的优化迭代过程。

课后练习

微信的性能监控分析工具Matrix终于开源了，文中大部分内容都是基于matrix-io-canary的分析。今天的课后作业是尝试接入 I/O Canary，查看一下自己的应用是否存在 I/O 相关的问题，请你在留言区跟同学们分享交流一下你的经验。

是不是觉得非常简单？我还有一个进阶的课后练习。在io_canary_jni.cc中发现目前 Matrix 只监控了主线程的 I/O 运行情况，这主要为了解决多线程同步问题。

//todo 解决非主线程打开，主线程操作问题

int ProxyOpen(const char *pathname, int flags, mode_t mode) {

事实上其他线程使用 I/O 不当，也会影响到应用的性能，“todo = never do”，今天就请你来尝试解决这个问题吧。但是考虑到性能的影响，我们不能简单地直接加锁。针对这个 case 是否可以做到完全无锁的线程安全，或者可以尽量降低锁的粒度呢？我邀请你一起来研究这个问题，给 Matrix 提交 Pull request，参与到开源的事业中吧。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

12 | 存储优化（上）：常见的数据存储方法有哪些？

通过专栏前面我讲的 I/O 优化基础知识，相信你肯定了解了文件系统和磁盘的一些机制，以及不同 I/O 方式的使用场景以及优缺点，并且可以掌握如何在线上监控 I/O 操作。

万丈高楼平地起，在理解并掌握这些基础知识的同时，你肯定还想知道如何利用这些知识指导我们写出更好的代码。

今天我来结合 Android 系统的一些特性，讲讲开发过程中常见存储方法的优缺点，希望可以帮你在日常工作中如何做出更好的选择。

Android 的存储基础

在讲具体的存储方法之前，我们应该对 Android 系统存储相关的一些基础知识有所了解。

1. Android 分区

I/O 优化中讲到的大部分知识更侧重 Linux 系统，对于 Android 来说，我们首先应该对 Android 分区的架构和作用有所了解。在我们熟悉的 Windows 世界中，我们一般都把系统安装在 C 盘，然后还会有几个用来存放应用程序和数据的分区。

Android 系统可以通过 /proc/partitions 或者 df 命令来查看的各个分区情况，下图是 Nexus 6 中 df 命令的运行结果。

什么是分区呢？分区简单来说就是将设备中的存储划分为一些互不重叠的部分，每个部分都可以单独格式化，用作不同的目的。这样系统就可以灵活的针对单独分区做不同的操作，例如在系统还原（recovery）过程，我们不希望会影响到用户存储的数据。

从上面的表中你可以看到，每个分区非常独立，不同的分区可以使用的不同的文件系统。其中比较重要的有：

/system 分区：它是存放所有 Google 提供的 Android 组件的地方。这个分区只能以只读方式 mount。这样主要基于稳定性和安全性考虑，即使发生用户突然断电的情况，也依然需要保证 /system 分区的内容不会受到破坏和篡改。

/data 分区：它是所有用户数据存放的地方。主要为了实现数据隔离，即系统升级和恢复的时候会擦除整个 /system 分区，但是却不会影响 /data 的用户数据。而恢复出厂设置，只会擦除 /data 的数据。

/vendor 分区：它是存放厂商特殊系统修改的地方。特别是在 Android 8.0 以后，隆重推出了“Treble”项目。厂商 OTA 时可以只更新自己的 /vendor 分区即可，让厂商能够以更低的成本，更轻松、更快速地将设备更新到新版 Android 系统。

2. Android 存储安全

除了数据的分区隔离，存储安全也是 Android 系统非常重要的一部分，存储安全首先考虑的是权限控制。

第一，权限控制

Android 的每个应用都在自己的应用沙盒内运行，在 Android 4.3 之前的版本中，这些沙盒使用了标准 Linux 的保护机制，通过为每个应用创建独一无二的 Linux UID 来定义。简单来说，我们需要保证微信不能访问淘宝的数据，并且在没有权限的情况下也不能访问系统的一些保护文件。

在 Android 4.3 引入了SELinux（Security Enhanced Linux）机制进一步定义 Android 应用沙盒的边界。那它有什么特别的呢？它的作用是即使我们进程有 root 权限也不能为所欲为，如果想在 SELinux 系统中干任何事情，都必须先在专门的安全策略配置文件中赋予权限。

第二，数据加密

除了权限的控制，用户还会担心在手机丢失或者被盗导致个人隐私数据泄露。加密或许是一个不错的选择，它可以保护丢失或被盗设备上的数据。

Android 有两种设备加密方法：全盘加密和文件级加密。全盘加密是在 Android 4.4 中引入的，并在 Android 5.0 中默认打开。它会将 /data 分区的用户数据操作加密 / 解密，对性能会有一定的影响，但是新版本的芯片都会在硬件中提供直接支持。

我们知道，基于文件系统的加密，如果设备被解锁了，加密也就没有用了。所以 Android 7.0 增加了基于文件的加密。在这种加密模式下，将会给每个文件都分配一个必须用用户的 passcode 推导出来的密钥。特定的文件被屏幕锁屏之后，直到用户下一次解锁屏幕期间都不能访问。

可能有些同学会问了，Android 的这两种设备加密方法跟应用的加密有什么不同，我们在应用存储还需要单独的给敏感文件加密吗？

我想说的是，设备加密方法对应用程序来说是透明的，它保证我们读取到的是解密后的数据。对于应用程序特别敏感的数据，我们也需要采用 RSA、AES、chacha20 等常用方式做进一步的存储加密。

常见的数据存储方法

Android 为我们提供了很多种持久化存储的方案，在具体介绍它们之前，你需要先问一下自己，什么是存储？

每个人可能都会有自己的答案，在我看来，存储就是把特定的数据结构转化成可以被记录和还原的格式，这个数据格式可以是二进制的，也可以是 XML、JSON、Protocol Buffer 这些格式。

对于闪存来说，一切归根到底还是二进制的，XML、JSON 它们只是提供了一套通用的二进制编解码格式规范。既然有那么多存储的方案，那我们在选择数据存储方法时，一般需要考虑哪些关键要素呢？

1. 关键要素

在选择数据存储方法时，我一般会想到下面这几点，我把它们总结给你。

那上面这些要素哪个最重要呢？数据存储方法不能脱离场景来考虑，我们不可能把这六个要素都做成最完美。

我来解释一下这句话。如果首要考虑的是正确性，那我们可能需要采用冗余、双写等方案，那就要容忍对时间开销产生的额外影响。同样如果非常在意安全，加解密环节的开销也必不可小。如果想针对启动场景，我们希望选择在初始化时间和读取时间更有优势的方案。

2. 存储选项

总的来说，我们需要结合应用场景选择合适的数据存储方法。那 Android 为应用开发者提供了哪些存储数据的方法呢？你可以参考存储选项，综合来看，有下面几种方法。

SharedPreferences

ContentProvider

文件

数据库

今天我先来讲 SharedPreferences 和 ContentProvider 这两个存储方法，文件和数据库将放到“存储优化”后面两期来讲。

第一，SharedPreferences 的使用。

SharedPreferences是 Android 中比较常用的存储方法，它可以用来存储一些比较小的键值对集合。

虽然 SharedPreferences 使用非常简便，但也是我们诟病比较多的存储方法。它的性能问题比较多，我可以轻松地说出它的“七宗罪”。

跨进程不安全。由于没有使用跨进程的锁，就算使用MODE_MULTI_PROCESS，SharedPreferences 在跨进程频繁读写有可能导致数据全部丢失。根据线上统计，SP 大约会有万分之一的损坏率。

加载缓慢。SharedPreferences 文件的加载使用了异步线程，而且加载线程并没有设置线程优先级，如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题，比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms，我建议提前用异步线程预加载启动过程用到的 SP 文件。

全量写入。无论是调用 commit() 还是 apply()，即使我们只改动其中的一个条目，都会把整个内容全部写到文件。而且即使我们多次写入同一个文件，SP 也没有将多次修改合并为一次，这也是性能差的重要原因之一。

卡顿。由于提供了异步落盘的 apply 机制，在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播，或者被调用 onPause 等一些时机，系统会强制把所有的 SharedPreferences 对象数据落地到磁盘。如果没有落地完成，这时候主线程会被一直阻塞。这样非常容易造成卡顿，甚至是 ANR，从线上数据来看 SP 卡顿占比一般会超过 5%。

讲到这里，如果你对 SharedPreferences 机制还不熟悉的话，可以参考《彻底搞懂 SharedPreferences》。

坦白来讲，系统提供的 SharedPreferences 的应用场景是用来存储一些非常简单、轻量的数据。我们不要使用它来存储过于复杂的数据，例如 HTML、JSON 等。而且 SharedPreference 的文件存储性能与文件大小相关，每个 SP 文件不能过大，我们不要将毫无关联的配置项保存在同一个文件中，同时考虑将频繁修改的条目单独隔离出来。

我们也可以替换通过复写 Application 的 getSharedPreferences 方法替换系统默认实现，比如优化卡顿、合并多次 apply 操作、支持跨进程操作等。具体如何替换呢？在今天的 Sample 中我也提供了一个简单替换实现。

public class MyApplication extends Application {

  @Override

  public SharedPreferences getSharedPreferences(String name, int mode)

  {

     return SharedPreferencesImpl.getSharedPreferences(name, mode);

  }

}

对系统提供的 SharedPreferences 的小修小补虽然性能有所提升，但是依然不能彻底解决问题。基本每个大公司都会自研一套替代的存储方案，比如微信最近就开源了MMKV。

下面是 MMKV 对于 SharedPreferences 的“六要素”对比。

你可以参考 MMKV 的实现原理和性能测试报告，里面有一些非常不错的思路。例如利用文件锁保证跨进程的安全、使用 mmap 保证数据不会丢失、选用性能和存储空间更好的 Protocol Buffer 代替 XML、支持增量更新等。

根据 I/O 优化的分析，对于频繁修改的配置使用 mmap 的确非常合适，使用者不用去理解 apply() 和 commit() 的差别，也不用担心数据的丢失。同时，我们也不需要每次都提交整个文件，整体性能会有很大提升。

第二，ContentProvider 的使用。

为什么 Android 系统不把 SharedPreferences 设计成跨进程安全的呢？那是因为 Android 系统更希望我们在这个场景选择使用 ContentProvider 作为存储方式。ContentProvider 作为 Android 四大组件中的一种，为我们提供了不同进程甚至是不同应用程序之间共享数据的机制。

Android 系统中比如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持。它的使用十分简单，你可以参考官方文档。

当然，在使用过程也有下面几点需要注意。

启动性能

ContentProvider 的生命周期默认在 Application onCreate() 之前，而且都是在主线程创建的。我们自定义的 ContentProvider 类的构造函数、静态代码块、onCreate 函数都尽量不要做耗时的操作，会拖慢启动速度。

可能很多同学都不知道 ContentProvider 还有一个多进程模式，它可以和 AndroidManifest 中的 multiprocess 属性结合使用。这样调用进程会直接在自己进程里创建一个 push 进程的 Provider 实例，就不需要跨进程调用了。需要注意的是，这样也会带来 Provider 的多实例问题。

稳定性

ContentProvider 在进行跨进程数据传递时，利用了 Android 的 Binder 和匿名共享内存机制。简单来说，就是通过 Binder 传递 CursorWindow 对象内部的匿名共享内存的文件描述符。这样在跨进程传输中，结果数据并不需要跨进程传输，而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存，这样来实现不同进程访问相同数据的目的。

正如我前面 I/O 优化所讲的，基于 mmap 的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候，可能不一定划算。所以 ContentProvider 提供了一种 call 函数，它会直接通过 Binder 来传输数据。

Android 的 Binder 传输是有大小限制的，一般来说限制是 1~2MB。ContentProvider 的接口调用参数和 call 函数调用并没有使用匿名共享机制，比如要批量插入很多数据，那么就会出现一个插入数据的数组，如果这个数组太大了，那么这个操作就可能会出现数据超大异常。

安全性

虽然 ContentProvider 为应用程序之间的数据共享提供了很好的安全机制，但是如果 ContentProvider 是 exported，当支持执行 SQL 语句时就需要注意 SQL 注入的问题。另外如果我们传入的参数是一个文件路径，然后返回文件的内容，这个时候也要校验合法性，不然整个应用的私有数据都有可能被别人拿到，在 intent 传递参数的时候可能经常会犯这个错误。

最后我给你总结一下 ContentProvider 的“六要素”优缺点。

总的来说，ContentProvider 这套方案实现相对比较笨重，适合传输大的数据。

总结

虽然 SharedPreferences 和 ContentProvider 都是我们日常经常使用的存储方法，但是里面的确会有大大小小的暗坑。所以我们需要充分了解它们的优缺点，这样在工作中可以更好地使用和优化。

如何在合适的场景选择合适的存储方法是存储优化的必修课，你应该学会通过正确性、时间开销、空间开销、安全、开发成本以及兼容性这六大关键要素来分解某个存储方法。

在设计某个存储方案的时候也是同样的道理，我们无法同时把所有的要素都做得最好，因此要学会取舍和选择，在存储的世界里不存在全局最优解，我们要找的是局部的最优解。这个时候更应明确自己的诉求，大胆牺牲部分关键点的指标，将自己场景最关心的要素点做到最好。

课后作业

下面是 MMKV 给出的性能测试报告，你可以看到跟系统的 SharedPreferences 相比，主要差距在于写的速度。

没有实践就没有发言权，今天我们一起来尝试测试对比 MMKV 与系统 SharedPreferences 的性能差异。请将你的测试结果和分析体会，写在留言区跟同学们分享交流吧。

今天的练习Sample是通过复写 Application 的 getSharedPreferences 方法替换系统默认实现，这种方式虽然不是最好的方法，不过它主要的优点在于代码的侵入性比较低，无需修改太多的代码。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

13 | 存储优化（中）：如何优化数据存储？

“将特定结构的数据转化为另一种能被记录和还原的格式”，这是我在上一期对存储下的一个定义。

再来复习一下数据存储的六个关键要素：正确性、时间开销、空间开销、安全、开发成本和兼容性。我们不可能同时把所有要素都做到最好，所谓数据存储优化就是根据自己的使用场景去把其中的一项或者几项做到最好。

更宽泛来讲，我认为数据存储不一定就是将数据存放到磁盘中，比如放到内存中、通过网络传输也可以算是存储的一种形式。或者我们也可以把这个过程叫作对象或者数据的序列化。

对于大部分的开发者来说，我们不一定有精力去“创造”一种数据序列化的格式，所以我今天主要来讲讲 Android 常用的序列化方法如何进行选择。

对象的序列化

应用程序中的对象存储在内存中，如果我们想把对象存储下来或者在网络上传输，这个时候就需要用到对象的序列化和反序列化。

对象序列化就是把一个 Object 对象所有的信息表示成一个字节序列，这包括 Class 信息、继承关系信息、访问权限、变量类型以及数值信息等。

1. Serializable

Serializable 是 Java 原生的序列化机制，在 Android 中也有被广泛使用。我们可以通过 Serializable 将对象持久化存储，也可以通过 Bundle 传递 Serializable 的序列化数据。

Serializable 的原理

Serializable 的原理是通过 ObjectInputStream 和 ObjectOutputStream 来实现的，我们以 Android 6.0 的源码为例，你可以看到ObjectOutputStream的部分源码实现：

private void writeFieldValues(Object obj, ObjectStreamClass classDesc)  {

    for (ObjectStreamField fieldDesc : classDesc.fields()) {

        ...

        Field field = classDesc.checkAndGetReflectionField(fieldDesc);

        ...

整个序列化过程使用了大量的反射和临时变量，而且在序列化对象的时候，不仅会序列化当前对象本身，还需要递归序列化对象引用的其他对象。

整个过程计算非常复杂，而且因为存在大量反射和 GC 的影响，序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多，导致它的大小比 Class 文件本身还要大很多，这样又会导致 I/O 读写上的性能问题。

Serializable 的进阶

既然 Serializable 性能那么差，那它有哪些优势呢？可能很多同学都不知道它还有一些进阶的用法，你可以参考《Java 对象序列化，您不知道的 5 件事》这篇文章。

writeObject 和 readObject 方法。Serializable 序列化支持替代默认流程，它会先反射判断是否存在我们自己实现的序列化方法 writeObject 或反序列化方法 readObject。通过这两个方法，我们可以对某些字段做一些特殊修改，也可以实现序列化的加密功能。

writeReplace 和 readResolve 方法。这两个方法代理序列化的对象，可以实现自定义返回的序列化实例。那它有什么用呢？我们可以通过它们实现对象序列化的版本兼容，例如通过 readResolve 方法可以把老版本的序列化对象转换成新版本的对象类型。

Serializable 的序列化与反序列化的调用流程如下。

// 序列化

E/test:SerializableTestData writeReplace

E/test:SerializableTestData writeObject

// 反序列化

E/test:SerializableTestData readObject

E/test:SerializableTestData readResolve

Serializable 的注意事项

Serializable 虽然使用非常简单，但是也有一些需要注意的事项字段。

不被序列化的字段。类的 static 变量以及被声明为 transient 的字段，默认的序列化机制都会忽略该字段，不会进行序列化存储。当然我们也可以使用进阶的 writeReplace 和 readResolve 方法做自定义的序列化存储。

serialVersionUID。在类实现了 Serializable 接口后，我们需要添加一个 Serial Version ID，它相当于类的版本号。这个 ID 我们可以显式声明也可以让编译器自己计算。通常我建议显式声明会更加稳妥，因为隐式声明假如类发生了一点点变化，进行反序列化都会由于 serialVersionUID 改变而导致 InvalidClassException 异常。

构造方法。Serializable 的反序列默认是不会执行构造函数的，它是根据数据流中对 Object 的描述信息创建对象的。如果一些逻辑依赖构造函数，就可能会出现问题，例如一个静态变量只在构造函数中赋值，当然我们也可以通过进阶方法做自定义的反序列化修改。

2. Parcelable

由于 Java 的 Serializable 的性能较低，Android 需要重新设计一套更加轻量且高效的对象序列化和反序列化机制。Parcelable 正是在这个背景下产生的，它核心的作用就是为了解决 Android 中大量跨进程通信的性能问题。

Parcelable 的永久存储

Parcelable 的原理十分简单，它的核心实现都在Parcel.cpp。

你可以发现 Parcel 序列化和 Java 的 Serializable 序列化差别还是比较大的，Parcelable 只会在内存中进行序列化操作，并不会将数据存储到磁盘里。

当然我们也可以通过Parcel.java的 marshall 接口获取 byte 数组，然后存在文件中从而实现 Parcelable 的永久存储。

// Returns the raw bytes of the parcel.

public final byte[] marshall() {

    return nativeMarshall(mNativePtr);

}

// Set the bytes in data to be the raw bytes of this Parcel.

public final void unmarshall(byte[] data, int offset, int length) {

    nativeUnmarshall(mNativePtr, data, offset, length);

}

Parcelable 的注意事项

在时间开销和使用成本的权衡上，Parcelable 机制选择的是性能优先。

所以它在写入和读取的时候都需要手动添加自定义代码，使用起来相比 Serializable 会复杂很多。但是正因为这样，Parcelable 才不需要采用反射的方式去实现序列化和反序列化。

虽然通过取巧的方法可以实现 Parcelable 的永久存储，但是它也存在两个问题。

系统版本的兼容性。由于 Parcelable 设计本意是在内存中使用的，我们无法保证所有 Android 版本的Parcel.cpp实现都完全一致。如果不同系统版本实现有所差异，或者有厂商修改了实现，可能会存在问题。

数据前后兼容性。Parcelable 并没有版本管理的设计，如果我们类的版本出现升级，写入的顺序及字段类型的兼容都需要格外注意，这也带来了很大的维护成本。

一般来说，如果需要持久化存储的话，一般还是不得不选择性能更差的 Serializable 方案。

3. Serial

作为程序员，我们肯定会追求完美。那有没有性能更好的方案并且可以解决这些痛点呢？

事实上，关于序列化基本每个大公司都会自己自研的一套方案，我在专栏里推荐 Twitter 开源的高性能序列化方案Serial。那它是否真的是高性能呢？我们可以将它和前面的两套方案做一个对比测试。

从图中数据上看来，Serial 在序列化与反序列化耗时，以及落地的文件大小都有很大的优势。

从实现原理上看，Serial 就像是把 Parcelable 和 Serializable 的优点集合在一起的方案。

由于没有使用反射，相比起传统的反射序列化方案更加高效，具体你可以参考上面的测试数据。

开发者对于序列化过程的控制较强，可定义哪些 Object、Field 需要被序列化。

有很强的 debug 能力，可以调试序列化的过程。

有很强的版本管理能力，可以通过版本号和 OptionalFieldException 做兼容。

数据的序列化

Serial 性能看起来还不错，但是对象的序列化要记录的信息还是比较多，在操作比较频繁的时候，对应用的影响还是不少的，这个时候我们可以选择使用数据的序列化。

1. JSON

JSON 是一种轻量级的数据交互格式，它被广泛使用在网络传输中，很多应用与服务端的通信都是使用 JSON 格式进行交互。

JSON 的确有很多得天独厚的优势，主要有：

相比对象序列化方案，速度更快，体积更小。

相比二进制的序列化方案，结果可读，易于排查问题。

使用方便，支持跨平台、跨语言，支持嵌套引用。

因为每个应用基本都会用到 JSON，所以每个大厂也基本都有自己的“轮子”。例如 Android 自带的 JSON 库、Google 的Gson、阿里巴巴的Fastjson、美团的MSON。

各个自研的 JSON 方案主要在下面两个方面进行优化：

便利性。例如支持 JSON 转换成 JavaBean 对象，支持注解，支持更多的数据类型等。

性能。减少反射，减少序列化过程内存与 CPU 的使用，特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显。

在数据量比较少的时候，系统自带的 JSON 库还稍微有一些优势。但在数据量大了之后，差距逐渐被拉开。总的来说，Gson 的兼容性最好，一般情况下它的性能与 Fastjson 相当。但是在数据量极大的时候，Fastjson 的性能更好。

2. Protocol Buffers

相比对象序列化方案，JSON 的确速度更快、体积更小。不过为了保证 JSON 的中间结果是可读的，它并没有做二进制的压缩，也因此 JSON 的性能还没有达到极致。

如果应用的数据量非常大，又或者对性能有更高的要求，此时Protocol Buffers是一个非常好的选择。它是 Google 开源的跨语言编码协议，Google 内部的几乎所有 RPC 都在使用这个协议。

下面我来总结一下它的优缺点。

性能。使用了二进制编码压缩，相比 JSON 体积更小，编解码速度也更快，感兴趣的同学可以参考protocol-buffers 编码规则。

兼容性。跨语言和前后兼容性都不错，也支持基本类型的自动转换，但是不支持继承与引用类型。

使用成本。Protocol Buffers 的开发成本很高，需要定义.proto 文件，并用工具生成对应的辅助类。辅助类特有一些序列化的辅助方法，所有要序列化的对象，都需要先转化为辅助类的对象，这让序列化代码跟业务代码大量耦合，是侵入性较强的一种方式。

对于 Android 来说，官方的 Protocol Buffers 会导致生成的方法数很多。我们可以修改它的自动代码生成工具，例如在微信中，每个.proto 生成的类文件只会包含一个方法即 op 方法。

public class TestProtocal extends  com.tencent.mm.protocal.protobuf {

    @Override

    protected final int op(int opCode, Object ...objs) throws IOException {

        if (opCode == OPCODE_WRITEFIELDS) {

           ...

        } else if (opCode == OPCODE_COMPUTESIZE) {

           ...

Google 后面还推出了压缩率更高的 FlatBuffers，对于它的使用你可以参考《FlatBuffers 体验》。最后，我再结合“六要素”，帮你综合对比一下 Serial、JSON、Protocol Buffers 这三种序列化方案。

存储监控

通过本地实验我们可以对比不同文件存储方法的性能，但是实验室环境不一定能真实反映用户实际的使用情况，所以我们同样需要对存储建立完善的监控。那么应该监控哪些内容呢？

1. 性能监控

正确性、时间开销、空间开销、安全、开发成本和兼容性，对于这六大关键要素来说，在线上我更关注：

正确性

在专栏第 9 期中我讲过，应用程序、文件系统或者磁盘都可以导致文件损坏。

在线上我希望可以监控存储模块的损坏率，在专栏上一期中也提到 SharedPreferences 的损坏率大约在万分之一左右，而我们内部自研的 SharedPreferences 的损耗率在十万分之一左右。如何界定一个文件是损坏的？对于系统 SP 我们将损坏定义为文件大小变为 0，而自研的 SP 文件头部会有专门的校验字段，比如文件长度、关键位置的 CRC 信息等，可以识别出更多的文件损坏场景。在识别出文件损坏之后，我们还可以进一步做数据修复等工作。

时间开销

存储模块的耗时也是我非常关心的，而线上的耗时监控分为初始化耗时与读写耗时。每个存储模块的侧重点可能不太一样，例如在启动过程中使用的存储模块我们可能希望初始化可以快一些。

同样以系统的 SharedPreferences 为例，在初始化过程它需要读取并解析整个文件，如果内容超过 1000 条，初始化的时间可能就需要 50～100ms。我们内部另外一个支持随机读写的存储模块，初始化时间并不会因为存储条数的数量而变化，即使有几万条数据，初始化时间也在 1ms 以内。

空间开销

空间的占用分为内存空间和 ROM 空间，通常为了性能的提升，会采用空间换时间的方式。内存空间需要考虑 GC 和峰值内存，以及在一些数据量比较大的情况会不会出现 OOM。ROM 空间需要考虑做清理逻辑，例如数据超过 1000 条或者 10MB 后会触发自动清理或者数据合并。

2. ROM 监控

除了某个存储模块的监控，我们也需要对应用整体的 ROM 空间做详细监控。为什么呢？这是源于我发现有两个经常会遇到的问题。

以前经常会收到用户的负反馈：微信的 ROM 空间为什么会占用 2GB 之多？是因为数据库太大了吗，还是其他什么原因，那时候我们还真有点不知所措。曾经我们在线上发现一个 bug 会导致某个配置重复下载，相同的内容一个用户可能会下载了几千次。

download_1 download_2 download_3 ....

线上我们有时候会发现在遍历某个文件夹时，会出现卡顿或者 ANR。在专栏第 10 期我也讲过，文件遍历的耗时跟文件夹下的文件数量有关。曾经我们也出现过一次 bug 导致某个文件夹下面有几万个文件，在遍历这个文件夹时，用户手机直接重启了。需要注意的是文件遍历在 API level 26 之后建议使用FileVisitor替代 ListFiles，整体的性能会好很多。

ROM 监控的两个核心指标是文件总大小与总文件数，例如我们可以将文件总大小超过 400MB 的用户比例定义为空间异常率，将文件数超过 1000 个的用户比例定义为数量异常率，这样我们就可以持续监控应用线上的存储情况。

当然监控只是第一步，核心问题在于如何能快速发现问题。类似卡顿树，我们也可以构造用户的存储树，然后在后台做聚合。但是用户的整个存储树会非常非常大，这里我们需要通过一些剪枝算法。例如只保留最大的 3 个文件夹，每个文件夹保留 5 个文件，但在这 5 个文件我们需要保留一定的随机性，以免所有人都会上传相同的内容。

在监控的同时，我们也要有远程控制的能力，用户投诉时可以实时拉取这个用户的完整存储树。对线上发现的存储问题，我们可以动态下发清理规则，例如某个缓存文件夹超过 200MB 后自动清理、删除某些残留的历史文件等。

总结

对于优化存储来说，不同的应用关注点可能不太一样。对小应用来说，可能开发成本是最重要的，我们希望开发效率优先；对于成熟的应用来说，性能会更加重要。因此选择什么样的存储方案，需要结合应用所处的阶段以及使用场景具体问题具体分析。

无论是优化某个存储方案的性能，还是应用整体的 ROM 存储，我们可能对存储监控关注比较少。而如果这块出现问题，对用户的体验影响还是非常大的。例如我们知道微信占用的 ROM 空间确实不小，为了解决这个问题，特别推出了空间清理的功能，而且在 ROM 空间不足等场景，会弹框提示用户操作。

课后作业

今天的课后作业是，你的应用选择了哪种对象序列化和数据序列化方案？对数据的存储你还有哪些体会？请你在留言区写写你的方案和想法，与其他同学一起交流。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

14 | 存储优化（下）：数据库SQLite的使用和优化

我们先来复习一下前面讲到的存储方法的使用场景：少量的 Key Value 数据可以直接使用 SharedPreferences，稍微复杂一些的数据类型也可以通过序列化成 JSON 或者 Protocol Buffers 保存，并且在开发中获取或者修改数据也很简单。

不过这几种方法可以覆盖所有的存储场景吗？数据量在几百上千条这个量级时它们的性能还可以接受，但如果是几万条的微信聊天记录呢？而且如何实现快速地对某几个联系人的数据做增删改查呢？

对于大数据的存储场景，我们需要考虑稳定性、性能和可扩展性，这个时候就要轮到今天的“主角”数据库登场了。讲存储优化一定绕不开数据库，而数据库这个主题又非常大，我也知道不少同学学数据库的过程是从入门到放弃。那么考虑到我们大多是从事移动开发的工作，今天我就来讲讲移动端数据库 SQLite 的使用和优化。

SQLite 的那些事儿

虽然市面上有很多的数据库，但受限于库体积和存储空间，适合移动端使用的还真不多。当然使用最广泛的还是我们今天的主角 SQLite，但同样还是有一些其他不错的选择，例如创业团队的Realm、Google 的LevelDB等。

在国内那么多的移动团队中，微信对 SQLite 的研究可以算是最深入的。这其实是业务诉求导向的，用户聊天记录只会在本地保存，一旦出现数据损坏或者丢失，对用户来说都是不可挽回的。另一方面，微信有很大一批的重度用户，他们有几千个联系人、几千个群聊天，曾经做过一个统计，有几百万用户的数据库竟然大于 1GB。对于这批用户，如何保证他们可以正常地使用微信是一个非常大的挑战。

所以当时微信专门开展了一个重度用户优化的专项。一开始的时候我们集中在 SQLite 使用上的优化，例如表结构、索引等。但很快就发现由于系统版本的不同，SQLite 的实现也有所差异，经常会出现一些兼容性问题，并且也考虑到加密的诉求，我们决定单独引入自己的 SQLite 版本。

“源码在手，天下我有”，从此开启了一条研究数据库的“不归路”。那时我们投入了几个人专门去深入研究 SQLite 的源码，从 SQLite 的 PRAGMA 编译选项、Cursor 实现优化，到 SQLite 源码的优化，最后打造出从实验室到线上的整个监控体系。

在 2017 年，我们开源了内部使用的 SQLite 数据库WCDB。这里多说两句，看一个开源项目是否靠谱，就看这个项目对产品本身有多重要。微信开源坚持内部与外部使用同一个版本，虽然我现在已经离开了微信团队，但还是欢迎有需要的同学使用 WCDB。

在开始学习前我要提醒你，SQLite 的优化同样也很难通过一两篇文章就把每个细节都讲清楚。今天的内容我选择了一些比较重要的知识点，并且为你准备了大量的参考资料，遇到陌生或者不懂的地方需要结合参考资料反复学习。

1. ORM

坦白说可能很多 BAT 的高级开发工程师都不完全了解 SQLite 的内部机制，也不能正确地写出高效的 SQL 语句。大部分应用为了提高开发效率，会引入 ORM 框架。ORM（Object Relational Mapping）也就是对象关系映射，用面向对象的概念把数据库中表和对象关联起来，可以让我们不用关心数据库底层的实现。

Android 中最常用的 ORM 框架有开源greenDAO和 Google 官方的Room，那使用 ORM 框架会带来什么问题呢？

使用 ORM 框架真的非常简单，但是简易性是需要牺牲部分执行效率为代价的，具体的损耗跟 ORM 框架写得好不好很有关系。但可能更大的问题是让很多的开发者的思维固化，最后可能连简单的 SQL 语句都不会写了。

那我们的应用是否应该引入 ORM 框架呢？可能程序员天生追求偷懒，为了提高开发效率，应用的确应该引入 ORM 框架。但是这不能是我们可以不去学习数据库基础知识的理由，只有理解底层的一些机制，我们才能更加得心应手地解决疑难的问题。

考虑到可以更好的与 Android Jetpack 的组件互动，WCDB 选择 Room 作为 ORM 框架。

2. 进程与线程并发

如果我们在项目中有使用 SQLite，那么下面这个SQLiteDatabaseLockedException就是经常会出现的一个问题。

android.database.sqlite.SQLiteDatabaseLockedException: database is locked

  at android.database.sqlite.SQLiteDatabase.dbopen

  at android.database.sqlite.SQLiteDatabase.openDatabase

  at android.database.sqlite.SQLiteDatabase.openDatabase

SQLiteDatabaseLockedException 归根到底是因为并发导致，而 SQLite 的并发有两个维度，一个是多进程并发，一个是多线程并发。下面我们分别来讲一下它们的关键点。

多进程并发

SQLite 默认是支持多进程并发操作的，它通过文件锁来控制多进程的并发。SQLite 锁的粒度并没有非常细，它针对的是整个 DB 文件，内部有 5 个状态，具体你可以参考下面的文章。

官方文档：SQLite locking

SQLite 源码分析：SQLite 锁机制简介

SQLite 封锁机制

简单来说，多进程可以同时获取 SHARED 锁来读取数据，但是只有一个进程可以获取 EXCLUSIVE 锁来写数据库。对于 iOS 来说可能没有多进程访问数据库的场景，可以把 locking_mode 的默认值改为 EXCLUSIVE。

PRAGMA locking_mode = EXCLUSIVE

在 EXCLUSIVE 模式下，数据库连接在断开前都不会释放 SQLite 文件的锁，从而避免不必要的冲突，提高数据库访问的速度。

多线程并发

相比多进程，多线程的数据库访问可能会更加常见。SQLite 支持多线程并发模式，需要开启下面的配置，当然系统 SQLite 会默认开启多线程Multi-thread 模式。

PRAGMA SQLITE_THREADSAFE = 2

跟多进程的锁机制一样，为了实现简单，SQLite 锁的粒度都是数据库文件级别，并没有实现表级甚至行级的锁。还有需要说明的是，同一个句柄同一时间只有一个线程在操作，这个时候我们需要打开连接池 Connection Pool。

如果使用 WCDB 在初始化的时候可以指定连接池的大小，在微信中我们设置的大小是 4。

public static SQLiteDatabase openDatabase (String path,

                    SQLiteDatabase.CursorFactory factory,

                    int flags,

                    DatabaseErrorHandler errorHandler,

                    int poolSize)

跟多进程类似，多线程可以同时读取数据库数据，但是写数据库依然是互斥的。SQLite 提供了 Busy Retry 的方案，即发生阻塞时会触发 Busy Handler，此时可以让线程休眠一段时间后，重新尝试操作，你可以参考《微信 iOS SQLite 源码优化实践》这篇文章。

为了进一步提高并发性能，我们还可以打开WAL（Write-Ahead Logging）模式。WAL 模式会将修改的数据单独写到一个 WAL 文件中，同时也会引入了 WAL 日志文件锁。通过 WAL 模式读和写可以完全地并发执行，不会互相阻塞。

PRAGMA schema.journal_mode = WAL

但是需要注意的是，写之间是仍然不能并发。如果出现多个写并发的情况，依然有可能会出现 SQLiteDatabaseLockedException。这个时候我们可以让应用中捕获这个异常，然后等待一段时间再重试。

} catch (SQLiteDatabaseLockedException e) {

    if (sqliteLockedExceptionTimes < (tryTimes - 1)) {

        try {

            Thread.sleep(100);

        } catch (InterruptedException e1) {

        }

    }

    sqliteLockedExceptionTimes++；

}

总的来说通过连接池与 WAL 模式，我们可以很大程度上增加 SQLite 的读写并发，大大减少由于并发导致的等待耗时，建议大家在应用中可以尝试开启。

3. 查询优化

说到数据库的查询优化，你第一个想到的肯定是建索引，那我就先来讲讲 SQLite 的索引优化。

索引优化

正确使用索引在大部分的场景可以大大降低查询速度，微信的数据库优化也是通过索引开始。下面是索引使用非常简单的一个例子，我们先从索引表找到数据对应的 rowid，然后再从原数据表直接通过 rowid 查询结果。

关于 SQLite 索引的原理网上有很多文章，在这里我推荐一些参考资料给你：

SQLite 索引的原理

官方文档：Query Planning

MySQL 索引背后的数据结构及算法原理

这里的关键在于如何正确的建立索引，很多时候我们以为已经建立了索引，但事实上并没有真正生效。例如使用了 BETWEEN、LIKE、OR 这些操作符、使用表达式或者 case when 等。更详细的规则可参考官方文档The SQLite Query Optimizer Overview，下面是一个通过优化转换达到使用索引目的的例子。

BETWEEN：myfiedl 索引无法生效

SELECT * FROM mytable WHERE myfield BETWEEN 10 and 20;

转换成：myfiedl 索引可以生效

SELECT * FROM mytable WHERE myfield >= 10 AND myfield <= 20;

建立索引是有代价的，需要一直维护索引表的更新。比如对于一个很小的表来说就没必要建索引；如果一个表经常是执行插入更新操作，那么也需要节制的建立索引。总的来说有几个原则：

建立正确的索引。这里不仅需要确保索引在查询中真正生效，我们还希望可以选择最高效的索引。如果一个表建立太多的索引，那么在查询的时候 SQLite 可能不会选择最好的来执行。

单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑，如果查询结果集过大，还是希望可以通过复合索引直接在索引表返回查询结果。

索引字段的选择。整型类型索引效率会远高于字符串索引，而对于主键 SQLite 会默认帮我们建立索引，所以主键尽量不要用复杂字段。

总的来说索引优化是 SQLite 优化中最简单同时也是最有效的，但是它并不是简单的建一个索引就可以了，有的时候我们需要进一步调整查询语句甚至是表的结构，这样才能达到最好的效果。

页大小与缓存大小

在 I/O 文件系统中，我讲过数据库就像一个小文件系统一样，事实上它内部也有页和缓存的概念。

对于 SQLite 的 DB 文件来说，页（page）是最小的存储单位，如下图所示每个表对应的数据在整个 DB 文件中都是通过一个一个的页存储，属于同一个表不同的页以 B 树（B-tree）的方式组织索引，每一个表都是一棵 B 树。

跟文件系统的页缓存（Page Cache）一样，SQLite 会将读过的页缓存起来，用来加快下一次读取速度。页大小默认是 1024Byte，缓存大小默认是 1000 页。更多的编译参数你可以查看官方文档PRAGMA Statements。

PRAGMA page_size = 1024

PRAGMA cache_size = 1000

每个页永远只存放一个表或者一组索引的数据，即不可能同一个页存放多个表或索引的数据，表在整个 DB 文件的第一个页就是这棵 B 树的根页。继续以上图为例，如果想查询 rowID 为 N+2 的数据，我们首先要从 sqlite_master 查找出 table 的 root page 的位置，然后读取 root page、page4 这两个页，所以一共会需要 3 次 I/O。

从上表可以看到，增大 page size 并不能不断地提升性能，在拐点以后可能还会有副作用。我们可以通过 PRAGMA 改变默认 page size 的大小，也可以再创建 DB 文件的时候进行设置。但是需要注意如果存在老的数据，需要调用 vacuum 对数据表对应的节点重新计算分配大小。

在微信的内部测试中，如果使用 4KB 的 page size 性能提升可以在 5%～10%。但是考虑到历史数据的迁移成本，最终还是使用 1024Byte。所以这里建议大家在新建数据库的时候，就提前选择 4KB 作为默认的 page size 以获得更好的性能。

其他优化

关于 SQLite 的使用优化还有很多很多，下面我简单提几个点。

慎用“select*”，需要使用多少列，就选取多少列。

正确地使用事务。

预编译与参数绑定，缓存被编译后的 SQL 语句。

对于 blob 或超大的 Text 列，可能会超出一个页的大小，导致出现超大页。建议将这些列单独拆表，或者放到表字段的后面。

定期整理或者清理无用或可删除的数据，例如朋友圈数据库会删除比较久远的数据，如果用户访问到这部分数据，重新从网络拉取即可。

在日常的开发中，我们都应该对这些知识有所了解，再来复习一下上面学到的 SQLite 优化方法。通过引进 ORM，可以大大的提升我们的开发效率。通过 WAL 模式和连接池，可以提高 SQLite 的并发性能。通过正确的建立索引，可以提升 SQLite 的查询速度。通过调整默认的页大小和缓存大小，可以提升 SQLite 的整体性能。

SQLite 的其他特性

除了 SQLite 的优化经验，我在微信的工作中还积累了很多使用的经验，下面我挑选了几个比较重要的经验把它分享给你。

1. 损坏与恢复

微信中 SQLite 的损耗率在 1/20000～1/10000 左右，虽然看起来很低，不过意考虑到微信的体量，这个问题还是不容忽视的。特别是如果某些大佬的聊天记录丢失，我们团队都会承受超大的压力。

创新是为了解决焦虑，技术都是逼出来的。对于 SQLite 损坏与恢复的研究，可以说是微信投入比较大的一块。关于 SQLite 数据库的损耗与修复，以及微信在这里的优化成果，你可以参考下面这些资料。

How To Corrupt An SQLite Database File

微信 SQLite 数据库修复实践

微信移动端数据库组件 WCDB 系列（二） — 数据库修复三板斧

WCDB Android 数据库修复

2. 加密与安全

数据库的安全主要有两个方面，一个是防注入，一个是加密。防注入可以通过静态安全扫描的方式，而加密一般会使用 SQLCipher 支持。

SQLite 的加解密都是以页为单位，默认会使用 AES 算法加密，加 / 解密的耗时跟选用的密钥长度有关。下面是WCDB Android Benchmark的数据，详细的信息请查看链接里的说明，从结论来说对 Create 来说影响会高达到 10 倍。

关于 WCDB 加解密的使用，你可以参考《微信移动数据库组件 WCDB（四） — Android 特性篇》。

3. 全文搜索

微信的全文搜索也是一个技术导向的项目，最开始的时候性能并不是很理想，经常会被人“批斗”。经过几个版本的优化迭代，目前看效果还是非常不错的。

关于全文搜索，你可以参考这些资料：

SQLite FTS3 and FTS4 Extensions

微信全文搜索优化之路

移动客户端多音字搜索

关于 SQLite 的这些特性，我们需要根据自己的项目情况综合考虑。假如某个数据库存储的数据并不重要，这个时候万分之一的数据损坏率我们并不会关心。同样是否需要使用数据库加密，也要根据存储的数据是不是敏感内容。

SQLite 的监控

首先我想说，正确使用索引，正确使用事务。对于大型项目来说，参与的开发人员可能有几十几百人，开发人员水平参差不齐，很难保证每个人都可以正确而高效地使用 SQLite，所以这次时候需要建立完善的监控体系。

1. 本地测试

作为一名靠谱的开发工程师，我们每写一个 SQL 语句，都应该先在本地测试。我们可以通过 EXPLAIN QUERY PLAN 测试 SQL 语句的查询计划，是全表扫描还是使用了索引，以及具体使用了哪个索引等。

sqlite> EXPLAIN QUERY PLAN SELECT * FROM t1 WHERE a=1 AND b>2;

QUERY PLAN

|--SEARCH TABLE t1 USING INDEX i2 (a=? AND b>?)

关于 SQLite 命令行与 EXPLAIN QUERY PLAN 的使用，可以参考Command Line Shell For SQLite以及EXPLAIN QUERY PLAN。

2. 耗时监控

本地测试过于依赖开发人员的自觉性，所以很多时候我们依然需要建立线上大数据的监控。因为微信集成了自己的 SQLite 源码，所以可以非常方便地增加自己想要的监控模块。

WCDB 增加了SQLiteTrace的监控模块，有以下三个接口：

我们可以通过这些接口监控数据库 busy、损耗以及执行耗时。针对耗时比较长的 SQL 语句，需要进一步检查是 SQL 语句写得不好，还是需要建立索引。

3. 智能监控

对于查询结果的监控只是我们监控演进的第二阶段，在这个阶段我们依然需要人工介入分析，而且需要比较有经验的人员负责。

我们希望 SQL 语句的分析可以做到智能化，是完全不需要门槛的。微信开源的 Matrix 里面就有一个智能化分析 SQLite 语句的工具：Matrix SQLiteLint – SQLite 使用质量检测。它根据分析 SQL 语句的语法树，结合我们日常数据库使用的经验，抽象出索引使用不当、select*等六大问题。

可能有同学会感叹为什么微信的人可以想到这样的方式，事实上这个思路在 MySQL 中是非常常见的做法。美团也开源了它们内部的 SQL 优化工具 SQLAdvisor，你可以参考这些资料：

SQL 解析在美团的应用

美团点评 SQL 优化工具 SQLAdvisor 开源

总结

数据库存储是一个开发人员的基本功，清楚 SQLite 的底层机制对我们的工作会有很大的指导意义。

掌握了 SQLite 数据库并发的机制，在某些时候我们可以更好地决策应该拆数据表还是拆数据库。新建一个数据库好处是可以隔离其他库并发或者损坏的情况，而坏处是数据库初始化耗时以及更多内存的占用。一般来说，单独的业务都会使用独立数据库，例如专门的下载数据库、朋友圈数据库、聊天数据库。但是数据库也不宜太多，我们可以有一个公共数据库，用来存放一些相对不是太大的数据。

在了解 SQLite 数据库损坏的原理和概率以后，我们可以根据数据的重要程度决定是否要引入恢复机制。我还讲了如何实现数据库加密以及对性能的影响，我们可以根据数据的敏感程度决定是否要引入加密。

最后我再强调一下，SQLite 优化真的是一个很大的话题，在课后你还需要结合参考资料再进一步反复学习，才能把今天的内容理解透彻。

课后作业

在你的应用中是否使用数据库存储呢，使用了哪种数据库？是否使用 ORM？在使用数据库过程中你有哪些疑问或者经验呢？欢迎留言跟我和其他同学一起讨论。

如果你的应用也在使用 SQLite 存储，今天的课后练习是尝试接入 WCDB，对比测试系统默认 SQLite 的性能。尝试接入Matrix SQLiteLint，查看是否存在不合理的 SQLite 使用。

除了今天文章中的参考资料，我还给希望进阶的同学准备了下面的资料，欢迎有兴趣的同学继续深入学习。

SQLite 官方文档

SQLite 源码分析

全面解析 SQLite

图书《SQLite 权威指南（第 2 版）》

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

15 | 网络优化（上）：移动开发工程师必备的网络优化知识

专栏前面我们已经学习过文件 I/O 和存储优化，相信你已经掌握了文件 I/O 和存储的性能分析以及优化思路。今天我们就再接再厉，继续学习系统中另外一种常见的 I/O——网络 I/O。

我在写今天的文章时，回想了一下大学期间学的那本几百页厚的《计算机网络》，当时学得也是云里雾里，网络的确涉及了方方面面太多的知识。那我们作为移动开发者来说，都需要掌握哪些必备的网络知识呢？文件 I/O 跟网络 I/O 又有哪些差异呢？

今天我们不谈“经典巨著”，一起来解决移动开发工程师面对的网络问题。

网络基础知识

现在已经很难找到一款完全不需要网络的应用，即使是单机应用，也会存在数据上报、广告等各种各样的网络请求。既然网络已经无处不在，我们必须要掌握哪些基础知识呢？

1. 无线网络

在过去十年，移动互联网的高速增长离不开无线网络的普及。无线网络多种多样，而且各有各的特点，并且适合使用的场景也不同。

下图是 iPhone Xs 支持的无线网络类型，你可以看到 WiFi、蜂窝网络、蓝牙、NFC 这些都是我们日常经常使用的无线网络类型。

“千兆级 LTE”指的是蜂窝网络在理论上速度可以达到光纤级别的 1Gbps（125MB/s）。虽然基于 4G 标准，但通过MIMO（多输入多输出）、使用载波聚合的LAA等技术，现在已经发展到千兆级 LTE。2020 年我们也即将迎来 5G 的商用，它的理论传输速率可以达到 20Gbps。目前 5G 的标准还没有完全 release，关于 5G 的原理我推荐你看看这篇文章。

“802.11ac 无线网络”指的是我们经常使用的 WiFi。WiFi 由 IEEE 定义和进行标准化规范，跟任何流行的技术一样，IEEE 也一直在积极地发布新的协议。目前最常用的是802.11ac标准，它的理想速率可以达到 866.7Mbps。

从硬件维度上来看，所有的无线网络都通过基带芯片支持，目前高通在基带芯片领域占据了比较大的优势。之前由于苹果和高通的专利诉讼，iPhone Xs 选用了英特尔的基带芯片，但同时也出现大量的用户投诉网络连接异常。

市面上有那么多的无线网络标准和制式，还有双卡双待等各种特色功能，因此基带芯片对技术的要求非常高。随着未来 5G 的商用与普及，国内也会迎来新的一波换机潮。这对各大芯片厂商来说是机遇也是挑战，目前高通、MTK、华为都已经发布了 5G 基带芯片。如果你对当前的 5G 格局感兴趣，可以阅读《全世界 5G 格局》。

2. Link Turbo

像 5G 这种新的标准，可以极大地提升网络速度，但缺点是它需要新的基站设备和手机设备支持，这个过程起码需要几年的时间。

手机厂商为了提升用户的网络体验，也会做各种各样的定制优化，华为最近在荣耀 V20 推出的Link Turbo 网络聚合加速技术就是其中比较硬核的一种“黑科技”。

从硬件角度来说，WiFi 和蜂窝网络属于基带芯片的不同模块，我们可以简单的把它们理解为类似双网卡的情形。所谓的 Link Turbo 就是在使用 WiFi 的同时使用移动网络加速。

可能有人会疑惑，我都已经连接 WiFi 了，为什么还要使用收费的移动网络呢？有这个疑问的人肯定没有试过使用公司网络打“王者”团战卡成狗的情形，其实 WiFi 可能会因为下面的一些原因导致很不稳定。

事实上，双通道的技术也并不是华为首发。类似 iPhone 的无线网络助理、小米和一加的自适应 WLAN，它们都能在侦测到 WiFi 网络不稳定时，自动切换到移动网络。iPhone 在连接 WiFi 的时候，移动网络也是依然可以连接的。

而 Link Turbo 硬核的地方在于可以同时使用两条通道传输数据，而且支持 TCP 与 UDP。其中 TCP 支持使用的是开源的MultiPath TCP（iOS 7 也有引入），而 UDP 则是华为自研的 MultiPath UDP。

当然这个功能目前比较鸡肋，主要是由于一是覆盖的用户比较少，当前只有 V20 一台机器支持，而且还需要用户手动开启；二是改造成本，双通道需要我们的后台服务器也做一些改造才能支持。

但是这项技术还是有一定的价值，一方面流量越来越便宜，很多用户不再那么 care 流量资费的问题。另一方面华为可以直接跟阿里云、华为云、腾讯云以及 CDN 服务商合作，屏蔽应用后台服务器的改造成本。目前爱奇艺、斗鱼和映客这些应用都在尝试接入。

讲到这里你可能会问，为什么今天我会花这么多时间来讲 Link Turbo 技术？并不是因为我收了广告费，而是我发现很多时候在优化到一定程度之后，单靠应用本身很难再有大的突破。这个时候可能要考虑跟手机厂商、芯片厂商或者运营商合作，因此我们要随时关注行业的动态，并且清楚这些新技术背后的本质所在。

网络 I/O

在前面的专栏里，我讲了文件 I/O 的处理流程以及不同 I/O 方式的使用场景，今天我们再一起来看看网络 I/O 跟文件 I/O 有哪些差异。

1. I/O 模型

“一切皆文件”，Linux 内核会把所有外部设备都看作一个文件来操作。在网络 I/O 中系统对一个 Socket 的读写也会有相应的描述符，称为 socket fd（Socket 描述符）。

如下图以 Socket 读取数据 recvfrom 调用为例，它整个 I/O 流程分为两个阶段：

等待 Socket 数据准备好。

将数据从内核拷贝到应用进程中 。

在《UNIX 网络编程》中将 UNIX 网络 I/O 模型分为以下五种。

在开发过程中，比较常用的有阻塞 I/O、非阻塞 I/O 以及多路复用 I/O。关于 UNIX 网络 I/O 模型的更多资料，你可以参考《UNIX 网络编程》第六章、《聊聊 Linux 五种 I/O 模型》、《Unix 网络 I/O 模型及 Linux 的 I/O 多路复用模型》。

在查资料的时候我发现网上有很多文章的描述还是存在问题的，我们需要辩证地看。

多路复用 I/O 一定比阻塞 I/O 要好？跟文件 I/O 一样，最简单的 I/O 并发方式就是多线程 + 阻塞 I/O。如果我们同一时间活动的网络连接非常多，使用多路复用 I/O 性能的确更好。但是对于客户端来说，这个假设不一定成立。对于多路复用 I/O 来说，整个流程会增加大量的 select/epoll 这样的系统调用，不一定比阻塞 I/O 要快。

epoll 一定比 select/poll 要好？如果同一时间的连接数非常少的情况，select 的性能不会比 epoll，很多时候会比 epoll 更好。

epoll 使用了 mmap 减少内核到用户空间的拷贝？网上很多的文章都说 epoll 使用了 mmap 的技术，但是我查看了 Linux 与 Android 的epoll 实现，并没有找到相关的实现。而且我个人认为也不太可能会这样实现，因为直接共享内存可能会引发比较大的安全漏洞。

2. 数据处理

在下一期我还会跟你一起分析当前一些热门网络库的 I/O 模型，现在我们再往底层走走，看看底层收发包的流程是怎么样的。

跟文件 I/O 一样，网络 I/O 也使用了中断。不过网络 I/O 的中断会更加复杂一些，它同时使用了软中断和硬中断。通过硬中断通知 CPU 有数据来了，但这个处理会非常轻量。耗时的操作移到软中断处理函数里面来慢慢处理。其中查看系统软中断可以通过 /proc/softirqs 文件，查看硬中断可以通过 /proc/interrupts 文件。

关于网卡收发包的流程网上的资料不多，感兴趣的同学可以参考下面几篇文章：

网卡收包流程

Linux 网络 - 数据包的接收过程

Linux 网络 - 数据包的发送过程

Illustrated Guide to Monitoring and Tuning the Linux Networking Stack: Receiving Data

考虑到这块比较复杂，我在专栏里提供给你参考资料，有兴趣的同学可以进一步深入研究。

网络性能评估

我们常说的网络性能优化，通常都优化哪些方面呢？有的同学可能会关注网络的带宽和服务器成本，特别是直播、视频类的企业，这部分的成本非常高昂。虽然有的时候会做一些取舍，但是用户的访问速度与体验是所有应用的一致追求。

1. 延迟与带宽

如果说速度是关键，那对网络传输速度有决定性影响的主要有以下两个方面：

延迟：数据从信息源发送到目的地所需的时间。

带宽：逻辑或物理通信路径最大的吞吐量。

回想一下文件 I/O 性能评估似乎已经很复杂了，但是它至少整个流程都在手机系统内部。对于网络来说，整个流程涉及的链路就更加复杂了。一个数据包从手机出发要经过无线网络、核心网络以及外部网络（互联网），才能到达我们的服务器。

那延迟和带宽又跟什么因素有关呢？这里面涉及的因素也非常多，例如信号的强度、附近有没有基站、距离有多远等；还跟使用的网络制式，正在使用 3G、4G 还是 5G 网络有关，并且网络的拥塞情况也会产生影响，比如是不是在几万人聚集的大型活动场所等。

下面是不同网络制式的带宽和延迟的一般参考值，你可以在脑海里建立一个大致的印象。

当出现上面说到的那些因素时，网络访问的带宽要大打折扣，延迟会加倍放大。而高延迟、低带宽的网络场景也就是我们常说的“弱网络”，它主要特点有：

关于“弱网络”如何进行优化，我在微信时针对弱网络优化投入了大量的精力，这也是我在下一期所要讲的重点。不过我想说的是，即使未来 5G 普及了，但是各种各样的影响因素依然存在，弱网络优化这个课题是有长期存在的价值。

另外一个方面，不同的应用对延迟和带宽的侧重点可能不太一样。对于直播类应用或者“王者荣耀”来说，延迟会更重要一些；对腾讯视频、爱奇艺这种点播的应用来说，带宽会更加重要一些。网络优化需要结合自己应用的实际情况来综合考虑。

2. 性能测量

正如文件 I/O 测量一样，有哪些方法可以帮助我们评估网络的性能呢？

对于网络来说，我们关心的是下面这些指标：

吞吐量：网络接口接收和传输的每秒字节数。

延迟：系统调用发送 / 接收延时、连接延迟、首包延迟、网络往返时间等。

连接数：每秒的连接数。

错误：丢包计数、超时等。

Linux 提供了大量的网络性能分析工具，下面这些工具是 Android 支持并且比较实用的，这些工具完整的功能请参考文档或者网上其他资料，这里就不再赘述了。

如果你对 Linux 底层更加熟悉，可以直接查看 /proc/net，它里面包含了许多网络统计信息的文件。例如 Android 的TrafficState接口就是利用 /proc/net/xt_qtaguid/stats 和 /proc/net/xt_qtaguid/iface_stat_fmt 文件来统计应用的流量信息。

总结

从网络通信发展的历程来说，从 2G 到 4G 经历了十几年的时间，这背后离不开几百万个基站、几亿个路由器以及各种各样的专利支持。虽然网络标准不停地演进，不过受限于基建，它的速度看起来很快，但是又很慢。

那对我们自己或者应用会有哪些思考呢？HTTP 2.0、HTTP 3.0（QUIC）等网络技术一直在向前演进，我们需要坚持不懈地学习，思考它们对我们可以产生哪些影响，这是对网络“快”的思考。TCP 和 UDP 协议、弱网络等很多东西在这二十多年来依然没有太大的改变，网络的基础知识对我们来说还是非常重要的，这是对网络“慢”的思考。

课后作业

在讲 Link Turbo 的时候我说过，iPhone 的无线网络助理、小米和一加的自适应 WLAN 它们在检测 WiFi 不稳定时会自动切换到移动网络。那请你思考一下，它们是如何实现侦测，如何区分是应用后台服务器出问题还是 WiFi 本身有问题呢？今天的作业是在留言区写写你对这个问题的看法，欢迎留言跟我和其他同学一起讨论。

今天我推荐一本必读的网络书籍：《Web 性能权威指南》，它里面第一句话就讲得非常好，我把它分享给你：“合格的开发者知道怎么做，而优秀的开发者知道为什么那么做”。

对于想进一步深入研究的同学，你可以研读这些书籍：

《UNIX 网络编程》

《TCP/IP 详解 卷 1：协议》

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

16 | 网络优化（中）：复杂多变的移动网络该如何优化？

在 PC 互联网时代，网络优化已经是一项非常复杂的工作。对于移动网络来说，弱网络、网络切换、网络劫持这些问题更加突出，网络优化这项工作也变得更加艰巨。

那作为一名移动开发者，面对复杂多变的移动网络我们该如何去优化呢？可能也有人会说，我只要用好 AFNetworking/OkHttp 这些成熟网络库就可以了，并不需要额外去做什么优化。那你确定你真的能用好这些网络库吗？它们内部是怎样实现的、有哪些差异点、哪个网络库更好呢？

虽然我们可能只是客户端 App 开发人员，但在关于网络优化还是可以做很多事情的，很多大型的应用也做了很多的实践。今天我们一起来看一下，如何让我们的应用在各种的网络条件下都能“快人一步”。

移动端优化

回想上一期我给出的网络架构图，一个数据包从手机出发要经过无线网络、核心网络以及外部网络（互联网），才能到达我们的服务器。那整个网络请求的速度会跟哪些因素有关呢？

从上面这张图上看，客户端网络库实现、服务器性能以及网络链路的质量都是影响网络请求速度的因素。下面我们先从客户端的网络库说过，看看应该如何进行网络优化。

1. 何为网络优化

在讲怎么去优化网络之前，我想先明确一下所谓的网络优化，究竟指的是什么？在我看来，核心内容有以下三个：

速度。在网络正常或者良好的时候，怎样更好地利用带宽，进一步提升网络请求速度。

弱网络。移动端网络复杂多变，在出现网络连接不稳定的时候，怎样最大程度保证网络的连通性。

安全。网络安全不容忽视，怎样有效防止被第三方劫持、窃听甚至篡改。

除了这三个问题，我们可能还会关心网络请求造成的耗电、流量问题，这两块内容我们在后面会统一地讲，今天就不再展开。

那对于速度、弱网络以及安全的优化，又该从哪些方面入手呢？首先你需要先搞清楚一个网络请求的整个过程。

从图上看到，整个网络请求主要分为几个步骤，而整个请求的耗时可以细分到每一个步骤里面。

DNS 解析。通过 DNS 服务器，拿到对应域名的 IP 地址。在这个步骤，我们比较关注 DNS 解析耗时情况、运营商 LocalDNS 的劫持、DNS 调度这些问题。

创建连接。跟服务器建立连接，这里包括 TCP 三次握手、TLS 密钥协商等工作。多个 IP/ 端口该如何选择、是否要使用 HTTPS、能否可以减少甚至省下创建连接的时间，这些问题都是我们优化的关键。

发送 / 接收数据。在成功建立连接之后，就可以愉快地跟服务器交互，进行组装数据、发送数据、接收数据、解析数据。我们关注的是，如何根据网络状况将带宽利用好，怎么样快速地侦测到网络延时，在弱网络下如何调整包大小等问题。

关闭连接。连接的关闭看起来非常简单，其实这里的水也很深。这里主要关注主动关闭和被动关闭两种情况，一般我们都希望客户端可以主动关闭连接。

所谓的网络优化，就是围绕速度、弱网络、安全这三个核心内容，减少每一个步骤的耗时，打造快速、稳定且安全的高质量网络。

2. 何为网络库

在实际的开发工作中，我们很少会像《UNIX 网络编程》那样直接去操作底层的网络接口，一般都会使用网络库。Square 出品的OkHttp是目前最流行的 Android 网络库，它还被 Google 加入到 Android 系统内部，为广大开发者提供网络服务。

那网络库究竟承担着一个什么样的角色呢？在我看来，它屏蔽了下层复杂的网络接口，让我们可以更高效地使用网络请求。

如上图所示，一个网络库的核心作用主要有以下三点：

统一编程接口。无论是同步还是异步请求，接口都非常简单易用。同时我们可以统一做策略管理，统一进行流解析（JSON、XML、Protocol Buffers）等。

全局网络控制。在网络库内部我们可以做统一的网络调度、流量监控以及容灾管理等工作。

高性能。既然我们把所有的网络请求都交给了网络库，那网络库是否实现高性能就至关重要。既然要实现高性能，那我会非常关注速度，CPU、内存、I/O 的使用，以及失败率、崩溃率、协议的兼容性等方面。

不同的网络库实现差别很大，比较关键有这几个模块：

那网络库实现到底哪家强？接下来我们一起来对比 OkHttp、Chromium 的Cronet以及微信Mars这三个网络库的内部实现。

3. 高质量网络库

据我了解业内的蘑菇街、头条、UC 浏览器都在 Chromium 网络库上做了二次开发，而微信 Mars 在弱网络方面做了大量优化，拼多多、虎牙、链家、美丽说这些应用都在使用 Mars。

下面我们一起来对比一下各个网络库的核心实现。对于参与网络库相关工作来说，我的经验还算是比较丰富的。在微信的时候曾经参与过 Mars 的开发，目前也在基于 Chromium 网络库做二次开发。

为什么我从来没使用过 OkHttp？主要因为它并不支持跨平台，对于大型应用来说跨平台是非常重要的。我们不希望所有的优化 Android 和 iOS 都要各自去实现一套，不仅浪费人力而且还容易出问题。

对于 Mars 来说，它是一个跨平台的 Socket 层解决方案，并不支持完整的 HTTP 协议，所以 Mars 从严格意义上来讲并不是一个完整的网络库。但是它在弱网络和连接上做了大量的优化，并且支持长连接。关于 Mars 的网络多优化的更多细节，你可以参考Wiki右侧的文章列表。

Chromium 网络库作为标准的网络库，基本上可以说是找不到太大的缺点。而且我们可以享受 Google 后续网络优化的成果，类似 TLS 1.3、QUIC 支持等。

但是它针对弱网络场景没有做太多定制的优化，也不支持长连接。事实上目前我在 Chromium 网络库的二次开发主要工作也是补齐弱网络优化与长连接这两个短板。

大网络平台

对于大公司来说，我们不能只局限在客户端网络库的双端统一上。网络优化不仅仅是客户端的事情，所以我们有了统一的网络中台，它负责提供前后台一整套的网络解决方案。

阿里的ACCS、蚂蚁的mPaaS、携程的网络服务都是公司级的网络中台服务，这样所有的网络优化可以让整个集团的所有接入应用受益。

下图是 mPaaS 的网络架构图，所有网络请求都会先经过统一的接入层，再转发到业务服务器。这样我们可以在业务服务器无感知的情况下，在接入层做各种各样的网络优化。

1. HTTPDNS

DNS 的解析是我们网络请求的第一项工作，默认我们使用运营商的 LocalDNS 服务。这块耗时在 3G 网络下可能是 200～300ms，4G 网络也需要 100ms。

解析慢并不是默认 LocalDNS 最大的“原罪”，它还存在一些其他问题：

稳定性。UDP 协议，无状态，容易域名劫持（难复现、难定位、难解决），每天至少几百万个域名被劫持，一年至少十次大规模事件。

准确性。LocalDNS 调度经常出现不准确，比如北京的用户调度到广东 IP，移动的运营商调度到电信的 IP，跨运营商调度会导致访问慢，甚至访问不了。

及时性。运营商可能会修改 DNS 的 TTL，导致 DNS 修改生效延迟。不同运营商的服务实现不一致，我们也很难保证 DNS 解析的耗时。

为了解决这些问题，就有了 HTTPDNS。简单来说自己做域名解析的工作，通过 HTTP 请求后台去拿到域名对应的 IP 地址，直接解决上述所有问题。

微信有自己部署的 NEWDNS，阿里云和腾讯云也有提供自己的 HTTPDNS 服务。对于大网络平台来说，我们会有统一的 HTTPDNS 服务，并将它和运维系统打通。在传统的 DNS 基础上，还会增加精准的流量调度、网络拨测 / 灰度、网络容灾等功能。

关于 HTTPDNS 的更多知识，你可以参考百度的《DNS 优化》。对客户端来说，我们可以通过预请求的方法，提前拿到一批域名的 IP，不过这里需要注意 IPv4 与 IPv6 协议栈的选择问题。

2. 连接复用

在 DNS 解析之后，我们来到了创建连接这个环节。创建连接要经过 TCP 三次握手、TLS 密钥协商，连接建立的代价是非常大的。这里我们主要的优化思路是复用连接，这样不用每次请求都重新建立连接。

在前面我就讲过连接管理，网络库并不会立刻把连接释放，而是放到连接池中。这时如果有另一个请求的域名和端口是一样的，就直接拿出连接池中的连接进行发送和接收数据，少了建立连接的耗时。

这里我们利用 HTTP 协议里的 keep-alive，而 HTTP/2.0 的多路复用则可以进一步的提升连接复用率。它复用的这条连接支持同时处理多条请求，所有请求都可以并发在这条连接上进行。

虽然 H2 十分强大，不过这里还有两个问题需要解决。一个是同一条 H2 连接只支持同一个域名，一个是后端支持 HTTP/2.0 需要额外的改造。这个时候我们只需要在统一接入层做改造，接入层将数据转换到 HTTP/1.1 再转发到对应域名的服务器。

这样所有的服务都不用做任何改造就可以享受 HTTP/2.0 的所有优化，不过这里需要注意的是 H2 的多路复用在本质上依然是同一条 TCP 连接，如果所有的域名的请求都集中在某一条连接中，在网络拥塞的时候容易出现 TCP 队首阻塞问题。

对于客户端网络库来说，无论 OkHttp 还是 Chromium 网络库对于 HTTP/2.0 的连接，同一个域名只会保留一条连接。对于一些第三方请求，特别是文件下载以及视频播放这些场景可能会遇到对方服务器单连接限速的问题。在这种情况下我们可以通过修改网络库实现，也可以简单的通过禁用 HTTP/2.0 协议解决。

3. 压缩与加密

压缩

讲完连接，我们再来看看发送和接收的优化。我第一时间想到的还是减少传输的数据量，也就是我们常说的数据压缩。首先对于 HTTP 请求来说，数据主要包括三个部分：

请求 URL

请求 header

请求 body

对于 header 来说，如果使用 HTTP/2.0 连接本身的头部压缩技术，因此需要压缩的主要是请求 URL 和请求 body。

对于请求 URL 来说，一般会带很多的公共参数，这些参数大部分都是不变的。这样不变的参数客户端只需要上传一次即可，其他请求我们可以在接入层中进行参数扩展。

对于请求 body 来说，一方面是数据通信协议的选择，在网络传输中目前最流行的两种数据序列化方式是 JSON 和 Protocol Buffers。正如我之前所说的一样，Protocol Buffers 使用起来更加复杂一些，但在数据压缩率、序列化与反序列化速度上面都有很大的优势。

另外一方面是压缩算法的选择，通用的压缩算法主要是如 gzip，Google 的Brotli或者 Facebook 的Z-standard都是压缩率更高的算法。其中如果 Z-standard 通过业务数据样本训练出适合的字典，是目前压缩率表现最好的算法。但是各个业务维护字典的成本比较大，这个时候我们的大网络平台的统一接入层又可以大显神威了。

例如我们可以抽样 1% 的请求数据用来训练字典，字典的下发与更新都由统一接入层负责，业务并不需要关心。

当然针对特定数据我们还有其他的压缩方法，例如针对图片我们可以使用 webp、hevc、SharpP等压缩率更高的格式。另外一方面，基于 AI 的图片超清化也是一大神器，QQ 空间通过这个技术节约了大量的带宽成本。

安全

数据安全也是网络重中之重的一个环节，在大网络平台中我们都是基于 HTTPS 的 HTTP/2 通道，已经有了 TLS 加密。如果大家不熟悉 TLS 的基础知识，可以参考微信后台一个小伙伴写的《TLS 协议分析》。

但是 HTTPS 带来的代价也是不小的，它需要 2-RTT 的协商成本，在弱网络下时延不可接受。同时后台服务解密的成本也十分高昂，在大型企业中需要单独的集群来做这个事情。

HTTPS 的优化有下面几个思路：

连接复用率。通过多个域名共用同一个 HTTP/2 连接、长连接等方式提升连接复用率。

减少握手次数。TLS 1.3可以实现 0-RTT 协商，事实上在 TLS 1.3 release 之前，微信的mmtls、Facebook 的fizz、阿里的 SlightSSL 都已在企业内部大规模部署。

性能提升。使用 ecc 证书代替 RSA，服务端签名的性能可以提升 4～10 倍，但是客户端校验性能降低了约 20 倍，从 10 微秒级降低到 100 微秒级。另外一方面可以通过 Session Ticket 会话复用，节省一个 RTT 耗时。

使用 HTTPS 之后，整个通道是不是就一定高枕无忧呢？如果客户端设置了代理，TLS 加密的数据可以被解开并可能被利用 。这个时候我们可以在客户端将“证书锁定”（Certificate Pinning），为了老版本兼容和证书替换的灵活性，建议锁定根证书。

我们也可以对传输内容做二次加密，这块在统一接入层实现，业务服务器也同样无需关心这个流程。需要注意的是二次加密会增加客户端与服务器的处理耗时，我们需要在安全性与性能之间做一个取舍。

4. 其他优化

关于网络优化的手段还有很多，一些方案可能是需要用钱堆出来的，比如部署跨国的专线、加速点，多 IDC 就近接入等。

除此之外，使用CDN 服务、P2P 技术也是比较常用的手段，特别在直播这类场景。总的来说，网络优化我们需要综合用户体验、带宽成本以及硬件成本等多个因素来考虑。

下面为你献上一张高质量网络的全景大图。

QUIC 与 IPv6

今天已经讲得很多了，可能还有小伙伴比较关心最近一些比较前沿的技术，我简单讲一下 QUIC 和 IPv6。

1. QUIC

QUIC 协议由 Google 在 2013 年实现，在 2018 年基于 QUIC 协议的 HTTP 更被确认为HTTP/3。在连接复用中我说过 HTTP/2 + TCP 会存在队首阻塞的问题，基于 UDP 的 QUIC 才是终极解决方案。

如下图所示，你可以把 QUIC 简单理解为 HTTP/2.0 + TLS 1.3 + UDP。

事实上，它还有着其他的很多优势：

灵活控制拥塞协议。如果想对 TCP 内部的拥塞控制算法等模块进行优化和升级，整体周期是相对较长的。对于 UDP 来说，我们不需要操作系统支持，随时可改，例如可以直接使用 Google 的BBR 算法。

“真”连接复用。不仅解决了队首阻塞的问题，在客户端网络切换的时候也不需要重连，用户使用 App 的体验会更加流畅。

既然 QUIC 那么好，为什么我们在生产环境没有全部切换成 QUIC 呢？那是因为有很多坑还没有踩完，目前发现的主要问题还有：

创建连接成功率。主要是 UDP 的穿透性问题，NAT 局域网路由、交换机、防火墙等会禁止 UDP 443 同行，目前 QUIC 在国内建连的成功率大约在 95% 左右。

运营商支持。运营商针对 UDP 通道支持不足，表现也不稳定。例如 QoS 限速丢包，有些小的运营商甚至还直接不支持 UDP 包。

尽管有这样那样的问题，但是 QUIC 一定是未来。当然，通过大网络平台的统一接入层，我们业务基本无需做什么修改。目前据我了解，腾讯、微博、阿里都在内部逐步加大 QUIC 的流量，具体细节可以参考我给出的链接。

2. IPv6

运维人员都会深深的感觉到 IP 资源的珍贵，而致力于解决这个问题的 IPv6 却在中国一直非常沉寂。根据《2017 年 IPV6 支持度报告》，在中国只有 0.38% 的用户使用 v6。

IPv6 不仅针对 IoT 技术，对万物互联的时代有着非常大的意义。而且它对网络性能也有正向的作用，在印度经过我们测试，使用 IPv6 网络相比 IPv4 连接耗时可以降低 10%～20%。推行 IPv6 后，无穷无尽的 IP 地址意味着可以告别各种 NAT，P2P、QUIC 的连接也不再是问题。

在过去的一年，无论是阿里云还是腾讯云都做了大量 IPv6 的工作。当然主要也是接入层的改造，尽量不需要业务服务做太多修改。

总结

移动技术发展到今天，跨终端和跨技术栈的联合优化会变得越来越普遍。有的时候我们需要跳出客户端开发的视角，从更高的维度去思考整个大网络平台。当然网络优化的水还是非常深的，有时候我们需要对协议层也有比较深入的研究，也要经常关注国外的一些新的研究成果。

2018 年随着工信部发布《推进互联网协议第六版（IPv6）规模部署行动计划》的通知，所有的云提供商需要在 2020 年完成 IPv6 的支持。QUIC 在 2018 年被定为 HTTP/3 草案，同时 3GPP 也将 QUIC 列入 5G 核心网协议第二阶段标准（3GPP Release 16）。

随着 5G、QUIC 与 IPv6 未来在中国的普及，网络优化永不止步，它们将推动我们继续努力去做更多尝试，让用户可以有更好的网络体验。

课后作业

你的应用使用的是哪个网络库？对于网络优化，你还有哪些实践经验？欢迎留言跟我和其他同学一起讨论。

网络优化是一个很大的话题，在课后你还需要进一步扩展学习。除了今天文章里给出的链接，这里还提供一些参考资料给你：

微信客户端怎样应对弱网络

阿里亿级日活网关通道架构演进

阿里巴巴 HTTP 2.0 实践及无线通信协议的演进之路

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

17 | 网络优化（下）：大数据下网络该如何监控？

通过上一期的学习，我们对如何打造一个高质量的网络已经有了一个整体的认识。但是这就足够了吗？回想一下，一个网络请求从手机到后台服务器，会涉及基站、光纤、路由器这些硬件设施，也会跟运营商和服务器机房有关。

不论是基站故障、光纤被挖断、运营商挟持，还是我们的机房、CDN 服务商出现故障，都有可能会引起用户网络出现问题。你有没有感觉线上经常突发各种千奇百怪的网络问题，很多公司的运维人员每天过得胆战心惊、疲于奔命。

“善良”的故障过了一段时间之后莫名其妙就好了，“顽固”的故障难以定位也难以解决。这些故障究竟是如何产生的？为什么突然就恢复了？它们影响了多少用户、哪些用户？想要解决这些问题离不开高质量的网络，而高质量的网络又离不开强大的监控。今天我们就一起来看看网络该如何监控吧。

移动端监控

对于移动端来说，我们可能会有各种各样的网络请求。即使使用了 OkHttp 网络库，也可能会有一些开发人员或者第三方组件使用了系统的网络库。那应该如何统一的监控客户端的所有的网络请求呢？

1. 如何监控网络

第一种方法：插桩。

为了兼容性考虑，我首先想到的还是插桩。360 开源的性能监控工具ArgusAPM就是利用 Aspect 切换插桩，实现监控系统和 OkHttp 网络库的请求。

系统网络库的插桩实现可以参考TraceNetTrafficMonitor，主要利用Aspect的切面功能，关于 OkHttp 的拦截可以参考OkHttp3Aspect，它会更加简单一些，因为 OkHttp 本身就有代理机制。

@Pointcut("call(public okhttp3.OkHttpClient build())")

public void build() {

}

@Around("build()")

public Object aroundBuild(ProceedingJoinPoint joinPoint) throws Throwable {

    Object target = joinPoint.getTarget();

    if (target instanceof OkHttpClient.Builder &&    Client.isTaskRunning(ApmTask.TASK_NET)) {

        OkHttpClient.Builder builder = (OkHttpClient.Builder) target;

        builder.addInterceptor(new NetWorkInterceptor());

    }

    return joinPoint.proceed();

}

插桩的方法看起来很好，但是并不全面。如果使用的不是系统和 OkHttp 网络库，又或者使用了 Native 代码的网络请求，都无法监控到。

第二种方法：Native Hook。

跟 I/O 监控一样，这个时候我们想到了强大的 Native Hook。网络相关的我们一般会 Hook 下面几个方法 ：

连接相关：connect。

发送数据相关：send 和 sendto。

接收数据相关：recv 和 recvfrom。

Android 在不同版本 Socket 的逻辑会有那么一些差异，以 Android 7.0 为例，Socket 建连的堆栈如下：

java.net.PlainSocketImpl.socketConnect(Native Method)

java.net.AbstractPlainSocketImpl.doConnect

java.net.AbstractPlainSocketImpl.connectToAddress

java.net.AbstractPlainSocketImpl.connect

java.net.SocksSocketImpl.connect

java.net.Socket.connect

com.android.okhttp.internal.Platform.connectSocket

com.android.okhttp.Connection.connectSocket

com.android.okhttp.Connection.connect

“socketConnect”方法对应的 Native 方法定义在PlainSocketImpl.c，查看makefile可以知道它们会编译在 libopenjdk.so 中。不过在 Android 8.0，整个调用流程又完全改变了。为了兼容性考虑，我们直接 PLT Hook 内存的所有 so，但是需要排除掉 Socket 函数本身所在的 libc.so。

hook_plt_method_all_lib("libc.so", "connect", (hook_func) &create_hook);

hook_plt_method_all_lib("libc.so, "send", (hook_func) &send_hook);

hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &recvfrom_hook);

...

这种做法不好的地方在于会把系统的 Local Socket 也同时接管了，需要在代码中增加过滤条件。在今天的 Sample 中，我给你提供了一套简单的实现。其实无论是哪一种 Hook，如果熟练掌握之后你会发现它并不困难。我们需要耐心地寻找，梳理清楚整个调用流程。

第三种方法：统一网络库。

尽管拿到了所有的网络调用，想想会有哪些使用场景呢？模拟网络数据、统计应用流量，或者是单独代理 WebView 的网络请求。

一般来说，我们不会非常关心第三方的网络请求情况，而对于我们应用自身的网络请求，最好的监控方法还是统一网络库。不过我们可以通过插桩和 Hook 这两个方法，监控应用中有哪些地方使用了其他的网络库，而不是默认的统一网络库。

在上一期内容中，我说过“网络质量监控”应该是客户端网络库中一个非常重要的模块，它也会跟大网络平台的接入服务共同协作。通过统一网络库的方式，的确无法监控到第三方的网络请求。不过我们可以通过其他方式拿到应用的整体流量使用情况，下面我们一起来看看。

2. 如何监控流量

应用流量监控的方法非常简单，一般通过 TrafficStats 类。TrafficState 是 Android API 8 加入的接口，用于获取整个手机或者某个 UID 从开机算起的网络流量。至于如何使用，你可以参考 Facebook 一个历史比较久远的开源库network-connection-class。

getMobileRxBytes()        // 从开机开始 Mobile 网络接收的字节总数，不包括 Wifi

getTotalRxBytes()         // 从开机开始所有网络接收的字节总数，包括 Wifi

getMobileTxBytes()        // 从开机开始 Mobile 网络发送的字节总数，不包括 Wifi

getTotalTxBytes()         // 从开机开始所有网络发送的字节总数，包括 Wifi

它的实现原理其实也非常简单，就是利用 Linux 内核的统计接口。具体来说，是下面两个 proc 接口。

// stats 接口提供各个 uid 在各个网络接口（wlan0, ppp0 等）的流量信息

/proc/net/xt_qtaguid/stats

// iface_stat_fmt 接口提供各个接口的汇总流量信息

proc/net/xt_qtaguid/iface_stat_fmt

TrafficStats 的工作原理是读取 proc，并将目标 UID 下面所有网络接口的流量相加。但如果我们不使用 TrafficStats 接口，而是自己解析 proc 文件呢？那我们可以得到不同网络接口下的流量，从而计算出 WiFi、2G/3G/4G、VPN、热点共享、WiFi P2P 等不同网络状态下的流量。

不过非常遗憾的是，Android 7.0 之后系统已经不让我们直接去读取 stats 文件，防止开发者可以拿到其他应用的流量信息，因此只能通过 TrafficStats 拿到自己应用的流量信息。

除了流量信息，通过 /proc/net 我们还可以拿到大量网络相关的信息，例如网络信号强度、电平强度等。Android 手机跟 iPhone 都有一个网络测试模式，感兴趣的同学可以尝试一下。

iPhone：打开拨号界面，输入“*3001#12345#*”，然后按拨号键。

Android 手机：打开拨号界面，输入“*#*#4636#*#*”，然后按拨号键（可进入工程测试模式，部分版本可能不支持）。

为什么系统可以判断此时的 WiFi“已连接，但无法访问互联网”？回想一下专栏第 15 期我给你留的课后作业：

iPhone 的无线网络助理、小米和一加的自适应 WLAN 它们在检测 WiFi 不稳定时会自动切换到移动网络。那请你思考一下，它们是如何实现侦测，如何区分是应用后台服务器出问题还是 WiFi 本身有问题呢？

我看了一下同学们的回复，大部分同学认为需要访问一个公网 IP 的方式。其实对于手机厂商来说根据不需要，它在底层可以拿到的信息有很多。

网卡驱动层信息。如射频参数，可以用来判断 WiFi 的信号强度；网卡数据包队列长度，可以用来判断网络是否拥塞。

协议栈信息。主要是获取数据包发送、接收、时延和丢包等信息。

如果一个 WiFi 发送过数据包，但是没有收到任何的 ACK 回包，这个时候就可以初步判断当前的 WiFi 是有问题的。这样系统可以知道当前 WiFi 大概率是有问题的，它并不关心是不是因为我们后台服务器出问题导致的。

大网络平台监控

前面我讲了一些应用网络请求和流量的监控方法，但是还没真正回答应该如何去打造一套强大的网络监控体系。跟网络优化一样，网络监控不是客户端可以单独完成的，它也是整个大网络平台的一个重要组成部分。

不过首先我们需要在客观上承认这件事情做起来并不容易，因为网络问题会存在下面这些特点：

实时性。部分网络问题过时不候，可能很快就丢失现场。

复杂性。可能跟国家、地区、运营商、版本、系统、机型、CDN 都有关，不仅维度多，数据量也巨大。

链路长。整个请求链条非常长，客户端故障、网链障络、服务故障都有可能。

因此所谓的网络监控，并不能保证可以明确找到故障的原因。而我们目标是希望快速发现问题，尽可能拿到更多的辅助信息，协助我们更容易地排查问题。

下面我分别从客户端与接入层的角度出发，一起来看看哪些信息可以帮助我们更好地发现问题和解决问题。

1. 客户端监控

客户端的监控使用统网络库的方式，你可以想想我们需要关心哪些内容：

时延。一般我们比较关心每次请求的 DNS 时间、建连时间、首包时间、总时间等，会有类似 1 秒快开率、2 秒快开率这些指标。

维度。网络类型、国家、省份、城市、运营商、系统、客户端版本、机型、请求域名等，这些维度主要用于分析问题。

错误。DNS 失败、连接失败、超时、返回错误码等，会有 DNS 失败率、连接失败率、网络访问的失败率这些指标。

通过这些数据，我们也可以汇总出应用的网络访问大图。例如在国内无论我们去到哪里都会问有没有 WiFi，WiFi 的占比会超过 50%。这其实远远比海外高，在印度 WiFi 的占比仅仅只有 15% 左右。

同样的我们分版本、分国家、分运营商、分域名等各种各样的维度，来监控我们的时延和错误这些访问指标。

由于维度太多，每个维度的取值范围也很广，如果是实时计算整个数据量会非常非常大。对于客户端的上报数据，微信可以做到分钟级别的监控报警。不过为了运算简单我们会抛弃 UV，只计算每一分钟部分维度的 PV。

2. 接入层监控

客户端监控的数据会比接入层更加丰富，因为有可能会出现部分数据还没到达接入层就已经被打回，例如运营商劫持的情况。

但是接入层的数据监控还是非常有必要的，主要的原因是：

实时性。客户端如果使用秒级的实时上报，对用户性能影响会比较大。服务端就不会存在这个问题，它很容易可以做到秒级的监控。

可靠性。如果出现某些网络问题，客户端的数据上报通道可能也会受到影响，客户端的数据不完全可靠。

那接入层应该关心哪些数据呢？一般来说，我们会比较关心服务的入口和出口流量、服务端的处理时延、错误率等。

3. 监控报警

无论是客户端还是接入层的监控，它们都是分层的。

实时监控。秒级或者分钟级别的实时监控的信息会相比少一些，例如只有访问量（PV）、错误率，没有去拆分几百个上千个维度，也没有独立访问用户数（UV），实时监控的目的是最快速度发现问题。

离线监控。小时或者天级别的监控我们可以拓展出全部的维度来做监控，它的目的是在监控的同时，可以更好地圈出问题的范围。

下面是一个简单根据客户端、国家以及运营商维度分析的示例。当然更多的时候是某一个服务出现问题，这个时候通过分域名或者错误码就可以很容易的找到原因。

那在监控的同时如何实现准确的自动化报警呢？这同样也是业界的一个难题，它的难度在于如果规则过于苛刻，可能会出现漏报；如果过于宽松，可能会出现太多的误报。

业界一般存在两种报警的算法，一套是基于规则，例如失败率与历史数据相比暴涨、流量暴跌等。另一种是基于时间序列算法或者神经网络的智能化报警，使用者不需要录入任何规则，只需有足够长的历史数据，就可以实现自动报警。智能化报警目前准确性也存在一些问题，在智能化基础上面添加少量规则可能会是更好的选择。

如果我们收到一个线上的网络报警，通过接入层和客户端的监控报表，也会有了一个大致的判断。那怎么样才能确定问题的最终原因？我们是否可以拿到用户完整的网络日志？甚至远程地诊断用户的网络情况？关于“网络日志和远程诊断，如何快速定位网络问题”，我会把它单独成篇放在专栏第二模块里，再来讲讲这个话题。

总结

监控、监控又是监控，很多性能优化工作其实都是“三分靠优化，七分靠监控”。

为什么监控这么重要呢？对于大公司来说，每一个项目参与人员可能成百上千人。并且大公司要的不是今天或者这个版本可以做好一些事情，而是希望保证每天每个版本都能持续保持应用的高质量。另一方面有了完善的分析和监控的平台，我们可以把复杂的事情简单化，把一些看起来“高不可攀”的优化工作，变成人人都可以做。

最后多谈两句我的感受，我们在工作的时候，希望你可以看得更远，从更高的角度去思考问题。多想想如果我能做好这件事情，怎么保证其他人不会犯错，或者让所有人都可以做得更好。

课后作业

对于网络问题，你尝试过哪些监控方法？有没有令你印象深刻的网络故障，最终又是通过什么方式解决的呢？欢迎留言跟我和其他同学一起讨论。

今天我们练习的Sample是通过 PLT Hook，代理 Socket 相关的几个重要函数，这次还增加了一个一次性 Hook 所有已经加载 Library 的方法。

int hook_plt_method_all_lib(const char* exclueLibname, const char* name, hook_func hook) {

  if (refresh_shared_libs()) {

    // Could not properly refresh the cache of shared library data

    return -1;

  }

  int failures = 0;

  for (auto const& lib : allSharedLibs()) {

      if (strcmp(lib.first.c_str(), exclueLibname) != 0) {

        failures += hook_plt_method(lib.first.c_str(), name, hook);

      }

  }

  return failures;

}

希望你通过这几次课后练习，可以学会将 Hook 技术应用到实践当中。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

18 | 耗电优化（上）：从电量优化的演进看耗电分析

曾经有一句笑话说的是“用 Android 手机的男人一定是个好男人，因为他每天必须回家充电，有时候还得 1 天 2 次”。

我们现在工作和生活都离不开手机，但是却很难找到一款可以完全信赖、可以使用一整天的手机。在十年前的功能机时代，诺基亚可以做到十几天的超长待机。而现在的智能机时代，7nm 的 CPU、8GB 内存、512GB 的闪存，硬件一直在飞速发展，为什么电池的发展就不适用摩尔定律，电池技术一直没有突破性的进展呢？

功耗是手机厂商一直都非常重视的，OPPO 更是直接以“充电 5 分钟，通话 2 小时”作为卖点。省电优化也是每年 Google I/O 必讲的内容，那么 Android 系统都为省电做了哪些努力呢？我们可以怎么样衡量应用的耗电呢？

耗电的背景知识

回顾一下专栏前面的内容，我已经讲过内存、CPU、存储和网络这几块内容了。LPDDR5 内存、7nm CPU、UFS 3.0 闪存、5G 芯片，硬件一直以“更快、更小”的目标向前飞速发展。

但是手机上有一个重要部件多年来都没有革命性的突破，被我们吐槽也最多，那就是电池。智能手机的发展就像木桶原理一样，扼住智能手机发展咽喉的终究还是电池。

电池技术有哪些重要的评判标准？电池技术这些年究竟又有哪些进展？下面我们一起来聊聊手机电池的知识。

1. 电池技术

我们先看看苹果和华为这两大巨头最新旗舰机的表现。苹果的 iPhone XS Max 内置锂离子充电电池，电池容量为 3174mAh，30 分钟最多可充至 50% 电量。

华为 Mate 20 Pro 升级到 4200mAh 高度大容量锂离子电池，并首次搭载 40W 华为超级快充技术，30 分钟充电约 70%，还有 15W 高功率无线快充和反向无线充电“黑科技”。而 Mate 20 X 更是把电池容量升级到 5000mAh，还创造性地将石墨烯技术应用到智能手机中。

从上面两款旗舰机的电池介绍中，我们可以发现手机电池的一些关键指标。

电池容量。更大的电池容量意味着更长的续航时间，我们可以通过增加电池的体积或者密度来达到这个效果。智能手机的大部分空间都贡献给电池了，以华为 Mate 20 为例，电池占了所有内部组件中 48% 的空间，电池容量制约了手机迈向更轻、更薄。

充电时间。如果电池容量不是那么容易突破，那只能曲线救国考虑如何用更短的时间把电池充满。这里就需要依靠快充技术了，OPPO“充电 5 分钟，通话 2 小时”指的是VOOC 闪充技术。快充技术无非是增大电流或者电压，目前主要分为两大解决方案，一个是高压低电流快充方案，另一个是低压大电流快充方案。关于快充技术的盘点，你可以参考这篇文章。

寿命。电池寿命一般使用充电循环次数来衡量，一次充电循环表示充满电池全部电量，但是并不要求一次性完成。例如在之前电池充到了 25%，如果再充 75%，两次组合在一起算是一次充电周期。去年苹果因为“降速门”面临了多起诉讼，通过处理器限速来解决续航不足的问题。根据苹果官方数据，500 次充电循环 iPhone 电池剩余容量为原来的 80%。

安全性。手机作为用户随时携带的物品，安全性才是首要考虑的因素。特别是从三星 Note 7 爆炸以来，各大手机厂商都在电池容量方面更加保守。所以无论是电池的密度，还是快充技术，我们首要保证的都是安全性。

从历史久远的镍铬、镍氢，到现在普遍使用的锂离子电池，还是被称为革命性技术的石墨烯电池，虽然达不到摩尔定律，但电池技术其实也在不停地发展，感兴趣的同学可以参考《手机电池技术进步》。

事实上 Mate 20 X 只是使用石墨烯技术用于散热系统，并不是真正意义上的石墨烯电池。根据最新的研究成果表明，使用石墨烯材料可以让电池容量增加 45%，充电速度可以加快 5 倍，循环寿命更高达 3500 次左右。可能在未来，12 分钟就能把我们的手机电池充满，如果能够实现普及的话，将是电池发展史上的一个重要里程碑。

2. 电量和硬件

1000mAh 的功能机我们可以使用好几天，为什么 5000mAh 的智能机我们需要每天充电？这是因为我们现在的手机需要视频通话，需要打“王者”“吃鸡”，硬件设备的种类和性能早就不可同日而语。

但是“王者”“吃鸡”等应用程序不会直接去消耗电池，而是通过使用硬件模块消耗相应的电能，下图是手机中一些比较耗电的硬件模块。

CPU、屏幕、WiFi 和数据网络、GPS 以及音视频通话都是我们日常的耗电大户。坦白说，智能手机硬件的飞速提升，许多其实都是厂商叫卖的噱头。绝大部分硬件对于我们来说都已经处于性能过剩的状态，但多余的性能同时也在消耗电量。

所以资源调度机制是厂商功耗优化最重要的手段，例如在卡顿优化的时候我就讲过，CPU 芯片会分大小核架构，会灵活地为不同任务分配相应的运算资源。手机基带、GPS 这些模块在不使用时也会进入低功耗或者休眠模式，达到降低功耗的目的。

现在越来越多厂商利用深度学习的本地 AI 来优化资源的调度，对 GPU、运行内存等资源进行合理分配，确保可以全面降低耗电量。厂商需要在高性能跟电量续航之间寻找一个平衡点，有的厂商可能倾向于用户有更好的性能，有的厂商会倾向于更长的续航。

功耗的确非常重要，我做手机预装项目时，发现厂商会对耗电有非常严格的规定，这也让我对功耗的认识更深刻了。但是为了为了保证头部应用能有更好的体验，厂商愿意给它们分配更多的资源。所以出现了高通的CPU Boost、微信的 Hardcode 以及各个厂商的合作通道。

但是反过来问一句，为什么厂商只把微信和 QQ 放到后台白名单，但没有把淘宝、支付宝、抖音等其他头部应用也一起加入呢？根据我的猜测，耗电可能是其中一个比较重要的因素。

3. 电量和应用程序

各个硬件模块都会耗电，而且不同的硬件耗电量也不太一样，那我们如何评估不同应用程序的耗电情况呢？

根据物理学的知识，电能的计算公式为

电能 = 电压 * 电流 * 时间

对于手机来说电压一般不会改变，例如华为 Mate 20 的恒定电压是 3.82V。所以在电压恒定的前提下，只需要测量电流和时间就可以确定耗电。

最终不同模块的耗电情况可以通过下面的这个公式计算：

模块电量 (mAh) = 模块电流 (mA) * 模块耗时 (h)

模块耗时比较容易理解，但是模块电流应该怎样去获取呢？Android 系统要求不同的厂商必须在 /frameworks/base/core/res/res/xml/power_profile.xml 中提供组件的电源配置文件。

power_profiler.xml文件定义了不同模块的电流消耗值以及该模块在一段时间内大概消耗的电量，你也可以参考 Android Developer 文档《Android 电源配置文件》。当然电流的大小和模块的状态也有关系，例如屏幕在不同亮度时的电流肯定会不一样。

Android 系统的电量计算PowerProfile也是通过读取power_profile.xml的数值而已，不同的厂商具体的数值都不太一样，我们可以通过下面的方法获取：

从手机中导出/system/framework/framework-res.apk文件。

使用反编译工具（如 apktool）对导出文件framework-res.apk进行反编译。

查看power_profile.xml文件在framework-res反编译目录路径：/res/xml/power_profile.xml。

对于系统的电量消耗情况，我们可以通过 dumpsys batterystats 导出。

adb shell dumpsys batterystats > battery.txt

// 各个 Uid 的总耗电量，而且是粗略的电量计算估计。

Estimated power use (mAh):

    Capacity: 3450, Computed drain: 501, actual drain: 552-587

    ...

    Idle: 41.8

    Uid 0: 135 ( cpu=103 wake=31.5 wifi=0.346 )

    Uid u0a208: 17.8 ( cpu=17.7 wake=0.00460 wifi=0.0901 )

    Uid u0a65: 17.5 ( cpu=12.7 wake=4.11 wifi=0.436 gps=0.309 )

    ...

// reset 电量统计

adb shell dumpsys batterystats --reset

BatteryStatsService是对外的电量统计服务，但具体的统计工作是由BatteryStatsImpl来完成的，而 BatteryStatsImpl 内部使用的就是 PowerProfile。BatteryStatsImpl 会为每一个应用创建一个 UID 实例来监控应用的系统资源使用情况，统计的系统资源包括下面图里的内容。

电量的使用也会跟环境有关，例如在零下十度的冬天电量会消耗得更快一些，系统提供的电量测量方法只是提供一个参考的数值。不过通过上面的这个方法，我们可以成功把电量的测量转化为功能模块的使用时间或者次数。

准确的测量电量并不是那么容易，在《大众点评 App 的短视频耗电量优化实战》一文中，为我们总结了下面几种电量测试的方法。

当测试或者其他人反馈耗电问题时，bug report结合Battery Historian是最好的排查方法。

//7.0 和 7.0 以后

$ adb bugreport bugreport.zip

//6.0 和 6.0 之前:

$ adb bugreport > bugreport.txt

// 通过 historian 图形化展示结果

python historian.py -a bugreport.txt > battery.html

Android 耗电的演进历程

虽然 iPhone XS Max 电池容量只有 3174mAh，远远低于大部分 Android 的旗舰机，但是很多时候我们发现它的续航能力会优于大部分的 Android 手机。

仔细想想这个问题就会发现，Android 是基于 Linux 内核，而 Linux 大部分使用在服务器中，它对功耗并没有做非常严格苛刻的优化。特别是国内会有各种各样的“保活黑科技”，大量的应用在后台活动简直就是“电量黑洞”。

那 Android 为了电量优化都做了哪些努力呢？Google I/O 每年都会单独讲解耗电优化，下面我们一起来看看 Android 在耗电方面都做了哪些改变。

1. 野蛮生长：Pre Android 5.0

在 Android 5.0 之前，系统并不是那么完善，对于电量优化相对还是比较少的。特别没有对应用的后台做严格的限制，多进程、fork native 进程以及广播拉起等各种保活流行了起来。

用户手机用电如流水，会明显感受到下面几个问题：

耗电与安装应用程序的数量有关。用户安装越多的应用程序，无论是否打开它们，手机耗电都会更快。

App 耗电量与 App 使用时间无关。用户希望 App 的耗电量应该与它的使用时间相关，但是有些应用即使常年不打开，依然非常耗电。

电量问题排查复杂。无论是电量的测量，还是耗电问题的排查都异常艰难。

当然在 Android 5.0 之前，系统也有尝试做一些省电相关的优化措施。

2. 逐步收紧：Android 5.0～Android 8.0

Android 5.0 专门开启了一个Volta 项目，目标是改善电池的续航。在优化电量的同时，还增加了的 dumpsys batteryst 等工具生成设备电池使用情况统计数据。

从 Android 6.0 开始，Google 开始着手清理后台应用和广播来进一步优化省电。在这个阶段还存在以下几个问题：

省电模式不够省电。Doze 低功耗模式限制得不够严格，例如屏幕关闭还可以获取位置、后台应用的网络权限等。

用户对应用控制力度不够。用户不能简单的对某些应用做更加细致的电量和后台行为的控制，但是其实国内很多的厂商已经提前实现了这个功能。

Target API 开发者响应不积极。为了不受新版本的某些限制，大部分国内的应用坚持不把 Target API 升级到 Oreo 以上，所以很多省电的功能事实上并没有生效。

3. 最严限制：Android 9.0

我在 Android 9.0 刚出来的时候，正常使用了一天手机，在通知栏竟然弹出了下面这样一个提示：微信正在后台严重耗电。

尽管经过几个版本的优化，Android 的续航问题依然没有根本性的改善。但你可以看到的是，从 Android 9.0 开始，Google 对电源管理引入了几个更加严格的限制。

通过应用待机分组功能，我们可以确保应用使用的电量和它们的使用时间成正比，而不是和手机上安装的应用数量成正比。对于不常用的应用，它们可以“作恶”的可能性更小了。通过省电模式和应用后台限制，用户可以知道哪些应用是耗电的应用，我们也可以对它们做更加严格的限制。

另一方面，无论是 Google Play 还是国内的 Android 绿色联盟，都要求应用在一年内更新到最新版本的 Target API。电池续航始终是 Android 的生命线，我相信今年的 Android Q 也会推出更多的优化措施。

总结

今天我讲了应用程序、Android 系统、手机硬件与电池之间的关系，也回顾了 Android 耗电优化的演进历程。那落实到具体工作时，我们应该如何去做耗电优化呢？下一期我们来解决这个问题。

在讲内存、CPU、存储和网络这些知识的时候，我都会讲一些硬件相关的知识。主要是希望帮你建立一套从应用层到操作系统，再到硬件的整体认知。当你的脑海里面有一套完整的知识图谱时，才能更得心应手地解决一些疑难问题，进而可以做好对应的性能优化工作。

课后作业

今天的课后作业是，在日常的开发过程中，你遇到过哪些耗电问题？遇到这些问题的时候，你一般通过哪些手段去定位和修复呢？欢迎留言跟我和其他同学一起讨论。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

19 | 耗电优化（下）：耗电的优化方法与线上监控

相比启动、卡顿、内存和网络的优化来说，可能大多数应用对耗电优化的关注不是太多。当然并不是我们不想做耗电优化，更多时候是感觉有些无从下手。

不同于启动时间、卡顿率，耗电在线上一直缺乏一个可以量化的指标。Android 系统通过计算获得的应用耗电数据只是一个估算值，从 Android 4.4 开始，连这个估算值也无法拿到了。当有用户投诉我们应用耗电的时候，我们一般也无所适从，不知道该如何定位、如何分析。

耗电优化究竟需要做哪些工作？我们如何快速定位代码中的不合理调用，并且持续监控应用的耗电情况呢？今天我们就一起来学习耗电的优化方法和线上监控方案。

耗电优化

在开始讲如何做耗电优化之前，你需要先明确什么是耗电优化，做这件事情的目的究竟是什么。

1. 什么是耗电优化

有些同学可能会疑惑，所谓的耗电优化不就是减少应用的耗电，增加用户的续航时间吗？但是落到实践中，如果我们的应用需要播放视频、需要获取 GPS 信息、需要拍照，这些耗电看起来是无法避免的。

如何判断哪些耗电是可以避免，或者是需要去优化的呢？你可以看下面这张图，当用户去看耗电排行榜的时候，发现“王者荣耀”使用了 7 个多小时，这时用户对“王者荣耀”的耗电是有预期的。

假设这个时候发现某个应用他根本没怎么使用（前台时间很少），但是耗电却非常多。这种情况会跟用户的预期差别很大，他可能就会想去投诉。

所以耗电优化的第一个方向是优化应用的后台耗电。知道了系统是如何计算耗电的，那反过来看，我们也就可以知道应用在后台不应该做什么，例如长时间获取 WakeLock、WiFi 和蓝牙的扫描等。为什么说耗电优化第一个方向就是优化应用后台耗电，因为大部分厂商预装项目要求最严格的正是应用后台待机耗电。

当然前台耗电我们不会完全不管，但是标准会放松很多。你再来看看下面这张图，如果系统对你的应用弹出这个对话框，可能对于微信来说，用户还可以忍受，但是对其他大多数的应用来说，可能很多用户就直接把你加入到后台限制的名单中了。

耗电优化的第二个方向是符合系统的规则，让系统认为你耗电是正常的。而 Android P 是通过 Android Vitals 监控后台耗电，所以我们需要符合 Android Vitals 的规则，目前它的具体规则如下：

虽然上面的标准可能随时会改变，但是可以看到，Android 系统目前比较关心后台 Alarm 唤醒、后台网络、后台 WiFi 扫描以及部分长时间 WakeLock 阻止系统后台休眠。

2. 耗电优化的难点

既然已经明确了耗电优化的目的和方向，那我们就开始动手吧。但我想说的是，只有当你跳进去的时候，才能发现耗电优化这个坑有多深。它主要有下面几个问题：

缺乏现场，无法复现。用户上传某个截图，你的应用耗电占比 30%。通过电量的详细使用情况，我们可能会有一些猜测。但是用户也无法给出更丰富的信息，以及具体是在什么场景发生的，可以说是毫无头绪。

信息不全，难以定位。如果是开发人员或者厂商可以提供 bug report，利用 Battery Historian 可以得到非常全的耗电统计信息。但是 Battery Historian 缺失了最重要的堆栈信息，代码调用那么复杂，可能还有很多的第三方 SDK，我们根本不知道是哪一行代码申请了 WakeLock、使用了 Sensor、调用了网络等。

无法评估结果。通过猜测，我们可能会尝试一些解决方案。但是从 Android 4.4 开始，我们无法拿到应用的耗电信息。尽管我们解决了某个耗电问题，也很难去评估它是否已经生效，以及对用户产生的价值有多大。

3. 耗电优化的方法

无法复现、难以定位，也无法评估结果，耗电优化之路实在是不容易。在真正去做优化之前，先来看看我们的应用为什么需要在后台耗电？

大部分的开发者不是为了“报复社会”，故意去浪费用户的电量，主要可能有以下一些原因：

某个需求场景。最普遍的场景就是推送，为了实现推送我们只能做各种各样的保活。在需求面前，用户的价值可能被排到第二位。

代码的 Bug。因为某些逻辑考虑不周，可能导致 GPS 没有关闭、WakeLock 没有释放。

所以相反地，耗电优化的思路也非常简单。

找到需求场景的替代方案。以推送为例，我们是否可以更多地利用厂商通道，或者定时的拉取最新消息这种模式。如果真是迫不得已，是不是可以使用 foreground service 或者引导用户加入白名单。后台任务的总体指导思想是减少、延迟和合并，可以参考微信一个小伙写的《Android 后台调度任务与省电》。在后台运行某个任务之前，我们都需要经过下面的思考：

符合 Android 规则。首先系统的大部分耗电监控，都是在手机在没有充电的时候。我们可以选择在用户充电时才去做一些耗电的工作，具体方法可查看官方文档《监控电池电量和充电状态》。其次是尽早适配最新的 Target API，因为高版本系统后台限制本来就非常严格，应用在后台耗电本身就变得比较困难了。

IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);

Intent batteryStatus = context.registerReceiver(null, ifilter);

// 获取用户是否在充电的状态或者已经充满电了

int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);

boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;

异常情况监控。即使是最严格的 Android P，系统也会允许应用部分地使用后台网络、Alarm 以及 JobSheduler 事件（不同的分组，限制次数不同）。因此出现异常情况的可能性还是存在的，更不用说低版本的系统。对于异常的情况，我们需要类似 Android Vitals 电量监控一样，将规则抽象出来，并且增加上更多辅助我们定位问题的信息。

耗电监控

在 I/O 监控中，我指定了重复 I/O、主线程 I/O、Buffer 过大以及 I/O 泄漏这四个规则。对于耗电监控也是如此，我们首先需要抽象出具体的规则，然后收集尽量多的辅助信息，帮助问题的排查。

1. Android Vitals

前面已经说过 Android Vitals 的几个关于电量的监控方案与规则，我们先复习一下。

Alarm Manager wakeup 唤醒过多

频繁使用局部唤醒锁

后台网络使用量过高

后台 WiFi scans 过多

在使用了一段时间之后，我发现它并不是那么好用。以 Alarm wakeup 为例，Vitals 以每小时超过 10 次作为规则。由于这个规则无法做修改，很多时候我们可能希望针对不同的系统版本做更加细致的区分。

其次跟 Battery Historian 一样，我们只能拿到 wakeup 的标记的组件，拿不到申请的堆栈，也拿不到当时手机是否在充电、剩余电量等信息。

对于网络、WiFi scans 以及 WakeLock 也是如此。虽然 Vitals 帮助我们缩小了排查的范围，但是依然需要在茫茫的代码中寻找对应的可疑代码。

2. 耗电监控都监控什么

Android Vitals 并不是那么好用，而且对于国内的应用来说其实也根本无法使用。不管怎样，我们还是需要搭建自己的耗电监控系统。

那我们的耗电监控系统应该监控哪些内容，怎么样才能比 Android Vitals 做得更好呢？

监控信息。简单来说系统关心什么，我们就监控什么，而且应该以后台耗电监控为主。类似 Alarm wakeup、WakeLock、WiFi scans、Network 都是必须的，其他的可以根据应用的实际情况。如果是地图应用，后台获取 GPS 是被允许的；如果是计步器应用，后台获取 Sensor 也没有太大问题。

现场信息。监控系统希望可以获得完整的堆栈信息，比如哪一行代码发起了 WiFi scans、哪一行代码申请了 WakeLock 等。还有当时手机是否在充电、手机的电量水平、应用前台和后台时间、CPU 状态等一些信息也可以帮助我们排查某些问题。

提炼规则。最后我们需要将监控的内容抽象成规则，当然不同应用监控的事项或者参数都不太一样。

由于每个应用的具体情况都不太一样，下面是一些可以用来参考的简单规则。

在安卓绿色联盟的会议中，华为公开过他们后台资源使用的“红线”，你也可以参考里面的一些规则：

2. 如何监控耗电

明确了我们需要监控什么以及具体的规则之后，终于可以来到实现这个环节了。跟 I/O 监控、网络监控一样，我首先想到的还是 Hook 方案。

Java Hook

Hook 方案的好处在于使用者接入非常简单，不需要去修改自己的代码。下面我以几个比较常用的规则为例，看看如果使用 Java Hook 达到监控的目的。

WakeLock。WakeLock 用来阻止 CPU、屏幕甚至是键盘的休眠。类似 Alarm、JobService 也会申请 WakeLock 来完成后台 CPU 操作。WakeLock 的核心控制代码都在PowerManagerService中，实现的方法非常简单。

// 代理 PowerManagerService

ProxyHook().proxyHook(context.getSystemService(Context.POWER_SERVICE), "mService", this)；

@Override

public void beforeInvoke(Method method, Object[] args) {

    // 申请 Wakelock

    if (method.getName().equals("acquireWakeLock")) {

        if (isAppBackground()) {

            // 应用后台逻辑，获取应用堆栈等等

         } else {

            // 应用前台逻辑，获取应用堆栈等等

         }

    // 释放 Wakelock

    } else if (method.getName().equals("releaseWakeLock")) {

       // 释放的逻辑

    }

}

Alarm。Alarm 用来做一些定时的重复任务，它一共有四个类型，其中ELAPSED_REALTIME_WAKEUP和RTC_WAKEUP类型都会唤醒设备。同样，Alarm 的核心控制逻辑都在AlarmManagerService中，实现如下：

// 代理 AlarmManagerService

new ProxyHook().proxyHook(context.getSystemService

(Context.ALARM_SERVICE), "mService", this)；

public void beforeInvoke(Method method, Object[] args) {

    // 设置 Alarm

    if (method.getName().equals("set")) {

        // 不同版本参数类型的适配，获取应用堆栈等等

    // 清除 Alarm

    } else if (method.getName().equals("remove")) {

        // 清除的逻辑

    }

}

其他。对于后台 CPU，我们可以使用卡顿监控学到的方法。对于后台网络，同样我们可以通过网络监控学到的方法。对于 GPS 监控，我们可以通过 Hook 代理LOCATION_SERVICE。对于 Sensor，我们通过 Hook SENSOR_SERVICE中的“mSensorListeners”，可以拿到部分信息。

通过 Hook，我们可以在申请资源的时候将堆栈信息保存起来。当我们触发某个规则上报问题的时候，可以将收集到的堆栈信息、电池是否充电、CPU 信息、应用前后台时间等辅助信息也一起带上。

插桩

虽然使用 Hook 非常简单，但是某些规则可能不太容易找到合适的 Hook 点。而且在 Android P 之后，很多的 Hook 点都不支持了。

出于兼容性考虑，我首先想到的是写一个基础类，然后在统一的调用接口中增加监控逻辑。以 WakeLock 为例：

public class WakelockMetrics {

    // Wakelock 申请

    public void acquire(PowerManager.WakeLock wakelock) {

        wakeLock.acquire();

        // 在这里增加 Wakelock 申请监控逻辑

    }

    // Wakelock 释放

    public void release(PowerManager.WakeLock wakelock, int flags) {

        wakelock.release();

        // 在这里增加 Wakelock 释放监控逻辑

    }

}

Facebook 也有一个耗电监控的开源库Battery-Metrics，它监控的数据非常全，包括 Alarm、WakeLock、Camera、CPU、Network 等，而且也有收集电量充电状态、电量水平等信息。

Battery-Metrics 只是提供了一系列的基础类，在实际使用中，接入者可能需要修改大量的源码。但对于一些第三方 SDK 或者后续增加的代码，我们可能就不太能保证可以监控到了。这些场景也就无法监控了，所以 Facebook 内部是使用插桩来动态替换。

遗憾的是，Facebook 并没有开源它们内部的插桩具体实现方案。不过这实现起来其实并不困难，事实上在我们前面的 Sample 中，已经使用过 ASM、Aspectj 这两种插桩方案了。后面我也安排单独一期内容来讲不同插桩方案的实现。

插桩方案使用起来兼容性非常好，并且使用者也没有太大的接入成本。但是它并不是完美无缺的，对于系统的代码插桩方案是无法替换的，例如 JobService 申请 PARTIAL_WAKE_LOCK 的场景。

总结

从 Android 系统计算耗电的方法，我们知道了需要关注哪些模块的耗电。从 Android 耗电优化的演进历程，我们知道了 Android 在耗电优化的一些方向以及在意的点。从 Android Vitals 的耗电监控，我们知道了耗电优化的监控方式。

但是系统的方法不一定可以完全适合我们的应用，还是需要通过进一步阅读源码、思考，沉淀出一套我们自己的优化实践方案。这也是我的性能优化方法论，在其他的领域也是如此。

课后作业

在你的项目中，做过哪些耗电优化和监控的工作吗？你的实现方案是怎样的？欢迎留言跟我和其他同学一起讨论。

今天的课后练习是，按照文中的思路，使用 Java Hook 实现 Alarm、WakeLock 和 GPS 的耗电监控。具体的规则跟文中表格一致，请将完善后的代码通过 Pull requests 提交到Chapter19中。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

20 | UI 优化（上）：UI 渲染的几个关键概念

在开始今天的学习前，我祝各位同学新春快乐、工作顺利、身体健康、阖家幸福，绍文给您拜年啦！

每个做 UI 的 Android 开发，上辈子都是折翼的天使。

多年来，有那么一群苦逼的 Android 开发，他们饱受碎片化之苦，面对着各式各样的手机屏幕尺寸和分辨率，还要与“凶残”的产品和 UI 设计师过招，日复一日、年复一年的做着 UI 适配和优化工作，蹉跎着青春的岁月。更加不幸的是，最近两年这个趋势似乎还愈演愈烈：刘海屏、全面屏，还有即将推出的柔性折叠屏，UI 适配将变得越来越复杂。

UI 优化究竟指的是什么呢？我认为所谓的 UI 优化，应该包含两个方面：一个是效率的提升，我们可以非常高效地把 UI 的设计图转化成应用界面，并且保证 UI 界面在不同尺寸和分辨率的手机上都是一致的；另一个是性能的提升，在正确实现复杂、炫酷的 UI 设计的同时，需要保证用户有流畅的体验。

那如何将我们从无穷无尽的 UI 适配中拯救出来呢？

UI 渲染的背景知识

究竟什么是 UI 渲染呢？Android 的图形渲染框架十分复杂，不同版本的差异也比较大。但是无论怎么样，它们都是为了将我们代码中的 View 或者元素显示到屏幕中。

而屏幕作为直接面对用户的手机硬件，类似厚度、色彩、功耗等都是厂家非常关注的。从功能机小小的黑白屏，到现在超大的全面屏，我们先来看手机屏幕的发展历程。

1. 屏幕与适配

作为消费者来说，通常会比较关注屏幕的尺寸、分辨率以及厚度这些指标。Android 的碎片化问题令人痛心疾首，屏幕的差异正是碎片化问题的“中心”。屏幕的尺寸从 3 英寸到 10 英寸，分辨率从 320 到 1920 应有尽有，对我们 UI 适配造成很大困难。

除此之外，材质也是屏幕至关重要的一个评判因素。目前智能手机主流的屏幕可分为两大类：一种是 LCD（Liquid Crystal Display），即液晶显示器；另一种是 OLED（Organic Light-Emitting Diode 的）即有机发光二极管。

最新的旗舰机例如 iPhone XS Max 和华为 Mate 20 Pro 使用的都是 OLED 屏幕。相比 LCD 屏幕，OLED 屏幕在色彩、可弯曲程度、厚度以及耗电都有优势。正因为这些优势，全面屏、曲面屏以及未来的柔性折叠屏，使用的都是 OLED 材质。关于 OLED 与 LCD 的具体差别，你可以参考《OLED 和 LCD 区别》和《手机屏幕的前世今生，可能比你想的还精彩》。今年柔性折叠屏肯定是最大的热点，不过 OLED 的单价成本要比 LCD 高很多。

对于屏幕碎片化的问题，Android 推荐使用 dp 作为尺寸单位来适配 UI，因此每个 Android 开发都应该很清楚 px、dp、dpi、ppi、density 这些概念。

通过 dp 加上自适应布局可以基本解决屏幕碎片化的问题，也是 Android 推荐使用的屏幕兼容性适配方案。但是它会存在两个比较大的问题：

不一致性。因为 dpi 与实际 ppi 的差异性，导致在相同分辨率的手机上，控件的实际大小会有所不同。

效率。设计师的设计稿都是以 px 为单位的，开发人员为了 UI 适配，需要手动通过百分比估算出 dp 值。

除了直接 dp 适配之外，目前业界比较常用的 UI 适配方法主要有下面几种：

限制符适配方案。主要有宽高限定符与 smallestWidth 限定符适配方案，具体可以参考《Android 目前稳定高效的 UI 适配方案》《smallestWidth 限定符适配方案》。

今日头条适配方案。通过反射修正系统的 density 值，具体可以参考《一种极低成本的 Android 屏幕适配方式》《今日头条适配方案》。

2. CPU 与 GPU

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

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

3. OpenGL 与 Vulkan

对于硬件绘制，我们通过调用 OpenGL ES 接口利用 GPU 完成绘制。OpenGL是一个跨平台的图形 API，它为 2D/3D 图形处理硬件指定了标准软件接口。而 OpenGL ES 是 OpenGL 的子集，专为嵌入式设备设计。

在官方硬件加速的文档中，可以看到很多 API 都有相应的 Android API level 限制。

这是为什么呢？其实这主要是受OpenGL ES版本与系统支持的限制，直到最新的 Android P，有 3 个 API 是仍然没有支持。对于不支持的 API，我们需要使用软件绘制模式，渲染的性能将会大大降低。

Android 7.0 把 OpenGL ES 升级到最新的 3.2 版本同时，还添加了对Vulkan的支持。Vulkan 是用于高性能 3D 图形的低开销、跨平台 API。相比 OpenGL ES，Vulkan 在改善功耗、多核优化提升绘图调用上有着非常明显的优势。

在国内，“王者荣耀”是比较早适配 Vulkan 的游戏，虽然目前兼容性还有一些问题，但是 Vulkan 版本的王者荣耀在流畅性和帧数稳定性都有大幅度提升，即使是战况最激烈的团战阶段，也能够稳定保持在 55～60 帧。

Android 渲染的演进

跟耗电一样，Android 的 UI 渲染性能也是 Google 长期以来非常重视的，基本每次 Google I/O 都会花很多篇幅讲这一块。每个开发者都希望自己的应用或者游戏可以做到 60 fps 如丝般顺滑，不过相比 iOS 系统，Android 的渲染性能一直被人诟病。

Android 系统为了弥补跟 iOS 的差距，在每个版本都做了大量的优化。在了解 Android 的渲染之前，需要先了解一下 Android 图形系统的整体架构，以及它包含的主要组件。

我曾经在一篇文章看过一个生动的比喻，如果把应用程序图形渲染过程当作一次绘画过程，那么绘画过程中 Android 的各个图形组件的作用是：

画笔：Skia 或者 OpenGL。我们可以用 Skia 画笔绘制 2D 图形，也可以用 OpenGL 来绘制 2D/3D 图形。正如前面所说，前者使用 CPU 绘制，后者使用 GPU 绘制。

画纸：Surface。所有的元素都在 Surface 这张画纸上进行绘制和渲染。在 Android 中，Window 是 View 的容器，每个窗口都会关联一个 Surface。而 WindowManager 则负责管理这些窗口，并且把它们的数据传递给 SurfaceFlinger。

画板：Graphic Buffer。Graphic Buffer 缓冲用于应用程序图形的绘制，在 Android 4.1 之前使用的是双缓冲机制；在 Android 4.1 之后，使用的是三缓冲机制。

显示：SurfaceFlinger。它将 WindowManager 提供的所有 Surface，通过硬件合成器 Hardware Composer 合成并输出到显示屏。

接下来我将通过 Android 渲染演进分析的方法，帮你进一步加深对 Android 渲染的理解。

1. Android 4.0：开启硬件加速

在 Android 3.0 之前，或者没有启用硬件加速时，系统都会使用软件方式来渲染 UI。

整个流程如上图所示：

Surface。每个 View 都由某一个窗口管理，而每一个窗口都关联有一个 Surface。

Canvas。通过 Surface 的 lock 函数获得一个 Canvas，Canvas 可以简单理解为 Skia 底层接口的封装。

Graphic Buffer。SurfaceFlinger 会帮我们托管一个BufferQueue，我们从 BufferQueue 中拿到 Graphic Buffer，然后通过 Canvas 以及 Skia 将绘制内容栅格化到上面。

SurfaceFlinger。通过 Swap Buffer 把 Front Graphic Buffer 的内容交给 SurfaceFinger，最后硬件合成器 Hardware Composer 合成并输出到显示屏。

整个渲染流程是不是非常简单？但是正如我前面所说，CPU 对于图形处理并不是那么高效，这个过程完全没有利用到 GPU 的高性能。

硬件加速绘制

所以从 Androd 3.0 开始，Android 开始支持硬件加速，到 Android 4.0 时，默认开启硬件加速。

硬件加速绘制与软件绘制整个流程差异非常大，最核心就是我们通过 GPU 完成 Graphic Buffer 的内容绘制。此外硬件绘制还引入了一个 DisplayList 的概念，每个 View 内部都有一个 DisplayList，当某个 View 需要重绘时，将它标记为 Dirty。

当需要重绘时，仅仅只需要重绘一个 View 的 DisplayList，而不是像软件绘制那样需要向上递归。这样可以大大减少绘图的操作数量，因而提高了渲染效率。

2. Android 4.1：Project Butter

优化是无止境的，Google 在 2012 年的 I/O 大会上宣布了 Project Butter 黄油计划，并且在 Android 4.1 中正式开启了这个机制。

Project Butter 主要包含两个组成部分，一个是 VSYNC，一个是 Triple Buffering。

VSYNC 信号

在讲文件 I/O 跟网络 I/O 的时候，我讲到过中断的概念。对于 Android 4.0，CPU 可能会因为在忙别的事情，导致没来得及处理 UI 绘制。

为解决这个问题，Project Buffer 引入了VSYNC，它类似于时钟中断。每收到 VSYNC 中断，CPU 会立即准备 Buffer 数据，由于大部分显示设备刷新频率都是 60Hz（一秒刷新 60 次），也就是说一帧数据的准备工作都要在 16ms 内完成。

这样应用总是在 VSYNC 边界上开始绘制，而 SurfaceFlinger 总是 VSYNC 边界上进行合成。这样可以消除卡顿，并提升图形的视觉表现。

三缓冲机制 Triple Buffering

在 Android 4.1 之前，Android 使用双缓冲机制。怎么理解呢？一般来说，不同的 View 或者 Activity 它们都会共用一个 Window，也就是共用同一个 Surface。

而每个 Surface 都会有一个 BufferQueue 缓存队列，但是这个队列会由 SurfaceFlinger 管理，通过匿名共享内存机制与 App 应用层交互。

整个流程如下：

每个 Surface 对应的 BufferQueue 内部都有两个 Graphic Buffer ，一个用于绘制一个用于显示。我们会把内容先绘制到离屏缓冲区（OffScreen Buffer），在需要显示时，才把离屏缓冲区的内容通过 Swap Buffer 复制到 Front Graphic Buffer 中。

这样 SurfaceFlinge 就拿到了某个 Surface 最终要显示的内容，但是同一时间我们可能会有多个 Surface。这里面可能是不同应用的 Surface，也可能是同一个应用里面类似 SurefaceView 和 TextureView，它们都会有自己单独的 Surface。

这个时候 SurfaceFlinger 把所有 Surface 要显示的内容统一交给 Hareware Composer，它会根据位置、Z-Order 顺序等信息合成为最终屏幕需要显示的内容，而这个内容会交给系统的帧缓冲区 Frame Buffer 来显示（Frame Buffer 是非常底层的，可以理解为屏幕显示的抽象）。

如果你理解了双缓冲机制的原理，那就非常容易理解什么是三缓冲区了。如果只有两个 Graphic Buffer 缓存区 A 和 B，如果 CPU/GPU 绘制过程较长，超过了一个 VSYNC 信号周期，因为缓冲区 B 中的数据还没有准备完成，所以只能继续展示 A 缓冲区的内容，这样缓冲区 A 和 B 都分别被显示设备和 GPU 占用，CPU 无法准备下一帧的数据。

如果再提供一个缓冲区，CPU、GPU 和显示设备都能使用各自的缓冲区工作，互不影响。简单来说，三缓冲机制就是在双缓冲机制基础上增加了一个 Graphic Buffer 缓冲区，这样可以最大限度的利用空闲时间，带来的坏处是多使用的了一个 Graphic Buffer 所占用的内存。

对于 VSYNC 信号和 Triple Buffering 更详细的介绍，可以参考《Android Project Butter 分析》。

数据测量

“工欲善其事，必先利其器”，Project Butter 在优化 UI 渲染性能的同时，也希望可以帮助我们更好地排查 UI 相关的问题。

在 Android 4.1，新增了 Systrace 性能数据采样和分析工具。在卡顿和启动优化中，我们已经使用过 Systrace 很多次了，也可以用它来检测每一帧的渲染情况。

Tracer for OpenGL ES 也是 Android 4.1 新增加的工具，它可逐帧、逐函数的记录 App 用 OpenGL ES 的绘制过程。它提供了每个 OpenGL 函数调用的消耗时间，所以很多时候用来做性能分析。但因为其强大的记录功能，在分析渲染问题时，当 Traceview、Systrace 都显得棘手时，还找不到渲染问题所在时，此时这个工具就会派上用场了。

在 Android 4.2，系统增加了检测绘制过度工具，具体的使用方法可以参考《检查 GPU 渲染速度和绘制过度》。

3. Android 5.0：RenderThread

经过 Project Butter 黄油计划之后，Android 的渲染性能有了很大的改善。但是不知道你有没有注意到一个问题，虽然我们利用了 GPU 的图形高性能运算，但是从计算 DisplayList，到通过 GPU 绘制到 Frame Buffer，整个计算和绘制都在 UI 主线程中完成。

UI 主线程“既当爹又当妈”，任务过于繁重。如果整个渲染过程比较耗时，可能造成无法响应用户的操作，进而出现卡顿。GPU 对图形的绘制渲染能力更胜一筹，如果使用 GPU 并在不同线程绘制渲染图形，那么整个流程会更加顺畅。

正因如此，在 Android 5.0 引入了两个比较大的改变。一个是引入了 RenderNode 的概念，它对 DisplayList 及一些 View 显示属性做了进一步封装。另一个是引入了 RenderThread，所有的 GL 命令执行都放到这个线程上，渲染线程在 RenderNode 中存有渲染帧的所有信息，可以做一些属性动画，这样即便主线程有耗时操作的时候也可以保证动画流畅。

在官方文档 《检查 GPU 渲染速度和绘制过度》中，我们还可以开启 Profile GPU Rendering 检查。在 Android 6.0 之后，会输出下面的计算和绘制每个阶段的耗时：

如果我们把上面的步骤转化线程模型，可以得到下面的流水线模型。CPU 将数据同步（sync）给 GPU 之后，一般不会阻塞等待 GPU 渲染完毕，而是通知结束后就返回。而 RenderThread 承担了比较多的绘制工作，分担了主线程很多压力，提高了 UI 线程的响应速度。

4. 未来

在 Android 6.0 的时候，Android 在 gxinfo 添加了更详细的信息；在 Android 7.0 又对 HWUI 进行了一些重构，而且支持了 Vulkan；在 Android P 支持了 Vulkun 1.1。我相信在未来不久的 Android Q，更好地支持 Vulkan 将是一个必然的方向。

总的来说，UI 渲染的优化必然会朝着两个方向。一个是进一步压榨硬件的性能，让 UI 可以更加流畅。一个是改进或者增加更多的分析工具，帮助我们更容易地发现以及定位问题。

总结

今天我们通过 Android 渲染的演进历程，一步一步加深对 Android 渲染机制的理解，这对我们 UI 渲染优化工作会有很大的帮助。

但是凡事都要两面看，硬件加速绘制虽然极大地提高了 Android 系统显示和刷新的速度，但它也存在那么一些问题。一方面是内存消耗，OpenGL API 调用以及 Graphic Buffer 缓冲区会占用至少几 MB 的内存，而实际上会占用更多一些。不过最严重的还是兼容性问题，部分绘制函数不支持是其中一部分原因，更可怕的是硬件加速绘制流程本身的 Bug。由于 Android 每个版本对渲染模块都做了一些重构，在某些场景经常会出现一些莫名其妙的问题。

例如每个应用总有那么一些 libhwui.so 相关的崩溃，曾经这个崩溃占我们总崩溃的 20% 以上。我们内部花了整整一个多月，通过发了几十个灰度，使用了 Inline Hook、GOT Hook 等各种手段。最后才定位到问题的原因是系统内部 RenderThread 与主线程数据同步的 Bug，并通过规避的方法得以解决。

课后作业

人们都说 iOS 系统更加流畅，对于 Android 的 UI 渲染你了解多少呢？在日常工作中，你是使用哪种方式做 UI 适配的，觉得目前在渲染方面最大的痛点是什么？欢迎留言跟我和其他同学一起讨论。

在 UI 渲染这方面，其实我也并不是非常资深，针对文中所讲的，如果你有更好的思路和想法，一定给我留言，欢迎留下你的想法。

Android 渲染架构非常庞大，而且演进得也非常快。如果你还有哪些不理解的地方，可以进一步阅读下面的参考资料：

2018 Google I/O：Drawn out: how Android renders

官方文档：Android 图形架构

浏览器渲染：一颗像素的诞生

Android 屏幕绘制机制及硬件加速

Android 性能优化之渲染篇

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

21 | UI 优化（下）：如何优化 UI 渲染？

孔子曰：“温故而知新”，在学习如何优化 UI 渲染之前，我们先来回顾一下在“卡顿优化”中学到的知识。关于卡顿优化，我们学习了 4 种本地排查卡顿的工具，以及多种线上监控卡顿、帧率的方法。为什么要回顾卡顿优化呢？那是因为 UI 渲染也会造成卡顿，并且肯定会有同学疑惑卡顿优化和 UI 优化的区别是什么。

在 Android 系统的 VSYNC 信号到达时，如果 UI 线程被某个耗时任务堵塞，长时间无法对 UI 进行渲染，这时就会出现卡顿。但是这种情形并不是我们今天讨论的重点，UI 优化要解决的核心是由于渲染性能本身造成用户感知的卡顿，它可以认为是卡顿优化的一个子集。

从设计师和产品的角度，他们希望应用可以用丰富的图形元素、更炫酷的动画来实现流畅的用户体验。但是 Android 系统很有可能无法及时完成这些复杂的界面渲染操作，这个时候就会出现掉帧。也正因如此我才希望做 UI 优化，因为我们有更高的要求，希望它能达到流畅画面所需要的 60 fps。这里需要说的是，即使 40 fps 用户可能不会感到明显的卡顿，但我们也仍需要去做进一步的优化。

那么接下来我们就来看看，如何让我们的 UI 渲染达到 60 fps？有哪些方法可以帮助我们优化 UI 渲染性能？

UI 渲染测量

通过上一期的学习，你应该已经掌握了一些 UI 测试和问题定位的工具。

测试工具：Profile GPU Rendering 和 Show GPU Overdraw，具体的使用方法你可以参考《检查 GPU 渲染速度和绘制过度》。

问题定位工具：Systrace 和 Tracer for OpenGL ES，具体使用方法可以参考《Slow rendering》。

在 Android Studio 3.1 之后，Android 推荐使用Graphics API Debugger（GAPID）来替代 Tracer for OpenGL ES 工具。GAPID 可以说是升级版，它不仅可以跨平台，而且功能更加强大，支持 Vulkan 与回放。

通过上面的几个工具，我们可以初步判断应用 UI 渲染的性能是否达标，例如是否经常出现掉帧、掉帧主要发生在渲染的哪一个阶段、是否存在 Overdraw 等。

虽然这些图形化界面工具非常好用，但是它们难以用在自动化测试场景中，那有哪些测量方法可以用于自动化测量 UI 渲染性能呢？

1. gfxinfo

gfxinfo可以输出包含各阶段发生的动画以及帧相关的性能信息，具体命令如下：

adb shell dumpsys gfxinfo 包名

除了渲染的性能之外，gfxinfo 还可以拿到渲染相关的内存和 View hierarchy 信息。在 Android 6.0 之后，gxfinfo 命令新增了 framestats 参数，可以拿到最近 120 帧每个绘制阶段的耗时信息。

adb shell dumpsys gfxinfo 包名 framestats

通过这个命令我们可以实现自动化统计应用的帧率，更进一步还可以实现自定义的“Profile GPU Rendering”工具，在出现掉帧的时候，自动统计分析是哪个阶段的耗时增长最快，同时给出相应的建议。

2. SurfaceFlinger

除了耗时，我们还比较关心渲染使用的内存。上一期我讲过，在 Android 4.1 以后每个 Surface 都会有三个 Graphic Buffer，那如何查看 Graphic Buffer 占用的内存，系统是怎么样管理这部分的内存的呢？

你可以通过下面的命令拿到系统 SurfaceFlinger 相关的信息：

adb shell dumpsys SurfaceFlinger

下面以今日头条为例，应用使用了三个 Graphic Buffer 缓冲区，当前用在显示的第二个 Graphic Buffer，大小是 1080 x 1920。现在我们也可以更好地理解三缓冲机制，你可以看到这三个 Graphic Buffer 的确是在交替使用。

+ Layer 0x793c9d0c00 (com.ss.***。news/com.**.MainActivity)

   // 序号            // 状态           // 对象        // 大小

  >[02:0x794080f600] state=ACQUIRED, 0x794081bba0 [1080x1920:1088,  1]

   [00:0x793e76ca00] state=FREE    , 0x793c8a2640 [1080x1920:1088,  1]

   [01:0x793e76c800] state=FREE    , 0x793c9ebf60 [1080x1920:1088,  1]

继续往下看，你可以看到这三个 Buffer 分别占用的内存：

Allocated buffers:

0x793c8a2640: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900

0x793c9ebf60: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900

0x794081bba0: 8160.00 KiB | 1080 (1088) x 1920 | 1 | 0x20000900

这部分的内存其实真的不小，特别是现在手机的分辨率越来越大，而且还很多情况应用会有其他的 Surface 存在，例如使用了SurfaceView或者TextureView等。

那系统是怎么样管理这部分内存的呢？当应用退到后台的时候，系统会将这些内存回收，也就不会再把它们计算到应用的内存占用中。

+ Layer 0x793c9d0c00 (com.ss.***。news/com.**.MainActivity)

   [00:0x0] state=FREE

   [01:0x0] state=FREE

   [02:0x0] state=FREE

那么如何快速地判别 UI 实现是否符合设计稿？如何更高效地实现 UI 自动化测试？这些问题你可以先思考一下，我们将在后面“高效测试”中再详细展开。

UI 优化的常用手段

让我们再重温一下 UI 渲染的阶段流程图，我们的目标是实现 60 fps，这意味着渲染的所有操作都必须在 16 ms（= 1000 ms／60 fps）内完成。

所谓的 UI 优化，就是拆解渲染的各个阶段的耗时，找到瓶颈的地方，再加以优化。接下来我们一起来看看 UI 优化的一些常用的手段。

1. 尽量使用硬件加速

通过上一期学习，相信你也发自内心地认同硬件加速绘制的性能是远远高于软件绘制的。所以说 UI 优化的第一个手段就是保证渲染尽量使用硬件加速。

有哪些情况我们不能使用硬件加速呢？之所以不能使用硬件加速，是因为硬件加速不能支持所有的 Canvas API，具体 API 兼容列表可以见drawing-support文档。如果使用了不支持的 API，系统就需要通过 CPU 软件模拟绘制，这也是渐变、磨砂、圆角等效果渲染性能比较低的原因。

SVG 也是一个非常典型的例子，SVG 有很多指令硬件加速都不支持。但我们可以用一个取巧的方法，提前将这些 SVG 转换成 Bitmap 缓存起来，这样系统就可以更好地使用硬件加速绘制。同理，对于其他圆角、渐变等场景，我们也可以改为 Bitmap 实现。

这种取巧方法实现的关键在于如何提前生成 Bitmap，以及 Bitmap 的内存需要如何管理。你可以参考一下市面常用的图片库实现。

2. 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 都已经“净身出户”，不会保留之前的状态。微信曾经就因为这个缓存，导致出现不同的用户聊天记录错乱。

3. 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，不必在主线程中执行。

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的脚本语言。允许开发者以较少的代码实现功能复杂且性能优越的应用程序。

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

RenderScript 渲染利器

RenderScript : 简单而快速的图像处理

Android RenderScript 简单高效实现图片的高斯模糊效果

总结

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

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

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

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

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

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

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

课后作业

在你平时的工作中，做过哪些 UI 优化的工作，有没有什么“大招”跟其他同学分享？对于 Litho, Flutter，你又有什么看法？欢迎留言跟我和其他同学一起讨论。

今天还有两个课后小作业，尝试使用 Litho 和 Flutter 这两个框架。

1. 使用 Litho 实现一个信息流界面。

2. 使用 Flutter 写一个 Hello World，分析安装包的体积。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

22 | 包体积优化（上）：如何减少安装包大小？

曾经在 15 年的时候，我在 WeMobileDev 公众号就写过一篇文章《Android 安装包相关知识汇总》，也开源了一个不少同学都使用过的资源混淆工具AndResGuard。

现在再看看这篇 4 年前的文章，就像看到了 4 年前的自己，感触颇多啊。几年过去了，网上随意一搜都有大量安装包优化的文章，那还有哪些“高深”的珍藏秘笈值得分享呢？

时至今日，微信包体积也从当年的 30MB 增长到现在的 100MB 了。我们经常会想，现在 WiFi 这么普遍了，而且 5G 都要来了，包体积优化究竟还有没有意义？它对用户和应用的价值在哪里？

安装包的背景知识

还记得在 2G 时代，我们每个月只有 30MB 流量，那个时候安装包体积确实至关重要。当时我在做“搜狗输入法”的时候，我们就严格要求包体积在 5MB 以内。

几年过去了，我们对包体积的看法有什么改变吗？

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 比国内的研究真的要高深很多，希望他们可以补充一些文档，让我们学习起来更轻松一些。

课后作业

你的应用会关注包体积吗？你做过哪些包体积优化的工作，有哪些好的方法可以跟同学们分享呢？欢迎留言跟我和其他同学一起讨论。

今天的练习Sample，尝试使用 ReDex 这个项目来优化我们应用的包体积，主要有下面几个小任务：

strip debuginfo

分包优化

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

23 | 包体积优化（下）：资源优化的进阶实践

上一期我们聊了 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 文件信息都会有影响。而且因为资源文件数量非常非常多，导致这部分的体积非常可观。

那我们能不能把所有的资源文件都合并成同一个大文件，这样做肯定会比资源混淆方案效果更好。

事实上，大部分的换肤方案也是采用这个思路，这个大资源文件就相当于一套皮肤。因此我们完全可以把这套方案推广开来，但是实现起来还是需要解决不少问题的。

资源的解析。我们需要模拟系统实现资源文件的解析，例如把 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 方案扫描具体的缺点是什么呢？

Lint 作为一个静态扫描工具，它最大的问题在于没有考虑到 ProGuard 的代码裁剪。在 ProGuard 过程我们会 shrink 掉大量的无用代码，但是 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，也并没有把方案做到最好，依然存在一些妥协的地方。

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

课后作业

对于 Android 的编译流程，你还有不理解的地方吗？对于安装包中的资源，你还有哪些好的优化方案？欢迎留言跟我和其他同学一起讨论。

不知道你有没有想过，其实“第三阶段”的无用资源删除方案也并不是终极解决方案，因为它并没有考虑到无用的 Assets 资源。

但是对于 Assets 资源，代码中会有各种各样的引用方式，如果想准确地识别出无用的 Assets 并不是那么容易。当初在 Matrix 中，我们尝试提供了一套简单的实现，你可以参考UnusedAssetsTask。

希望你在课后也可以进一步思考，我们可以如何识别出无用的 Assets 资源，在这个过程中会遇到哪些问题？

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

24 | 想成为Android高手，你需要先搞定这三个问题

专栏上线已经两个多月，模块一“高质量开发”也已经更新完毕，你掌握地如何呢？我知道有不少同学一直随着专栏更新积极学习、认真完成课后的练习作业，并且及时给我反馈，作为专栏作者我很欣慰。

但也有不少同学表示很难跟上专栏的进度，似乎对“如何成为 Android 开发高手”感到更加迷茫也更困惑了。“这个专栏实在是太难了！”“我日常工作根本用不上这些知识！”“我应该怎样做才能更好地学习这个专栏？”。太难了、工作用不到、想学但是又不知道从何入手，这是我听到同学们反馈最多的三个问题。

今天既然是专栏“模块一”的答疑时间，那我就来解答这三个问题，力求帮助迷茫的你拨开云雾，找到通向 Android 开发高手之路。

前几年在业务红利期，Atlas、Tinker、React Native、Weex、小程序大行其道，我们的应用程序堆积了各种各样的框架以及无数的业务代码。当现在需要出海东南亚，需要下沉到三四线城市时，在面对各种低端机和恶劣网络条件的时候，再猛然回头一看，会发现原来我们已经欠下了如此恐怖的“技术债”。

如果想成为一名开发高手，只做好需求是远远不够的，还需要有系统性解决应用性能和架构问题的能力。而这些问题本来就是很复杂的，可能对于一些同学来说解决复杂问题会感到很难，但你要成为高手，就一定要具备解决复杂问题的能力。下面我就先来谈谈这个专栏真的这么“难”吗？

问题一：这个专栏太难了？

性能优化就像一个坑，你永远不知道自己跳进去的坑有多深 。

在过去几个月，我一直在填“启动优化”这个坑，也对这句话有了更深的感触。

业务优化：应用层。开始的时候通过业务代码的优化，我很轻松地就把启动速度优化了 50%。但正如《大停滞》所说的，这只是摘完了“所有低垂的果实”。之后很快就陷入了停滞，就像冲进了一条漆黑的隧道，不知道还可以做哪些事情，很多现象从表面也不知道如何解释：I/O 有时候为什么那么慢？线程的优化应该怎么样去衡量？

Android Framework：系统框架层。为了冲出这条漆黑的隧道，我去研究了 Android 的内存管理、文件系统、渲染框架等各个模块；学习如何去优化和监控 I/O、线程、卡顿以及帧率，建立了各种各样的性能监控框架。为什么我对监控如此重视？这是因为对于大厂来说，“挖坑容易，填坑难”，几十上百人协同开发一个项目，我们不希望只是解决具体的某一个问题，而是要彻底解决某一类问题。但想要实现一个监控框架，前提是需要对 Framework 有非常充分地理解和研究。

Linux Kernel：内核层。再深入下去，我还需要利用 Linux 的一些机制，例如 ftrace、Perf、JVMTI 等。在做 I/O 的类重排、文件重排评估的时候，还需要自己去修改内核的参数，去刷 ROM。

Hardware：硬件层。高端机和低端机的硬件差异究竟在哪里？eMMC 闪存和 UFS 闪存的区别是什么？除了更加了解硬件的性能和特性，到了这个阶段我还希望可以向手机厂商和硬件厂商要性能。例如高通的CPU Boost、微信的 Hardcoder、OPPO 的开放平台等。

启动优化的过程，就像是一个知识爬坡的过程。我们不停地尝试往底层深入，希望去摘更高的果实。那再回到你疑惑的问题：这个专栏难不难？难，因为它试图为你从上往下拆解整个知识架构。

坦白说，现在很多移动开发工程师更像是 API 工程师，背后的数据结构、算法和架构相关的知识是不达标的。这个时候如果想往底层走，就会感觉步步艰辛。但是上层的 API 很容易被 Deprecated，即使你对 Android 的所有 API 倒背如流也无法成为真正的开发高手。这样的你，即便以后把 Android 替换成 Fuchsia，你也还只是一个 Dart API 工程师。

相反越底层的东西越不容易过时，假如我们以后面对的不是 Linux 内核的系统，比如 Fuchsia OS，也可以根据已经掌握的系统知识套用到现有的操作系统上，因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的，在迁移到新的系统上时可以有一个全局的视角，帮助你快速上手。

因此我希望 5 年后再回头看这个专栏，它依然不会过时。所以从知识的深度来看，这个专栏的确难。那从知识的广度来看呢？

崩溃、内存、卡顿、I/O、渲染、网络…这个专栏涉及的知识的确非常多，而且这个专栏也只能提纲挈领，还需要你花时间去补充文章里给你链接的更多知识。

所以说无论从知识的深度还是广度来看，专栏的确算是比较难。那这个专栏究竟有多难呢？可以说超越了大多数腾讯 T3 或者阿里 P7 的水平。如果你还没到达这个级别，看不懂是正常的，因为大部分内容 BAT 的工程师第一遍可能也看不懂。

把这个专栏写“难”，并不是因为我想炫技，而是成为一名真正的 Android 开发高手本来就没有想象得那么容易。只有看到差距才有前进的动力，2019 年你需要真正迈出走向高手的第一步。

问题二：专栏所讲的工作上用不到？

我一直只是做业务，没有机会接触性能。对于这个专栏，我更期待可以对日常工作有帮助的内容。

正如我上面所说的，之所以选择写这些内容，是因为它们是移动开发高手所必须掌握的。如果我告诉你 MAT 怎么用、Profiler 怎么用，或者告诉你如何去写界面，或许这些内容对你日常工作能有帮助，但仅仅这些你依然不可能成为一名 Android 的高手。

这个专栏目希望可以提高你的个人能力，帮助你成长，或许不一定与你当前的工作完全契合，那这个问题怎么解决呢？我认为完全不需要等着别人给你安排，我们在完成工作之余，可以尝试去解决一些应用性能和架构的问题，又或者是团队效率的问题。

这样你工作上的表现也会超出上级的预期，并且可能以后这些高级问题大家都会来咨询你。同事对你建立了信任，这些事情以后可能就都由你负责了，你也成为了大家心目中的“开发高手”。

当然如果你认为目前的平台对未来的发展制约太多，那这个专栏同样也是你去面试大厂的一块非常好的敲门砖。

“打铁还需自身硬”，专栏里很多内容大厂面试官可能也不熟悉，我专栏里讲的很多问题其实大厂目前做得也都不很完善。在专栏中，我会力求去分析腾讯、阿里、头条、Facebook、Google 等国内外大厂目前遇到的问题、尝试解决的方案，以及未来优化的方向。希望可以扩宽你的视野，帮你知道大厂在玩什么、他们都在意什么，因为这些对于面试来说也非常重要。

虽然这个专栏涉及那么多的内容，但毕竟我们不可能每一项都精通。之前我在一篇文章曾经讲过微信的 T 型人才理论，说的是微信在面试时，不会问你 Android 和 iOS 的 API 怎么使用，而是希望候选人在某一个领域研究得特别牛、特别深入，并且是可以打动面试官的。这意味着如果你在某一个领域证明过自己，那微信也会愿意在其他领域给你机会。这里我推荐你看看《谈谈腾讯的技术价值观与技术人才修炼》这篇文章。

不夸张地说，LeetCode 适量刷题，加上这个专栏知识的广度，如果再找其中一两个知识点更加深入地研究，这样的话进入大公司是不会有太大问题的。

问题三：这个专栏应该怎么学习

我工作用不上，平时还那么忙，应该怎么去学习这个专栏呢？

如果专栏的学习可以跟我们的工作紧密结合在一起，的确是一个非常理想的情况。但是即使是理想情况，关键也还是要靠个人的自驱力。

在极客时间的年终总结里，看到一句话特别有感触：“2018 年买了 32 个专栏，完成了开篇词的学习”。这个专栏应该怎么学？你首先应该抛弃焦虑，无所畏惧地往前冲。

既然腾讯 T3 或者阿里 P7 都会觉得难，如果看不懂真的不要气馁，也不要焦虑，可以结合参考资料慢慢看。因为专栏一直都在，可以按照自己的节奏来学习，甚至可以用 2019 年一整年的时间来“死磕”它，但千万不要放弃。

还记得当初你在专栏“导读”里立下的 flag 吗，你可以利用这个专栏好好地将知识架构补充完整。我们的基础能力提升了，未来无论是大前端还是 Flutter 都会有用武之地，也就更加无需担心 Android 系统是否会被颠覆。

这个专栏应该怎么学？我给你的第二个建议是多看、多想、多实践。看再多的文章，不去思考文章所讲的内容和意图也是没用的；思考再多，不去动手真正实践也是没用的。

正因为实践这么重要，所以我在写专栏时才会把大量的时间花在 Sample 上面。想想现在有那么多的开源项目，可能我们只是调用 API 或者提一两个 issue，并不算是真正使用。想要真正用好开源项目，需要你去研究内部的机制，思考作者的意图。只有在认真研究之后，我们才能发现优化的空间。

在学习专栏时，我建议可以先挑一两个知识点开始深入学习。如果你觉得崩溃相关的内容比较困难，可以先略过，等学习完其他知识后再回头来看，肯定会有不一样的体会。

我们的学习过程也是一个树立信心的过程，可能在某几个阶段会感到煎熬，但是只要你攀登上去了，一切都会柳暗花明。

另外，专栏的很多文章我喜欢用演进的思路去讲，比如耗电的演进、渲染的演进、Android Runtime 的演进等。同样的，你成为高手的道路应该也是不停向前演进的，可能刚开始时不一定是最好的，但是只要方向是正确的，终究可以到达“高手”这个终点。

最后，也是我反复强调过的，专栏的学习还需要结合大量的背景知识和外部资料，推荐的书籍你可以参考《专栏学得苦？可能你还需要一份配套学习书单》。

总结

今天我们一起来定一个“小目标”也不迟：按照专栏给出的方向尝试一下、努力一下，走向通往 Android 开发的高手之路。

未来移动开发无论是变成大前端还是 Flutter 的世界，性能、效率和架构都是永恒不变的主线。今天我们在 Android 开发打下的坚实基础，未来也会帮助我们更好地理解和深入新的开发模式或者新的系统。崩溃、内存、存储、渲染、I/O、网络…这些知识以及它们背后的底层原理依然还是非常重要的。对于其他领域也是如此，一个前端开发工程师不能只知道 HTML、CSS 和 JS 语法，还需要知道它们编译的原理、浏览器实现的原理以及底层的渲染机制等。

最后我想说一个人学习可能会比较孤独，如果可以找到更多志同道合的朋友一起学习，效果可能会更好。正如很多开发所提倡的结对编程，推荐你看看《两位拯救谷歌的超级工程师的故事》。除了结对编程的范例之外，文中有一段话对我的触动也非常大。

Jeff 与 Sanjay 对于计算机的工作原理非常熟悉，能够立足 bit 层级进行思考。Jeff 曾经整理出一份《每位程序员都应该了解的那些延迟数字》清单。虽然名为“每位程序员都应该了解”，但大多数从业者对这些数字其实非常陌生——例如一级缓存引用通常需要半纳秒，或者从内存中顺序读取 1MB 大概需要 250 微秒等等。但这些数字已经直接烙进了 Jeff 与 Sanjay 的大脑当中。凭借着他们对谷歌核心软件的多次重写，该系统的容量已经提升至新的数量级。

2019 年已经过去将近 1/6 了，今年你定的目标完成得怎么样了？还有哪些学习计划？有什么感受想跟其他同学分享吗？欢迎留言跟我和其他同学一起见证你的成长。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

25 | 如何提升组织与个人的研发效能？

通过“高质量开发”模块的学习，相信你已经对打造一款高质量应用信心满满了。不过人们常说“提质增效”，总喜欢把质量和效率联系在一起，我们都希望在保证质量的前提下，为自己的团队提速。

特别是移动互联网在红海厮杀的今天，快速试错变得越来越重要，敏捷开发也被越来越多的团队所推崇。有些时候为了效率我们甚至愿意牺牲部分性能，而选择在合适的时间去偿还这些“债务”。

在“高质量开发”模块中，我侧重如何给应用交付的每个步骤做好“质检”。今天我们就一起来开启新的征程，从组织和个人研发效能的角度，重新帮你审视整个应用交付的过程。

组织的研发效能

1. 何为研发效能

在讨论如何优化组织研发效能之前，请你先思考一下什么是研发效能。

我们平常开发的过程，是从产品的一个需求想法，转变为功能并且发布上线。这个过程会涉及产品、设计、开发、测试，更多的时候可能还会拉上前端、后台或者算法。

产品的交付涉及很多的流程和人员，虽然设计人员出图很快、我们开发效率很高，但也并不能代表研发效能同样很高，研发效能是对整个产品最终交付的速度和质量负责。在《如何衡量研发效能》一文中，将研发效能定义为一个组织持续快速交付价值的能力。

在文中，作者从流动效率、资源效率和质量进一步拆解了研发效能，并提出了研发效能的五个衡量标准。

对于客户端研发来说，我们是不是只要保证按时按质实现需求就可以了呢？有很多公司，尽管实行“996”，产品、开发和测试看起来的确都很忙了，但是交付速度和质量却仍然不令人满意：产品埋怨开发效率低、开发埋怨产品需求不明确、测试埋怨开发质量差、开发埋怨测试发现不了问题等。

这是因为什么呢？对于研发效能这个话题，虽然我并不是这个领域的专家，但根据我多年的工作经验，我可以谈谈个人的两点思考：

提效是每个人的职责。尽管在 BAT 这些大厂，会有专门的研发效能部门，但是效能的提升并不是单单只依靠效能部门，或者认为是领导的事情，而是组织里面每一个人都应该去思考的事情。例如天猫设立的效能目标是“211”，也就是 2 周交付周期、1 周开发周期以及 1 小时发布时长。那团队中的每一个人都应该为这一共同目标而努力，回顾在每个发布迭代中遇到的问题以及改进的建议。

提效不仅限于写 Android 代码。尽管我们是 Android 开发工程师，但是我们的工作不应该局限在写 Android 代码上，关键还是解决需求场景。无论是 APM 系统、大网络平台、大数据平台，这种大型的前后端一体化解决方案，还是需求流程上或者增强产研测沟通信任的优化。只要是对提升效能有帮助的，我们都可以尝试去实践。在建立了这种整体统筹的思维之后，未来我们想转后端、前端，甚至是产品都会有很大的帮助。

微信在很早的时候就引入了 Google 的OKR绩效考核制度，记得在 2017 年 Android 团队定了“质量”“效能”和“影响力”这三个目标。

接着 Android 团队的 30 多个研发也都在围绕这三个目标来制定自己的工作计划，例如针对“效能”来说，有的人抽离一个 UI 库或者动画库，有的人写一个监控工具，有的人提升编译速度，有的人写一个 Web 的值班页面，有的人优化需求评审的流程…

这样大家集思广益，一起思考、一起讨论，为达成组织的共同目标而努力，这也是为什么微信开发人员虽然不多，但是战斗力在业界数一数二的原因。

2. 应用交付的流程

前面我从整个组织的角度，定义了研发效能的含义以及衡量它的五个标准。同时也结合我在微信的经历，谈了我关于提升研发效能的两点思考。可能大部分同学还是感觉整个产品的交付流程类似产品、UI 设计这些环节是研发人员无法把控的，那接下来我只从研发的流程来看如何提高效能。

正如我在专栏导读《如何打造高质量的应用》所说的，一个应用至少会经过开发、编译 CI、测试、灰度和发布这几个阶段。下面我从效能的角度，分别看看每个阶段需要关注什么问题。

开发阶段。开发阶段解决的是如何用尽可能短的时间，完成尽可能多的需求，并且保证开发的质量，不至于后期过多的返工。项目的架构应该如何选择？例如应该采用原生开发，还是 Web、React Native、Flutter 这样的跨平台方案。如何提升团队人员的能力以及工具和框架的成熟度？有哪些提升团队工作效率的技巧？

编译、CI 阶段。编译 CI 阶段解决的是如何发现和优化开发阶段的一些编码问题，以及快速构建出最终产物。Google 的 Gradle、Facebook 的 Buck 为编译速度做了哪些努力？Flutter 的 Hot Reload 为什么可以这么快？AspectJ、ASM、ReDex 这三种插桩方法的原理和差别是什么？腾讯的 RDM、阿里的摩天轮这些编译构建平台有什么特别之处？

测试阶段。测试阶段是为了发现交付过程的质量问题。测试的确不容易，自动化测试成本高，也不容易把控发布质量。那如何可以让测试覆盖更多的场景，Monkey、性能测试、UI 测试应该怎么实践？腾讯的 RDM、蚂蚁的伙伴是如何做到人人都是测试？网易的 Airtest 测试框架有哪些过人之处？

灰度、发布阶段。灰度发布是为了验证产品的效果。发布并不是把包丢出去就可以了，我们需要对自己的产品负责。那如何准确、快速地评估产品数据？头条、快手是如何做到精准运营和 A/B 测试？如果遇到疑难的线上问题应该怎么解决？复杂多变的网络问题又应该怎样去定位和分析？

当然为了提升在这个过程的效率，我们会用到一些很有用的第三方工具，例如用于 CodeReview 的 Gerrit、持续集成的 Jenkins、代码审计的 Coverity 等。

工具不仅可以将大量人工操作变成自动化，也可以方便团队更好的协作。例如我们把需求的流转、进度安排变得可视化，可以大大地减少产研测团队之间消息的隔阂，在这方面阿里的 AONE 或者腾讯的 TPAD 都做得非常不错。

项目管理、需求管理、代码托管、构建 / 部署、测试平台…都是我们常用的工具，类似阿里的云效会提供这样的一站式平台，从需求发起到分支管理、代码 review，再到测试发布。在过去，这些工具都是各大公司研发效能部门多年的结晶，一般都不愿意对外提供。但是得益于云时代、TOB 时代的到来，现在都愿意打包成商品供我们使用。

当然每个项目都会有自己特殊的情况，这些工具也不一定可以完全符合我们的需要，我们可以根据自己的情况选择合适的服务，或者直接开发自己的工具。

个人的研发效率

个人作为整个组织的一部分，我们效率的提升也会对组织有正向的作用。特别是对某些小团队或者独立开发者来说，个人可能就代表了整个团队。

关于个人效率的提升和时间的管理，有很多书籍专门在讲这个内容。下面从我看到的一些不好的现象，谈谈我个人两个比较深的体会。

1. 思考

年轻人千万不要碰的东西之一，便是能获得短期快感的软件。它们会在不知不觉中偷走你的时间，消磨你的意志力，摧毁你向上的勇气。

随着我们接触到的信息越来越多，越来越多的人很难保持对事情的专注力。工作期间经常想着去刷一下抖音、头条、微信、王者荣耀，强行把时间打破成碎片。

跟产品开了一天的会，他的需求有了，你的代码呢？

可能也有一部分同学他们不刷抖音和头条，但是在上班时间也会被各种邮件、钉钉、会议折磨得痛不欲生。针对这个问题，我的做法是每天上午和下午都会至少保留一个小时“目空一切”的时间，不看邮件、不看钉钉、不接会议。当然有的时候也是无法避免被老板当面拉回到“现实”。

我经常看到团队里面的一些人也存在这种现象，最终表现可能是这个人经常“996”，看起来很忙，但是产出并不高，而且个人成长也不明显。

每天我们应该需要有一段时间真正的静下心来工作，而且每过一段时间也要重新审视一下自己的工作，有哪些地方做的不够好？有没有什么事情是自己或者团队的人正在反复而低效在做的，是否可以优化。

2. 方法

关于方法，这里我只说两点，也是同学们经常会出现的问题。

做事的方法。曾经看到一些开发人员，非常喜欢用二分法来排查问题。当测试给他报 Bug 时，他会非常熟练的操作 Git 命令，花上一两个小时打出几十个验证包，不辞劳苦地尝试。当然二分法我也使用过，在毫无头绪的时候的确可以“死马当活马医”。但是我们在使用这个“大杀器”之前，起码应该经过自己的思考，尝试正面去迎击 Bug 本身。

提问的方法。在微信和 QQ 群里面，经常会看到有些同学在群里问一个问题，可能 Google 一下就可以得到答案。然后他们在群里灌了一个小时水，最后还是没有任何答案。在做 Tinker 开源的时候，我有时也被一些使用者问得心情不再愉悦。事实上提问题是非常体现技术和职业素养的，我们在提问题之前需要经过自己的思考和努力，在这里推荐你看看《提问的艺术》。

总结

“吾日三省吾身”，无论是组织的研发效能，还是个人的工作效率，我们都需要学会经常去回顾和思考，快速演进、快速迭代，争取未来做得更好。

你在工作和学习的效率上，遇到过哪些问题？对于如何提升工作和学习的效率，你还有什么好的方法和建议吗？欢迎留言分享给我和其他同学。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

26 | 关于编译，你需要了解什么？

作为 Android 工程师，我们每天都会经历无数次编译；而对于大型项目来说，每次编译就意味着要花去一杯咖啡的时间。可能我讲具体的数字你会更有体会，当时我在微信时，全量编译 Debug 包需要 5 分钟，而编译 Release 包更是要超过 15 分钟。

如果每次编译可以减少 1 分钟，对微信整个 Android 团队来说就可以节约 1200 分钟（团队 40 人 × 每天编译 30 次 × 1 分钟）。所以说优化编译速度，对于提升整个团队的开发效率是非常重要的。

那应该怎么样优化编译速度呢？微信、Google、Facebook 等国内外大厂都做了哪些努力呢？除了编译速度之外，关于编译你还需要了解哪些知识呢？

关于编译

虽然我们每天都在编译，那到底什么是编译呢？

你可以把编译简单理解为，将高级语言转化为机器或者虚拟机所能识别的低级语言的过程。对于 Android 来说，这个过程就是把 Java 或者 Kotlin 转变为 Android 虚拟机运行的Dalvik 字节码的过程。

编译的整个过程会涉及词法分析、语法分析 、语义检查和代码优化等步骤。对于底层编译原理感兴趣的同学，你可以挑战一下编译原理的三大经典巨作：龙书、虎书、鲸鱼书。

但今天我们的重点不是底层的编译原理，而是希望一起讨论 Android 编译需要解决的问题是什么，目前又遇到了哪些挑战，以及国内外大厂又给出了什么样的解决方案。

1. Android 编译的基础知识

无论是微信的编译优化，还是 Tinker 项目，都涉及比较多的编译相关知识，因此我在 Android 编译方面研究颇多，经验也比较丰富。Android 的编译构建流程主要包括代码、资源以及 Native Library 三部分，整个流程可以参考官方文档的构建流程图。

Gradle是 Android 官方的编译工具，它也是 GitHub 上的一个开源项目。从 Gradle 的更新日志可以看到，当前这个项目还更新得非常频繁，基本上每一两个月都会有新的版本。对于 Gradle，我感觉最痛苦的还是 Gradle Plugin 的编写，主要是因为 Gradle 在这方面没有完善的文档，因此一般都只能靠看源码或者断点调试的方法。

但是编译实在太重要了，每个公司的情况又各不相同，必须强行造一套自己的“轮子”。已经开源的项目有 Facebook 的Buck以及 Google 的Bazel。

为什么要自己“造轮子”呢？主要有下面几个原因：

统一编译工具。Facebook、Google 都有专门的团队负责编译工作，他们希望内部的所有项目都使用同一套构建工具，这里包括 Android、Java、iOS、Go、C++ 等。编译工具的统一优化，所有项目都会受益。

代码组织管理架构。Facebook 和 Google 的代码管理有一个非常特别的地方，就是整个公司的所有项目都放到同一个仓库里面。因此整个仓库非常庞大，所以他们也不会使用 Git。目前 Google 使用的是Piper，Facebook 是基于HG修改的，也是一种基于分布式的文件系统。

极致的性能追求。Buck 和 Bazel 的性能的确比 Gradle 更好，内部包含它们的各种编译优化。但是它们或多或少都有一些定制的味道，例如对 Maven、JCenter 这样的外部依赖支持的也不是太好。

“程序员最痛恨写文档，还有别人不写文档”，所以它们的文档也是比较少的，如果想做二次定制开发会感到很痛苦。如果你想把编译工具切换到 Buck 和 Bazel，需要下很大的决心，而且还需要考虑和其他上下游项目的协作。当然即使我们不去直接使用，它们内部的优化思路也非常值得我们学习和参考。

Gradle、Buck、Bazel 都是以更快的编译速度、更强大的代码优化为目标，我们下面一起来看看它们做了哪些努力。

2. 编译速度

回想一下我们的 Android 开发生涯，在编译这件事情上面究竟浪费了多少时间和生命。正如前面我所说，编译速度对团队效率非常重要。

关于编译速度，我们最关心的可能还是编译 Debug 包的速度，尤其是增量编译（incremental build）的速度，希望可以做到更加快速的调试。正如下图所示，我们每次代码验证都要经过编译和安装两个步骤。

编译时间。把 Java 或者 Kotlin 代码编译为“.class“文件，然后通过 dx 编译为 Dex 文件。对于增量编译，我们希望编译尽可能少的代码和资源，最理想情况是只编译变化的部分。但是由于代码之间的依赖，大部分情况这并不可行。这个时候我们只能退而求其次，希望编译更少的模块。Android Plugin 3.0使用 Implementation 代替 Compile，正是为了优化依赖关系。

安装时间。我们要先经过签名校验，校验成功后会有一大堆的文件拷贝工作，例如 APK 文件、Library 文件、Dex 文件等。之后我们还需要编译 Odex 文件，这个过程特别是在 Android 5.0 和 6.0 会非常耗时。对于增量编译，最好的优化是直接应用新的代码，无需重新安装新的 APK。

对于增量编译，我先来讲讲 Gradle 的官方方案Instant Run。在 Android Plugin 2.3 之前，它使用的 Multidex 实现。在 Android Plugin 2.3 之后，它使用 Android 5.0 新增的 Split APK 机制。

如下图所示，资源和 Manifest 都放在 Base APK 中， 在 Base APK 中代码只有 Instant Run 框架，应用的本身的代码都在 Split APK 中。

Instant Run 有三种模式，如果是热交换和温交换，我们都无需重新安装新的 Split APK，它们的区别在于是否重启 Activity。对于冷交换，我们需要通过adb install-multiple -r -t重新安装改变的 Split APK，应用也需要重启。

虽然无论哪一种模式，我们都不需要重新安装 Base APK。这让 Instant Run 看起来是不是很不错，但是在大型项目里面，它的性能依然非常糟糕，主要原因是：

多进程问题。“The app was restarted since it uses multiple processes”，如果应用存在多进程，热交换和温交换都不能生效。因为大部分应用都会存在多进程的情况，Instant Run 的速度也就大打折扣。

Split APK 安装问题。虽然 Split APK 的安装不会生成 Odex 文件，但是这里依然会有签名校验和文件拷贝（APK 安装的乒乓机制）。这个时间需要几秒到几十秒，是不能接受的。

javac 问题。在 Gradle 4.6 之前，如果项目中运用了 Annotation Processor。那不好意思，本次修改以及它依赖的模块都需要全量 javac，而这个过程是非常慢的，可能会需要几十秒。这个问题直到Gradle 4.7才解决，关于这个问题原因的讨论你可以参考这个Issue。

你还可以看看这一个 Issue：“full rebuild if a class contains a constant”，假设修改的类中包含一个“public static final”的变量，那同样也不好意思，本次修改以及它依赖的模块都需要全量 javac。这是为什么呢？因为常量池是会直接把值编译到其他类中，Gradle 并不知道有哪些类可能使用了这个常量。

询问 Gradle 的工作人员，他们出给的解决方案是下面这个：

// 原来的常量定义:

public static final int MAGIC = 23

// 将常量定义替换成方法:

public static int magic() {

  return 23;

}

对于大型项目来说，这肯定是不可行的。正如我在 Issue 中所写的一样，无论我们是不是真正改到这个常量，Gradle 都会无脑的全量 javac，这样肯定是不对的。事实上，我们可以通过比对这次代码修改，看看是否有真正改变某一个常量的值。

但是可能用过阿里的Freeline或者蘑菇街的极速编译的同学会有疑问，它们的方案为什么不会遇到 Annotation 和常量的问题？

事实上，它们的方案在大部分情况比 Instant Run 更快，那是因为牺牲了正确性。也就是说它们为了追求更快的速度，直接忽略了 Annotation 和常量改变可能带来错误的编译产物。Instant Run 作为官方方案，它优先保证的是 100% 的正确性。

当然 Google 的人也发现了 Instant Run 的种种问题，在 Android Studio 3.5 之后，对于 Android 8.0 以后的设备将会使用新的方案“Apply Changes”代替 Instant Run。目前我还没找到关于这套方案更多的资料，不过我认为应该是抛弃了 Split APK 机制。

一直以来，我心目中都有一套理想的编译方案，这套方案安装的 Base APK 依然只是一个壳 APK，真正的业务代码放到 Assets 的 ClassesN.dex 中。

无需安装。依然使用类似 Tinker 热修复的方法，每次只把修改以及依赖的类插入到 pathclassloader 的最前方就可以，不熟悉的同学可以参考《微信 Android 热补丁实践演进之路》中的 Qzone 方案。

Oatmeal。为了解决首次运行时 Assets 中 ClassesN.dex 的 Odex 耗时问题，我们可以使用“安装包优化“中讲过的 ReDex 中的黑科技：Oatmeal。它可以在 100 毫秒以内生成一个完全解释执行的 Odex 文件。

关闭 JIT。我们通过在 AndroidManifest 指定android:vmSafeMode=“true”，关闭虚拟机的 JIT 优化，这样也就不会出现 Tinker 在Android N 混合编译遇到的问题。

这套方案应该可以完全解决 Instant Run 当前的各种问题，我也希望对编译优化感兴趣的同学可以自行实现这一套方案，并能开源出来。

对于编译速度的优化，我还有几个建议：

更换编译机器。对于实力雄厚的公司，直接更换 Mac 或者其他更给力的设备作为编译机，这种方式是最简单的。

Build Cache。可以将大部分不常改变的项目拆离出去，并使用远端 Cache模式保留编译后的缓存。

升级 Gradle 和 SDK Build Tools。我们应该及时去升级最新的编译工具链，享受 Google 的最新优化成果。

使用 Buck。无论是 Buck 的 exopackage，还是代码的增量编译，Buck 都更加高效。但我前面也说过，一个大型项目如果要切换到 Buck，其实顾虑还是比较多的。在 2014 年初微信就接入了 Buck，但是因为跟其他项目协作的问题，导致在 2015 年切换回 Gradle 方案。相比之下，可能目前最热的 Flutter 中Hot Reload秒级编译功能会更有吸引力。

当然最近几个 Android Studio 版本，Google 也做了大量的其他优化，例如使用AAPT2替代了 AAPT 来编译 Android 资源。AAPT2 实现了资源的增量编译，它将资源的编译拆分成 Compile 和 Link 两个步骤。前者资源文件以二进制形式编译 Flat 格式，后者合并所有的文件再打包。

除了 AAPT2，Google 还引入了 d8 和 R8，下面分别是 Google 提供的一些测试数据。

那什么是 d8 和 R8 呢？除了编译速度的优化，它们还有哪些其他的作用？

3. 代码优化

对于 Debug 包编译，我们更关心速度。但是对于 Release 包来说，代码的优化更加重要，因为我们会更加在意应用的性能。

下面我就分别讲讲 ProGuard、d8、R8 和 ReDex 这四种我们可能会用到的代码优化工具。

ProGuard

在微信 Release 包 12 分钟的编译过程里，单独 ProGuard 就需要花费 8 分钟。尽管 ProGuard 真的很慢，但是基本每个项目都会使用到它。加入了 ProGuard 之后，应用的构建过程流程如下：

ProGuard 主要有混淆、裁剪、优化这三大功能，它的整个处理流程是：

其中优化包括内联、修饰符、合并类和方法等 30 多种，具体介绍与使用方法你可以参考官方文档。

d8

Android Studio 3.0 推出了d8，并在 3.1 正式成为默认工具。它的作用是将“.class”文件编译为 Dex 文件，取代之前的 dx 工具。

d8 除了更快的编译速度之外，还有一个优化是减少生成的 Dex 大小。根据 Google 的测试结果，大约会有 3%～5% 的优化。

R8

R8 在 Android Studio 3.1 中引入，它的志向更加高远，它的目标是取代 ProGuard 和 d8。我们可以直接使用 R8 把“.class”文件变成 Dex。

同时，R8 还支持 ProGuard 中混淆、裁剪、优化这三大功能。由于目前 R8 依然处于实验阶段，网上的介绍资料并不多，你可以参考下面这些资料：

ProGuard 和 R8 对比：ProGuard and R8: a comparison of optimizers。

Jake Wharton 大神的博客最近有很多 R8 相关的文章：https://jakewharton.com/blog/。

R8 的最终目的跟 d8 一样，一个是加快编译速度，一个是更强大的代码优化。

ReDex

如果说 R8 是未来想取代的 ProGuard 的工具，那 Facebook 的内部使用的ReDex其实已经做到了。

Facebook 内部的很多项目都已经全部切换到 ReDex，不再使用 ProGuard 了。跟 ProGuard 不同的是，它直接输入的对象是 Dex，而不是“.class”文件，也就是它是直接针对最终产物的优化，所见即所得。

在前面的文章中，我已经不止一次提到 ReDex 这个项目，因为它里面的功能实在是太强大了，具体可以参考专栏前面的文章《包体积优化（上）：如何减少安装包大小？》。

Interdex：类重排和文件重排、Dex 分包优化。

Oatmeal：直接生成的 Odex 文件。

StripDebugInfo：去除 Dex 中的 Debug 信息。

此外，ReDex 中例如Type Erasure和去除代码中的Aceess 方法也是非常不错的功能，它们无论对包体积还是应用的运行速度都有帮助，因此我也鼓励你去研究和实践一下它们的用法和效果。但是 ReDex 的文档也是万年不更新的，而且里面掺杂了一些 Facebook 内部定制的逻辑，所以它用起来的确非常不方便。目前我主要还是直接研究它的源码，参考它的原理，然后再直接单独实现。

事实上，Buck 里面其实也还有很多好用的东西，但是文档里面依然什么都没有提到，所以还是需要“read the source code”。

Library Merge 和 Relinker

多语言拆分

分包支持

ReDex 支持

持续交付

Gradle、Buck、Bazel 它们代表的都是狭义上的编译，我认为广义的编译应该包括打包构建、Code Review、代码工程管理、代码扫描等流程，也就是业界最近经常提起的持续集成。

目前最常用的持续集成工具有 Jenkins、GitLab CI、Travis CI 等，GitHub 也有提供自己的持续集成服务。每个大公司都有自己的持续集成方案，例如腾讯的 RDM、阿里的摩天轮、大众点评的MCI等。

下面我来简单讲一下我对持续集成的一些经验和看法：

自定义代码检查。每个公司都会有自己的编码规范，代码检查的目的在于防止不符合规范的代码提交到远程仓库中。比如微信就定义了一套代码规范，并且写了专门的插件来检测。例如日志规范、不能直接使用 new Thread、new Handler 等，而且违反者将会得到一定的惩罚。自定义代码检测可以通过完全自己实现或者扩展 Findbugs 插件，例如美团它们就利用 Findbugs 实现了Android 漏洞扫描工具 Code Arbiter。

第三方代码检查。业界比较常用的代码扫描工具有收费的 Coverity，以及 Facebook 开源的Infer，例如空指针、多线程问题、资源泄漏等很多问题都可以扫描出来。除了增加检测流程，我最大的体会是需要同时增加人员的培训。我遇到很多开发者为了解决扫描出来的问题，空指针就直接判空、多线程就直接加锁，最后可能会造成更加严重的问题。

Code Review。关于 Code Review，集成 GitLab、Phabricator 或者 Gerrit 都是不错的选择。我们一定要重视 Code Review，这也是给其他人展示我们“伟大”代码的机会。而且我们自己应该是第一个 Code Reviewer，在给别人 Review 之前，自己先以第三者的角度审视一次代码。这样先通过自己这一关的考验，既尊重了别人的时间，也可以为自己树立良好的技术品牌。

持续集成涉及的流程有很多，你需要结合自己团队的现状。如果只是一味地去增加流程，有时候可能适得其反。

总结

在 Android 8.0，Google 引入了Dexlayout库实现类和方法的重排，Facebook 的 Buck 也第一时间引入了 AAPT2。ReDex、d8、R8 其实都是相辅相成，可以看到 Google 也在摄取社区的知识，但同时我们也会从 Google 的新技术发展里寻求思路。

我在写今天的内容时还有另外一个体会，Google 为了解决 Android 编译速度的问题，花了大量的力气结果却不尽如人意。我想说如果我们敢于跳出系统的制约，可能才会彻底解决这个问题，正如在 Flutter 上面就可以完美实现秒级编译。其实做人、做事也是如此，我们经常会陷入局部最优解的困局，或者走进“思维怪圈”，这时如果能跳出路径依赖，从更高的维度重新思考、审视全局，得到的体会可能会完全不一样。

课后作业

在你的工作中，遇到过哪些编译问题？有没有做过具体优化编译速度的工作？对于编译，你还有哪些疑问？欢迎留言跟我和其他同学一起讨论。

对于 Android Build System，可以说每年都会有不少的变化，也有很多新的东西出来。所以我们应该保持敏感度，你会发现很多工具都非常有用，例如 Desugar、Dexlayout、JVM TI、App Bundle 等。

今天的课后作业是，请你观看 2018 年 Google I/O 编译工具相关的视频，在留言中写下自己的心得体会。

What’s new with the Android build system (Google I/O '18)

What’s new in Android development tools

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

27 | 编译插桩的三种方法：AspectJ、ASM、ReDex

只要简单回顾一下前面课程的内容你就会发现，在启动耗时分析、网络监控、耗电监控中已经不止一次用到编译插桩的技术了。那什么是编译插桩呢？顾名思义，所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。

如上图所示，请你回忆一下 Java 代码的编译流程，思考一下插桩究竟是在编译流程中的哪一步工作？除了我们之前使用的一些场景，它还有哪些常见的应用场景？在实际工作中，我们应该怎样更好地使用它？现在都有哪些常用的编译插桩方法？今天我们一起来解决这些问题。

编译插桩的基础知识

不知道你有没有注意到，在编译期间修改和生成代码其实是很常见的行为，无论是 Dagger、ButterKnife 这些 APT（Annotation Processing Tool）注解生成框架，还是新兴的 Kotlin 语言编译器，它们都用到了编译插桩的技术。

下面我们一起来看看还有哪些场景会用到编译插桩技术。

1. 编译插桩的应用场景

编译插桩技术非常有趣，同样也很有价值，掌握它之后，可以完成一些其他技术很难实现或无法完成的任务。学会这项技术以后，我们就可以随心所欲地操控代码，满足不同场景的需求。

代码生成。除了 Dagger、ButterKnife 这些常用的注解生成框架，Protocol Buffers、数据库 ORM 框架也都会在编译过程生成代码。代码生成隔离了复杂的内部实现，让开发更加简单高效，而且也减少了手工重复的劳动量，降低了出错的可能性。

代码监控。除了网络监控和耗电监控，我们可以利用编译插桩技术实现各种各样的性能监控。为什么不直接在源码中实现监控功能呢？首先我们不一定有第三方 SDK 的源码，其次某些调用点可能会非常分散，例如想监控代码中所有 new Thread() 调用，通过源码的方式并不那么容易实现。

代码修改。我们在这个场景拥有无限的发挥空间，例如某些第三方 SDK 库没有源码，我们可以给它内部的一个崩溃函数增加 try catch，或者说替换它的图片库等。我们也可以通过代码修改实现无痕埋点，就像网易的HubbleData、51 信用卡的埋点实践。

代码分析。上一期我讲到持续集成，里面的自定义代码检查就可以使用编译插桩技术实现。例如检查代码中的 new Thread() 调用、检查代码中的一些敏感权限使用等。事实上，Findbugs 这些第三方的代码检查工具也同样使用的是编译插桩技术实现。

“一千个人眼中有一千个哈姆雷特”，通过编译插桩技术，你可以大胆发挥自己的想象力，做一些对提升团队质量和效能有帮助的事情。

那从技术实现上看，编译插桩是从代码编译的哪个流程介入的呢？我们可以把它分为两类：

Java 文件。类似 APT、AndroidAnnotation 这些代码生成的场景，它们生成的都是 Java 文件，是在编译的最开始介入。

字节码（Bytecode）。对于代码监控、代码修改以及代码分析这三个场景，一般采用操作字节码的方式。可以操作“.class”的 Java 字节码，也可以操作“.dex”的 Dalvik 字节码，这取决于我们使用的插桩方法。

相对于 Java 文件方式，字节码操作方式功能更加强大，应用场景也更广，但是它的使用复杂度更高，所以今天我主要来讲如何通过操作字节码实现编译插桩的功能。

2. 字节码

对于 Java 平台，Java 虚拟机运行的是 Class 文件，内部对应的是 Java 字节码。而针对 Android 这种嵌入式平台，为了优化性能，Android 虚拟机运行的是Dex 文件，Google 专门为其设计了一种 Dalvik 字节码，虽然增加了指令长度但却缩减了指令的数量，执行也更为快速。

那这两种字节码格式有什么不同呢？下面我们先来看一个非常简单的 Java 类。

public class Sample {

    public void test() {

        System.out.print("I am a test sample!");

    }

}

通过下面几个命令，我们可以生成和查看这个 Sample.java 类的 Java 字节码和 Dalvik 字节码。

javac Sample.java   // 生成 Sample.class，也就是 Java 字节码

javap -v Sample     // 查看 Sample 类的 Java 字节码

// 通过 Java 字节码，生成 Dalvik 字节码

dx --dex --output=Sample.dex Sample.class

dexdump -d Sample.dex   // 查看 Sample.dex 的 Dalvik 的字节码

你可以直观地看到 Java 字节码和 Dalvik 字节码的差别。

它们的格式和指令都有很明显的差异。关于 Java 字节码的介绍，你可以参考JVM 文档。对于 Dalvik 字节码来说，你可以参考 Android 的官方文档。它们的主要区别有：

体系结构。Java 虚拟机是基于栈实现，而 Android 虚拟机是基于寄存器实现。在 ARM 平台，寄存器实现性能会高于栈实现。

格式结构。对于 Class 文件，每个文件都会有自己单独的常量池以及其他一些公共字段。对于 Dex 文件，整个 Dex 中的所有 Class 共用同一个常量池和公共字段，所以整体结构更加紧凑，因此也大大减少了体积。

指令优化。Dalvik 字节码对大量的指令专门做了精简和优化，如下图所示，相同的代码 Java 字节码需要 100 多条，而 Dalvik 字节码只需要几条。

关于 Java 字节码和 Dalvik 字节码的更多介绍，你可以参考下面的资料：

Dalvik and ART

Understanding the Davlik Virtual Machine

编译插桩的三种方法

AspectJ 和 ASM 框架的输入和输出都是 Class 文件，它们是我们最常用的 Java 字节码处理框架。

1. AspectJ

AspectJ是 Java 中流行的 AOP（aspect-oriented programming）编程扩展框架，网上很多文章说它处理的是 Java 文件，其实并不正确，它内部也是通过字节码处理技术实现的代码注入。

从底层实现上来看，AspectJ 内部使用的是BCEL框架来完成的，不过这个库在最近几年没有更多的开发进展，官方也建议切换到 ObjectWeb 的 ASM 框架。关于 BCEL 的使用，你可以参考《用 BCEL 设计字节码》这篇文章。

从使用上来看，作为字节码处理元老，AspectJ 的框架的确有自己的一些优势。

成熟稳定。从字节码的格式和各种指令规则来看，字节码处理不是那么简单，如果处理出错，就会导致程序编译或者运行过程出问题。而 AspectJ 作为从 2001 年发展至今的框架，它已经很成熟，一般不用考虑插入的字节码正确性的问题。

使用简单。AspectJ 功能强大而且使用非常简单，使用者完全不需要理解任何 Java 字节码相关的知识，就可以使用自如。它可以在方法（包括构造方法）被调用的位置、在方法体（包括构造方法）的内部、在读写变量的位置、在静态代码块内部、在异常处理的位置等前后，插入自定义的代码，或者直接将原位置的代码替换为自定义的代码。

在专栏前面文章里我提过 360 的性能监控框架ArgusAPM，它就是使用 AspectJ 实现性能的监控，其中TraceActivity是为了监控 Application 和 Activity 的生命周期。

// 在 Application onCreate 执行的时候调用 applicationOnCreate 方法

@Pointcut("execution(* android.app.Application.onCreate(android.content.Context)) && args(context)")

public void applicationOnCreate(Context context) {

}

// 在调用 applicationOnCreate 方法之后调用 applicationOnCreateAdvice 方法

@After("applicationOnCreate(context)")

public void applicationOnCreateAdvice(Context context) {

    AH.applicationOnCreate(context);

}

你可以看到，我们完全不需要关心底层 Java 字节码的处理流程，就可以轻松实现编译插桩功能。关于 AspectJ 的文章网上有很多，不过最全面的还是官方文档，你可以参考《AspectJ 程序设计指南》和The AspectJ 5 Development Kit Developer’s Notebook，这里我就不详细描述了。

但是从 AspectJ 的使用说明里也可以看出它的一些劣势，它的功能无法满足我们某些场景的需要。

切入点固定。AspectJ 只能在一些固定的切入点来进行操作，如果想要进行更细致的操作则无法完成，它不能针对一些特定规则的字节码序列做操作。

正则表达式。AspectJ 的匹配规则是类似正则表达式的规则，比如匹配 Activity 生命周期的 onXXX 方法，如果有自定义的其他以 on 开头的方法也会匹配到。

性能较低。AspectJ 在实现时会包装自己的一些类，逻辑比较复杂，不仅生成的字节码比较大，而且对原函数的性能也会有所影响。

我举专栏第 7 期启动耗时Sample的例子，我们希望在所有的方法调用前后都增加 Trace 的函数。如果选择使用 AspectJ，那么实现真的非常简单。

@Before("execution(* **(..))")

public void before(JoinPoint joinPoint) {

    Trace.beginSection(joinPoint.getSignature().toString());

}

@After("execution(* **(..))")

public void after() {

    Trace.endSection();

}

但你可以看到经过 AspectJ 的字节码处理，它并不会直接把 Trace 函数直接插入到代码中，而是经过一系列自己的封装。如果想针对所有的函数都做插桩，AspectJ 会带来不少的性能影响。

不过大部分情况，我们可能只会插桩某一小部分函数，这样 AspectJ 带来的性能影响就可以忽略不计了。如果想在 Android 中直接使用 AspectJ，还是比较麻烦的。这里我推荐你直接使用沪江的AspectJX框架，它不仅使用更加简便一些，而且还扩展了排除某些类和 JAR 包的能力。如果你想通过 Annotation 注解方式接入，我推荐使用 Jake Wharton 大神写的Hugo项目。

虽然 AspectJ 使用方便，但是在使用的时候不注意的话还是会产生一些意想不到的异常。比如使用 Around Advice 需要注意方法返回值的问题，在 Hugo 里的处理方法是将 joinPoint.proceed() 的返回值直接返回，同时也需要注意Advice Precedence的情况。

2. ASM

如果说 AspectJ 只能满足 50% 的字节码处理场景，那ASM就是一个可以实现 100% 场景的 Java 字节码操作框架，它的功能也非常强大。使用 ASM 操作字节码主要的特点有：

操作灵活。操作起来很灵活，可以根据需求自定义修改、插入、删除。

上手难。上手比较难，需要对 Java 字节码有比较深入的了解。

为了使用简单，相比于 BCEL 框架，ASM 的优势是提供了一个 Visitor 模式的访问接口（Core API），使用者可以不用关心字节码的格式，只需要在每个 Visitor 的位置关心自己所修改的结构即可。但是这种模式的缺点是，一般只能在一些简单场景里实现字节码的处理。

事实上，专栏第 7 期启动耗时的 Sample 内部就是使用 ASM 的 Core API，具体你可以参考MethodTracer类的实现。从最终效果上来看，ASM 字节码处理后的效果如下。

相比 AspectJ，ASM 更加直接高效。但是对于一些复杂情况，我们可能需要使用另外一种 Tree API 来完成对 Class 文件更直接的修改，因此这时候你要掌握一些必不可少的 Java 字节码知识。

此外，我们还需要对 Java 虚拟机的运行机制有所了解，前面我就讲到 Java 虚拟机是基于栈实现。那什么是 Java 虚拟机的栈呢？，引用《Java 虚拟机规范》里对 Java 虚拟机栈的描述：

每一条 Java 虚拟机线程都有自己私有的 Java 虚拟机栈，这个栈与线程同时创建，用于存储栈帧（Stack Frame）。

正如这句话所描述的，每个线程都有的自己栈，所以在多线程应用程序中多个线程就会有多个栈，每个栈都有自己的栈帧。

如下图所示，我们可以简单认为栈帧包含 3 个重要的内容：本地变量表（Local Variable Array）、操作数栈（Operand Stack）和常量池引用（Constant Pool Reference）。

本地变量表。在使用过程中，可以认为本地变量表是存放临时数据的，并且本地变量表有个很重要的功能就是用来传递方法调用时的参数，当调用方法的时候，参数会依次传递到本地变量表中从 0 开始的位置上，并且如果调用的方法是实例方法，那么我们可以通过第 0 个本地变量中获取当前实例的引用，也就是 this 所指向的对象。

操作数栈。可以认为操作数栈是一个用于存放指令执行所需要的数据的位置，指令从操作数栈中取走数据并将操作结构重新入栈。

由于本地变量表的最大数和操作数栈的最大深度是在编译时就确定的，所以在使用 ASM 进行字节码操作后需要调用 ASM 提供的 visitMaxs 方法来设置 maxLocal 和 maxStack 数。不过，ASM 为了方便用户使用，已经提供了自动计算的方法，在实例化 ClassWriter 操作类的时候传入 COMPUTE_MAXS 后，ASM 就会自动计算本地变量表和操作数栈。

ClassWriter(ClassWriter.COMPUTE_MAXS)

下面以一个简单的“1+2“为例，它的操作数以 LIFO（后进先出）的方式进行操作。

ICONST_1 将 int 类型 1 推送栈顶，ICONST_2 将 int 类型 2 推送栈顶，IADD 指令将栈顶两个 int 类型的值相加后将结果推送至栈顶。操作数栈的最大深度也是由编译期决定的，很多时候 ASM 修改后的代码会增加操作数栈最大深度。不过 ASM 已经提供了动态计算的方法，但同时也会带来一些性能上的损耗。

在具体的字节码处理过程中，特别需要注意的是本地变量表和操作数栈的数据交换和 try catch blcok 的处理。

数据交换。如下图所示，在经过 IADD 指令操作后，又通过 ISTORE 0 指令将栈顶 int 值存入第 1 个本地变量中，用于临时保存，在最后的加法过程中，将 0 和 1 位置的本地变量取出压入操作数栈中供 IADD 指令使用。关于本地变量和操作数栈数据交互的指令，你可以参考虚拟机规范，里面提供了一系列根据不同数据类型的指令。

异常处理。在字节码操作过程中需要特别注意异常处理对操作数栈的影响，如果在 try 和 catch 之间抛出了一个可捕获的异常，那么当前的操作数栈会被清空，并将异常对象压入这个空栈中，执行过程在 catch 处继续。幸运的是，如果生成了错误的字节码，编译器可以辨别出该情况并导致编译异常，ASM 中也提供了字节码 Verify的接口，可以在修改完成后验证一下字节码是否正常。

如果想在一个方法执行完成后增加代码，ASM 相对也要简单很多，可以在字节码中出现的每一条 RETURN 系或者 ATHROW 的指令前，增加处理的逻辑即可。

3. ReDex

ReDex 不仅只是作为一款 Dex 优化工具，它也提供了很多的小工具和文档里没有提到的一些新奇功能。比如在 ReDex 里提供了一个简单的 Method Tracing 和 Block Tracing 工具，这个工具可以在所有方法或者指定方法前面插入一段跟踪代码。

官方提供了一个例子，用来展示这个工具的使用，具体请查看InstrumentTest。这个例子会将InstrumentAnalysis的 onMethodBegin 方法插入到除黑名单以外的所有方法的开头位置。具体配置如下：

"InstrumentPass" : {

    "analysis_class_name":      "Lcom/facebook/redextest/InstrumentAnalysis;",  // 存在桩代码的类

    "analysis_method_name": "onMethodBegin",    // 存在桩代码的方法

    "instrumentation_strategy": "simple_method_tracing"

,   // 插入策略，有两种方案，一种是在方法前面插入 simple_method_tracing，一种是在 CFG 的 Block 前后插入 basic_block_tracing

}

ReDex 的这个功能并不是完整的 AOP 工具，但它提供了一系列指令生成 API 和 Opcode 插入 API，我们可以参照这个功能实现自己的字节码注入工具，这个功能的代码在Instrument.cpp中。

这个类已经将各种字节码特殊情况处理得相对比较完善，我们可以直接构造一段 Opcode 调用其提供的 Insert 接口即可完成代码的插入，而不用过多考虑可能会出现的异常情况。不过这个类提供的功能依然耦合了 ReDex 的业务，所以我们需要提取有用的代码加以使用。

由于 Dalvik 字节码发展时间尚短，而且因为 Dex 格式更加紧凑，修改起来往往牵一发而动全身。并且 Dalvik 字节码的处理相比 Java 字节码会更加复杂一些，所以直接操作 Dalvik 字节码的工具并不是很多。

市面上大部分需要直接修改 Dex 的情况是做逆向，很多同学都采用手动书写 Smali 代码然后编译回去。这里我总结了一些修改 Dalvik 字节码的库。

ASMDEX，开发者是 ASM 库的开发者，但很久未更新了。

Dexter，Google 官方开发的 Dex 操作库，更新很频繁，但使用起来很复杂。

Dexmaker，用来生成 Dalvik 字节码的代码。

Soot，修改 Dex 的方法很另类，是先将 Dalvik 字节码转成一种 Jimple three-address code，然后插入 Jimple Opcode 后再转回 Dalvik 字节码，具体可以参考例子。

总结

今天我介绍了几种比较有代表性的框架来讲解编译插桩相关的内容。代码生成、代码监控、代码魔改以及代码分析，编译插桩技术无所不能，因此需要我们充分发挥想象力。

对于一些常见的应用场景，前辈们付出了大量的努力将它们工具化、API 化，让我们不需要懂得底层字节码原理就可以轻松使用。但是如果真要想达到随心所欲的境界，即使有类似 ASM 工具的帮助，也还是需要我们对底层字节码有比较深的理解和认识。

当然你也可以成为“前辈”，将这些场景沉淀下来，提供给后人使用。但有的时候“能力限制想象力”，如果能力不够，即使想象力到位也无可奈何。

课后作业

你使用过哪些编译插桩相关的工具？使用编译插桩实现过什么功能？欢迎留言跟我和其他同学一起讨论。

今天的课后作业是重温专栏第 7 期练习 Sample的实现原理，看看它内部是如何使用 ASM 完成 TAG 的插桩。在今天的Sample里，我也提供了一个使用 AspectJ 实现的版本。想要彻底学会编译插桩的确不容易，单单写一个高效的 Gradle Plugin 就不那么简单。

除了上面的两个 Sample，我也推荐你认真看看下面的一些参考资料和项目。

一起玩转 Android 项目中的字节码

字节码操纵技术探秘

ASM 6 Developer Guide

Java 字节码 (Bytecode) 与 ASM 简单说明

Dalvik and ART

Understanding the Davlik Virtual Machine

基于 ASM 的字节码处理工具：Hunter和Hibeaver

基于 Javassist 的字节码处理工具：DroidAssist

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

28 | 大数据与AI，如何高效地测试？

测试作为持续交付中重要的一个环节，它的使命是发现交付过程的质量问题。随着互联网迭代速度的加快，很多产品都是两周甚至每周一个版本，留给测试的时间越来越少。

那在这么短的时间，如何保障产品的质量，怎样高效地测试呢？我们研发模式在不断地变化，测试的定位又有哪些改变，而未来的测试又会发展成什么样的形态呢？

测试的演进历程

“专业的事情留给专业的人员”，社会各领域的分工越来越细，设计、研发、产品、测试大家各司其职，共同完成一个产品。但随着技术的发展，这样的分工并不是一成不变，从近两年大公司技术部门的组织调整来看，测试和开发的角色已经在不断融合。

互联网发展到今天，测试的职责发生了哪些改变？移动端测试又经历了怎么样的演进历程呢？

1. 测试的田园时代

在移动互联网起步之初，基本上还处在传统的软件研发阶段。产品将需求交给研发，研发实现后交给测试，测试把最终产品交付给用户。我们可以把这个阶段叫作测试的“田园时代“。

测试作为研发流程的一个环节，只是作为产品交付给用户前的一道屏障，承担着质量保证工作。在这个阶段测试有两个最重要的考核指标：每个版本发现的 Bug 数量和遗漏到线上的 Bug 数量。

在测试的“田园时代”，很多 Bug 可能会出现多次返工，产品的交付流程也很难快起来。这个阶段的主要问题有：

人员对立。测试的 KPI 是尽可能地发现开发人员的 Bug，他们之间非常容易发生对立。特别是出现线上事故的时候，开发埋怨测试没用，测试埋怨开发无能。

效率低下。虽然引入了 Monkey 这些基本的自动化工具，但是大多数的测试方法还是依赖人工执行。尽管我们不停地加大测试与研发人员的比例，但整个过程还是问题多多，很难缩短整个产品的交付周期。

2. 测试的效能时代

随着互联网竞争的加剧，交付速度成为了产品的核心能力。国内外的大公司开始适应趋势的变化，把测试团队的职责转变成为团队的效能服务。

测试不只是负责交付质量，还需要同时思考产品的质量和效率。正如我前面说到的“211”效能目标，也就是 2 周交付周期、1 周开发周期以及 1 小时发布时长，测试人员需要直接为这个目标负责，思考如何快速并且高质量完成产品的交付。

长兄于病视神，未有形而除之，故名不出于家。中兄治病，其在毫毛，故名不出于闾。若扁鹊者，镵血脉，投毒药，副肌肤，闲而名出闻于诸侯。

这是扁鹊讲三兄弟治病的故事，说的是长兄治病，是治于病情未发作之前，由于一般人不知道他事先能铲除病因，所以他的名气无法传出去。中兄治病，是治于病情初起之时，一般人以为他只能治轻微的小病，所以他的名气只在乡里。而扁鹊是治于病情严重之时，在经脉上穿针管来放血，在皮肤上敷药，所以都以为我的医术最高明，名气因此响遍天下。

从这个故事来看，扁鹊认为长兄的医术最高，可以做到“治病于未发”。回到我们今天所谈的测试，很多公司已经开始提出“测试左移”，也是希望测试在更早期的阶段介入到交付过程中，不仅是发现问题，要考虑更多的是如何避免问题的出现。

测试需要深入到产品从设计到发布的各个流程，要做到“比产品更懂技术，比研发更懂业务”。为了顺应这个变化，各大公司在组织架构上开始把大型的测试团队打散，揉碎进各个业务开发团队中。“产研测一体化”目的在于统一团队的目标和方向，让所有人为了提升产品效能这个共同目标而努力。这样团队中所有成员成为相亲相爱的一家人，也消除了“产研测”之间的对立现象。

但是在《如何衡量研发效能》一文中所提到的：“在产品迭代前期，团队集中设计、编码，引入缺陷，但并未即时地集成和验证。缺陷一直掩藏在系统中，直到项目后期，团队才开始集成和测试，缺陷集中爆发”。

虽然通过持续交付模式，可以一定程度上削减提交波峰，但是依然无法避免经常出现的“踩点”提交。在测试的效能时代，如何提升测试的效率依然是急需解决的问题。

在这个阶段，我给出的答案是持续集成的工具化和平台化。从需求发起到分支管理、Code Review、代码检查以及测试发布等，测试团队负责把控各式各样的工具或平台。

由于整个持续交付过程涉及各个阶段的平台工具，这里我只挑跟测试相关的两个平台重点来讲。

测试平台。竞品测试、弱网络测试、启动测试、UI 测试等，整个测试流程引入了大量的自动化工具。各大公司也改良或创造了不少好用的工具，例如腾讯的 New Monkey 工具，可以大大提升 Monkey 的智能度和覆盖率。除了我们比较熟悉的Espresso、UIAutomator、Appium以及Robotium自动化框架，像阿里的Macaca、Facebook 的Screenshot Tests for Android都是值得学习的开源测试框架。当然，也有一些公司把测试平台打包成服务对外公开，例如腾讯的WeTest、华为的开发者服务等。

下面是这些常用工具的简单对比，供你参考。

体验平台。在测试的效能时代，自动化并不能完全取代人工测试。在产品交付的不同阶段，我们需要提高团队内外人员的参与度。无论每日的 Daily Build、封版时的集中体验，还是测试期间的员工内部体验、灰度期间的外部众测平台，的目的是让尽量多的成员都参与到产品的体验中，提升团队成员对产品的认同感。当然各大公司也都有自己的体验平台，例如腾讯的 RDM、蚂蚁的伙伴 APP 等。

测试的效能时代也是目前大多数公司所处的阶段，据我了解很多公司的工程效能团队，也是从测试团队演进而来的。随着测试团队职能的转变以及技术深度的提升，会涌现出一大批资深的测试开发人员，也会有更多优秀的测试人员走向开发或者产品的岗位。

不过我发现关于测试国内外也有一些差异，例如国外十分推崇开发编写的 test case，但在国内却非常不容易推行。这主要是因为国内业务的迭代更加快速，开发需求都做不完，根本没有时间去写 test case，更不用说有的 test case 写起来可能比开发需求更费时间。

测试的智能时代

“人人都可以是测试”，虽然在稳定性、兼容性又或者是性能测试的一些场景上，我们做得非常不错，但是对于某些自动化测试场景，特别是 UI 测试，目前还达不到人工测试的水平。

就拿 UI 测试为例，由于版本迭代周期越来越短，而且 UI 变动又非常频繁，无论是开发还是业务测试人员，对写测试脚本和用例的积极性都不是很高。由于测试脚本的编写成本和维护成本比较高，可复用程度又比较低，所以 UI 测试往往费时费力，很多时候效率还不如人工测试。

那测试从效能时代走向下一阶段，在智能时代我们应该怎样去解决这些问题呢？

1. AI 在测试的应用

AI 技术在我们熟悉的围棋和星际争霸的人机大战中已经大放异彩了，那它在测试领域可以擦出哪些不一样的火花呢？

先看看我们在 UI 自动化测试中遇到的几个困境：

覆盖率。自动化测试可以覆盖多少场景，如何解决登录、网络异常等各种各样情况的干扰。

效率。我们是否可以提高写测试用例的效率，或者是降低它的编写成本，做到人人都可以写用例。

准确性。如何提高整个自动化测试过程的稳定性，对于测试流程和最终结果，是否还需要大量的人工干预分析。

网易开源的Airtest、爱奇艺的AIon，都尝试利用 AI 技术解决测试用例的编写效率和门槛问题。

这里主要用到图像识别以及 OCR 技术，以爱奇艺的 Alon 为例，它的整个处理流程是：

图片处理。首先是场景的识别，例如当前界面是否有弹出对话，是否是登陆页面等，然后通过对截屏进行图像分割。这里的难点在于文字的 OCR 识别以及布局的分类，例如怎么样把不同的切割图像进行分类，并且能够知道这块图像对应的内容。

结果判定。如何判定本次 UI 测试的结果是否是符合预期的，相似度达到多高的程度等。因为 UI 测试可能遇到的情况有很多，我们需要尽可能提升测试的准确性，减少人工干预的情况。

在 Alon 的参考文章中，还提到 UI2Code 这样一个应用场景，也就是把一个应用截图，或者把一个 UI 设计图，通过图像识别生成对应的代码。其实就是Pixel to App希望实现的效果，相信也是很多开发人员的梦想吧，让我们可以彻底从 UI 开发中解放出来，通过设计稿就可以直接生成最终的 UI 代码。

Airtest 和 Alon 解决的核心问题是 UI 自动化测试的效率，但是它们依然需要人工去编写测试用例。对于稳定性测试，虽然我上面提到过腾讯的 New Monkey，不过它依然存在很多缺陷：

覆盖场景。虽然是改进版本的 Monkey，但是它依然覆盖不了所有的用户场景，而且很多场景它的执行流程不同于真实的用户。

非智能。这里你可以理解为非人类，Monkey 的操作和人类的操作方式完全不同。

那有没有更智能的解决方案呢？Facebook 的Sapienz尝试希望像真实用户一样去使用我们开发的应用，它通过收集真实用户的操作路径来训练测试行为。而且在测试出崩溃后，Sapienz 会自动关联和定位代码，提升解决问题的效率。

虽然 AI 测试目前还无法完全取代人工测试，但随着技术的进一步成熟，相信它对测试领域的变革将会是革命性的。当然想要实现这个目标我们还有很多工作要做，这也意味着这其中还有很多技术创新的机会。

2. 大数据在测试的应用

现在越来越多的业务正在使用数据驱动的方式运作，测试的对象要从简单的代码转变为数据和算法。现在的业务越来越复杂，数据量越来越大，我们应该怎样及时发现产品的质量和业务问题呢？

国内的一些公司提出了基于大数据的“实时质量”体系，希望通过实时获取线上海量数据，完成业务数据校验和质量风险的感知。

这里的数据主要包括质量和业务两个方面。

质量。崩溃、启动速度、卡顿以及联网错误等质量问题，我们希望可以做到分钟级别的实时性，同时支持更加细粒度的维度分析。例如可以通过国家、城市、版本等维度来查看网络问题。

业务。对于产品的核心业务我们需要实现数据收集、分析与校验，打造数据监控和跟踪的能力。

对于基于大数据的“实时质量”测试体系，关键在于如何保证数据的实时性与准确性，这两点我会在“数据评估”的内容中与你详细讨论。

除了质量和业务数据，大公司做得比较多的还有用户反馈和舆情的跟踪和分析。各大公司基本都有自己的一套系统，通过爬取用户反馈、应用市场、微博、新闻资讯等各方面的消息来源，监控产品的舆情情况，你可以参考支付宝如何为移动端产品构建舆情分析体系。

总结

在 APM 系统搭建和持续交付优化过程，我接触了很多测试工程师，也关注了各大公司测试的现状。对于测试的职业道路发展，我个人的建议是：不管测试如何发展，测试效率如何提升，测试人员都要学会不断地变革，变革自己、变革整个研发流程。我们不能只守着自己的一亩三分田，需要尝试去做很多以前开发需要做的事情，比如性能、稳定性、安全等方面的工作。说得严重一点，如果你不能及时地更新自己的技术栈，不去往更深入的底层走，在测试的智能时代，首先淘汰的就是传统的功能测试人员。

在这个变革的时代，我身边也有很多独当一面的测试工程师通过平台化的机遇晋级为专家，也有一些优秀的测试转向了产品或研发，所以说还是要提高自身的能力，把机会握在自己手上。

“学如逆水行舟，不进则退”，对于开发人员同样如此。现在平台工具和框架越来越成熟，很多初学者拿着一大堆开源工具，也能写出炫酷的界面。如果我们不去进步，特别是在大环境不好的时候，也会很容易被淘汰。

今天我分享了一些我对高效测试的心得和体会，如果同学们里有测试领域的专家，非常欢迎你来谈谈对这个行业和发展的看法，分享一下你对高效测试的看法。

课后作业

你所在的公司，目前测试正在处于哪个阶段？对于测试，你还有哪些疑问？欢迎留言跟我和其他同学一起讨论。

今天的课后作业是，请你观看网易、爱奇艺以及 Facebook 关于 AI 在测试领域应用的分享，在留言中写下自己的心得体会。

网易：基于 AI 的网易 UI 自动化测试方案与实践

爱奇艺：基于 AI 的移动端自动化测试框架的设计与实践

Facebook：Automated fault-finding with Sapienz

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

29 | 从每月到每天，如何给版本发布提速？

还记得我们在持续交付设定的目标吗？我前面提到过，天猫的效能目标是“211”，也就是 2 周交付周期、1 周开发周期以及 1 小时发布时长。对于一些更加敏捷的产品，我们可能还会加快到每周一个版本。在如此快的节奏下，我们该如何保证产品的质量？还有哪些手段可以进一步为发布“提速保质”？

更宽泛地说，广义的发布并不仅限于把应用提交到市场。灰度、A/B 测试 、运营活动、资源配置…我们的发布类型越来越多，也越来越复杂。该如何建立稳健的发布质量保障体系，防止出现线上事故呢？

APK 的灰度发布

我们在讨论版本发布速度，是需要兼顾效率和质量。如果不考虑交付质量，每天一个版本也很轻松。在严格保证交付质量的前提下，两周发布一个版本其实并不容易。特别是出现紧急安全或者稳定性问题的时候，我们还需要有 1 小时的发布能力。

正如我在专栏“如何高效地测试”中说的，实现“高质高效”的发布需要强大的测试平台和数据验证平台的支撑。

下面我们一起来看看影响版本发布效率的那些重要因素，以及我对于提升版本发布速度的实践经验。

1. APK 灰度

测试平台负责对发布包做各种维度的诊断测试，通常会包括 Monkey 测试、性能测试（启动、内存、CPU、卡顿等）、竞品测试、UI 测试、弱网络测试等。但是即使通过云测平台能够同时测试几十上百台机器，本地测试依然无法覆盖所有的机型和用户路径。

为了安全稳定地发布新版本，我们需要先圈定少量用户安装试用，这就是灰度发布。而数据验证平台则负责收集灰度和线上版本的应用数据，这里可能包括性能数据、业务数据、用户反馈以及外部舆情等。

所以说，灰度效率首先被下面两个因素所影响：

测试效率。虽然灰度发布只影响少部分用户，但是我们需要尽可能保障应用的质量，以免造成用户流失。测试平台的发布测试时间是影响发布效率的第一个因素，我们希望可以在 1 小时内明确待定的发布包是否达到上线标准。

数据验证效率。数据的全面性、实时性以及准确性都会影响灰度版本的评估决策时间，是停止灰度发布，还是进一步扩大灰度的用户量级，或者可以直接发布到全量用户。对于核心数据，需要建立小时甚至分钟级别的实时监控。比如微信，对于性能数据可以在发布后 1 小时内评估完毕，而业务数据可以在 24 小时内评估完毕。

另外一方面，如果我们的灰度发布想覆盖一万名用户，那多长时间才有足够的用户下载和安装呢？渠道的能力对灰度发布效率的影响也十分巨大，在国内主要有下面几个灰度渠道。

在国内由于没有统一的应用商店，灰度渠道效率的确是一个非常严峻的问题。即使是微信，如果不动用“红点提示”这个大杀器，每天灰度发布到的用户量可能还不到十万。而国际市场有统一的 Google Play，可以通过Google Beta进行灰度。但是版本发布需要考虑 GP 审核的时间，目前 GP 审核速度相比之前有所加快，一般只需要一到两天时间。

通过灰度发布我们可以提前收集少部分用户新版本的性能和业务数据，但是它并不适用于精确评估业务数据的好坏。这主要是因为灰度的用户是有选择的，一般相对活跃的用户会被优先升级。

2. 动态部署

对于灰度发布，整个过程最大的痛点依然是灰度包的覆盖速度问题。而且传统的灰度方式还存在一个非常严重的问题，那就是无法回退。“发出去的包，就像泼出去的水”，如果出现严重问题，还可能造成灰度用户的流失。

Tinker 动态部署框架正是为了解决这个问题而诞生的，我们希望 Tinker 可以成为一种新的发布方式，用来取代传统的灰度甚至是正式版本的发布。相比传统的发布方式，热修复有很多得天独厚的优势。

快速。如果使用传统的发布方式，微信需要 10 天时间覆盖 50% 的用户。而通过热修复，在一天内可以覆盖 80% 以上的用户，在 3 天内可以覆盖 95% 以上的用户。

可回退。当补丁出现重大问题的时候，可以及时回退补丁，让用户回到基础版本，尽可能降低损失。

为了提升补丁发布的效率，微信还专门开发了 TinkerBoots 管理平台。TinkerBoots 平台不仅支持人数、条件等参数设置，例如可以选择只针对小米的某款机型下发 10000 人的补丁；而且平台也会打通数据验证平台，实现自动化的控量发布，自动监控核心指标的变化情况，保证发布质量。

Tinker 发布已经两年多了，虽然热修复技术可以解决很多问题，但作为 Tinker 的作者，我必须承认它对国内的 Android 开发造成了一些不好的影响。

用户是最好的测试。很多团队不再信奉前置的测试平台，他们认为反正有可以回退的动态部署发布，出现质量问题并不可怕，多发几个补丁就可以了。

性能低下。正如专栏“高质量开发”模块所说的，热修复、组件化这些黑科技会对应用的性能产生不小的影响，特别是启动的耗时。

从现在看来，热修复并不能取代发布，它更适合使用在少量用户的灰度发布。如果不是出现重大问题，一般情况也不应该发布针对所有用户的补丁。

组件化回归模块化，热修复回归灰度，这是国内很多大型 App 为了性能不得不做出的选择。如果想真正实现“随心所欲”的发布，可能需要倒逼开发模式的变革，例如从组件化转变为 Web、React Native/Weex 或者小程序来实现。

A/B 测试

正如我前面所说，APK 灰度是对已有功能的线上验证，它并不适合用于准确评估某个功能对业务的影响。

If you are not running experiments, you are probably not growing！
——by Sean Ellis

Sean Ellis 是增长黑客模型（AARRR）之父，增长黑客模型中提到的一个重要思想就是“A/B 测试”。 Google、 Facebook、国内的头条和快手等公司都非常热衷于 A/B 测试，希望通过测试进行科学的、数据驱动式的业务和产品决策。

那究竟什么是 A/B 测试？如何正确地使用 A/B 测试呢？

1. 什么是 A/B 测试

可能有同学会认为，A/B 测试并不复杂，只是简单地把灰度用户分为 A 和 B 两部分，然后通过对比收集到的数据，分析得到测试的结论。

事实上 A/B 测试的难点就是 A 和 B 用户群体的圈定，我们需要保证测试方案是针对同质人群、同一时间进行，确保了除方案变量之外其他变量都是一致的，这样才能将指标数据差异归到产品方案，从而选出优胜版本在线上发布，实现数据增长。

同质。指人群的各种特征分布的一致性。例如我们想验证产品方案对女性用户的购买意愿的影响，那么 A 版本和 B 版本选择的用户必须都是女性。当然特征分布不光是性别，还有国家、城市、使用频率、年龄、职业、新老用户等。

同时。在不同的时间点，用户的行为可能不太一样。例如在一些重大的节日，用户活跃度会升高，那如果 A 方案的作用时间在节日，B 方案的作用时间在非节日，很显然这种比较对于 B 方案是不公平的。

所以实现“同质同时”，并不是那么简单，首先需要丰富和精准的用户画像能力，例如版本、国家、城市、性别、年龄、喜好等用户属性。除此之外，还需要一整套强大的后台，完成测试控制、日志处理、指标计算、统计显著性指标等工作。

实现了“同质同时”之后，我们接着要找到产品方案的显著性指标，也就是方案想要证明的目标。例如我们优化了弹窗的提示语，目的是吸引更多的用户点击按钮，那按钮的点击率就是这个测试的显著性指标。

有了这些以后，那我们是不是就可以愉快地开始测试了？不对，你还需要先思考这两个问题：

流量选择。这个测试应该配置多少流量？配少了怕得不出准确的测试结论，配置多了可能要承担更大的风险。这个时候就需要用到最小样本量计算器。

A/B 测试作为一种抽样统计，它背后涉及大量的统计学原理，而这个计算器需要我们提供以下数值：

基准线指标值：请输入测试要优化的指标的基准值，比方“某按钮的点击率”，如果该值每日在 9%～11% 之间波动，则可输入 10%。
指标最小相对变化：请输入你认为有意义的最小相对变动值。假设你将要优化的指标为 10% 时，你在最小相对变化中输入 5%， 就意味着你认为绝对值变化在（9.5%, 10.5%）之间的变动没有意义，即便此时测试版本更优，你也不会采用。
统计功效 1−β：如果设置统计功效为 90%，可通俗理解为，在 A/B 测试中，当版本 A 和版本 B 的某项统计指标本质上存在显著差异时，可以正确地识别出版本 A 和版本 B 是有显著差异的概率是 90%。
显著性水平α：如果统计指标的差异超过具体的差异，我们才说测试的结果是显著的。

天数选择。A/B 测试需要持续几天，我们才可以认为测试是可靠的。一般来说可以通过下面的方法计算。

测试所需持续的天数 >= 计算获得用户数 / (场景日均流量 * 测试版本设置流量百分比）

2. 如何进行 A/B 测试

虽然各个大厂都有自己完善的 A/B 测试平台，但是 A/B 测试的科学设计并不容易，需要不断地学习，再加上大量的实践经验。

首先来说，所有的 A/B 测试都应该是有“预谋”的，也就是我们需要有对应的预期，需要设计好测试的每一个环节。一般来说，在测试开始之前，需要问清楚自己下面这些问题：

为了测试埋点、分流、统计的正确性，以及增加 A/B 测试的结论可信度，我们还会在 A/B 测试的同时，增加 A/A 测试。A/A 测试是 A/B 测试的“孪生兄弟”，有的互联网公司也叫空转测试。它主要用于评估我们测试本身是否科学，一般我推荐使用 A/A/B 的测试方式。

那在 Android 客户端有哪些实现 A/B 测试的方案呢？

用一句话来描述 A/B 测试的话就是，“拿到 A/B 测试的数据容易，拿到可信的 A/B 测试的数据却很难”，因此在指标设计、人群选择、时间设计、方案的设计都需要考虑清楚。同时也需要多思考、多实践，推荐你拓展阅读《移动 App A/B 测试中的 5 种常见错误》。

统一发布平台

我在专栏里多次提到，虽然我们做了大量的优化，依然受限于原生开发模式的各种天花板的限制。这时可以考虑跳出这些限制，例如 Web、React Native/Weex、小程序和 Flutter 都可以是解决问题的思路。

但是即使我们转变为新的开发模式，灰度和发布的步骤依然是不可或缺的。此外，我们还要面对各种各样的运营活动、推送弹窗、配置下发。

可能很多大厂的同学都深受其苦，面对各式各样的发布平台，一不小心就可能造成运营事故。那应该如何规范发布行为，避免出现下发事故呢？

1. 发布平台架构

每个应用都或多或少涉及下面这些发布类型，但是往往这些发布类型都分别放在大大小小的各种平台中，而且没有统一的管理规范流程，非常容易因为操作错误造成事故。

统一发布平台需要集中管理应用所有的数据下发业务，并建立严格规范的灰度发布流程。

管理。所有的发布都必须通过权限校验，需要经过审批。“谁发布，谁负责”，需要建立严格的事故定级制度。对于因为疏忽导致事故的人员，需要定级处理。

灰度。所有的发布一定要经过灰度测试，慢慢扩大影响的用户范围。但是需要承认，某些下发并不容易测试，例如之前沸沸扬扬的“圣诞节改变展示样式”的事件。对于这种在特定时间生效的运营活动，很难在线上灰度验证。

监控。统一发布平台需要对接应用的“实时数据平台”，在出现问题的时候，需要及时采取补救措施。

业务已经那么艰难了，如果下发了一个导致所有用户启动崩溃的闪屏活动，对应用造成的损失就难以衡量。所以规范的流程和章程，可以一定程度上避免问题的发生。当然监控也同样重要，它可以帮助我们及时发现问题并立刻止损。

2. 运营事故的应对

每天都有大量各种类型的发布，感觉就像在刀尖上行走一样。“人在江湖飘，哪能不挨刀”，当我们发现线上运营问题的时候，还有哪些挽救的措施呢？

启动安全保护。最低限度要保证用户不会由于运营配置导致无法启动应用。

动态部署。如果应用可以正常启动，那我们可以通过热修复的方式解决。但是热修复也存在一定的局限性，例如有 3%～5% 的用户会无法热修复成功，而且也无法做到立即生效。

远程控制。在应用中，我们还需要保留一条“救命”的指令通道。通过这条通道，可以执行删除某些文件、拉取补丁、修改某些配置等操作。但是为了避免自有通道失效，这个控制指令需要同时支持厂商通道，例如某个下发资源导致应用启动后界面白屏，这个时候我们可以通过下发指令去删除有问题的资源文件。

“万丈高楼平地起”，类似测试平台、发布平台或者数据平台这些内部系统很难短时间内做得非常完善，它们都是通过优化内部无数的小细节演进而来。所以我们需要始终保持耐心，朝着提升组织效能的方向前进。

总结

在过去那个做 A/B 测试非常艰难而且耗时耗力的年代，测试的每个环节都会经过千锤百炼。而现在通过功能强大的 A/B 测试系统，极大地降低测试的成本，提高了测试的效率，反而很多产品和开发人员变得有点滥用 A/B 测试，或者说有了“不去思考”的借口。

无论是研发主导的性能相关的 A/B 测试，还是产品主导的业务相关的 A/B 测试，其实很多时候都没有经过严谨的推敲，往往需要通过反反复复多次测试才能得到一个“结论”，而且还无法保证是可信的。所以无论是 A/B 测试，还是日常的灰度，都需要有明确的预期，真正去推敲里面的每一个环节。不要每次测试发布后，才发现实验设置不合理，或者发现这里那里漏了好几个数据打点，再反反复复进行修改，对参与测试的所有人来说都非常痛苦。

另外一方面，我们对某个事情的看法并不会一成不变。即使我是 Tinker 的作者，我也认为它只是某一阶段为了解决特定需求的产物。但是无论开发模式怎么改变，我们对质量和效率的追求是不会改变的。

课后作业

你的应用是否是 A/B 测试的狂热分子？对于 A/B 测试，你有哪些好的或者坏的的经历？欢迎留言跟我和其他同学一起讨论。

今天的课后作业是，思考自己的产品或者公司在灰度发布过程中存在哪些痛点？还有哪些优化的空间？请在留言中写下自己的心得体会。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

30 | 数据评估（上）：如何实现高可用的上报组件？

无论是“高效测试”中的实时监控，还是“版本发布”中的数据校验平台，我都多次提到了数据的重要性。

对于数据评估，我们的期望是“又快又准”。“快”，表示数据的时效性。我们希望在 1 小时内，甚至 1 分钟内就可以对数据进行评估，而不需要等上 1 天或者几天。“准”，表示数据的准确性，保证数据可以反映业务的真实情况，不会因为数据不准确导致做出错误的产品决策。

但是“巧妇难为无米之炊”，数据平台的准确性和时效性依赖客户端数据采集和上报的能力。那应该如何保证客户端上报组件的实时性和准确性？如何实现一个“高可用”的上报组件呢？

统一高可用的上报组件

可能有同学会疑惑，究竟什么是“高可用”的上报组件？我认为至少需要达到三个目标：

数据不会丢失。数据不会由于应用崩溃、被系统杀死这些异常情况而导致丢失。

实时性高。无论是前台进程还是后台进程，所有的数据都可以在短时间内及时上报。

高性能。这里主要有卡顿和流量两个维度，应用不能因为上报组件的 CPU 和 I/O 过度占用导致卡顿，也不能因为设计不合理导致用户的流量消耗过多。

但是数据的完整性、实时性和性能就像天平的两端，我们无法同时把这三者都做到最好。因此我们只能在兼顾性能的同时，尽可能地保证数据不会丢失，让上报延迟更小。

在“网络优化”中，我不止一次的提到网络库的统一。网络库作为一个重要的基础组件，无论是应用内不同的业务，还是 Android 和 iOS 多端，都应该用同一个网络库。

同理，上报组件也是应用重要的基础组件，我们希望打造的是统一并且高可用的上报组件。

一个数据埋点的过程，主要包括采样、存储、上报以及容灾这四个模块，下面我来依次拆解各个模块，一起看看其中的难点。

1. 采样模块

某些客户端数据量可能会非常大，我们并不需要将它们全部都上报到后台。比如说卡顿和内存这些性能数据，我们只需要抽取小部分用户统计就可以了。

采样模块是很多同学在设计时容易忽视的，但它却是所有模块中最为复杂的一项，需要考虑下面一些策略的选择。

大多数的组件采用的都是 PV 次数采样，这样的确是最简单的。但是我们更多是在性能数据埋点上采样，为了降低用户的影响面，我更加倾向于使用 UV 采样的方式。而且为了可以让更多的用户上报，我也希望每天都可以更换一批新的用户。

最终我选择的方案是“UV 采样 + 用户标识随机 + 每日更换用户”的方式，但是采样还需要满足三个标准。

准确性。如果配置了 1% 的采样比例，需要保证某一时刻只有 1% 的用户会上报这个数据。

均匀性。如果配置了 1% 的采样比例，每天都会更换不同的 1% 用户来上报这个数据。

切换的平滑性。用户的切换需要平滑，不能在用一个时间例如 12 点，所有用户同时切换，这样会导致后台数据不连贯。

实现上面这三个标准并不容易，在微信中我们采用了下面这个算法：

// id：用户标识，例如微信号、QQ 号

id_index = Hash(id) % 采样率倒数

time_index = (unix_timestamp / (24*60*60)) % 采样率倒数

上报用户 =（id_index == time_index）

每个采样持续 24 小时，使整个切换可以很平滑，不会出现所有用户同时在 0 点更换采样策略。有些用户在早上 10 点切换，有些用户在 11 点切换，会分摊到 24 小时中。并且从一个小时或者一天的维度来看，也都可以保证采样是准确的。

不同的埋点可以设置不同的采样率，它们之间是独立的、互不影响的。当然除了采样率，在采样策略里我们还可以增加其他的控制参数，例如：

上报间隔：可以配置每个埋点的上报间隔，例如 1 秒、1 分钟、10 分钟、60 分钟等。

上报网络：控制某些点只允许 WiFi 上传。

2. 存储模块

对于存储模块，我们的目标是在兼顾性能的同时，保证数据完全不会丢失。那应该如何实现呢？我们首先要思考进程和存储模式的选择。

业内最常见的上报组件是“单进程写 + 文件存储 + 内存缓存”，虽然这种方式实现最为简单，但是无论是跨进程的 IPC 调用堆积（IPC 调用总是很慢的）还是内存缓存，都可能会导致数据的丢失。

回顾一下在“I/O 优化”中，我列出的 mmap、内存与写文件的性能对比数据。

​

你可以看到 mmap 的性能非常不错，所以我们最终选择的是 “多进程写 + mmap”的方案，并且完全抛弃了内存缓存。不过 mmap 的性能也并不完美，它在某些时刻依然会出现异步落盘，所以每个进程 mmap 操作需要放到单独的线程中处理。

“多进程写 + mmap”的方案可以实现完全无锁、无 IPC 并且数据基本不会丢失，看上去简直完美，但是真正实现时是不是像图中那么简单呢？肯定不会那么简单，因为我们需要考虑埋点数据的聚合以及上报数据优先级。

埋点数据的聚合。为了减少上报的数据量，尤其是部分性能埋点，我们需要支持聚合上报。大部分组件都是使用上报时聚合的方式，但是这样无法解决存储时的数据量问题。由于我们使用的是 mmap，可以像操作内存一样操作文件中的数据，可以实现性能更优的埋点数据的聚合功能。

上报数据优先级。很多上报组件埋点时都会使用一个是否重要的参数，对于重要的数据采用直接落地的方式。对于我们的方案来说，已经默认所有的数据都是重要的。关于上报数据的优先级，我建议使用上报间隔来区分，例如 1 分钟、10 分钟或者 1 小时。

对于一些敏感数据，可能还需要支持加密。对于加密的数据，我建议使用单独的另一个 mmap 文件存储。

为什么上面我说的是数据基本不会丢失，而不是完全不会丢失呢？因为当数据还没有 mmap 落盘，也就是处于采样、存储内部逻辑时，这个时候如果应用崩溃依然会造成数据丢失。为了减少这种情况发生，我们做了两个优化。

精简处理逻辑。尽量减少每个埋点的处理耗时，每个埋点的数据处理时间需要压缩到 0.1 毫秒以内。

KillProcess 等待。在应用主动 KillProcess 之前，需要调用单独的函数，先等待所有队列中的埋点处理完毕。

3. 上报模块

对于上报模块，我们不仅需要满足上报实时性，还需要合理地优化流量的使用，主要需要考虑的策略有：

为了解决后台进程的上报实时性问题，我们采用了单进程上报的策略，我推荐使用保活能力比较强的进程作为唯一的上报进程。为了更加精细地控制上报间隔，我们采用更加复杂的班车制度模式。

后来经过仔细思考，最终的上报模块采用“多进程写 + 单进程上报”。这里有一个难点，那就是如何及时的收集所有已经停止的班车，会不会出现多进程同步的问题呢？我们是通过 Linux 的文件 rename 的原子性以及 FileObserver 机制，实现了一套完全无锁、高性能的文件同步模型。

每个进程在对应优先级的文件“停车”的时候，负责把文件 rename 到上报数据存放的目录中。因为 rename 是原子操作，所以不用担心两个进程会同时操作到同一个文件。而对应的上报进程只需要监听上报数据目录的变化，就可以实现文件状态的同步。这样就避免了多进程同步操作同一个文件的问题，整个过程也无需使用到跨进程的锁。

当然上报模块里的坑还有很多很多，例如合并上报文件时应该优先选择高优先级的文件；对于上报的包大小，在移动网络需要设置的比 WiFi 小一些，而不同优先级的文件需要合并组包，尽量吃满带宽；而且在弱网络的时候，我们需要把数据包设置得更小一些，先上报最高优先级的数据。

4. 容灾模块

虽然我们设计得上报模块已经很强大，但是如果使用者调用不合理，也可能会导致严重的性能问题。我曾经遇到过，某个同学在一个 for 循环连续埋了一百万个点；还有一次是某个用户因为长期没有网络，导致本地堆积了大量的数据。

一个强大的组件，它还需要具备容灾的能力，本地一般可以有下面这些策略。

​

容灾模块主要是保证即使出现开发者使用错误、组件内部异常等情况，也不会给用户的存储空间以及流量造成严重问题。

数据自监控

通过“多进程写 + mmap + 后台进程上报 + 班车模式”，我们实现了一套完全无锁、数据基本不会丢失、无跨进程 IPC 调用的高性能上报组件，并且通过容灾机制，它还可以实现异常情况的自动恢复。

那线上效果是不是真的这么完美？我们怎样确保上报组件的数据可靠性和时效性呢？答案依然是监控，我们需要建立一套完善的自监控体系，为后续进一步优化提供可靠的数据支撑。

1. 质量监控

上报组件的核心数据指标主要包括以下几个：

当然，如果我们追求更高的实时性，可以选择计算小时到达率，甚至是分钟到达率。

2. 容灾监控

当客户端出现容灾处理时，我们也会将数据单独上报到后台监控起来。

除了异常情况的监控，我们还希望将用户每日使用的移动流量和 WiFi 流量做更加细粒度的分区间监控，例如 0～1MB 的占比、1～5MB 的占比等。

总结

网络和数据都是非常重要的基础组件，今天我们一起打造了一款跨平台、高可用的上报组件。这也是目前比较先进的方案，在各方面的质量指标都比传统的方案有非常大的提升。

当然真正落实到编码，这里面还有非常多的细节需要考虑，也还有大大小小很多暗坑。而且虽然我们使用 C++ 实现，但是也还需要处理不同平台的些许差异，比如 iOS 根本不需要考虑多进程问题等。

在实践中我的体会是，当我们亲自动手去实现一个网络库或者上报组件的时候，才会深深体会到把一个新东西做出来并不困难，但是如果想要做到极致，那必然需要经过精雕细琢，更需要经过长时间的迭代和优化。

课后作业

你所在的公司，目前正在使用哪个数据上报组件？它存在哪些问题呢？欢迎留言跟我和其他同学一起讨论。

今天的课后作业是，在实现方案中我故意隐去了两个细节点，这里把它们当作课后作业留给你，请你在留言中写下自己的答案。

1. 采样策略的更新。当我们服务器采样策略更新的时候，如果不使用推送，怎样保证新的采样策略可以以最快速度在客户端生效？
2. 埋点进程突然崩溃。你有没有想到，如果 Process A 突然崩溃，那哪个进程、在什么时机、以哪种方式，应该负责把 Process A 对应的埋点数据及时 rename 到上报数据目录？

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

31 | 数据评估（下）：什么是大数据平台？

数据是连接产品和用户的桥梁，它反映了用户对产品的使用情况，是我们作出业务决策的重要依据。虽然通过“高可用的上报组件”，可以从源头上保障数据采集的准确性和实时性，但是随着 App 业务迭代的复杂化，经常会出现遗漏埋点、错误埋点、多端埋点不统一等情况，影响了业务数据的稳定性。

我见过很多团队的埋点文档管理得非常不规范，有的还在使用 Excel 来管理埋点文档，经常找不到某些埋点的定义。而随着埋点技术和流程的成熟，我们需要有一整套完整的方案来保证数据的稳定性。

那埋点应该遵循什么规范？如何实现对埋点整个流程的引导和监控？埋点管理、埋点开发、埋点测试验证、埋点数据监控…怎样打造一站式的埋点平台？在埋点平台之上，大数据平台又是什么样的呢？

埋点的基础知识

我们知道，一个业务埋点的上线需要经历需求、开发、测试等多个阶段，会涉及产品、开发和测试多方协作，而对于大型团队来说，可能还要加上专门的数据团队。

对于传统埋点来说，错埋、漏埋这样的问题总会反反复复出现。为了排查和解决数据的准确性问题，参与的各方团队都要耗费大量的精力。特别是如果埋点一旦出现问题，我们还需要依赖 App 发布新版本，可见埋点的修复周期长，而且成本也非常巨大。

那应该如何解决这个问题呢？请先来思考一下，我们应该如何实现一个正确的埋点。

如果想实现一个正确的埋点，必须要满足上面的这四个条件，需要有非常严格的埋点流程管理。因此，你需要做到：

统一的埋点规范。应用甚至是整个公司内部，从日志的格式、参数的含义都要有统一的规则。

统一的埋点流程。在整个埋点过程，产品、开发、测试和数据团队都要肩负起各自的职责，一起通力协作，通过统一、规范的流程来实现正确的埋点。

通过统一的埋点规范和流程，希望可以减少埋点开发的成本，保障数据的准确性。下面我们一起来看看具体应该如何实践。

1. 统一埋点规范

在打开淘宝的主页时，不知道你有没有注意到 URL 后面会带有一个 SPM 的参数。

https://www.taobao.com/?spm=a21bo.2017.201857.3.5af911d9ycCIDq

这个 SPM 代表什么含义呢？SPM 全称是 Super Position Model，也就是超级位置模型。简单来说，它是阿里内部统一的埋点规范协议，无论 H5 还是 Native 的 Android 和 iOS，都要遵循这套规范。

正如上面的链接一样，SPM 由 A.B.C.D 四段构成，各分段分别代表的含义如下。

A: 站点 / 业务， B: 页面， C: 页面区块， D: 区块内点位

 注：a21bo.2017.201857.3.5af911d9ycCIDq 一共有 5 位，这是网站特有的，最后一位分配的是一个随机特征码，只是用来保证每次点击 SPM 值的唯一性。

SPM 主要有页面访问、控件点击以及曝光三种类型的事件，它可以用来记录了用户点击或者查看当前页面的具体信息，还可以推算出用户来自上一个页面的哪个位置。基于 SPM 规范，淘宝可以得到用户每个页面 PV、点击率、停留时长、转化率、用户路径等各种维度的指标。

对于埋点规范来说，从公共参数到内部的各个业务参数，我们都需要定义完整的日志格式。目前，SPM 这套规范已经推广到阿里整个集团以及外部的合作伙伴中，这样通过各个部门、各个客户端的规范统一，不仅降低了内部学习和沟通协作的成本，而且对后续的数据存储、校验、分析都会带来极大的便利。

“一千个读者心中有一千个哈姆雷特”，每个公司的情况可能不一定相同，所以也不能保证阿里的埋点规范适合所有的企业。但是无论我们最终决定使用哪种规范，对于公司内部，至少是应用内部来说，埋点规范应该是统一的。

关于 SPM 规范，如果你想了解更多，可以参考《SPM 参数有什么作用》和《阿里巴巴的日志采集分享》。

2. 统一埋点流程

埋点的整个过程涉及产品、开发、测试、数据团队等多个团队，如果内部没有完善的流程规范，非常容易出现“四国大混战”。在出现数据问题的时候，也常常会出现互相推卸责任的情况。

“无规矩不成方圆”，我们需要制定统一的埋点流程，严格规范整个埋点过程的各个步骤以及每个参与者在相应步骤的分工和责任。

需求阶段。在需求评审阶段，产品需要列出具体的埋点需求。如果有数据团队的话，产品的埋点需求需要数据团队 review，测试同时也需要根据产品的埋点需求制定出对应的埋点测试方案。这个阶段主要由产品负责，需要保证需求方案和测试方案都是 OK 的。

开发阶段。在开发阶段，开发人员根据产品的埋点需求文档，根据具体的埋点规则在客户端中埋点。开发完成后，需要在本地自测通过。这个阶段由开发负责。

测试阶段。在测试阶段，测试人员根据产品的埋点需求和规则，通过之前指定的测试方案进行埋点的本地验收。这个阶段由测试负责。

灰度发布阶段。在灰度发布阶段，测试人员负责对埋点建立线上的监控，查看线上的数据是否符合埋点需求和规则，产品人员需要关心埋点数据是否是符合预期。这个阶段主要是测试负责，但是产品也同样需要参与。

通过统一埋点流程，我们明确规定了埋点各个阶段的任务与职责，这样可以减少埋点的成本、降低出错的概率。

3. 埋点方式

代码埋点、可视化埋点、声明式埋点、无痕埋点，对于埋点的方式，业界似乎有非常多的流派。在《美团点评前端无痕埋点实践》和《网易 HubbleData 之 Android 无埋点实践》都将埋点方式归为以下三类：

代码埋点。在需要埋点的节点调用接口直接上传埋点数据，友盟、百度统计等第三方数据统计服务商大都采用这种方案。

可视化埋点。通过可视化工具配置采集节点，在前端自动解析配置并上报埋点数据，从而实现所谓的“无痕埋点”， 代表方案有已经开源的Mixpanel。

无痕埋点。它并不是真正不需要埋点，而是自动采集全部事件并上报埋点数据，在后端数据计算时过滤出有用的数据，代表方案有国内的 GrowingIO。

我们平常使用最多的就是“代码埋点”方式，而对于“可视化埋点”和“无痕埋点”，它们都需要实现埋点的自动上报，需要实现事件的自动拦截。

怎么理解呢？你来回想一下，对于 SPM 方案中的页面切换事件，我们可以通过监听 Activity 或者 Fragment 的切换实现。那怎样自动监听控件的点击和曝光事件呢？

以监听点击事件为例，一般有下面几种方法：

插桩替换。对于控件的点击，我们可以通过 ASM 全局将 View.onClickListener 中的 onClick 方法覆写成我们自己的 Proxy 实现，在内部添加埋点代码。

Hook 替换。通过 Java 反射，从 RootView 开始，递归遍历所有的控件 View 对象，并 Hook 它对应的 OnClickListener 对象，同样将它替换成我们的 Proxy 实现。

AccessibilityDelegate 机制。通过 AccessibilityDelegate，我们可以检测到控件点击、选中、滑动、文本变化等状态。借助 AccessibilityDelegate，当控件触发点击行为时，通过具体的 AccessibilityEvent 回调添加埋点代码。

dispatchTouchEvent 机制。dispatchTouchEvent 方法是系统点击事件的分发函数，通过重写这些函数，就可以实现对所有点击事件的监听。

大数据平台

虽然我们有了统一的埋点规范和流程，但是整个流程依然是依赖人工手动的。就以埋点需求管理为例，很多团队还在使用 Excel 来管理，随着不断的修改，文档会越来越复杂，这样也不利于对历史进行跟踪。

那怎样将埋点管理、埋点开发、埋点测试验证、埋点数据监控打造成一站式的埋点平台呢？

1. 埋点一站式平台

埋点一站式平台可以实现管理埋点定义的可视化，辅助开发和测试定位埋点相关的问题。并且自动化验证本地和线上的埋点数据，以及自动分析和告警，也可以减少埋点开发和验证的成本，提升数据质量。

如上图所示，它主要由四个子平台组成。

埋点管理平台。对应用的整个埋点方案进行统一管理，包括埋点的各个字段的定义和规则，例如对于 QQ 号这个字段来说，要求是纯数字而且非空的。对于 SPM 规范，埋点管理平台也会记录每个页面对应的名称，例如淘宝首页会用 a123 来表示。

埋点开发辅助平台。开发辅助平台是为了提升开发埋点的效率，例如我前面说到的可视化埋点。或者通过埋点管理平台的字段和规则，自动生成代码，开发者可以一键导入埋点定义的类，只需要在代码中添加调用即可。

埋点验证平台。验证平台非常非常重要，对于开发人员的埋点测试和测试人员的本地验收，我们可以通过扫码或者其他方式，切换成数据的实时上传模式。埋点验证平台会拉取配置平台的埋点定义和规则，将客户端上报的数据进行实时展示和规则校验。比如说某个埋点漏了一个字段、多了一个字段，又或者是违反了预设的规则，例如 QQ 号有字母、数值为空等。

因为手工测试不一定可以覆盖所有的场景，所以我们还需要依赖自动化和灰度验证。它们整体思路还是一致的，只是借助的是线上的非实时通道，每小时或者每日定期输出数据验证的报告。

埋点监控平台。监控的目标是保证整个数据链路的健壮性，这里包括对客户端“高可用上报组件”的监控，例如上一期讲到的质量监控和容灾监控。还有对后端数据解析、存储、分析的监控，例如总日志量、日志异常量、日志的丢失量等。

不知道你是否注意到了，埋点管理平台还会对采样策略进行管理。回到专栏上一期我留给你课后作业的问题，当我们服务器采样策略更新的时候，如果不使用推送，怎样保证新的采样策略可以以最快速度在客户端生效呢？

其实非常简单，当用户更改了某个埋点的采样配置时，埋点配置平台会将采样策略版本号自增，然后将最新的策略以及版本号推送到数据采集模块。埋点 SDK 每次上报都会带上自己本地的策略版本号，如果本地的策略版本号小于服务器的版本号，那么数据采集模块会直接把最新的策略在回包中返回。

这种方式保证只要客户端有任意一个埋点上报成功，都可以拿到最新的采样策略。事实上，很多其他的配置都是采用类似的方式更新。

2. 数据产品

埋点的一站式平台，也只是数据平台的一小部分，它负责保证上报数据的准确性。按照我的理解，整个大数据平台的简化架构是下面这个样子的。

采集工具层。应用的上报组件负责数据埋点、日志的组装上报，它需要保证数据的准确性和实时性。

数据采集层。数据采集层对日志进行清洗、处理，可能还需要和我们的埋点一站式平台进行交互。然后需要根据数据的订阅情况将数据分发到不同的计算模块。

数据计算层。计算层主要分为离线计算和实时计算两部分，离线计算从数据接收到结果的产出，一般至少需要一个小时以上。而实时计算可以实现秒级、分钟级的计算，一般只会用于核心业务的监控。而且因为计算量的问题，实时计算一般只会计算 PV，不会计算 UV 结果。

数据服务层。无论是离线计算还是实时计算，我们都会把结果存放到数据的服务层，一般都会使用 DB。数据服务层主要是为了屏蔽底层的复杂实现，我们只需要从这里查询最终的计算结果就可以了。

数据产品层。数据产品一般分为两类，一类是业务型，一类是监控型。业务型一般用来查看和分析业务数据，比如页面的访问、页面的漏斗模型、页面的流向、用户行为路径分析等。监控型主要用来监控业务数据，例如实时的流量监控、或者是非实时的业务数据监控等。

数据服务层是一个非常好的设计，它让整个公司的人都可以非常简便地实现不同类型的数据产品。我们不需要关心下层复杂的数据采集和计算的实现，只要把数据拿出来，做一个满足自己的报表展示系统就可以了。

对于实时监控，微信的 IDKey、阿里的Sunfire都是非常强大的系统，它们可以实现客户端数据分钟级甚至秒级的实时 PV 监控。

对于中小型公司，可能没有能力搭建自己一整套的大数据平台，这个时候可能需要使用第三方的服务，例如阿里云提供了一套OneData服务。

当然我们也可以搭建一套自己的数据平台，但是对于海量大数据来说，一个稳定、高性能的数据计算层是非常复杂的，我们可以使用外部打包好的数据计算和服务层，例如阿里云的MaxCompute大数据计算服务。接着在数据计算层之上，再来实现符合我们自己需求的数据产品。

下面是一个简单的数据平台整体架构图，你也可以参考大众点评的实现《UAS：大众点评用户行为系统》。

总结

在过去的几年里，大数据也是一个经常被提起的概念。对于大数据，或者说与之配套的大数据平台，我自己的体会主要有两点。

1. 技术变革是为了解决需求。如果淘宝没有面对每天亿级的用户访问数量，没有一次又一次的被卷入数据的黑洞中，也不会有他们对大数据方面所做的各种艰苦努力，技术是为了解决业务场景中的痛点。另一方面看，大数据的确存在门槛，如果在中小型企业可能不一定有这样锻炼的机会。

2. 基础设施建设没有捷径可走。高可用的上报组件、埋点一站式平台以及各种各样的数据产品，这些基础设施的建设需要有足够的耐心，投入足够的人力、物力。为什么要采用这样的规范和流程？为什么架构会这样设计？虽然这些方案可能不是最优的，但也是通过血与泪、通过大量的实践，慢慢演进得来的。

课后作业

你所在的公司，有没有统一的埋点规范和埋点流程？对于数据相关的配套设施建设得怎么样？在数据保障方面遇到了哪些问题？欢迎留言跟我和其他同学一起讨论。

在数据平台的建设上面，国际的 Facebook、国内的阿里都是做得非常不错的公司，我推荐你看看阿里数据专家们写的一本书《大数据之路 阿里巴巴大数据实践》。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

32 | 线上疑难问题该如何排查和跟踪？

“95% 以上的崩溃都能解决或者规避，大部分的系统崩溃也是如此”，这是我在专栏“崩溃优化”中曾经夸下的海口。

虽然收集了尽可能丰富的崩溃现场，但总会有一些情况是事先没有预料到的，我们无法直接从崩溃日志里找到原因。事实上我们面临的难题远远不止崩溃，比如说用户投诉文件下载到 99% 之后无法继续，那如何确定是用户手机网络不好，是后台服务器出错，还是客户端代码的 Bug？

我们的业务逻辑越来越复杂，应用运行的环境也变得越来越复杂，因此在实际工作中总会遇到大大小小的线上疑难问题。对于这些问题，如何将它们“抽丝剥茧”，有哪些武器可以帮助我们更好地排查和跟踪呢？

用户日志

对于疑难问题，我们可以把它们分为崩溃和非崩溃两类。一般有哪些传统的排查手段呢？

本地尝试复现。无论是崩溃还是功能性的问题，只要有稳定的复现路径，我们都可以在本地采用各种各样的手段或工具进行反复分析。但是真正的疑难问题往往都很难复现，它们可能跟用户机型、本地存储数据等环境有关。

发临时包或者灰度包。如果发临时包给用户，整个过程用户配合繁琐，而且解决问题的时间也会很长。很多时候我们根本无法联系到用户，这个时候只能通过发线上灰度包的方式。但是为了一步步缩小问题的范围，我们可能又需要一次次地灰度。

我们多么希望能有一些“武器”，帮助工程师用非常低的成本，在非常短的时间内，尽可能地收集足够丰富的信息，更快速地排查和解决问题。

1. Xlog

在日常开发过程中，我们经常会使用 Logcat 日志来排查定位代码中的问题。

对于线上问题，我们也希望可以有用户的完整日志，这样即使问题不能复现，通过日志也可能定位到具体的原因。所谓“养兵千日，用兵一时”，客户端日志只有当出现问题且不容易复现时才会体现出它的重要作用。但是为了保证关键时刻有日志可用，就需要保证程序整个生命周期内都要打日志，所以日志方案的选择至关重要。

在过去因为性能和可靠性问题，通常我们只针对某少部分人动态打开日志开关。那如何实现一套高性能、日志不会丢失并且安全的日志方案呢？微信在 2014 年就实现了自己的高性能日志模块 Xlog，并且在 2016 年作为 Mars 的一部分开源到GitHub。关于 Xlog 的更多实现细节，你可以参考源码或者会议分享。

Xlog 方案的出现，可以让全量用户全天候打开日志，也不需要担心对应用性能造成太大的影响。但是 Xlog 只是一个高性能的日志工具，最终是否能解决我们的线上问题，还需要看我们如何去使用它。

所以微信制定了严格的日志规范，定期对拉取的日志作规则检查，一旦发现有违反规则的情况，会作出一定的处罚。下面是其中的一些日志规范，我选取一些分享给你。

日志打点怕打太多也怕太少，担心出现问题没有足够丰富的信息去定位分析问题。应该打多少日志，如何去打日志并没有一个非常严格的准则，这需要整个团队在长期实践中慢慢去摸索。在最开始的时候，可能大家都不重视也不愿意去增加关键代码的日志，但是当我们通过日志平台解决了一些疑难问题以后，团队内部的成功案例越来越多的时候，这种习惯也就慢慢建立起来了。

2. Logan

对于移动应用来说，我们可能会有各种各样的日志类型，例如代码日志、崩溃日志、埋点日志、用户行为日志等。由于不同类型的日志都有自己的特点，这样会导致日志比较分散，比如我们要查一个问题，需要在各个不同的日志平台查不同的日志。美团为了解决这个问题，提出了统一日志平台的思路，也在Github 开源了自己的移动端基础日志库 Logan。

Logan 整合了各式各样的日志平台，打造成一个统一的日志平台，进一步提升了开发人员查找问题的效率。不过无论是 Logan 还是 Xlog，日志一般会通过下面两种方式上报。

Push 拉取。通过推送命令，只拉取特定用户的日志。

主动上报。在用户反馈问题、出现崩溃等一些预设场景，主动上报日志。

对于用户日志，我们是否已经做到尽善尽美了？手动埋点的覆盖范围有限，如果关键位置没有预先埋点，那可能就需要重新发包。所以美团在 Logan 基础上，还推出了Android 动态日志系统 Holmes。

Holmes 的实现跟美团的 Robust 热修复思路差不多，需要对每个方法进行了插桩来记录方法执行路径，也就是在方法的开头插入一段桩代码，当方法运行的时候就会记录方法签名、进程、线程、时间等形成一条完整的执行信息。但是这套方案性能的技术难点比较多，一般只会动态针对出现问题的用户开启。

虽然这个思路有一定的启发性，但我认为其实并不太实用。一来，对每个方法插桩，会对安装包体积和性能造成很大的影响，导致这套方案过于笨重；二来，很多疑难问题都具有偶发性，当用户出现问题后，再去打开用户日志，可能也不能保证用户的问题可以复现。

动态调试

“只要你能在本地复现，我就能解决”，这可能是开发对测试说过最多的话了。在本地，我们可以通过增加日志，或者使用 Debugger、GDB 等这样的调试工具反复进行验证。

针对远程用户，如果我们可以做到具备跟本地一样的动态调试能力，想想都觉得激动人心。那有没有方案能实现远程动态调试呢？

1. 远程调试

动态调试，又或者是动态跟踪，它们属于高级的调试技术。事实上，它并不是什么新鲜的话题，例如 Linux 中大名鼎鼎的 DTrace 和 SystemTap、Java 的 BTrace，都是非常成熟的方案。我推荐你仔细阅读《动态调试漫谈》和《Java 动态追踪技术探究》，特别是前者，让我有非常大的收获。

在 Android 端，我们能不能实现对用户做动态调试呢？在回答这个问题之前，请先来思考一下平时我们通过 Android Studio 进行调试的底层原理是什么。

其实我们的学习委员鹏飞之前已经讲过这块内容了，回到“Android JVM TI 机制详解”中说到的 Debugger Architecture。Java 的调试框架是通过 JPDA（Java Platform Debugger Architecture，Java 平台调试体系结构），它定义了一套独立且完整的调试体系，主要由以下三部分组成：

JVM TI：Java 虚拟机工具接口（被调试者）。

JDWP：Java 调试协议（通道）。

JDI：Java 调试接口（调试者）。

如果你想了解更多关于 Java 调试框架的信息，可以重新回顾一下“Android JVM TI 机制详解”里给出的参考链接。

对于 Android 来说，它的调试框架也是在 Java 调试框架基础上进行的扩展，主要包括 Android Studio（JDI）、ddmlib、adb server、adb daemon、Android 应用这五个组成部分。

如果想要实现对用户的远程调试，我们需要修改其中的两个部分。

JDWP（传输通道）。我们不能使用系统 adb 的方式，而是希望将用户的调试信息经过网络通道发送给我们。

JDI（前端展示）。对于客户端调试数据的展示，我们不太容易直接复用 Android Studio，需要重新实现自己的数据展示页面。

关于具体实现的细节，你可以参考美团的文章《Android 远程调试的探索与实现》，最终整体的流程如下。

当然不同于本地 Debug 包的调试，对于用户调试我们还需要考虑如何突破 ProGuard 和 Debugable 的影响。总的来说，这套方案有非常大的技术价值，可以加深我们对 Java 调试框架的理解。但是它并不实用，因为大部分的场景，我们很难在用户不配合的前提下做好调试。而且调试过程也可能会出现各种各样的情况，并不容易控制。

不过退而求其次，通过这个思路，我们可以在本地实现“无线调试”（无需 adb），又或者是实现对混淆包的调试。

2. 动态部署

如果说远程调试并不很实用，那有没有其他方法让用户感知不到我们在进行调试呢？

用户无感知、代码更新，这不正是动态部署所具备的能力，而且动态部署天生就非常适合使用在疑难问题的排查上。

精细化。通过发布平台，我们可以只选择某些问题用户做动态更新。也可以圈定某一批用户，例如某个问题只在华为的某款机型出现，那我可以只针对华为的这款机型下发。

场景。对于疑难问题的排查，我们一般只会增加日志或者简单修改逻辑，动态部署在这个场景是完全可以满足的。

可重复、可回退。对于疑难问题，我们可能需要反复尝试不同的解决思路，动态部署完全可以解决这个需求。而且在问题解决后，我们可以及时将无用的 Patch 回退。

我还记得曾经为了解决 libhw.so 的崩溃问题，我们历经一个月，一共发布了 30 多个动态部署，反复地增加日志、增加 Hook 点，最终才得以解决。

3. 远程控制

动态部署存在生效时间比较慢（几分钟到十几分钟）、无法覆盖 100% 用户（修改 AndroidManifest 或者用户手机没有剩余空间）等问题。对于一些特定问题，我们可以通过下发预设规则的方式来处理。

网络远程诊断是一个非常经典的例子，假如有个用户反馈某个网页无法打开，我们可以通过本地或者远程下发指令方式，对用户的整个网络请求过程做完整的检测，看看是不是用户的网络问题，是不是 DNS 的问题，在请求的哪个阶段出错了，错误码是什么等。

Mars 里面也有一个专门的 SDT 网络诊断模块，我们可以顺便回顾一下 Mars 整个知识结构图。

除了网络的远程诊断之外，网络疑难问题的排查和跟踪本身就是一个非常大的话题。它涉及业务请求从域名解析 / 流量调度到业务统一接入，再到业务调用的整个访问链路，属于大网络平台的一环。

我们可以通过客户端生成的 traceId，将统一收集和整合客户端日志、服务端调用日志、自建 CDN 等日志，建立以用户为维度的监控平台，提供问题定位功能 。例如 Google 的 Dapper、阿里的 EagleEye、微信的点击流平台、QQ 的全链路监控平台等，都是通过这个思路实现的。

类似网络远程诊断，又或者是删除某些文件、上报某些信息，这些预设规则是建立在我们已经踩过某个坑，或者更多情况是已经无数次踩到同一个坑，并且忍无可忍，才会搭建一套相应的诊断规则。那我们能不能不通过动态部署，也可以简单的调用某些 Java 代码呢？

这个时候就不得不提到非常强大的 Lua 脚本语言，iOS 之前大名鼎鼎的Wax 热修复、腾讯 Unity3D 的热更新方案，都是使用 Lua 来实现。Lua 的 VM 非常小，只有 200KB 不到，充分保证了时间和内存开销的可控。我们可以对目标用户下发指令，动态地执行一段代码并将结果上报，或者在方法运行的时候去获取某些对象、参数的快照信息。

下面是 Lua 和 Android 的调用事例。

// lua 脚本函数

function setText(textView)

    tv:setText("set by Lua."..s); // 这里的 s 变量由 java 注入

    tv:setTextSize(30);

end

// android 调用

lua.pushString( "from java" );   // 压入欲注入变量的值

lua.setGlobal( "s" );            // 压入变量名

lua.getGlobal( "setText" );      // 获取 Lua 函数

lua.pushJavaObject( textView );  // 压入参数

lua.pcall( 1, 0, 0 );            // 执行函数

对于 Lua 的使用，你可以参考官方文档。为了方便我们在 Android 更加容易的使用 Lua，也有不少开源库对 Lua 做了更好的封装，例如AndroLua，阿里也有一套基于 Lua 实现的动态化界面框架LuaViewSDK。

美团的 Holmes 也利用 Lua 增加了 DB 查询、上报普通文本、ShardPreferences 查询、获取 Context 对象、查询权限、追加埋点到本地、上传文件等综合能力。正因为 Lua 脚本如此强大，很多大厂 App 也都在 Android 中集成 Lua。

总结

对于美团、支付宝、淘宝这些超级应用来说，不同的平台、不同的业务可能有上千人同时在一个应用上面开发协作。业务量大、多地区协作开发、业务类型多，每当出现问题都会感到耗时耗力，心力交瘁。

正因为反复“痛过”，才会有了微信的用户日志和点击流平台，才会有美团的 Logan 和 Homles 统一日志系统。所谓团队的“提质增效”，就是寻找团队中这些痛点，思考如何去改进。无论是流程的自动化，还是开发新的工具、新的平台，都是朝着这个目标前进。

课后作业

在你的工作中，遇到或者解决过哪些经典的疑难问题？还有哪些强大的疑难问题的排查武器？欢迎留言分享给我和其他同学。

无论是推送拉取用户日志，还是远程调试命令的下发，我们都需要具备区分用户的能力。对于微信这样强登录的应用，我们可以使用微信号作为用户标识。但是用户登陆之前的日志该如何收集呢？

对于用户唯一标识，Google 有自己的最佳实践方案。对于大部分非强登录的应用，搭建自己的用户标识体系非常重要。用户唯一标识需要考虑漂移率、碰撞率以及是否跨应用等因素，业界常用的方案有阿里的 UTDID、腾讯 MTA ID。

今天的课后作业是，应用该如何实现自己的用户标识体系，请你在留言中写下自己的答案。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

33 | 做一名有高度的移动开发工程师

专栏更新至今，不知不觉第二模块“高效开发”也已经更新完了。稳定性、内存、卡顿、I/O、网络，“高质量开发”模块打通了从应用层、Android 系统层、Linux 内核层再到硬件层的优化路径，帮助我们打通“任督二脉”，成为一名 Android 开发高手。

所谓“高效开发”，可以给我们带来了什么呢？移动互联网发展到今天，所有人都说“提质增效”，但是团队效能不是靠我们封装一个工具类或者组件，给其他人低成本复用就够了。持续交付平台、测试平台、发布平台、数据平台、网络平台…我希望你可以跳出客户端的限制，去思考整个产品的研发流程有哪些痛点，不同团队的协作有哪些优化空间，尝试去提升产品的质量和团队的效率。

我们需要的是多想一步，哪怕只是多思考一小步，对自身的成长可能就价值巨大。想要成为一名全面的“开发高手”，不仅要具备系统性解决应用性能和架构问题的攻坚能力，也要有从全局俯视体系和流程的思维能力。这就是我在“高效开发”里希望带给你的思考，希望你可以成为一名“站在高处”的移动开发工程师。

成为有高度的移动开发工程师

在微信的时候，我非常推崇 T 型技术人才理论，所谓的“T”无非就是横向和纵向两个维度。纵向解决的是深度问题，横向解决的是广度问题。

一个有高度的移动开发工程师，需要能纵向深入，也要能横向全面地思考每一个问题。比如说团队希望治理数据的准确性和实时性问题，如果站在客户端的角度上看，就是思考如何去实现一套数据不会丢失、实时性高以及高性能的埋点上报组件。我们知道，这里面的进程模式、存储模型、同步机制等都很复杂，要做一个高可用的上报组件确实需要具备一定的技术深度。

但是如果站在更高的角度上看，你会发现上报组件的优化并不能从根本上解决团队的数据问题。埋点的规范是什么？埋点的流程是什么？产品、研发、数据、测试几个团队对于数据有哪些痛点？我们需要梳理一个埋点从产品定义、客户端埋点开发、测试验证、后端数据处理、数据展示和监控的整个过程。针对团队的数据治理，我们需要体系化的思考每一个点的问题，从更高的角度去全局思考。

那应该如何来提升自己的高度，站在高处去思考问题呢？下面是我的一些思考。

1. 从终端到跨端

在 App 开发最原始的时代，为了实现代码的内部复用，我们封装了各种各样的 Util 工具类。随着移动互联网的发展，应用越来越多也越来越复杂，代码还需要在不同应用中复用。这个时候客户端组件呈现出爆发式增长，例如图片库中的 Glide、Fresco、Picasso，组件化中 Atlas、DroidPlugin、RePlugin 等。

回想一下，因为当时 Android 系统的不成熟和不完善，反而造就了一个百花齐放的移动开发时代。在这个时代里，我们总可以找到很多优化的点，并且持续打磨。随着应用业务复杂性和要求的提升，单纯在客户端的单点优化已经满足不了业务的诉求了，比如在直播、小程序这样的复杂场景。

这个时候我们第一步就要跳出自身客户端的角色限制，从更为全局的角度看问题、思考问题。你需要明白，客户端的实现只是其中一小块内容而已。假如你接到一个提升页面打开速度的任务，极致优化的基础是我们能深入研究浏览器的渲染原理和缓存机制，但是前端和后端能够做些什么，又应该做些什么呢？除此之外，页面哪里产生、如何发布、发布到哪里、如何下载、如何解析、如何渲染、如何衡量和监控页面的性能，这些全部都是我们需要思考的问题。

一方面，在你的项目还没有证明它的价值之前，可能很多公司并不愿意投入很多的人力。这个时候我们只能去包办前后端，就像当年微信的日志平台、APM 平台，记得还是我们用 Tornado 先简单搭建起来。等到这个项目证明了它的价值，才会拉更多的人参与进来。所以这就需要我们具备跨端的能力，目标是解决产品的问题，要知道客户端技术并不是唯一的选择。

另外一方面，针对持续交付平台、测试平台、网络平台、数据平台，很多平台客户端开发者本来就是使用方，我们应该更清楚里面的痛点是什么，有哪些可以改进的地方，所以客户端开发者应该更能主导这些平台的演进。

2. 从平台到中台

正如我上面说到的，组件化只是客户端技术最基本的抽象的体现。怎么理解呢？以性能组件为例，虽然我们收集了应用各个维度的性能数据，但是这些数据在后台如何聚合、如何存储、如何分析、如何报警，我们并没提供解决方案。

每个接入的应用还是要花很大的力气去搭建一整套系统，为了解决这个问题，集成式服务化的建设开始出现，比如以 Google 的 Firebase 为代表的各个开发者平台。为了解决应用不同的场景，我们不断地孵化出不同的服务平台。

移动开发早就已经过了单兵作战的年代了，客户端单点的深耕细作已经不是唯一考量的因素。有没有配套的服务、服务是不是简单易用，这些因素对于开发者来说越来越重要。特别是对于大厂来说，一个公司有几十上百个应用，对于公共业务需要避免重复劳动。国内蚂蚁的 mPaaS、阿里云的 EMAS 移动开发者平台，都是遵循这样的服务化思路。

但是平台化是不是就是服务的最终形态呢？你有没有体验过这种的痛苦：一个新应用需要接入公司内部十几个不同的平台，它们的账号信息、注册信息都相互独立，很多功能我们还需要单独去跟每个平台联调测试。为了解决各个平台的割裂，在平台化的基础上，又提出了中台化的思路。

什么是中台呢？简单的理解就是把这些分散的平台又统一为一个超大的平台。有人会想我们是不是在开历史的倒车？还记得当年我们将一个庞大的系统分拆成各个子平台是多么的艰难。事实上，这里中台的“统一”，更多是面向开发者层面的，例如都使用同一个账号、不需要重复注册、平台之间互相闭环等。

关于中台的更多资料，你可以参考《从平台到中台【上】》和《从平台到中台【下】》。在国内，阿里的中台是做得最好的。当然腾讯、头条这些公司也都意识到了它的重要性，最近都在积极调整组织架构，成立了专门的中台部门。但是无论是中台还是平台，都是靠无数大大小小的优化点堆积起来得，它们都需要慢慢地积累，很难在非常短的时间内建设得非常完善。

热点问题答疑

针对高效开发，我全面介绍了目前各大公司的做法和思路。对于持续交付、测试、发布、数据、网络、日志，它们本身涉及的知识是十分庞大的，所以就正如高质量开发一样，即使专栏的内容不能完全理解，也大可不必焦虑，可以反复多读几次专栏的文章和扩展的参考资料，相信你每次学习都会有不同的收获。

你可以按照自己的节奏持续地学习下去，这样我相信无论是对你的视野还是对移动开发的理解，都会有很大的收获。但是在向提升高度的方向迈进时，我们或多或少都会有一些疑问，下面我就挑选几个比较重要的问题，和你进一步聊聊我的看法。

1. 如何提升个人的专注力和效率？

人的大脑就像 CPU 一样，如果频繁切换进程和线程，这个代价是非常大的。一会看一下微信，一会刷一下抖音，一会看一下头条，当我们重新切换回工作线程的时候，起码需要几分钟才能重新进入状态。

那靠个人的自制力能不能解决这个问题？能，但是非常遗憾的是大部分人都没有这个能力，或者说自制力不够强大。不要给自己被诱惑的机会，因为大部分人都无法承受诱惑。我的建议是，直接卸载掉这些可能影响我们工作的软件（微信可能卸载不了，这也是它强大的地方，笑）。

2. 个人发展与公司平台的关系

可能 DebugCat 同学的疑问你也会有共鸣，我每天的工作就是写写界面、调调动画，面对的都是无休无止的业务，你讲的这些东西虽然高大上，但是并没有机会接触到。

对于小型团队来说，更多的是拿来主义，去使用一些第三方的平台。对于大型团队来说，可能你才有机会真正参与到这些平台的开发。但是也有人说在小型团队可以独当一面，但在大厂只能做一颗小小的螺丝钉。

我相信业务和团队这些限制因素的确客观存在，而且对我们的影响的确巨大。但是这并不是决定性的因素，你要问问自己有没有真的去努力。

如果你在大厂，就应该从客户端到后端，尽可能全面深入研究你参与的模块，多想想如何把你所做的模块优化到极致，并且在巨大的用户量面前依然能够稳定运行。如果你在初创团队，在业余时间也要坚持学习，持续探索自己的技术深度。这样在将来，无论是初创团队内部的晋升，还是跳到大厂，这样努力的经验都可以成为未来无数次面试、加薪的一大亮点。

总结

移动开发工程师想要真正站在高处，既需要有技术深度，又要有广度。很明显你下一个问题是：应该先钻研深度，还是扩展广度呢？

我建议你应该至少先在一个技术领域付出大量的精力，深入钻研透彻，然后再去思考广度的问题。这是因为经验丰富的程序员学新的东西都非常快，因为现在已经不那么容易出现太多全新的技术，所谓的新技术其实都是旧技术的重新组合和微创新。

成长是没有捷径的，我发现技术圈也有部分人喜欢在论坛写写文章或者出去授课，在业界可能还小有名气，受到不少人追捧。但是只要真正去大厂面试，可能就会被打回原形。我推荐你看看这篇文章，这里把里面的一句话推荐给你：

老老实实看书，踏踏实实做事儿，早日兑现自己曾经吹过的牛逼。

“金三银四”，最近也是找工作的高峰期。从很多同学的面试经历来看，现在只会单纯写业务代码的人找工作特别难，比如很多大厂的面试官都会针对性能优化的细节，考察你是否真正搞懂底层的机制和原理。环境的要求越来越高，所以我们也要积极转变，踏踏实实的学习。最后我也推荐你看看《程序员成长路线》，希望今天讲的这些“大道理”对你有所启发。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

34 | 聊聊重构：优秀的架构都是演进而来的

每个程序员心中都有一个成为架构师的梦想，那成为架构师这个目标是否“遥不可及”呢？从我的工作经历来看，我一共负责过搜狗输入法、微信等 4 款亿级产品的架构工作，可能有同学会好奇这些大型的 App 是如何做架构设计的。从我接手的这些应用的现实情况来看，看似光鲜的外表下都有一颗千疮百孔的心：各种日志随便输出、单例满天飞、生命周期混乱、线程乱创建、线程不安全这些问题随处可见。

所以你可以看到每个大型应用都背负着沉重的历史技术债务，架构师很重要的一项工作就是重构“老态龙钟”的陈旧架构。在接下来的“架构演进”模块中，我们一起学习架构该如何的重构和演进，帮助我们及时偿还这些“历史债务”。

虽然我们天天都在谈“架构”，那你有没有想过究竟什么是架构呢？

什么是架构

什么是架构，每个人都有自己的看法。在我看来，所谓的架构就是面对业务需求场景给出合适的解决方案，使业务能够快速迭代，从而达到“提质增效”的目标。

我先举个例子，告诉你什么是架构，以及架构的作用。我曾经为了解决 UI 渲染卡顿这个需求场景，我们设计了异步创建 View、异步布局与主线程渲染这个架构。不过架构只是设计的抽象，对于具体的实现，我们可以称之为框架。好的框架可以隐藏大家不需要关心的部分，提升我们的效率。例如 Facebook 的 Litho、微信的Vending，它们通过框架约束和异步来解决 Android 应用 UI 线程卡顿问题。

如果说监控是为了发现问题，核心在于“防”，那好的架构可以直接避免出现问题，所以架构设计的目标在于“治”。为了帮助你更好地理解架构，我们先从 Android 的架构设计说起。

1. Android 的架构

在官方文档《平台架构》中，对 Android 的描述如下：

Android 是一种基于 Linux 的开放源代码软件栈，为广泛的设备和机型而创建。

在深入 Android 架构之前，我们先来思考一下 Android 需要满足的需求场景，也就是各方对它的诉求。

用户。在低内存的设备上也可以流畅地运行各种应用，并且有持久的续航能力。

开发者。应用开发简便，并且可以在平台上获得收益。

硬件厂商。无论是芯片厂商还是手机制造商，都可以低成本地适配和升级自己的系统。

回想 Android 诞生之初，它为了团结一切可以团结的力量，广泛取得硬件厂商、开发者以及用户的支持。所以在做架构设计的时候，就充分考虑到了这些因素。

Java API 接口层。长久以来 Java 一直霸占第一大编程语言宝座，具有广泛的开发者基础。从当时来看，Android 选择 Java 作为接口语言是十分明智的。从现在看，只是没有在 Oracle 之前抢先收购 Sun 是比较失策的，导致出现当前的专利诉讼困局。总的来说，开发者通过自身熟悉的 Java 语言，友好的接口层就可以使用系统和硬件的各种能力，快速搭建出自己的应用。

硬件抽象层。设备制造商需要为内核开发硬件驱动程序，为了降低设备制造商的开发成本，Android 选择成熟、开源的 Linux 内核，并且将 Android 打造成一个免费、开源的操作系统，吸引了更多的手机厂商入局。但是芯片厂商和手机厂商还是需要一起做大量的工作才可以更新到最新的系统，在 Android 8.0 之后，Android 新增了 HAL 硬件抽象层。它向上屏蔽了硬件的具体实现，使小米这些手机厂商可以跳过芯片厂商，单独更新 Android 的 Framework 框架。

应用层。在 Android 设计之初，它就考虑到移动设备的各种限制。为了用户在低内存设备有更好的体验，同样做了大量的工作。例如设计了基于寄存器架构、可执行文件更小的 Dalvik 虚拟机以及内核的 Low Memory Killer 等。为了满足用户的基本需求，Android 系统在推出之初就内置许多的基础应用。并且通过吸引更多开发者进入，也在不断地满足用户的各种需求场景。当然，Android 也在持续优化 Framework 和 Runtime 的性能，例如我前面提到过的黄油计划、耗电优化等。

架构是为了需求场景服务，而 Android 的架构正是为了更好地满足硬件设备厂商、开发者以及用户而设计的。我非常推荐你看看《关于 Android 设计及其意义》和《Android 技术架构演进与未来》，可以让你从 Android 系统设计到技术支撑系统发展有更加深刻的理解。

2. 如何做架构选型

对于 Android 开发者来说，很多架构和框架已经非常成熟，通常我们更多面临的问题是如何为自己的应用选择合适的框架。回顾一下专栏前面学习过的内容，其实我们已经做过一次次的选择，例如 OkHttp、Cronet、Mars 应该选择哪个作为我们的高质量网络库，JSON、Protocol Buffers 数据序列化方案该如何选择等。

网络库、图片库、UI 框架、消息通信框架、存储框架，无论是 GitHub 还是 Google 官方都有非常多的方案，在选择过程我们主要要考虑下面三个因素：

框架的成熟度。框架是否已经被大量应用所实践，特别是亿级以上的应用。还有就是框架目前是否还在维护、框架的性能如何等。

工具链的成熟度。配套的工具链是否成熟、完善。例如 Flutter 作为一门新的技术，从开发、编译、测试到发布，是否有完善的工具链支持。

人员的学习成本、文档是否完备。结合团队的现状，需要考虑框架的学习成本是否可以接受、学习路径是否平滑、有没有足够的文档和社区支持。

对于架构选型，康威定律是比较重要的准则，这里推荐你看看《从康威定律和技术债看研发之痛》这篇文章，我们的组织架构、代码架构以及流程都应该跟我们团队的规模相匹配。这句话怎么理解呢？就是架构设计或者架构选型不能好高骛远，我们有多大的规模，就做多少的支撑。警惕长期的事情短期做，或者短期的事情长期做。

微信在 2013 年就开始了模块化改造，与此同时淘宝则进行了组件化改造。为什么会有这样的差别呢？因为当时微信只有一个团队在开发，Android 端也就 30 人不到。为了代码的隔离，微信将基础组件下沉，放到单独的仓库，由专门的人员负责。对于业务来说，依然只需要保留同一个仓库，只是拆成不同的业务 Module。感兴趣的同学可以参考《微信 Android 模块化架构重构实践》。

但对于淘宝来说，当时就有几百人同时在一个应用上面开发，而且这些人分别属于不同的团队，分散在全国各地。所以无论是基于代码的权限保护，还是从开发效率的考量，都要求将所有业务模块隔离开来，也就是每个业务模块都应该是单独的仓库。

什么是架构演进

“没有过不去的坎，只有过不完的坎”。在业务发展的过程，总会遇到一些新的问题，而且可能在发展到某一时刻时，一些旧的问题就不复存在了。例如为了兼容 Android 4.X，当年我们在架构上做了大量的兼容设计，但是当不再需要兼容 4.X 设备的时候，这些包袱我们就可以适时抛弃掉。

1. 为什么要做架构演进

架构是为了业务需求场景服务，那它也要顺应业务的变化而适时调整。也就是说，架构需要跟随业务的发展而演进。

“君有疾在腠理，不治将恐深”，微信每年都会经历一次大的重构，因为我们坚持代码架构最终都会腐烂，该推倒了就该重构，不要一直修修补补。架构演进可以给团队带来下面几个变化：

打破不满。需要打破保守的做法，要积极面对不合理的地方。团队定期需要着手开启重构，将大家平日对代码的不满释放出来。将架构的腐化（效率降低、抱怨上升）转化为架构优化的动力。

重构信任。重构开发者之间的心态，不定期的推动模块重构。一些问题的解决，往往可以推动更多人去尝试。

团队培养。重构也是团队进步的机会，让更多的成员掌握架构能力，培养全员架构意识，实现“人人都是架构师”。

但是对于架构演进的过程，我们需要有辨别能力，也就是常说的“技术视野”。这里包括对各种技术栈的选择和比较、架构设计的考虑，要结合业务和团队当前的情况，做出合理的判断，要清楚的知道做什么事情收益最大等。

这里的反面例子可能就是辛辛苦苦造了一套轮子，结果发现别人早就有了，甚至比我们做得更好。这个问题的原因就在于你的技术视野。在“高质量开发”和“高效开发”模块，我反复地跟你分享目前国内外大厂的最新实践方案，正是希望提升我们的技术视野。特别是“高效开发”模块，可能有同学会认为这些话题太大了，跟自己好像关系不大。其实应用开发流程的每一个步骤都关系到你我，同时又涉及大量的内容，每一块铺开来可能都可以是一个新的专栏。而作为“Android 开发高手课”，我更想从顶层给你呈现完整的架构设计，而不是去详细分析某个细节优化点。这些都是希望可以帮你站在高处看问题，全面提升你的技术视野。

对于技术视野的培养，可能没有太多的捷径，需要我们经过长时间的实践，经历反反复复的挫折，才能从“巨婴”成长为“大师”。

在“架构演进”模块，为了进一步帮助我们提升技术视野和架构的能力，我准备了下面这些内容：

GOT Hook、PLT Hook、Inline Hook，我将对比三种 Native Hook 框架的原理和差异，告诉你应该如何去选择。以及对于 Native 开发，我还有哪些经验。

大前端纷纷扰扰，如何演进和选择，如何选择适合我们应用的跨平台和动态化方案。

技术日新月异，对于新技术我们应该如何考虑是否跟进，Flutter 是不是真的可以一统天下。

在应用开发之外，我们还邀请了三位专家，分别介绍他们在移动游戏、音视频和 AI 领域开发的经验。对于这些领域，它们的架构是什么样的，我们又该如何学习和转型。

2. 如何做架构演进

架构演进是必要的，但是我们需要充分认识到困难，真正去做远比想要难多了，特别是其中各种各样的历史包袱问题。

架构的演进，通常来说具体实践方式就是重构。如果我们下定决心要重构，我有两个小建议送给你：

演进式的，符合团队现状的。我们很难找到一个性能最好、成本最省、时间最快的方案，需要权衡性能、成本、时间三者的关系。如果我们时间充裕，那可以朝着更好的性能目标去努力。但如果时间紧急，我们可以分阶段去重构。

可度量的，每个阶段都要有成果。架构演进不能一味“憋大招”，最好能分阶段实施，并且每个阶段都要有成果。这样可以让团队成员更快地感受到优化成果，也可以激励更多的人参与到重构的事业中。

在《Android 技术架构演进与未来》一文中，回顾了 Android 版本的发布时间线。Android 系统每年都会发布一个新的版本，每个版本也会有大大小小的重构。重构的目的依然是希望更好地满足用户、开发者以及硬件厂商的诉求。例如为了提升手机的续航能力，我们可以回顾一下 Android 在耗电优化的演进历程。

为了应用程序执行速度更快，Android Runtime 也是每个版本都会优化的模块，下面是 Android Runtime 各个版本的演进历程。

对于虚拟机的运行机制与各个版本的差异，也是很多公司在面试时喜欢问的。下面是一些关于虚拟机架构演进比较不错的资料，我把它们分享给你。

What’s new in Android Runtime (Google I/O '18)

Android 8.0 中的 ART 功能改进

oat 格式演进

而 Android 8.0 的Treble计划，引入了 HAL 硬件抽象层，解决了硬件厂商升级难的问题。但是即使厂商升级到最新的系统也并不能直接交付给用户，这里还存在应用兼容性的问题。为什么 Android P 要极力推出 Hidden API 的限制？这里最初考虑的并不是安全性的问题，而是为了减少每次 Android 版本升级的兼容性适配时间，让 Android 版本的发布节奏快起来。

Hidden API 的设计也有出于架构演进的考量，Android 不希望出现修改 Framework 内部任意一个私有方法的时候，都可能会引起外部应用兼容适配，这会对重构带来非常大的包袱。

Android 系统如此，应用的架构演进也是如此。由于组件化带来的各种性能问题，支付宝和淘宝在架构上也顺应了这种变化。在工程结构上，它们依然保留组件在仓库上的代码隔离。但是在最终产物上，组件化已经回归模块化，非核心业务会逐渐迁移到 H5 或者小程序。

无论微信、支付宝、淘宝，大家都想当超级 App，努力成为满足用户尽可能多需求的微型操作系统。应用的架构也需要顺应业务形态的转变，在《敏捷开发与动态更新在支付宝 App 内的实践》一文中，也描述了支付宝这几年在架构升级驱动研发方式转变，推荐你仔细读读。

总结

从初步接触架构设计，到基本掌握架构的精髓，可以说同样也没有捷径可言。架构设计能力的成长是建立在一个又一个坑、一次又一次的重构之上。不过成为架构师这个目标并不“遥不可及”，在日常工作中我们可以反复进行锻炼。

架构设计不一定是整个应用或者系统的设计，也可以是一个模块或者一个需求的设计。每接手一个需求，我们可以对自己提更高的要求，更加细致地考虑问题。例如如何对现有代码的影响最小，如何快捷清晰的实现功能，在开发过程中如何对组件、控件做更好的封装，如何去优化性能，有没有哪些新的技术可以帮助开发这个需求等。

课后作业

一个技术人的一生应该有个代表作，给自己的技术生涯一个交代。在你的工作中，有没有令你感到满意的架构设计（某个应用、某个模块或者某个框架都可以）？你对架构演进有什么看法，又遇到过哪些问题？欢迎留言分享给我和其他同学。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

35 | Native Hook 技术，天使还是魔鬼？

相信一直坚持学习专栏的同学对 Hook 一定不会陌生，在前面很多期里我无数次提到 Hook。可能有不少同学对于 Hook还是“懵懵懂懂”，那今天我们从来头了解一下什么是 Hook。

Hook 直译过来就是“钩子”的意思，是指截获进程对某个 API 函数的调用，使得 API 的执行流程转向我们实现的代码片段，从而实现我们所需要得功能，这里的功能可以是监控、修复系统漏洞，也可以是劫持或者其他恶意行为。

相信许多新手第一次接触 Hook 时会觉得这项技术十分神秘，只能被少数高手、黑客所掌握，那 Hook 是不是真的难以掌握？希望今天的文章可以打消你的顾虑。

Native Hook 的不同流派

对于 Native Hook 技术，我们比较熟悉的有 GOT/PLT Hook、Trap Hook 以及 Inline Hook，下面我来逐个讲解这些 Hook 技术的实现原理和优劣比较。

1. GOT/PLT Hook

在Chapter06-plus中，我们使用了 PLT Hook 技术来获取线程创建的堆栈。先来回顾一下它的整个流程，我们将 libart.so 中的外部函数 pthread_create 替换成自己的方法 pthread_create_hook。

你可以发现，GOT/PLT Hook 主要是用于替换某个 SO 的外部调用，通过将外部函数调用跳转成我们的目标函数。GOT/PLT Hook 可以说是一个非常经典的 Hook 方法，它非常稳定，可以达到部署到生产环境的标准。

那 GOT/PLT Hook 的实现原理究竟是什么呢？你需要先对 SO 库文件的 ELF 文件格式和动态链接过程有所了解。

ELF 格式

ELF（Executableand Linking Format）是可执行和链接格式，它是一个开放标准，各种 UNIX 系统的可执行文件大多采用 ELF 格式。虽然 ELF 文件本身就支持三种不同的类型（重定位、执行、共享），不同的视图下格式稍微不同，不过它有一个统一的结构，这个结构如下图所示。

网上介绍 ELF 格式的文章非常多，你可以参考《ELF 文件格式解析》。顾名思义，对于 GOT/PLT Hook 来说，我们主要关心“.plt”和“.got”这两个节区：

.plt。该节保存过程链接表（Procedure Linkage Table）。

.got。该节保存着全局的偏移量表。

我们也可以使用readelf -S来查看 ELF 文件的具体信息。

链接过程

接下来我们再来看看动态链接的过程，当需要使用一个 Native 库（.so 文件）的时候，我们需要调用dlopen("libname.so")来加载这个库。

在我们调用了dlopen("libname.so")之后，系统首先会检查缓存中已加载的 ELF 文件列表。如果未加载则执行加载过程，如果已加载则计数加一，忽略该调用。然后系统会用从 libname.so 的dynamic节区中读取其所依赖的库，按照相同的加载逻辑，把未在缓存中的库加入加载列表。

你可以使用下面这个命令来查看一个库的依赖：

readelf -d <library> | grep NEEDED

下面我们大概了解一下系统是如何加载的 ELF 文件的。

读 ELF 的程序头部表，把所有 PT_LOAD 的节区 mmap 到内存中。

从“.dynamic”中读取各信息项，计算并保存所有节区的虚拟地址，然后执行重定位操作。

最后 ELF 加载成功，引用计数加一。

但是这里有一个关键点，在 ELF 文件格式中我们只有函数的绝对地址。如果想在系统中运行，这里需要经过重定位。这其实是一个比较复杂的问题，因为不同机器的 CPU 架构、加载顺序不同，导致我们只能在运行时计算出这个值。不过还好动态加载器（/system/bin/linker）会帮助我们解决这个问题。

如果你理解了动态链接的过程，我们再回头来思考一下“.got”和“.plt”它们的具体含义。

The Global Offset Table (GOT)。简单来说就是在数据段的地址表，假定我们有一些代码段的指令引用一些地址变量，编译器会引用 GOT 表来替代直接引用绝对地址，因为绝对地址在编译期是无法知道的，只有重定位后才会得到 ，GOT 自己本身将会包含函数引用的绝对地址。

The Procedure Linkage Table (PLT)。PLT 不同于 GOT，它位于代码段，动态库的每一个外部函数都会在 PLT 中有一条记录，每一条 PLT 记录都是一小段可执行代码。 一般来说，外部代码都是在调用 PLT 表里的记录，然后 PLT 的相应记录会负责调用实际的函数。我们一般把这种设定叫作“蹦床”（Trampoline）。

PLT 和 GOT 记录是一一对应的，并且 GOT 表第一次解析后会包含调用函数的实际地址。既然这样，那 PLT 的意义究竟是什么呢？PLT 从某种意义上赋予我们一种懒加载的能力。当动态库首次被加载时，所有的函数地址并没有被解析。下面让我们结合图来具体分析一下首次函数调用，请注意图中黑色箭头为跳转，紫色为指针。

我们在代码中调用 func，编译器会把这个转化为 func@plt，并在 PLT 表插入一条记录。

PLT 表中第一条（或者说第 0 条）PLT[0] 是一条特殊记录，它是用来帮助我们解析地址的。通常在类 Linux 系统，这个的实现会位于动态加载器，就是专栏前面文章提到的 /system/bin/linker。

其余的 PLT 记录都均包含以下信息：

跳转 GOT 表的指令（jmp *GOT[n]）。

为上面提到的第 0 条解析地址函数准备参数。

调用 PLT[0]，这里 resovler 的实际地址是存储在 GOT[2] 。

在解析前 GOT[n] 会直接指向 jmp *GOT[n] 的下一条指令。在解析完成后，我们就得到了 func 的实际地址，动态加载器会将这个地址填入 GOT[n]，然后调用 func。

如果对上面的这个调用流程还有疑问，你可以参考《GOT 表和 PLT 表》这篇文章，它里面有一张图非常清晰。

当第一次调用发生后，之后再调用函数 func 就高效简单很多。首先调用 PLT[n]，然后执行 jmp *GOT[n]。GOT[n] 直接指向 func，这样就高效的完成了函数调用。

总结一下，因为很多函数可能在程序执行完时都不会被用到，比如错误处理函数或一些用户很少用到的功能模块等，那么一开始把所有函数都链接好实际就是一种浪费。为了提升动态链接的性能，我们可以使用 PLT 来实现延迟绑定的功能。

对于函数运行的实际地址，我们依然需要通过 GOT 表得到，整个简化过程如下：

看到这里，相信你已经有了如何 Hack 这一过程的初步想法。这里业界通常会根据修改 PLT 记录或者 GOT 记录区分为 GOT Hook 和 PLT Hook，但其本质原理十分接近。

GOT/PLT Hook 实践

GOT/PLT Hook 看似简单，但是实现起来也是有一些坑的，需要考虑兼容性的情况。一般来说，推荐使用业界的成熟方案。

微信 Matrix 开源库的ELF Hook，它使用的是 GOT Hook，主要使用它来做性能监控。

爱奇艺开源的的xHook，它使用的也是 GOT Hook。

Facebook 的PLT Hook。

如果不想深入它内部的原理，我们只需要直接使用这些开源的优秀方案就可以了。因为这种 Hook 方式非常成熟稳定，除了 Hook 线程的创建，我们还有很多其他的使用范例。

“I/O 优化”中使用matrix-io-canary Hook 文件的操作。

“网络优化”中使用 Hook 了 Socket 的相关操作，具体你可以参考Chapter17。

这种 Hook 方法也不是万能的，因为它只能替换导入函数的方式。有时候我们不一定可以找到这样的外部调用函数。如果想 Hook 函数的内部调用，这个时候就需要用到我们的 Trap Hook 或者 Inline Hook 了。

2. Trap Hook

对于函数内部的 Hook，你可以先从头想一下，会发现调试器就具备一切 Hook 框架具有的能力，可以在目标函数前断住程序，修改内存、程序段，继续执行。相信很多同学都会使用调试器，但是对调试器如何工作却知之甚少。下面让我们先了解一下软件调试器是如何工作的。

ptrace

一般软件调试器都是通过 ptrace 系统调用和 SIGTRAP 配合来进行断点调试，首先我们来了解一下什么是 ptrace，它又是如何断住程序运行，然后修改相关执行步骤的。

所谓合格的底层程序员，对于未知知识的了解，第一步就是使用man命令来查看系统文档。

The ptrace() system call provides a means by which one process (the “tracer”) may observe and control the execution of another process (the “tracee”), and examine and change the tracee’s memory and registers. It is primarily used to implement breakpoint debugging and system call tracing.

这段话直译过来就是，ptrace 提供了一种让一个程序（tracer）观察或者控制另一个程序（tracee）执行流程，以及修改被控制程序内存和寄存器的方法，主要用于实现调试断点和系统调用跟踪。

我们再来简单了解一下调试器（GDB/LLDB）是如何使用 ptrace 的。首先调试器会基于要调试进程是否已启动，来决定是使用 fork 或者 attach 到目标进程。当调试器与目标程序绑定后，目标程序的任何 signal（除 SIGKILL）都会被调试器做先拦截，调试器会有机会对相关信号进行处理，然后再把执行权限交由目标程序继续执行。可以你已经想到了，这其实已经达到了 Hook 的目的。

如何 Hook

但更进一步思考，如果我们不需要修改内存或者做类似调试器一样复杂的交互，我们完全可以不依赖 ptrace，只需要接收相关 signal 即可。这时我们就想到了句柄（signal handler）。对！我们完全可以主动 raise signal，然后使用 signal handler 来实现类似的 Hook 效果。

业界也有不少人将 Trap Hook 叫作断点 Hook，它的原理就是在需要 Hook 的地方想办法触发断点，并捕获异常。一般我们会利用 SIGTRAP 或者 SIGKILL（非法指令异常）这两种信号。下面以 SIGTRAP 信号为例，具体的实现步骤如下。

注册信号接收句柄（signal handler），不同的体系结构可能会选取不同的信号，我们这里用 SIGTRAP。

在我们需要 Hook 得部分插入 Trap 指令。

系统调用 Trap 指令，进入内核模式，调用我们已经在开始注册好的信号接收句柄（signal handler）。

执行我们信号接收句柄（signal handler），这里需要注意，所有在信号接收句柄（signal handler）执行的代码需要保证async-signal-safe。这里我们可以简单的只把信号接收句柄当作蹦床，使用 logjmp 跳出这个需要 async-signal-safe（正如我在“崩溃分析”所说的，部分函数在 signal 回调中使用并不安全）的环境，然后再执行我们 Hook 的代码。

在执行完 Hook 的函数后，我们需要恢复现场。这里如果我们想继续调用原来的函数 A，那直接回写函数 A 的原始指令并恢复寄存器状态。

Trap Hook 实践

Trap Hook 兼容性非常好，它也可以在生产环境中大规模使用。但是它最大的问题是效率比较低，不适合 Hook 非常频繁调用的函数。

对于 Trap Hook 的实践方案，在“卡顿优化（下）”中，我提到过 Facebook 的Profilo，它就是通过定期发送 SIGPROF 信号来实现卡顿监控的。

3. Inline Hook

跟 Trap Hook 一样，Inline Hook 也是函数内部调用的 Hook。它直接将函数开始（Prologue）处的指令更替为跳转指令，使得原函数直接跳转到 Hook 的目标函数函数，并保留原函数的调用接口以完成后续再调用回来的目的。

与 GOT/PLT Hook 相比，Inline Hook 可以不受 GOT/PLT 表的限制，几乎可以 Hook 任何函数。不过其实现十分复杂，我至今没有见过可以用在生产环境的实现。并且在 ARM 体系结构下，无法对叶子函数和很短的函数进行 Hook。

在深入“邪恶的”细节前，我们需要先对 Inline Hook 的大体流程有一个简单的了解。

如图所示，Inline Hook 的基本思路就是在已有的代码段中插入跳转指令，把代码的执行流程转向我们实现的 Hook 函数中，然后再进行指令修复，并跳转回原函数继续执行。这段描述看起来是不是十分简单而且清晰？

对于 Trap Hook，我们只需要在目标地址前插入特殊指令，并且在执行结束后把原始指令写回去就可以了。但是对 Inline Hook 来说，它是直接进行指令级的复写与修复。怎么理解呢？就相当于我们在运行过程中要去做 ASM 的字节码修改。

当然 Inline Hook 远远比 ASM 操作更加复杂，因为它还涉及不同 CPU 架构带来的指令集适配问题，我们需要根据不同指令集来分别进行指令复写与跳转。

下面我先来简单说明一下 Android 常见的 CPU 架构和指令集：

x86 和 MIPS 架构。这两个架构已经基本没有多少用户了，我们可以直接忽视。一般来说我们只关心主流的 ARM 体系架构就可以了。

ARMv5 和 ARMv7 架构。它的指令集分为 4 字节对齐的定长的 ARM 指令集和 2 字节对齐的变长 Thumb/Thumb-2 指令集。Thumb-2 指令集虽为 2 字节对齐，但指令集本身有 16 位也有 32 位。其中 ARMv5 使用的是 16 位的 Thumb16，在 ARMv7 使用的是 32 位的 Thumb32。不过目前 ARMv5 也基本没有多少用户了，我们也可以放弃 Thumb16 指令集的适配。

ARMv8 架构。64 位的 ARMv8 架构可以兼容运行 32 位，所以它在 ARM32 和 Thumb32 指令集的基础上，增加了 ARM64 指令集。关于它们具体差异，你可以查看ARM 的官方文档。

ARM64 目前我还没有适配，不过 Google Play 要求所有应用在 2019 年 8 月 1 日之前需要支持 64 位，所以今年上半年也要折腾一下。但它们的原理基本类似，下面我以最主流的 ARMv7 架构为例，为你庖丁解牛 Inline Hook。

ARM32 指令集

ARMv7 中有一种广为流传的$PC=$PC+8的说法。这是指 ARMv7 中的三级流水线（取指、解码、执行），换句话说$PC寄存器总是指向正在取指的指令，而不是指向正在执行的指令。取指总会比执行快 2 个指令，在 ARM32 指令集下 2 个指令的长度为 8 个字节，所以$PC寄存器的值总是比当前指令地址要大 8。

是不是感觉有些复杂，其实这是为了引出 ARM 指令集的常用跳转方法：

LDR PC, [PC, #-4] ;0xE51FF004

$TRAMPOLIN_ADDR

在了解了三级流水线以后，就不会对这个 PC-4 有什么疑惑了。

按照我们前面描述的 Inline Hook 的基本步骤，首先插入跳转指令，跳入我们的蹦床（Trampoline），执行我们实现的 Hook 后函数。这里还有一个“邪恶的”细节，由于指令执行是依赖当前运行环境的，即所有寄存器的值，而我们插入新的指令是有可能更改寄存器的状态的，所以我们要保存当前全部的寄存器状态到栈中，使用 BLX 指令跳转执行 Hook 后函数，执行完成后，再从栈中恢复所有的寄存器，最后才能像未 Hook 一样继续执行原先函数。

在执行完 Hook 后的函数后，我们需要跳转回原先的函数继续执行。这里不要忘记我们在一开始覆盖的 LDR 指令，我们需要先执行被我们复写的指令，然后再使用如下指令，继续执行原先函数。

LDR PC, [PC, #-4]

HOOKED_ADDR+8

是不是有一种大功告成的感觉？其实这里还有一个巨大的坑在等着我们，那就是指令修复。前面我提到保存并恢复了寄存器原有的状态，已达到可以继续像原有程序一样的继续执行。但仅仅是恢复寄存器就足够么？显然答案是否定的，虽然寄存器被我们完美恢复了，但是 2 条备份的指令被移动到了新的地址。当执行它们的时候，$PC寄存器的值是与原先不同的。这条指令的操作如果涉及$PC的值，那么它们将会执行出完全不同的结果。

到这里我就不对指令修复再深入解析了，感兴趣的同学可以在留言区进行讨论。

Inline Hook 实践

对于 Inline Hook，虽然它功能非常强大，而且执行效率也很高，但是业界目前还没有一套完全稳定可靠的开源方案。Inline Hook 一般会使用在自动化测试或者线上疑难问题的定位，例如“UI 优化”中说到 libhwui.so 崩溃问题的定位，我们就是利用 Inline Hook 去收集系统信息。

业界也有一些不错的参考方案：

Cydia Substrate。在Chapter3中，我们就使用它来 Hook 系统的内存分配函数。

adbi。支付宝在GC 抑制中使用的 Hook 框架，不过已经好几年没有更新了。

各个流派的优缺点比较

最后我们再来总结一下不同的 Hook 方式的优缺点：

1.GOT/PLT Hook 是一个比较中庸的方案，有较好的性能，中等的实现难度，但其只能 Hook 动态库之间的调用的函数，并且无法 Hook 未导出的私有函数，而且只存在安装与卸载 2 种状态，一旦安装就会 Hook 所有函数调用。

2.Trap Hook 最为稳定，但由于需要切换运行模式（R0/R3），且依赖内核的信号机制，导致性能很差。

3.Inline Hook 是一个非常激进的方案，有很好的性能，并且也没有 PLT 作用域的限制，可以说是一个非常灵活、完美的方案。但其实现难度极高，我至今也没有看到可以部署在生产环境的 Inline Hook 方案，因为涉及指令修复，需要编译器的各种优化。

但是需要注意，无论是哪一种 Hook 都只能 Hook 到应用自身的进程，我们无法替换系统或其他应用进程的函数执行。

总结

总的来说 Native Hook 是一门非常底层的技术，它会涉及库文件编译、加载、链接等方方面面的知识，而且很多底层知识是与 Android 甚至移动平台无关的。

在这一领域，做安全的同学可能会更有发言权，我来讲可能班门弄斧了。不过希望通过这篇文章，让你对看似黑科技的 Hook 有一个大体的了解，希望可以在自己的平时的工作中使用 Hook 来完成一些看似不可能的任务，比如修复系统 Bug、线上监控 Native 内存分配等。

课后作业

今天的信息量是不是有点大？关于 Native Hook，你对它有什么看法，还有哪些疑问？欢迎留言跟我和其他同学一起讨论。

Native Hook 技术的确非常复杂，即使我们不懂得它的内部原理，我们也应该学会使用成熟的开源框架去实现一些功能。当然对于想进一步深入研究的同学，推荐你学习下面这些资料。

链接程序和库指南

程序员的自我修养：链接、装载与库

链接器和加载器 Linkers and Loaders

Linux 二进制分析 Learning Linux Binary Analysis

如果你对调试器的研究也非常有兴趣，强烈推荐Eli Bendersky写的博客，里面有一系列非常优秀的底层知识文章。其中一些关于 debugger 的，感兴趣的同学可以去阅读，并亲手实现一个简单的调试器。

how-debuggers-work-part-1

how-debuggers-work-part-2-breakpoints

how-debuggers-work-part-3-debugging-information

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。最后别忘了在评论区提交今天的作业，我也为认真完成作业的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

36 | 跨平台开发的现状与应用

在 2016 年，我参加了两场移动技术大会，在当时的 GMTC 大会上，原生开发看起来如日中天。

转眼到了 2017 年，HTML5 性能越来越好，Facebook 的 React Native、阿里的 Weex 等跨平台方案在越来越多的公司中实践，微信小程序更是给了原生开发最沉重的一击，许多小公司可能不再需要开发自己的应用。

“Write once, run anywhere”，人们对跨平台开发的尝试从来没有停止过。特别在这个终端碎片化的时代，一份代码可以在同一个平台不同的系统版本，甚至在不同的平台上运行，对开发者的吸引力越来越大。

回想一下，HTML5 与 Native 开发的斗争已经持续快十年了，那它们的现状是怎样的呢？React Native 和 Weex 方案有哪些优势，又存在什么问题？小程序是不是真的可以一统江湖？焦虑的 Native 开发又应该如何在这个潮流之下谋发展呢？

跨平台开发的现状

从 2017 年开始，GMTC“移动技术大会”就更名为“大前端技术大会”。从现在看来，前端开发和 Native 开发并没有谁取代谁，而是正在融合，融合之后的产物就是所谓的“大前端”。为了顺应这种趋势，很多大公司的组织架构也做了相应的调整，把前端团队和 iOS、Android 一起合并为大前端团队。

移动 Web 技术、React Native 和 Weex、小程序，它们是目前最常用到的跨平台开发方案，下面我们一起来看看它们的应用现状。当然对于今年最为火热的 Flutter 技术，专栏后面我会花专门的篇幅去介绍。

1. Web

从桌面时代开始，以浏览器为载体的 Web 技术就具备跨平台、动态更新、扩展性强等优点。随着移动设备性能的增强，Web 页面的性能也逐渐变得可以接受。客户端中出现越来越多的内嵌 Web 页面，很多应用也会把一些功能模块改为 Web 实现。

浏览器内核

一个 Web 页面是由 HTML + CSS + JavaScript 组成，通过浏览器内核执行并渲染成开发者预期的界面。

浏览器内核主要包括两大块功能，它们分别是：

浏览器引擎。浏览器引擎负责处理 HTML 和 CSS，遵照的是 W3C 标准。

JavaScript 引擎。JS 引擎负责处理 JS，遵照的是 ECMAScript 标准。

它们两者互相独立但又有非常紧密的结合，而且在不同浏览器内核中的实现也是不太一样的。但随着微软的 Edge 宣布将内核切换成 Chromium，目前这个战场主要就剩下苹果和 Google 两个玩家，它们的浏览器引擎分别是 Webkit 和 Blink（其实 Blink 也是 fork 自 Webkit），JS 引擎分别是JavaScriptCore和V8。

对于浏览器的渲染流程，可能很多 Android 开发并没有前端同学熟悉。一般来说，HTML、CSS、JS 以及页面用到的一些其他资源（图片、视频、字体等）都需要从网络下载。而 HTML 会被解析成 DOM，CSS 会被解析成 CSSOM，JS 会由 JS 引擎执行，最后整合 DOM 和 CSSOM 之后合成为一棵 Render Tree。

当然整个浏览器的渲染流程不是三言两语就可以说清楚的，下面是我找的一些不错的参考资料，感兴趣的同学可以深入学习：

浏览器渲染：一颗像素的诞生；强烈推荐看 PPT： Life of a Pixel

浏览器引擎：What is a browser engine?

浏览器系列教程：How Browsers Work: Behind the scenes of modern web browsers

Google Web 开发者官网：Rendering on the Web

虽然Chromium是开源的，但是因为它的复杂性，国内对它有深入研究的人非常少，而拥有定制修改能力的人更是少之又少。因此这块需要投入大量的人力物力，国内比较有名的是 UC 浏览器的 U4 内核以及腾讯浏览器的 X5 内核。

性能现状

基于 WebView 的 H5 跨平台方案，优点确实非常明显。但是性能是它目前最大的问题，主要表现在以下两个方面：

启动白屏时间。WebView 是一个非常重量级的控件，无论是 WebView 的初始化，还是整个渲染流程都非常耗时。这导致界面启动的时候会出现一段白屏时间，体验非常糟糕。

响应流畅度。由于单线程、历史包袱等原因，页面的渲染和 JavaScript 的执行效率都不如原生。在一些重交互或者动画复杂的场景，H5 的性能还无法满足诉求。

所以在移动端 H5 主要应用在一些交互不太复杂的场景，一般来说即使帧率不如原生，但也基本符合要求。从我个人的感受来看，H5 当前最大的问题在于启动的白屏时间。

对于 Android 界面启动的过程，我们在窗口动画还没结束的时候，大部分时候就已经完成了页面的渲染。启动一个 Activity 界面，我们一般要求在 300 毫秒以内。

回顾一下浏览器内核渲染的流程，我们其实可以把整个过程拆成三个部分：

Native 时间。主要是 Activity、WebView 创建以及 WebView 初始化的时间。虽然首次创建 WebView 的时间会长一些，但总体 Native 时间是可控的。

网络时间。这里包括 DNS、TCP、SSL 的建连时间和下载主文档的时间。当解析主文档的时候，也需要同步去下载主文档依赖的 CSS 和 JS 资源，以及必要的数据。

渲染时间。浏览器内核构建 Render Tree、Layout 并渲染到屏幕的时间。

如上图所示，我们会更加关心用户看到完整的页面的时间 T2，这里可以用T2 秒开率作为启动速度的衡量标准。

优化方法

Native 界面的 T2 秒开率做到 90% 以上并不困难，相比之下大部分没有经过优化的 Web 页面的 T2 秒开率可能都在 40% 以下，差距还是非常明显的。

那又应该如何去优化呢？从前端的角度来看，常用的优化方法有：

加快请求速度。整个启动过程中，网络时间是最不可控的。这里的优化方法有很多，例如预解析 DNS、减少域名数、减少 HTTP 请求数、CDN 分发、请求复用、懒加载、Gzip 压缩、图片格式压缩。

代码优化。主文档的大小越小越好（要求小于 15KB），这里要求我们对 HTML、CSS 以及 JS 进行代码优化。以 JS 为例，前端的库和框架真的太多了，可能一不小心就引入了各种的依赖框架。对于核心页面，我们要求只能使用原生 JS 或者非常轻量级的 JS 框架，例如使用只有几 KB 的 Preact 代替庞大的 React 框架。

SSR。对于浏览器的渲染流程，我上面描述的是 CSR 渲染模式，在这种模式下，服务器只返回页面的基本框架。事实上还有一种非常流行的SSR（Server Side Rendering）渲染模式，服务器可以一次性生成直接进行渲染的 HTML。这样在 T2 之前，我们可以做到只有一个网络请求，但是带来的代价就是服务器计算资源的增加。一般来说，我们会在服务器前置 CDN 来解决访问量的问题。

通过上面的这些优化，特别是 SSR 这个“终极大招”，页面的 T2 秒开率达到 70% 并不是非常困难的事情。

前端同学能做的都已经做了，接下来我们还可以做些什么呢？这个时候就需要客户端开发登场了。

WebView 预创建。提前创建和初始化 WebView，以及实现 WebView 的复用，这块大约可以节省 100～200 毫秒。

缓存。H5 是有多级的缓存机制，例如 Memory Cache 存放在内存中，一般资源响应回来就会放进去，页面关闭就会释放。Client Cache 也就是客户端缓存，例如我们最常用的离线包方案，提前将需要网络请求的数据下发到客户端，通过拦截浏览器的资源请求实现加载。Http Cache是我们比较熟悉的缓存机制，而 Net Cache 就是指 DNS 解析结果的缓存，或预连接的缓存等。

从性能上看，Memory Cache > Client Cache >= Http Cache > Net Cache。所谓的缓存，就是在用户真正点击打开页面之前，提前把数据、资源下载到本地内存或者磁盘中，并放到内核相应的缓存中。例如即使我们使用了 SSR，也可以在用户点击之前，提前把服务器渲染好的 HTML 下载好，这样用户真正打开页面的时候，可以做到完全没有网络请求。

通过预请求的优化，即使比较复杂的页面，T2 秒开率也可以达到 80% 以上。但是既然是预请求就会有命中率的问题，服务器也增加了没有真正命中的请求数。所以在客户端性能和服务器压力之间，我们需要找到一个平衡点。

那还有没有进一步优化的空间？这个时候需要我们进一步往底层走，需要我们有定制修改甚至优化内核的能力。例如很多接口官方的浏览器内核可能并没有暴露，而腾讯和 UC 的内核里面都会有很多的特殊接口。

托管所有网络请求。我们不仅可以托管浏览器的 Get 请求，其他的所有 Post 请求也能接管，这样我们可以做非常多的定制化优化。

私有接口。我们可以暴露很多浏览器的一些非公开接口。以预渲染为例，我可以指定在内存直接渲染某个页面，当用户真正打开的时候，只需要直接做刷新就可以了，实现真正的“秒开”。

兼容性和安全。Android 的碎片化导致浏览器内核的兼容性实在令人头疼，而且旧版本内核还存在不少的安全漏洞。在应用自带浏览器内核可以解决这些问题，而且高版本的内核特性也会更加完善，例如支持 TLS 1.3、QUIC 等。但是带来的代价是安装包增大 20MB 左右，当然我们也可以采用动态下载的方式。

定制的自有页面 + 定制的浏览器内核 + 极致的优化，即使是比较复杂的页面 T2 秒开率也可以达到 90% 以上，平均 T2 时间可以做到 400 毫秒以下。

2. React Native 和 Weex

基于 WebView 的 H5 跨平台方案，经过近乎疯狂的性能优化，看起来性能真的不错了。但是对于一些交互和动画复杂的场景（例如左右滑屏、手势），性能还是无法满足要求。

Facebook 在 2015 年开源了React Native，它抛弃了 WebView，利用 JavaScriptCore 来做桥接，将 JavaScript 调用转为 Native 调用。也就是说，React Native 最终会生成对应的自定义原生控件，走的是系统原生的渲染流程。

而阿里在 2016 年也开源了Weex，它的思路跟 React Native 很像，但是上层 DSL 使用的是 Vue。对于 Weex 和 React Native 的架构介绍，网上的文章非常多，例如《大前端的下一站何去何从？》和《Weex 技术演进》。

但是世上哪有十全十美的方案？React Native/Weex 方案为了能达到接近原生开发的性能和交互体验，必然要在跨平台和动态性上面做出了牺牲。

React Native 和 Weex 向上对接了前端生态，向下对接了原生渲染，看起来是非常完美的方案。但是前端和客户端，客户端中的 Android 和 iOS，它们的差异并不那么容易抹平，强行融合就会遇到各种各样的坑。

“React Native 从入门到放弃”是很多开发者的心声，去年 Airbnb、Udacity 都相继宣布放弃使用 React Native。React Native/Weex 并没有彻底解决跨平台的问题，而且考虑到对外分享和降级容灾的需要，我们依然需要开发一个 H5 版本的页面。

为了解决这个问题，React Native 的使用者需要引入一层非常重的中间层，期望在这个中间层中帮助我们去抹平这些差异。例如京东的JDReact、携程的Ctrip React Native。

既然 React Native 和 Weex 在跨平台上面做了牺牲，那它的性能和交互是不是能直接对齐 Native 开发呢？非常遗憾， 目前它们的性能我觉得主要还有两个瓶颈。

JS 的执行时间。React Native 和 Weex 使用的JavaScriptCore引擎，虽然它每年都在进步，但是 JS 是解释性的动态语言，它的执行效率相比 AOT 编译后的 Java，性能依然会在几倍以上的差距。

跨语言的通信成本。既然要对接前端和原生两个生态，就无法避免 JS -> C++ -> Java/Objective-C 之间频繁的通信和转换，所以这里面会涉及各种序列化，对性能的影响比较大。

虽然相比 H5 方案在性能方面有了很大的提升，但是 React Native 和 Weex 也要面对启动时间慢、帧率不如原生的性能问题。它属于一种比较中庸的方案，当然也会有自己的应用场景。例如一些二级页面（例如淘宝的分会场），它们的业务也比较重要，但是交互不会特别复杂，同时希望保持一定的动态化能力。

当然，Facebook 已经意识到 React Native 的种种性能问题，目前正在疯狂重构中，希望让 React Native 更加轻量化、更适应混合开发，接近甚至达到原生的体验。Facebook 现在透漏的信息并不多，感兴趣的同学可以参考《庖丁解牛！深入剖析 React Native 下一代架构重构》。

3. 小程序

2017 年初，张小龙宣布微信小程序诞生。如今小程序已经走过了两年，在这两年间，小程序的生态也在健康的发展。

每一个应用都有成为超级 App 的梦想，各个大厂纷纷推出自己的小程序框架：微信、厂商、支付宝、今日头条、百度、淘宝、Google Play，小程序这个战场已然是“七国大乱战”。

但是小程序并不属于一种跨平台开发方案，大家更看重的是它的渠道优势，考虑如何通过微信、支付宝这些全民 App 获得更多的流量和用户。从技术上看，小程序的框架技术也是开放的，我们可以采用 H5 方案，也可以采用 React Native 和 Weex，甚至是 Flutter。

从实践上看，我们一起来看看已经正式上线的微信小程序、快应用、支付宝小程序以及百度小程序的差异（技术方面大家公开得并不多，可以参考《支付宝小程序框架》）。

我们可以看到除了独树一帜的快应用，其他小程序的技术方案基本都跟随了微信。但是考虑到 H5 在一些场景的性能问题，利用浏览器内核提供的同层渲染能力，在 WebView 之上支持一些原生的控件。如果哪一天微信小程序支持了所有的原生控件，那也就成为了另外一套 React Native/Weex 方案。

“神仙打架，百姓遭殃”，如果我们想从所有的小程序厂商上面获得流量，那就要开发七个不同的小程序。不过幸运的是，支付宝小程序和快应用也希望已有的微信小程序能快速迁移到自己平台上，所以它们的 DSL 设计都参考了微信的语法，可以说微信推出的 DSL 已然成为了事实标准。

如上图所示，我们希望有一套可以整合所有小程序框架的解决方案，一次开发就可以生成不同的小程序。滴滴的Chameleon和京东的Taro都致力于解决这个问题，目前它们都已经在 GitHub 上开源。

跨平台开发的应用

从移动开发诞生之初，跨平台就已经是大家前赴后继不断追求的目标。我们可以看看 nwind 在 2015 年写的一篇文章《聊聊移动端跨平台开发的各种技术》。如今四年过去了，大部分观点依然成立，并且从最后 Dart 的介绍中，我们甚至可以看到现在 Flutter 的雏形。

1. 跨平台开发的场景

Android、iOS、PC，不同的平台人们的操作习惯、喜好都不尽相同。对于大公司来说，完全的跨平台开发可能是一个伪命题，不同的平台应用的 UI 和交互都不太一样。

那我们对跨平台苦苦追寻了那么多年，希望得什么呢？以我的经验来看，跨平台主要的应用场景有：

部分业务。某个业务或者页面的跨平台共享，有的时候我们还希望可以做到跨应用。例如“全民答题”的时候，可以看到这个功能可以运行在头条系的各个应用中。一个公司共用同一套跨平台方案有非常重大的意义，业务可以在不同的应用中尝试。

核心功能。C++ 才是生命力最顽强的跨平台方案，大公司也将越来越多的核心模块往底层迁移，例如网络库、数据上报、加解密、音视频等。

2. 跨平台开发对比

H5 的跨平台方案只要投入不太高的开发成本，就能开发出性能、功能还不错的应用。但是如果想做到极致优化，很容易发现开发者可控的东西实在比较少，性能和功能都依赖浏览器的支持。

这个时候如果想走得更远，我们不仅需要了解浏览器的内部机制，可能还需要具备定制、修改浏览器内核的能力，这也是阿里、腾讯、头条和百度都要组建内核团队的原因。

原生开发则相反，刚开始要投入很高的开发成本，但是一旦开始有产出之后，开发者能够有更的发挥空间，而 React Native 和 Weex 方案更是希望打造兼顾跨平台、开发成本以及性能的全方位解决方案。

从目前来看，每一种方案都有着自己的使用场景，无论是 React Natve 还是 H5，都无法完全取代 Native 开发。当然这里也有一个例外，那就是如果我们不再开发应用，全面投向小程序。小程序跟原生开发的竞争，更多的是在渠道层面的竞争。

总结

现在好像有个观点说“Android 开发没人要”，大家都想转去做大前端开发，是不是真的是这样呢？事实上，无论我们使用哪一种跨平台方案，它们最终都要运行在 Android 平台上。崩溃、内存、卡顿、耗电这些问题依然存在，而且可能会更加复杂。而且从 H5 极致体验优化的例子来看，很多优化是需要深入研究平台特性和系统底层机制，我们在“高质量开发”中学到的底层和系统相关的知识依然很重要。

对开发者来说，唯一不变的就是学习能力。掌握了学习能力和钻研的精神，就能够应对这些趋势变化。无论移动开发未来如何变化，哪怕有一天 AI 真的能够自动写代码，具备应变能力的人也丝毫不会惧怕的。

课后作业

跨平台开发也是一个很大很大的话题，今天我只能算是抛砖引玉。对于跨平台开发，你有什么看法？在你的应用中，使用了哪种跨平台开发方式？欢迎留言跟我和其他同学一起讨论。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

37 | 移动开发新大陆：工作三年半，移动开发转型手游开发

你好，我是张绍文。15 年认识庆文的时候，他还在微信读书负责 Android 端的性能优化工作。某一天，他跟我说想转岗去尝试一下游戏开发，当时我脑海里浮现了两个想法，一个是游戏部门传说中 60 个月的年终奖，看起来游戏是一个非常有“钱途”的方向；另外一个还是担忧，抛弃掉 Android 开发多年的积累，转去完全不熟悉的游戏领域，他是否可以胜任。

两年过去了，庆文以他的个人经历亲身证明了两件事情：
第一，游戏开发并没有想象中那么困难。当年他是 Android 版微信读书的技术核心，如今在新的岗位上依然也是技术核心。“一通则百通”，技术是相通的，最珍贵的是我们的学习能力和钻研精神。
第二，客户端平台知识依然非常重要。手游虽然有非常独立的开发体系，但是它还是运行在 Android 或者 iOS 系统之上。App 开发需要用到的很多技术，游戏开发也需要用到。作为 Android 开发，我们一层一层往底层走的优化能力，反而是其他大部分游戏开发所不具备的，也是我们的优势所在。

在我看来，无论移动的未来如何发展，不管是大前端的天下，还是转向手游、IoT、AI、音视频等其他方向，今天我们所熟悉的 Android 平台知识以及学习这些知识所沉淀下来的能力和方法，都是最宝贵的财富。下面我们一起来看看庆文同学的移动开发转型手游开发的那些事。

你好，我是李庆文，来自腾讯旗下一间手游工作室，应绍文的邀请，在《Android 开发高手课》里和你聊聊手游开发那些事。

我在 2017 年 4 月，从 App 客户端开发转岗成为一名“用命创造快乐”的手游客户端开发。在转为手游开发之前，虽然玩游戏会被游戏精美的画面吸引，但是并不清楚游戏是如何将这些精细的画面呈现出来，觉得游戏开发好像一个神秘的组织一样，不知道游戏开发团队的同学每天在做什么。如果你也对游戏开发有些好奇，不知道是不是也有我之前同样的困惑？现在我以一个亲历者的身份，跟你讲讲转向手游开发的那事儿。

揭开手游开发的面纱

一般来说，一个手游项目组包括制作人、策划组、美术组、运营组、程序组、音频组。其中游戏核心部分完全由项目组自己完成，部分美术、音频可以交给外包来完成。

策划组分为玩法策划、数值策划，是整个游戏的灵魂，一个游戏的核心玩法是否好玩、周边系统能否更好地支撑核心玩法，是整个游戏成败的关键。有些团队也会把运营组放到策划组当中，运营同学主要的工作职责是外部合作、策划游戏内的活动、根据活动数据对游戏玩法或者活动进行相应调整，希望可以尽量延长游戏生命周期并提高游戏收入。

美术组包括原画师、视觉设计、2D/3D 视觉特效设计，其中负责视觉特效的同学与负责程序的同学沟通较多，主要是因为程序需要与动画配合，比如某个动画播放到某一个关键帧之后，程序要处理某个特定逻辑。

App 开发的程序组同学关注的更多是手机系统的架构，希望尽量多了解系统能为 App 提供的接口或者能力。而手游的程序组同学更多是关注游戏引擎的架构和如何在引擎的基础上优化游戏性能。

下面我以 Cocos 引擎为例，带你看看手游的开发流程以及手游是如何运行的。

1. 游戏架构

以我的游戏项目为例，游戏整体架构如下图所示。

其中：

资源更新模块让游戏能够不断发布 Bugfix 和 Features。

配置系统提供给策划和运营同学，主要是编辑 XLS 文件用于配置游戏内的关卡、活动等。策划同学的配置会在游戏编译过程中自动转表，根据配置文件生成 Lua 文件。

Services 管理着游戏内的全部数据。

网络模块负责与后台建立 Socket 并通信。

游戏内使用 MVC 模型控制各个游戏内模块的跳转。

在单个功能模块中，逻辑与动画分离，逻辑层产生动画事件，动画层消费动画事件并让游戏内的精灵根据事件执行特定动作。

2. 游戏场景设计

Cocos 采用节点树形结构来管理游戏对象，一个游戏可以划分为不同的场景（Scene），一个场景又可以分为不同的层（Layer），一个层又可以拥有任意个精灵（Sprite）。游戏里关卡、周边系统的切换也就是一个一个场景的切换，就像在电影中变换舞台和场地一样，由导演（Director）负责不同游戏场景之间的切换。一个 Cocos 游戏的基本结构如下：

在同一个场景中的多个精灵可以通过执行动作（Action）来实现缩放、移动、旋转，从而达到让游戏“动起来”的目的。

Cocos 引擎中，坐标系 X 轴向右、Y 轴向上，Z 轴（Z-Order）垂直于手机屏幕指向屏幕外，由于是 2D 游戏，所以 Z 轴只用作控制游戏元素的前后顺序。同一个场景中两个重叠或者部分重叠的 Sprite，Z-Order 大的精灵会遮挡住 Z-Order 小的精灵，相同 Z-Order 的精灵，后添加到场景中的会遮挡住先添加的。

有了树形结构和 Z-Order，就能利用 Cocos 引擎构建出任意 2D 游戏场景了。

3. 手游的运行流程

游戏的主 Activity 在onCreate时候创建一个 GLSurfaceView，它继承自 View，是引擎用于绘制游戏内容的。同时 GLSurfaceView 也接受玩家的点击事件，用于引擎与玩家的交互。

GLSurfaceView 创建之后，引擎开始执行main.lua脚本，导演调用runWithScene接口，游戏就进入到第一个“场景”中了。GLSurfaceView 中维护了一个while循环，循环中发生的事件（比如 SurfaceView 创建、宽高发生变化等）通过Renderer接口传递给外部。Renderer其中一个接口就是onDrawFrame，Cocos 会在onDrawFrame接口实现中根据游戏设置的帧率，每间隔一段事件通知一次导演执行一帧游戏循环。

借用《我所理解的 Cocos2d-x》书中的单帧处理流程图片，你可以看到在每帧中：

先响应用户输入，因为用户输入可能影响到当前帧接下来的游戏逻辑。

根据每个精灵设置的动画，计算精灵的位置、缩放等属性。

执行游戏逻辑，比如更新玩家得分、修改当前游戏状态等。在这里开发者依然可以更改精灵的各种属性。

遍历 UI 树，根据之前对精灵属性的设置，生成绘制命令，交给 OpenGL 绘制，最后把渲染的内容显示到屏幕上。

游戏与 App 开发的异同

1. 关于热更新

热更新是 App 开发和游戏开发都避不开的话题，但是 App 的热更新与游戏的热更新有着本质的区别。App 开发的热更新涉及整个系统层面的实现细节，比如我在老东家工作期间实现的热更新框架，需要了解的内容非常多，包括 Dex 文件的加载过程、SO 文件查找过程和 APK 编译过程的详细细节，每个不小心都可能导致热更新不生效甚至导致严重的外网 Bug。

然而使用脚本的游戏引擎是天生支持热更新的。手游热更新，也叫作资源更新，更新内容包括代码、纹理图片、界面等。

以 Cocos 引擎为例，Cocos 引擎的核心是 C++ 实现的，对外提供了 JS、Lua 接口。业务开发过程中绝大部分代码是 Lua 代码，只有涉及系统相关接口，比如支付、音频播放、WebView 等，才需要写一部分系统相关的代码。Lua 代码经过编译之后生成的二进制代码是“.luac”文件，并通过 luaL_loadbuffer 接口来加载一段“.luac”的文件内容。Cocos 引擎在初始化过程中会设置“代码查找路径”，加载 Lua 代码的时候会挨个遍历每一个被设置进来的路径，直到找到或者找不到对应的“.luac”文件。而热更新只需要下载“.luac”文件，放到优先的查找路径中，游戏重启之后引擎就会优先加载新的代码啦。

当然还有另外一种更为激进的方式，不需要重启游戏就能达到热更新的目的。

Cocos 以 LuaEngine 为入口，执行 Lua 代码驱动 C++ 部分的图形库、音频库、物理引擎等。LuaEngine 缓存了通过luaL_loadbuffer接口加载起来的代码，只要重启整个 LuaEngine 清理掉缓存的代码，就能在后续需要执行代码的时候去重新加载代码文件。这样就能做到不重启游戏也能达到真正“热”更新的目的。

其实 Unity 引擎也是类似，可以使用 xLua 等类似的技术，让 Unity 引擎也能开心地写 Lua 脚本。

对于热更新图片，只需要清理掉引擎缓存的图片缓存即可。这样引擎在下次使用该图片渲染界面是，发现在缓存中查找不到该图片，自然就会去加载新的图片了。

2. 游戏也需要优化安装包大小

在 App 开发过程中，优化安装包的大小是一个不可避免的话题。压缩图片、代码，插件动态下载等众多实现方案也都为大家所熟知。手游安装包大小可能并不像 App 安装包要求那么严格，热门游戏比如《绝地求生》《王者荣耀》，安装包大小几乎都接近 2GB。虽然玩家对游戏安装包大小不是特别在意，但有意识的减小安装包大小也是很有必要的，毕竟安装包大小会影响游戏转化率，进而影响游戏收入。

手游安装包中，图片资源占了安装包体积的绝大部分，以 PNG 图片为主。通常直接压缩 PNG 图片是有损压缩，经过游戏引擎渲染，游戏界面会出现很多噪点。这里有一种分离 Alpha 通道的压缩方案，可以供你参考。32 位透明 PNG 图片包含了四个通道：RGBA，其中每个通道占 8 Bit。把 RGBA 四个通道中的 Alpha 通道数据拆分出来存储为一张 PNG8 图片，剩下的 RGB 数据存储为一张 JPG 格式图片。JPG 格式的图片在保证高压缩率的同时也能保证图片质量，以此达到压缩图片大小的目的。这种方案的压缩率大概在 70% 左右。

在游戏运行过程中，使用纹理之前先把 JPG 和 PNG8 文件都读取到内存，将他们包含的 RGB 和 Alpha 数据重新合并后，交给 OpenGL 用于渲染。这个压缩方案简单易操作，并且运行时不需要额外的第三方库支持。

3. 游戏的性能优化

性能是开发者永远离不开的话题。手游和 App 开发一样，也需要关注游戏的各方面性能指标，比如内存、帧率等。

内存

纹理压缩，减少纹理占用的内存。我前面提到的安装优化包大小中压缩图片，也有助于减少内存占用。在美术同学能接受的前提下，降低图片深度也是快速优化内存的一个方法。

使用精灵池，尽量复用已经创建的精灵，减少屏幕中精灵创建的个数，及时回收不用的内存。

切换场景时尽快释放上一个场景的无用纹理。

帧率

减少 Draw Call。游戏引擎每次准备数据并发送给 GPU 去渲染的过程，称为一次 Draw Call。Cocos 把使用相同贴图、blendFunc、Shader 的精灵合并到同一个渲染命令中提交给 GPU。我们常说的合图就是把很多小图片合并到一张大图片中，这样当屏幕中有多个连续渲染的精灵用到同一个大图，CPU 就会（有可能）把它们合并之后统一提交给 GPU。另外 Cocos 合并渲染命令是根据精灵在场景中的顺序来做的，最终的合并效果不一定是最优的。我们可以实现自己更优化的合并逻辑。

减少屏幕内需要渲染的精灵个数。如果屏幕中有“成组”的元素需要绘制，比如排行榜中每个玩家的 Item，在 Cocos 中可以使用 CCRenderTexture，把每个“成组”的元素绘制到 CCRenderTexture 绑定的纹理，然后用 CCRenderTexture 替代原有的 Item。这与 Android 开发中自己实现 View 的 onDraw 函数类似，并且这也是减少 Draw Call 的一种方式。

分帧。真的遇到 CPU 密集型的任务时，例如同屏幕确实需要大量精灵，可以把它们拆分在不同帧里，尽量保证游戏内每一帧在规定时间内完成，否则会出现卡帧现象。

App 开发转手游开发的思考

刚刚过去的 2018 年是游戏比较惨淡的一年，游戏版号从暂停审批到现在终于慢慢开始放出，简直是千军万马过独木桥。但从目前来看，2018 年确实也像某些大咖说的那样，可能是游戏行业未来几年最好的一年。国内拿不到版号的游戏只能考虑出海来寻求出路，但是出海需要在海外当地运营或者找海外代理。自己运营可能会遇到水土不服，画风或者游戏内容也可能不满足当地玩家的需求；找海外代理，却可能面临连游戏代码都被代理商卖掉的风险。

手游早已经结束了野蛮生长的年代，6 年前可能随便一款游戏都可能成为爆款，类似捕鱼达人、保卫萝卜，它们诞生在 Android、iOS 手机爆发的那几年。而现在的游戏玩家需要的是更新颖的玩法和更加精细的游戏体验。例如 MOBA 类的《王者荣耀》、SLG 类的《乱世王者》，甚至细分领域的《恋与制作人》，也都在各自的玩法上不断打磨，让玩家心甘情愿“氪金”。如果想要转向这个赢者通吃的行业，确实需要谨慎，提前考察好目标团队是否符合你的职业发展、是否有盈利能力，尽量避免踩坑。

1. App 开发转手游开发，可行吗？

2018 年和 2019 年，市场对 App 开发的岗位需求量已经不像几年前那么大，而且也有一些同学也咨询过我 App 开发转手游开发是否合适。关于这个问题，我的回答是：可以转到手游开发岗位，而且难度并没有想象中那么大。

手游开发跟 App 开发相比，只不过是换了工作内容，关注的领域不同而已，基本原理是相通的。App 开发需要用到的很多技术，游戏开发依然也需要用到，比如卡顿分析、网络、I/O 优化。而且游戏的大部分核心逻辑库是与平台无关的，所以不必担心适应不了新的工作内容。下面我来总结一下转到手游开发的优势和劣势。

优势

对手机系统有深入了解，能迅速切入一部分游戏团队内的工作内容。比如我刚刚转到手游团队时解决的第一个问题：游戏在 Android 系统上 JNI 找不到函数的 Crash 稳稳占据了每个版本的前三名，版本 Crash 率由于这个问题而一直保持在 1% 左右。当时由于团队对系统没有深入了解，所以并没有很好的解决办法。这个问题其实读一遍 Android 系统查找 Native 函数的代码，基本就能找到解决方案了。

劣势

毕竟是两个不同的开发方向，转方向的同学也需要迎接压力。与毕业后直接进入游戏行业的同学相比，“半路出家”的同学相对缺乏原始技术积累，需要从基础的概念开始学起，比如上面提到的 Draw Call、合图、OpenGL、Shader，需要消耗大量精力去学习。所以需要转方向的同学足够自律，主动补齐这些技术短板。

2. 转方向后的技术路线

有些同学说 App 开发者的技术路线宽，担心手游开发者将来技术路线会越来越窄，将来不好找其他工作。有这个担心是正常的，手游开发岗位的市场需求量比移动开发需求量小太多，但这不应该成为害怕转手游开发的理由。个人觉得需要从一个更宏观的角度看待这两个不同的开发岗位，不要钻在技术的牛角尖里无法自拔。技术通道无风险，而更应该担心的是游戏的政策风险。

小游戏在 2017 年和 2018 年真是火了一把，鉴于小游戏入门简单、上手快，想要转方向的同学可以先用小游戏来练手。“麻雀虽小，五脏俱全”，看明白一个小游戏的执行流程之后，相信你对游戏开发也就会有一个感性的认识了。

最后祝各位开发者游戏愉快！

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

38 | 移动开发新大陆：Android音视频开发

你好，我是张绍文。俊杰目前负责微信的音视频开发工作，无论是我们平常经常使用到的小视频，还是最新推出的即刻视频，都是出自他手。俊杰在音视频方面有非常丰富的经验，今天有幸请到他来给我们分享他个人对 Android 音视频开发的心得和体会。

在日常生活中，视频类应用占据了我们越来越多的时间，各大公司也纷纷杀入这个战场，不管是抖音、快手等短视频类型，虎牙、斗鱼等直播类型，腾讯视频、爱奇艺、优酷等长视频类型，还是 Vue、美拍等视频编辑美颜类型，总有一款适合你。

未来随着 5G 普及以及网络资费的下降，音视频的前景是非常广阔的。但是另一方面，无论是音视频的编解码和播放器、视频编辑和美颜的各种算法，还是视频与人工智能的结合（AI 剪片、视频修复、超清化等），它们都涉及了方方面面的底层知识，学习曲线比较陡峭，门槛相对比较高，所以也造成了目前各大公司音视频相关人才的紧缺。如果你对音视频开发感兴趣，我也非常建议你去往这个方向尝试，我个人是非常看好音视频开发这个领域的。

当然音视频开发的经验是靠着一次又一次的“填坑”成长起来的，下面我们一起来看看俊杰同学关于音视频的认识和思考。

大家好，我是周俊杰，现在在微信从事 Android 多媒体开发。应绍文的邀请，分享一些我关于 Android 音视频开发的心得和体会。

不管作为开发者还是用户，现在我们每天都会接触到各种各样的短视频、直播类的 App，与之相关的音视频方向的开发也变得越来越重要。但是对于大多数 Android 开发者来说，从事 Android 音视频相关的开发可能目前还算是个小众领域，虽然可能目前深入这个领域的开发者还不是太多，但这个方向涉及的知识点可一点都不少。

音视频的基础知识

1. 音视频相关的概念

说到音视频，先从我们熟悉也陌生的视频格式说起。

对于我们来说，最常见的视频格式就是MP4格式，这是一个通用的容器格式。所谓容器格式，就意味内部要有对应的数据流用来承载内容。而且既然是一个视频，那必然有音轨和视轨，而音轨、视轨本身也有对应的格式。常见的音轨、视轨格式包括：

视轨：H.265(HEVC)、H.264。其中，目前大部分 Android 手机都支持 H.264 格式的直接硬件编码和解码；对于 H.265 来说，Android 5.0 以上的机器就支持直接硬件解码了，但是对于硬件编码，目前只有一部分高端芯片可以支持，例如高通的 8xx 系列、华为的 98x 系列。对于视轨编码来说，分辨率越大性能消耗也就越大，编码所需的时间就越长。

音轨：AAC。这是一种历史悠久音频编码格式，Android 手机基本可以直接硬件编解码，几乎很少遇到兼容性问题。可以说作为视频的音轨格式，AAC 已经非常成熟了。

对于编码本身，上面提到的这些格式都是有损编码，因此压缩编码本身还需要一个衡量压缩之后，数据量多少的指标，这个标准就是码率。同一个压缩格式下，码率越高质量也就越好。更多 Android 本身支持的编解码格式，你可以参考官方文档。

小结一下，要拍摄一个 MP4 视频，我们需要将视轨 + 音轨分别编码，然后作为 MP4 的数据流，再合成出一个 MP4 文件。

2. 音视频编码的流程

接下来，我们再来看看一个视频是怎么拍摄出来的。首先，既然是拍摄，少不了跟摄像头、麦克风打交道。从流程来说，以 H.264/AAC 编码为例，录制视频的总体流程是：

我们分别从摄像头 / 录音设备采集数据，将数据送入编码器，分别编码出视轨 / 音轨之后，再送入合成器（MediaRemuxer 或者类似 mp4v2、FFmpeg 之类的处理库），最终输出 MP4 文件。接下来，我主要以视轨为例，来介绍下编码的流程。

首先，直接使用系统的MediaRecorder录制整个视频，这是最简单的方法，直接就能输出 MP4 文件。但是这个接口可定制化很差，比如我们想录制一个正方形的视频，除非摄像头本身支持宽高一致的分辨率，否则只能后期处理或者各种 Hack。另外，在实际 App 中，除非对视频要求不是特别高，一般也不会直接使用 MediaRecorder。

视轨的处理是录制视频中相对比较复杂的部分，输入源头是 Camera 的数据，最终输出是编码的 H.264/H.265 数据。下面我来介绍两种处理模型。

第一种方法是利用 Camera 获取摄像头输出的原始数据接口（例如 onPreviewFrame），经过预处理，例如缩放、裁剪之后，送入编码器，输出 H.264/H.265。

摄像头输出的原始数据格式为 NV21，这是YUV颜色格式的一种。区别于 RGB 颜色，YUV 数据格式占用空间更少，在视频编码领域使用十分广泛。

一般来说，因为摄像头直接输出的 NV21 格式大小跟最终视频不一定匹配，而且编码器往往也要求输入另外一种 YUV 格式（一般来说是 YUV420P），因此在获取到 NV21 颜色格式之后，还需要进行各种缩放、裁剪之类的操作，一般会使用FFmpeg、libyuv这样的库处理 YUV 数据。

最后会将数据送入到编码器。在视频编码器的选择上，我们可以直接选择系统的 MediaCodec，利用手机本身的硬件编码能力。但如果对最终输出的视频大小要求比较严格的话，使用的码率会偏低，这种情况下大部分手机的硬件编码器输出的画质可能会比较差。另外一种常见的选择是利用x264来进行编码，画质表现相对较好，但是比起硬件编码器，速度会慢很多，因此在实际使用时最好根据场景进行选择。

除了直接处理摄像头原始数据以外，还有一种常见的处理模型，利用 Surface 作为编码器的输入源。

对于 Android 摄像头的预览，需要设置一张 Surface/SurfaceTexture 来作为摄像头预览数据的输出，而 MediaCodec 在 API 18+ 之后，可以通过createInputSurface来创建一张 Surface 作为编码器的输入。这里所说的另外一种方式就是，将摄像头预览 Surface 的内容，输出到 MediaCodec 的 InputSurface 上。

而在编码器的选择上，虽然 InputSurface 是通过 MediaCodec 来创建的，乍看之下似乎只能通过 MediaCodec 来进行编码，无法使用 x264 来编码，但利用 PreviewSurface，我们可以创建一个 OpenGL 的上下文，这样所有绘制的内容，都可以通过 glReadPixel 来获取，然后再讲读取数据转换成 YUV 再输入到 x264 即可（另外，如果是在 GLES 3.0 的环境，我们还可以利用PBO来加速 glReadPixles 的速度）。

由于这里我们创建了一个 OpenGL 的上下文，对于目前的视频类 App 来说，还有各种各样的滤镜和美颜效果，实际上都可以基于 OpenGL 来实现。

而至于这种方式录制视频具体实现代码，你可以参考下 grafika 中示例。

视频处理

1. 视频编辑

在当下视频类 App 中，你可以见到各种视频裁剪、视频编辑的功能，例如：

裁剪视频的一部分。

多个视频进行拼接。

对于视频裁剪、拼接来说，Android 直接提供了MediaExtractor的接口，结合 seek 以及对应读取帧数据 readSampleData 的接口，我们可以直接获取对应时间戳的帧的内容，这样读取出来的是已经编码好的数据，因此无需重新编码，直接可以输入合成器再次合成为 MP4。

我们只需要 seek 到需要裁剪原视频的时间戳，然后一直读取 sampleData，送入 MediaMuxer 即可，这是视频裁剪最简单的实现方式。

但在实践时会发现，seekTo并不会对所有时间戳都生效。比如说，一个4min左右的视频，我们想要 seek 到大概2min左右的位置，然后从这个位置读取数据，但实际调用 seekTo 到 2min 这个位置之后，再从 MediaExtractor 读取数据，你会发现实际获取的数据上可能是从 2min 这里前面一点或者后面一点位置的内容。这是因为 MediaExtractor 这个接口只能 seek 到视频关键帧的位置，而我们想要的位置并不一定有关键帧。这个问题还是要回到视频编码，在视频编码时两个关键帧之间是有一定间隔距离的。

如上图所示，关键帧被成为I 帧，可以被认为是一帧没有被压缩的画面，解码的时候无需要依赖其他视频帧。但是在两个关键帧之间，还存在这B 帧、P 帧这样的压缩帧，需要依赖其他帧才能完整解码出一个画面。至于两个关键帧之间的间隔，被称为一个GOP ，在 GOP 内的帧，MediaExtractor 是无法直接 seek 到的，因为这个类不负责解码，只能 seek 到前后的关键帧。但如果 GOP 过大，就会导致视频编辑非常不精准了（实际上部分手机的 ROM 有改动，实现的 MediaExtractor 也能精确 seek）。

既然如此，那要实现精确裁剪也就只能去依赖解码器了。解码器本身能够解出所有帧的内容，在引入解帧之后，整个裁剪的流程就变成了下面的样子。

我们需要先 seek 到需要位置的前一 I 帧上，然后送入解码器，解码器解除一帧之后，判断当前帧的PTS是否在需要的时间戳范围内，如果是的话，再将数据送入编码器，重新编码再次得到 H.264 视轨数据，然后合成 MP4 文件。

上面是基础的视频裁剪流程，对于视频拼接，也是类似得到多段 H.264 数据之后，才一同送入合成器。

另外，在实际视频编辑中，我们还会添加不少视频特效和滤镜。前面在视频拍摄的场景下，我们利用 Surface 作为 MediaCodec 的输入源，并且利用 Surface 创建了 OpenGL 的上下文。而 MediaCodec 作为解码器的时候，也可以在configure的时候，指定一张 Surface 作为其解码的输出。大部分视频特效都是可以通过 OpenGL 来实现的，因此要实现视频特效，一般的流程是下面这样的。

我们将解码之后的渲染交给 OpenGL，然后输出到编码器的 InputSurface 上，来实现整套编码流程。

2. 视频播放

任何视频类 App 都会涉及视频播放，从录制、剪辑再到播放，构成完整的视频体验。对于要播放一个 MP4 文件，最简单的方式莫过于直接使用系统的MediaPlayer，只需要简单几行代码，就能直接播放视频。对于本地视频播放来说，这是最简单的实现方式，但实际上我们可能会有更复杂的需求：

需要播放的视频可能本身并不在本地，很多可能都是网络视频，有边下边播的需求。

播放的视频可能是作为视频编辑的一部分，在剪辑时需要实时预览视频特效。

对于第二种场景，我们可以简单配置播放视频的 View 为一个 GLSurfaceView，有了 OpenGL 的环境，我们就可以在这上实现各种特效、滤镜的效果了。而对于视频编辑常见的快进、倒放之类的播放配置，MediaPlayer 也有直接的接口可以设置。

更为常见的是第一种场景，例如一个视频流界面，大部分视频都是在线视频，虽然 MediaPlayer 也能实现在线视频播放，但实际使用下来，会有两个问题：

通过设置 MediaPlayer 视频 URL 方式下载下来的视频，被放到了一个私有的位置，App 不容易直接访问，这样会导致我们没法做视频预加载，而且之前已经播放完、缓冲完的视频，也不能重复利用原有缓冲内容。

同视频剪辑直接使用 MediaExtractor 返回的数据问题一样，MediaPlayer 同样无法精确 seek，只能 seek 到有关键帧的地方。

对于第一个问题，我们可以通过视频 URL 代理下载的方式来解决，通过本地使用 Local HTTP Server 的方式代理下载到一个指定的地方。现在开源社区已经有很成熟的项目实现，例如AndroidVideoCache。

而对于第二个问题来说，没法精确 seek 的问题在有些 App 上是致命的，产品可能无法接受这样的体验。那同视频编辑一样，我们只能直接基于 MediaCodec 来自行实现播放器，这部分内容就比较复杂了。当然你也可以直接使用 Google 开源的ExoPlayer，简单又快捷，而且也能支持设置在线视频 URL。

看似所有问题都有了解决方案，是不是就万事大吉了呢？

常见的网络边下边播视频的格式都是 MP4，但有些视频直接上传到服务器上的时候，我们会发现无论是使用 MediaPlayer 还是 ExoPlayer，似乎都只能等待到整个视频都下载完才能开始播放，没有达到边下边播的体验。这个问题的原因实际上是因为 MP4 的格式导致的，具体来看，是跟 MP4格式中的 moov 有关。

MP4 格式中有一个叫作 moov 的地方存储这当前 MP4 文件的元信息，包括当前 MP4 文件的音轨视轨格式、视频长度、播放速率、视轨关键帧位置偏移量等重要信息，MP4 文件在线播放的时候，需要 moov 中的信息才能解码音轨视轨。

而上述问题发生的原因在于，当 moov 在 MP4 文件尾部的时候，播放器没有足够的信息来进行解码，因此视频变得需要直接下载完之后才能解码播放。因此，要实现 MP4 文件的边下边播，则需要将 moov 放到文件头部。目前来说，业界已经有非常成熟的工具，FFmpeg跟mp4v2都可以将一个 MP4 文件的 moov 提前放到文件头部。例如使用 FFmpeg，则是如下命令：

ffmpeg -i input.mp4 -movflags faststart -acodec copy -vcodec copy output.mp4

使用-movflags faststart，我们就可以把视频文件中的 moov 提前了。

另外，如果想要检测一个 MP4 的 moov 是否在前面，可以使用类似AtomicParsley的工具来检测。

在视频播放的实践中，除了 MP4 格式来作为边下边播的格式以外，还有更多的场景需要使用其他格式，例如 m3u8、FLV 之类，业界在客户端中常见的实现包括ijkplayer、ExoPlayer，有兴趣的同学可以参考下它们的实现。

音视频开发的学习之路

音视频相关开发涉及面很广，今天我也只是简单介绍一下其中基本的架构，如果想继续深入这个领域发展，从我个人学习的经历来看，想要成为一名合格的开发者，除了基础的 Android 开发知识以外，还要深入学习，我认为还需要掌握下面的技术栈。

语言

C/C++：音视频开发经常需要跟底层代码打交道，掌握 C/C++ 是必须的技能。这方面资料很多，相信我们都能找到。

ARM NEON 汇编：这是一项进阶技能，在视频编解码、各种帧处理低下时很多都是利用 NEON 汇编加速，例如 FFmpeg/libyuv 底层都大量利用了 NEON 汇编来加速处理过程。虽说它不是必备技能，但有兴趣也可以多多了解，具体资料可以参考 ARM 社区的教程。

框架

FFmpeg：可以说是业界最出名的音视频处理框架了，几乎囊括音视频开发的所有流程，可以说是必备技能。

libyuv：Google 开源的 YUV 帧处理库，因为摄像头输出、编解码输入输出也是基于 YUV 格式，所以也经常需要这个库来操作数据（FFmpeg 也有提供了这个库里面所有的功能，在libswscale都可以找到类似的实现。不过这个库性能更好，也是基于 NEON 汇编加速）。

libx264/libx265：目前业界最为广泛使用的 H.264/H.265 软编解码库。移动平台上虽然可以使用硬编码，但很多时候出于兼容性或画质的考虑，因为不少低端的 Android 机器，在低码率的场景下还是软编码的画质会更好，最终可能还是得考虑使用软编解码。

OpenGL ES：当今，大部分视频特效、美颜算法的处理，最终渲染都是基于 GLES 来实现的，因此想要深入音视频的开发，GLES 是必备的知识。另外，除了 GLES 以外，Vulkan也是近几年开始发展起来的一个更高性能的图形 API，但目前来看，使用还不是特别广泛。

ExoPlayer/ijkplayer：一个完整的视频类 App 肯定会涉及视频播放的体验，这两个库可以说是当下业界最为常用的视频播放器了，支持众多格式、协议，如果你想要深入学习视频播放处理，它们几乎也算是必备技能。

从实际需求出发，基于上述技术栈，我们可以从下面两条路径来深入学习。

1. 视频相关特效开发

直播、小视频相关 App 目前越来越多，几乎每个 App 相关的特效，往往都是利用 OpenGL 本身来实现。对于一些简单的特效，可以使用类似Color Look Up Table的技术，通过修改素材配合 Shader 来查找颜色替换就能实现。如果要继续学习更加复杂的滤镜，推荐你可以去shadertoy学习参考，上面有非常多 Shader 的例子。

而美颜、美型相关的效果，特别是美型，需要利用人脸识别获取到关键点，对人脸纹理进行三角划分，然后再通过 Shader 中放大、偏移对应关键点纹理坐标来实现。如果想要深入视频特效类的开发，我推荐可以多学习 OpenGL 相关的知识，这里会涉及很多优化点。

2. 视频编码压缩算法

H.264/H.265 都是非常成熟的视频编码标准，如何利用这些视频编码标准，在保证视频质量的前提下，将视频大小最小化，从而节省带宽，这就需要对视频编码标准本身要有非常深刻的理解。这可能是一个门槛相对较高的方向，我也尚处学习阶段，有兴趣的同学可以阅读相关编码标准的文档。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

39 | 移动开发新大陆： 边缘智能计算的趋势

你好，我是张绍文。今天文章的作者黄振是算法领域的专家，过去曾经和他合作过移动端的 AI 项目，无论是他的算法水平，还是工程上的能力，都让我深感佩服。今天我非常幸运地请到他来给我们分享关于移动边缘智能计算的认识。

如果说过去几年移动开发是那头在风口上的猪，现在这个风口明显已经转移到 AI，最明显的是国内应届生都纷纷涌向了 AI 方向。那作为移动开发，我们如何在 AI 浪潮之下占据自己的一席之地？

以我个人看法来看，即使目前 AI 在移动端的落地场景有限，但是 AI 这个“坑”我们是一定要跳的。我们可以尝试使用 TensorFlow 这些框架去做一些简单的 Demo，逐步加深对深度学习的理解。

Google 的TensorFlow Lite、Facebook 的Cafe2、腾讯的NCNN和FatherCNN、小米的MACE、百度的Paddle-Mobile，各个公司都开源了自己的移动深度学习框架。移动端未来必然是深度学习一个非常重要的战场，移动开发们需要发挥自己的平台特长。我们对于这些移动深度学习框架的优化应该更有发言权，要做到既有算法思维，也有更加强大的工程能力。就像之前黄振合作的项目中，整个框架的性能优化也是由我们来主导，使用了大量 ARM NEON 指令和汇编进行优化。

大家好，我是黄振，目前在一家大型互联网公司从事算法工作。我一直在关注边缘智能计算，正好过去也开展过移动端机器学习的项目，期间也接触了不少 Android 开发的同学。在一起合作期间，我也在思考 AI 和 Android 开发可以相结合的点，也看到团队中不少 Android 开发同学展现出的机器学习技术的开发能力。正好应绍文邀请，在 Android 开发专栏和你分享一下我对移动端机器学习的认识，希望对你有所帮助。

目前，技术的发展有两个趋势。一个趋势是，随着 5G 网络的发展，物联网使“万物互联”成为可能。物联网时代，也是云计算和边缘计算并存的世界，且边缘计算将扮演重要的角色。边缘计算是与云计算相反的计算范式，指的是在网络边缘节点（终端设备）进行数据处理和分析。边缘计算之所以重要，原因在于：首先，在物联网时代，将有数十亿的终端设备持续采集数据，带来的计算量将是云计算所不能承受的；其次，终端设备的计算能力不断提高，并不是所有的计算都需要在云端完成；再有，将数据传回云端计算再将结果返回终端的模式，不可避免存在时延，不仅影响用户体验，有些场景也是不可接受的；最后，随着用户对数据安全和隐私的重视，也会要求在终端进行数据加工和计算。

另一个趋势是，人工智能技术的迅速发展。最近几年，以深度学习为代表的人工智能技术取得了突破性的进展，不仅在社会上引起了人们的兴趣和关注，同时也正在重塑商业的格局。人工智能技术的重大价值在于，借助智能算法和技术，将之前必须人工才能完成的工作进行机器化，用机器的规模化解放人工，突破人力生产要素的瓶颈，从而大幅提高生产效率。在商业社会，“规模化”技术将释放巨大的能量、创造巨大的价值，甚至重塑商业的竞争，拓展整个商业的外部边界。

边缘计算既然有计算能力，在很多应用场景中就会产生智能计算的需求，两者结合在一起，就形成了边缘智能计算。

移动端机器学习的开发技术

目前，移动端机器学习的应用技术主要集中在图像处理、自然语言处理和语音处理。在具体的应用上，包括但不限于视频图像的物体检测、语言翻译、语音助手、美颜等，在自动驾驶、教育、医疗、智能家居和物联网等方面有巨大的应用需求。

1. 计算框架

因为深度学习算法具有很多特定的算法算子，为了提高移动端开发和模型部署的效率，各大厂都开发了移动端深度学习的计算框架，例如谷歌的TensorFlow Lite、Facebook 的Caffe2等，Android 开发的同学还是挺有必要去了解这些计算框架的。为了培养兴趣这方面的兴趣并获得感性的认识，你可以挑选一个比较成熟的大厂框架，例如 TensorFlow Lite，在手机开发一个物体检测的 Demo 练练手。

在实际项目中，TensorFlow Lite 和 Caffe2 等通常运行比较慢。在网上可以很容易找到各个计算框架的对比图。如果要真正入门的话，在这里我推荐大家使用NCNN框架。NCNN 是腾讯开源的计算框架，优点比较明显，代码结构清晰、文件不大，而且运行效率高。强烈推荐有兴趣的 Android 开发同学把源码阅读几遍，不仅有助于理解深度学习常用的算法，也有助于理解移动端机器学习的计算框架。

阅读 NCNN 源码抓住三个最基础的数据结构，Mat、Layer 和 Net。其中，Mat 用于存储矩阵的值，神经网络中的每个输入、输出以及权重，都是用 Mat 来存储的。Layer 实际上表示操作，所以每个 Layer 都必须有前向操作函数（forward 函数），所有算子，例如，卷积操作（convolution）、LSTM 操作等都是从 Layer 派生出来的。Net 用来表示整个网络，将所有数据节点和操作结合起来。

在阅读 NCNN 源码的同时，建议大家也看些关于卷积神经网络算法的入门资料，会有助于理解。

2. 计算性能优化

在实际项目中，如果某个路径成为时间开销的瓶颈，通常可以将该节点用 NDK 去实现。但在通常情况下，移动端机器学习的计算框架已经是 NDK 实现的，这时改进的方向是采用 ARM NEON 指令 + 汇编进行优化。

NEON 是适用于 ARM Cortex-A 系列处理器的一种 128Bit SIMD（Single Instruction, Multiple Data，单指令、多数据）扩展结构。ARM NEON 指令之所有能达到性能优化的目的，关键就在于单指令多数据。如下图所示：

两个操作数各有 128Bit，各包含 4 个 32Bit 的同类型的寄存器，通过如下所示的一条指令，就可以实现 4 个 32Bit 数据的并行计算，从而达到性能优化的效果。

VADDQ.S32 Q0,Q1,Q2

我们曾经用 NDK 的方式实现了深度学习算法中的 PixelShuffle 操作，后来采用 ARM NEON+ 汇编优化的方式，计算的效率提升 40 倍，效果还是很显著的。

如果还想进一步提高计算的性能，可以采用 Int 8 量化的方法。在深度学习里面，大多数运算都是基于 Float 32 类型进行的，Float 32 是 32Bit，128Bit 的寄存器一次可以存储 4 个 Float 32 类型数据；相比之下，Int 8 类型的数据可以存储 16 个。结合前面提到的单指令多数据，如果采用 Int 8 类型的数据，单条指令可以同时执行 16 个 Int 8 数据的运算，从而大大提高了并行性。

但是，将 Float 32 类型的数值量化到 Int 8 表达，不可避免会影响到数据的精度，而且量化的过程也是需要时间开销的，这些都是需要注意的地方。关于量化的方法，感兴趣的同学可以阅读这篇论文。

如果设备具有 GPU，还可以应用 OpenCL 进行 GPU 加速，例如小米开源的移动端机器学习框架MACE。

移动端机器学习的算法技术

对于刚开始学习算法的同学，我一直主张不要把算法想象得太复杂，也不要想得太数学化，否则容易让人望而生畏。数学是思维逻辑的表达，我们希望用数学帮助我们理解算法。我们要能够达到对算法的直观理解，从直观上知道和理解为什么这个算法会有这样的效果，只有这样才算是真正掌握了算法。相反，对于一个算法，如果你只记住了数学推导，没形成直观的理解，是不能够灵活应用的。

1. 算法设计

深度学习的图像处理具有很广的应用，而且比较直观和有趣，建议你可以从深度学习的图像处理入手。深度学习的图像处理，最基本的知识是卷积神经网络，所以你可以先学习卷积神经网络。网上有很多关于卷积神经网络的介绍，这里就不赘述了。

理解卷积神经网络关键是理解卷积神经网络的学习机制，理解为什么能够学习。想要理解这一点，首先需要明确两个关键点“前向传播”和“反向传播”。整个神经网络在结构和激活函数确定之后，所谓“训练”或者“学习”的过程，其实就是在不断地调整神经网络的每个权重，让整个网络的计算结果趋近于期望值（目标值）。前向传播是从输入端开始计算，目标是得到网络的输出结果，再将输出结果和目标值相比较，得到结果误差。之后将结果误差沿着网络结构反向传播拆解到每个节点，得到每个节点的误差，然后根据每个节点的误差调整该节点的权重。“前向传播”的目的是得到输出结果，“反向传播”的目的是通过反向传播误差来调整权重。通过两者互相交替迭代，希望达到输出结果和目标值一致。

理解卷积神经网络之后，我们可以动手实现手写字体识别的示例。掌握之后，可以接着学习深度学习里的物体检测算法，例如 YOLO、Faster R-CNN 等，最后可以动手用 TensorFlow 都写一遍，跑跑训练数据、调调参数，边动手边理解。在学习过程中，要特别留意算法模型的设计思想和解决思路。

2. 效果优化

效果优化指提升算法模型的准确率等指标，通常的方式有以下几种：

优化训练数据

优化算法设计

优化模型训练方式

优化训练数据

因为算法模型是从训练数据中学习的，模型无法学习到训练数据之外的模式。所以，在选择训练数据时要特别小心，必须使得训练数据包含实际场景中会出现的模式。精心挑选或标注训练数据，会有效提升模型的效果。训练数据的标注对效果非常重要，以至于有创业公司专门从事数据标注，还获得了不少融资。

优化算法设计

根据问题采用更好的算法模型，采用深度学习模型而不是传统的机器学习模型，采用具有更高特征表达能力的模型等，例如使用残差网络或 DenseNet 提高网络的特征表达能力。

优化模型训练方式

优化模型训练方式包括采用哪种损失函数、是否使用正则项、是否使用 Dropout 结构、使用哪种梯度下降算法等。

3. 计算量优化

虽然我们在框架侧做了大量的工作来提高计算性能，但是如果在算法侧能够减少计算量，那么整体的计算实时性也会提高。从模型角度，减少计算量的思路有两种，一种是设计轻量型网络模型，一种是对模型进行压缩。

轻量型网络设计

学术界和工业界都设计了轻量型卷积神经网络，在保证模型精度的前提下，大幅减少模型的计算量，从而减少模型的计算开销。这种思路的典型代表是谷歌提出的MobileNet，从名字上也可以看出设计的目标是移动端使用的网络结构。MobileNet 是将标准的卷积神经网络运算拆分成 Depthwise 卷积运算和 Pointwise 卷积运算，先用 Depthwise 卷积对输入各个通道分别进行卷积运算，然后用 Pointwise 卷积实现各个通道间信息的融合。

模型压缩

模型压缩包括结构稀疏化和蒸馏两种方式。

在逻辑回归算法中，我们通过引入正则化，使得某些特征的系数近似为 0。在卷积神经网络中，我们也希望通过引入正则化，使得卷积核的系数近似为 0。与普通的正则化不同的是，在结构稀疏化中，我们希望正则化实现结构性的稀疏，比如某几个通道的卷积核的系数全部近似为 0，从而可以将这几个通道的卷积核剪枝掉，减少不必要的计算开销。

蒸馏方法有迁移学习的意思，就是设计一个简单的网络，通过训练的方式，使得该简单的网络具有目标网络近似的表示能力，从而达到“蒸馏”的效果。

Android 开发同学的机会

移动端机器学习的计算框架和算法，前者负责模型计算的性能，减少时间开销；后者主要负责模型的精度，还可以通过一些算法设计减少算法的计算量，从而达到减少时间开销的目的。

需要注意的是，在移动端机器学习中，算法模型的训练通常是在服务器端进行的。目前，终端设备通常不负责模型的训练。在使用时，由终端设备加载训练结果模型，执行前向计算得到模型的计算结果。

但是前面讲了那么多行业趋势和机器学习的基本技术，那对于移动开发的同学来说，如何进入这个“热门”的领域呢？移动端机器学习是边缘智能计算范畴的一个领域，而且移动端开发是 Android 开发同学特别熟悉的领域，所以这也是 Android 开发同学的一个发展机会，转型进入边缘智能计算领域。Android 开发同学可以发挥自己的技术专业优势，先在边缘计算的终端设备程序开发中站稳脚跟，在未来的技术分工体系中有个坚固的立足点；同时，逐步学习深度学习算法，以备将来往前迈一步，进入边缘智能计算领域，创造更高的技术价值。

可能在大部分情况下，Android 开发同学在深度学习算法领域，跟专业的算法同学相比，不具有竞争优势，所以我们千万不要放弃自己所专长的终端设备的开发经验。对大多数 Android 开发同学而言，“专精 Android 开发 + 懂深度学习算法”才是在未来技术分工中，创造最大价值的姿势。

对于学习路径，我建议 Android 开发同学可以先学习卷积神经网络的基础知识（结构、训练和前向计算），然后阅读学习 NCNN 开源框架，掌握计算性能的优化方法，把开发技术掌握好。同时，可以逐步学习算法技术，主要学习各种常见的深度学习算法模型，并重点学习近几年出现轻量型神经网络算法。总之，Android 开发同学要重点掌握提高计算实时性的开发技术和算法技术，兼顾学习深度学习算法模型。

基于前面的描述，我梳理了移动端机器学习的技术大图供你参考。图中红圈的部分，是我建议 Android 开发同学重点掌握的内容。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

40 | 动态化实践，如何选择适合自己的方案？

在专栏第 36 期《跨平台开发的现状与应用》中，我分享了 H5、React Native/Weex、小程序这几种常见的跨平台开发方式。站在开发的角度，虽然跨平台的开发效率要比 Native 开发更高，但是这并不是大前端在国内盛行的最主要原因。

相比跨平台能力，国内对大前端的动态化能力更加偏执。这是为什么呢？移动互联网已经发展十年了，随着业务成熟和功能的相对稳定，整体重心开始偏向运营，强烈的运营需求对客户端架构和发布模式都提出了更高的要求。如果每个修改都需要经历开发、上线、版本覆盖等漫长的过程，根本无法达到快速响应的要求。

所以 H5、React Native/Weex、小程序在国内的流行，可以说是动态化能力远比跨平台能力重要。那我们应该选择哪一种动态化方式呢？正如我在跨平台开发所说的，目前这几种方案或多或少都还存在一些性能问题，如果一定要使用 Native 开发方式，又有哪些动态化方案？今天我们一起来学习应该如何选择适合自己的动态化方案。

动态化实践的背景

前几天在朋友圈看到说淘宝 iOS 客户端上一个版本的更新，已经是两个多月前的事情了。淘宝作为一个业务异常庞大且复杂的电商平台，这样的发布节奏在过去是很难想象的。

而现在即使不通过发布新版本，我们也能实现各式各样的运营活动和个性化推荐。依赖客户端的动态化能力，我们不需要等待应用商店审核，也无须依赖用户的主动更新，产品在快速迭代的同时，也有着非常强大的试错能力。

1. 常见的动态化方案

移动端动态化方案在最近几年一直是大家关注的重点，虽然它已经发展了很多年，但是每年都会有新的变化，这里我们先来看看各大公司有哪些已知的动态化方案。

在 2018 年北京 QCon 大会上，美团工程师分享了他们在动态化的实践《美团客户端动态化实践》。美团作为一个强运营的应用，对动态化有非常强烈的诉求，也有着非常丰富的实践经验，他们将动态化方案分为下面四种类型。

Web 容器增强。基于 H5 实现，但是还有离线包等各种优化手段加持，代表方案有 PWA、腾讯的 VasSonic、淘宝的 Zcache 以及大部分的小程序方案。

虚拟运行环境。使用独立的虚拟机运行，但最终使用原生控件渲染，代表方案有 React Native、Weex、快应用等。

业务插件化。基于 Native 的组件化开发，这种方式在淘宝、支付宝、美团、滴滴、360 等航母应用上十分常见。代表方案有阿里的 Atlas、360 的 RePlugin、滴滴的 VirtualAPK 等。除此之外，我认为各个热修复框架应该也属于业务插件化的一种类型，例如微信的 Tinker、美团的 Robust、阿里的 AndFix。

布局动态化。插件化或者热修复虽然可以做到页面布局和数据的动态修改，但是代价巨大，而且也不容易实现个性化运营。为了实现“千人千面”，淘宝和美团的首页结构都可以通过动态配置更新。代表的方案有阿里的 Tangram、Facebook 的 Yoga。

2. 动态化方案的选择

四大动态化方案哪家强，我们又应该如何选择？在回答这个问题之前，我们先来看看它们的差别。

目前我们还无法找到一种“十全十美”的动态化方案，每种方案都有自己的优缺点和对应的使用场景。比如 Web 容器增强方案在动态化能力、开发效率上有着非常大的优势，但在稳定性和流畅度却差强人意。恰恰相反，布局动态化方案在性能上面有非常不错的表现，但是在动态化能力和开发效率上面却受到不少限制。

所以说动态方案的选择，我们需要考虑下面这些因素。

业务类型。主要考虑业务的重要性、交互是否复杂、对性能的要求、是否长期迭代等因素。

团队技术栈和代码的历史包袱。在选择方案的时候，也需要结合团队的技术栈现状以及代码的历史包袱综合考虑。以微信为例，作为一个强交互的 IM 应用，团队基本以 Native 开发为主，而且微信基本没有太多运营上的需求，所以当时在动态化方案上只使用了 Tinker。当然团队的技术栈并不是永恒不变，有了微信小程序之后，内部的一些业务也尝试使用小程序来改造。

最终无论我们选择哪种动态化类型，我都建议公司内部同一种动态化类型都使用同一个方案，这样在统一技术栈的同时，也可以实现代码在不同业务之间的迁移。比如阿里内部的虚拟运行环境统一使用 Weex，一个业务在手淘的效果不错，也可以快速迁移到飞猪、天猫等其他应用中，实现应用的流量矩阵。

同样对于运营活动也是如此，阿里内部有一个叫PopLayer的组件，它可以在任意 Native 页面（这个页面甚至可以是 Browser）弹出 H5 的部署容器，可以在无需发版的情况下对已有的 Native 界面上浮出透明浮层，并且可以不影响 Native 页面本身的交互。这样做活动我们不需要在客户端提前一两个月开发代码，而且同一个活动也可以快速在公司内部的各个应用中上线。

Native 动态化方案

Web 容器增强和虚拟运行环境方案通过独立的 Runtime 和 JS-SDK 来桥接 Native 模块，而业务插件化则通过插件化框架和接口能力直接调用。相比之下前者更加抽象而不易造成代码混乱，这也是目前各大公司逐渐开始“去插件化”的原因。

最近两年，大前端开发越演越烈，传统的 Native 动态化方案是否还存在价值，它又该何去何从？热修复、插件化这些方案的未来又将如何演进？

1. 热修复和插件化

2016 年在开源 Tinker 的时候有两件事情是超出我预料的，一个是热修复在国内竟然有那么大的反响，另外一个就是它竟然如此的“坑坑不息”。

从《Tinker：技术的初心与坚持》一文中，你可以看到过去我们踩过的一小部分坑，但非常不幸的是，填坑之路至今依然没有结束。每次 Android 新版本发布，我们就像迎来期末考试一样步步惊心。

曾经微信希望使用 Tinker 来代替版本发布，在热修复的基础上实现四大组件的代理。但是 Android P 私有 API 限制的出现，基本打消了这个念头。热修复不能代替版本发布，但是我们可以通过它来实现一些应用商店不支持的功能，例如精准的灰度人数控制、渠道和用户属性选择、整包的 A/B 测试等。

另一方面，热修复给国内的 Android 生态也带来一些不太好的影响，比如增加用户 ROM 体积占用、App 启动变慢 15%、OTA 首次卡顿等。特别是 Android Q 之后，动态加载的 Dex 都只使用解释模式执行，会加剧对启动性能的影响。因为性能的问题，目前大公司基本暂停了全量用户的热修复，只使用热修复用于灰度和测试。

热修复如此，插件化也是如此。笨重的插件化框架不仅影响应用的启动速度，而且多团队协作的时候并没有想象的那么和谐，接口混乱、仓库不好管理、编译速度慢这些问题都会存在。插件化回归模块化和组件化，这也是目前各大公司都在逐步推进的事情。

前一阵子，徐川在《移动开发的罗曼蒂克消亡史 》一文中回顾了热修复和插件化的前世今生。时间一转三年过去了，对于曾经参与这个浪潮的一份子来说，我可以做的只是顺应潮流的变化。

热修复的未来

Tinker 设计之初参考了 Instant Run 的编译方案，但是正如专栏第 26 期《关于编译，你需要了解什么？》中所说的，Google 在 Android Studio 3.5 之后，对于 Android 8.0 以上的设备将会使用Apply Changes替代之前的 Instant Run 方案。

Apply Changes 不再使用插入 PathClassloader 的方式，而是使用我们已经多次讨论过的 JVM TI。在 Android 8.0 之后，JVM TI 开始逐渐支持 ClassTransform 和 ClassRedefine 这两个接口，它们可以允许虚拟机在运行时动态修改类，实现运行时的动态字节码编织。事实上这个技术在 JVM 就已经非常成熟，Java 服务端利用这两个接口实现了类似热部署、远程调试、动态追踪等能力，具体你可以参考《Java 动态追踪技术探究》。

那热修复的未来将要走向何方？本来我对热修复的未来是非常悲观的，但是 Android Q 给了我们一个很大的惊喜。我们知道，Android P 在中国有非常多的应用出现了兼容性问题，其中大部分是热修复、插件化以及加固等原因造成的（Google 提供的数据是 43% 的兼容性问题由这三个问题造成）。

为了解决这个问题，并且减少我们对私有 API 的调用，Google 在 Android P 新增了AppComponentFactory API，并且在 Android Q 增加了替换 Classloader 的接口 instantiateClassloader。在 Android Q 以后，我们才可以实现在运行时替换已经存在 ClassLoader 和四大组件。中国热修复的先驱们用自己的“牺牲”，总算换来了 Google 官方的支持。我们使用 Google 官方 API 就可以实现热修复，这样以后 Android 版本再升级也不用担惊受怕了。移动开发的罗曼蒂克并没有消亡，Native 的热修复再次迎来了春天。

插件化的未来

对于插件化的未来，我们需要思考如何“回归官道”。Google 在 2018 年推出了Android App Bundles，它可以实现模块的动态下载，但是与插件化不同的是，它并不支持四大组件代理的能力。

但是 Android App Bundles 方案依赖 Play Service，在国内我们根本无法使用。爱奇艺的Qigsaw可能对我们有所启发，它基于 Android App Bundles 实现（支持动态更新，但是不支持四大组件代理），同时完全仿照 AAB 提供的 Play Core Library 接口加载插件，如果有国际化需求的公司可以在国内版和国际版上无缝切换。这种方案不仅可以使用 Google 提供的编译工具链，也支持国际国内双轨，相当于 Google 为我们维护整个组件化框架，在国内只需要实现自己的“Play Service”即可。

当然和热修复一样，如果使用AppComponentFactory API，我们也可以实现插件化的四大组件代理。但是具体实现上依然需要在 AndroidManifest 中预先注册四大组件，然后具体的替换规则可以在我们自定义的 AppComponentFactory 实现类中埋好。

以 Activity 替换为例，我们可以将某些类名的 Activity 替换成其他的 Activity，新的 Activity 可以在补丁中，也可以在其他插件中。

热修复和插件化作为 Native 动态化方案，它们有一定的局限性。随着移动技术的发展，部分功能可能会被替换成小程序等其他动态化方案。但是从目前来看，它们依然有非常大的存在价值和使用场景。

2. 布局动态化

正如上文所说，像淘宝、美团首页这些场景，我们对性能要求非常高，这里只能使用 Native 实现。但是首页也是流量的聚集地，“提增长、提留存、提转化”都要求我们有强大的运营能力。最近两年，淘宝、天猫一直推行“千人千面”，每个用户看到的主页布局、内容可能都不太一样。

布局动态化正是在这个背景之下应运而生，在我看来，布局动态化需要具备下面三个能力。

UI 容器化。能够动态地新增、调整 UI 界面而无需发版。

能力接口化。点击、跳转等通用能力可以通过路由协议对外提供，满足 UI 容器化后的调用需求。

数据通道化。数据上报也可以通过字段配置，实现客户端根据配置自动上报。

在具体的实践上，天猫开源的Tangram是一个不错的选择。但是 Tangram 的整体方案会相对复杂一些，我们也可以基于底层的VirtualView做二次开发。

总的来说，布局动态化相比虚拟运行环境来说，它不仅实现了 UI 的动态新增和修改，也有着良好的体验和性能，同时接入和学习成本也比较低。

总结

“路漫漫其修远兮，吾将上下而求索”，我们对动态化实践的探索一直没有停止。今年，Flutter 也强势地杀入了这个“战场”，那 Flutter 在跨平台和动态化方面的表现如何，我们将在专栏下一期中揭晓。

动态化如今在国内是炙手可热的研究方向，虽然每个公司都强行造了自己的轮子，但是动态化方案目前还有很多没有解决的问题。所以在我们解决这些问题的过程中，也还会不断演变出其他的各种新方案。

总的来说，各种类型的动态化方案，目前都能找到自己的应用场景。移动技术在快速的发展，我们无法准确预料到未来，比如说在 Android P 我们正准备放弃热修复的时候，Android Q 又使它重新焕发了青春。但是我们坚信，无论未来采用何种方案，都是为了给用户更好的体验，同时让业务可以更快的迭代，并在不断的尝试中，给用户提供更好的产品。

课后作业

对于动态化实践，你有什么看法？在你的应用中，使用了哪种动态化方式？欢迎留言跟我和其他同学一起讨论。

跨平台和动态化可以说是大前端时代最大的两个特点，也是每年技术大会的重点。今天的课后作业是仔细阅读下面大会的分享内容，学习各大公司对大前端的实践经验，并留言写写你的心得体会。

2018 年北京 QCon：美团《美团客户端动态化实践》。

2018 年 GMTC：闲鱼《基于 Google+Flutter 的移动端跨平台应用实践》。

2018 年 GMTC：京东《当插件化遇上 Android+P》。

2018 年 GMTC：小米《快应用开发与实现指南》。

2018 年 GMTC：绿色守护《Android+ 研发的昨天、今天与明天》。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

41 | 聊聊Flutter，面对层出不穷的新技术该如何跟进？

“天下苦秦久矣”，不管是 H5、React Native，还是过去两年火热的小程序，这些跨平台方案在性能和稳定性上总让我们诟病不已。最明显的例子是 React Native 已经发布几年了，却一直还处在 Beta 阶段。

Flutter 作为今年最火热的移动开发新技术，从我们首次看到 Beta 测试版，到 2018 年 12 月的 1.0 正式版，总共才经过了 9 个多月。Flutter 在保持原生性能的前提下实现了跨平台开发，而且更是成为 Google 下一代操作系统 Fuchsia 的 UI 框架，为移动技术的未来发展提供了非常大的想象空间。

高性能、跨平台，而且更是作为 Google 下一个操作系统的重要部分，Flutter 已经有这么多光环加身，那我们是否应该立刻投身这个浪潮之中呢？新的技术、新的框架每一年都在不断涌现，我们又应该如何跟进呢？

Flutter 的前世今生

大部分所谓的“新技术”最终都会被遗忘在历史的长河中，面对新技术，我们首先需要持怀疑态度，在决定是否跟进之前，你需要了解它的方方面面。下面我们就一起来看看 Flutter 的前世今生。

Flutter 的早期开发者 Eric Seidel 曾经参加过一个访谈What is Flutter，在这个访谈中他谈到了当初为什么开发 Flutter，以及 Flutter 的一些设计原则和方向。
﻿﻿

Eric Seidel 和 Flutter 早期的几位开发人员都是来自 Chrome 团队，他们在排版和渲染方面具有非常丰富的经验。那为什么要去开发 Flutter？一直以来他们都为浏览器的性能而感到沮丧，有一天他们决定跳出 Web 的范畴，在 Chromium 基础上通过删除大量的代码，抛弃 Web 的兼容性，竟然发现性能是之前的 20 倍。对此我也深有感触，最近半年也一直在做主 App 的 Lite 版本，安装包也从 42MB 降到 8MB，通过删除大量的历史业务，性能比主包要好太多太多。

正如访谈中所说，Flutter 是一个 Google 内部孵化多年的产品，从它开发之初到现在，一直秉承着两个最重要的设计原则：

性能至上。内置布局和渲染引擎，使用 Skia 通过 GPU 做光栅化。选择 Dart 语言作为开发语言，在发布正式版本时使用 AOT 编译，不再需要通过解析器解释执行或者 JIT。并且支持 Tree Shaking 无用代码删除，减少发布包的体积。

效率至上。在开发阶段支持代码的Hot Reload，实现秒级编译更新。重视开发工具链，从开发、调试、测试、性能分析都有完善的工具。内置 Runtime 实现真正的跨平台，一套代码可以同时生成 Android/iOS 应用，降低开发成本。

那为什么要选择 Dart 语言？“Dart 团队办公室离 Flutter 团队最近”肯定是其中的一个原因，但是 Dart 也为 Flutter 追求性能和效率的道路提供了大量的帮助，比如 AOT 编译、Tree Shaking、Hot Reload、多生代无锁垃圾回收等。正因为这些特性，Flutter 在筛选了多种语言后，最终选择了 Dart。这里也推荐你阅读Why Flutter Uses Dart这篇文章。

总的来说，Flutter 是一个内置了布局和渲染引擎，使用 Dart 作为开发语言，采用 React 方式编写 UI，支持一套代码在多端运行的框架。但是正如专栏前面所说的，大前端时代的核心诉求是跨平台和动态化，下面我们就一起来看看 Flutter 在这两方面的表现。

1. Flutter 的跨平台开发

虽然 React Native/Weex 使用了系统原生 UI 组件，通过原生渲染的方式来提升渲染速度和 UI 流畅度，但是因为 JS 执行效率、JSBridge 的通信代价等因素，性能依然存在瓶颈，而且我们也无法抹平不同系统的平台差异，因此这样的跨平台方案注定艰难。

正如 Eric Seidel 在访谈所说，Flutter 是从浏览器引擎简化而来，无论是它的布局引擎（例如也是使用 CSS Flexbox 布局），还是渲染流水线的设计，都跟浏览器都有很多相似之处。但是它抛弃了浏览器沉重的历史包袱和 Web 的兼容性，实现了在保持性能的前提下实现跨平台开发。

回想一下在专栏第 37 期《工作三年半，移动开发转型手游开发》中，我们还描述过另外一套内置 Runtime 的跨平台方案：Cocos 引擎。

在我看来，Cocos 和 Unity 这些游戏引擎才是最早并且成熟的跨平台框架，它们对性能的要求也更加苛刻。即使是“王者荣耀”和“吃鸡”这么复杂的游戏，我们也可以做得非常流畅。

Flutter 和游戏引擎一样，也提供了一套自绘界面的 UI Toolkit。游戏引擎虽然能实现跨平台开发，但它致力于服务更有“钱途”的游戏开发。游戏引擎对于 App 开发，特别是混合开发支持并不完善。

如下图所示，我们可以看到三种跨平台方案的对比，Flutter 可以说是三者中最为轻量的。

2. Flutter 的动态化实践

在专栏第 40 期《动态化实践，如何选择适合自己的方案》中，我提到了一个观点：“相比跨平台能力，国内对大前端的动态化能力更加偏执”。

在性能、跨平台、动态性这个铁三角中，我们不能同时将三个都做到最优。如果 Flutter 在性能、跨平台和动态性都比浏览器更好，那就不会出现 Flutter 这个框架了，而是成为 Chromium 的一个跨时代版本。

浏览器选择的是跨平台和动态性，而 Flutter 选择的就是性能和跨平台。Flutter 正是牺牲了 Web 的动态性，使用 Dart 语言的 AOT 编译，使用只有 5MB 的轻量级引擎，才能实现浏览器做不到的高性能。Flutter 的第一波受众是 Android 上使用 Java、Kotlin，iOS 上使用 Objective-C、Swift 的 Native 开发。未来 Flutter 是否能以此为突破口，进一步蚕食 Web 领域的份额，现在还不得而知。

那 Flutter 是否支持动态更新呢？由于编译成 AOT 代码，在 iOS 是绝对不可以动态更新的。对于 Android，Flutter 的动态更新能力其实 Tinker 就已经实现了。从官方的提供方案来看，其实也只是替换下面的变化文件，可以说是 Tinker 的简化版。

官方的动态修复方案可以说是非常鸡肋的，并且也是要求应用重启才能生效。如果同时修改了 Native 代码，这也是不支持的。

更进一步说，Flutter 在 Google Play 上是否允许动态更新也是抱有疑问的。从 Google Play 上面的开发者政策中心规定上看，在 Google Play 也是不允许动态更新可执行文件。

从《从 Flutter 的编译模式》一文中，我们可以通过“flutter build apk --build-shared-library”将 Dart 代码编译成 app.so。无论是 libflutter.so，还是 app.so（其实 vm_*、isolate_* 也是可执行文件）的动态更新，都违反了 Google Play 的政策。

当然不排除 Google 为了推广 Flutter，为它的动态更新开绿灯。最近我也在咨询 Google Play 的政策组，目前还没有收到答复，如果后续有进一步的结果，我也可以同步给各位同学。

总的来说，Flutter 的动态化能力理论上只能通过 JIT 编译模式解决，但是这会带来性能和代码体积的巨大影响。当然，闲鱼也在探索一套 Flutter 的布局动态化方案，你可以参考文章《Flutter 动态化的方案对比及最佳实现》。

面对新技术，该如何选择

通过上面的学习，我们总算对 Flutter 的方方面面都有所了解。可以说 Flutter 是一个性能和效率至上，但是动态化能力非常有限的的框架。

目前闲鱼 App、美团外卖、今日头条 App、爱奇艺开播助手、网易新闻客户端、京东JDFlutter、马蜂窝旅游 App，都分享过他们在使用 Flutter 的一些心得体会。如果有兴趣接入 Flutter，非常推荐你认真看看前人的经验和教训。

无论是 Flutter，还是其他新的技术，在抉择是否跟进的时候，我们需要考虑以下一些因素：

收益。接入新的技术或者框架，给我们带来什么收益，例如稳定性、性能、效率、安全性等方面的提升。

迁移成本。如果想得到新技术带来的收益，需要我们付出什么代价，例如新技术的学习成本、原来架构的改造成本等。

成熟度。简单来说，就是这个新技术是否靠谱。跟选择开源项目一样，团队规模、能力是否达标、对项目是否重视都是我们需要考虑的因素。

社区氛围。主要是看跟进这个技术的人够不够多、文档资料是否丰富、遇到问题能否得到帮助等。

1. 对于 Flutter，我是怎么看的

Flutter 是一个非常有前景的技术，这一点是毋庸置疑的。我曾经专门做过一次全面的评估分析，但是得出的结论是暂时不会在我负责的应用中接入，主要原因如下。

目前我还没有跟进 Flutter 的核心原因在于收益不够巨大，如果有足够大的收益，其他几个因素都不是问题。而我负责的应用目前使用 H5、小程序作为跨平台和动态化方案，通过极致优化后性能基本可以符合要求。

从另外一方面来说，新技术的学习和引入，无论是对历史代码、架构，还是我们个人的知识体系，都是一次非常好的重构机会。我非常希望每过一段时间，可以引入一些新的东西，打破大家对现有架构的不满。

新的技术或多或少有很多不完善的地方，这是挑战，也是机会。通过克服一个又一个的困难和挑战，并且在过程中不断地总结和沉淀，我们最终可能还收获了团队成员技术和其他能力的成长。以闲鱼为例，他们在 Flutter 落地的同时，不仅将他们的经验总结成几十篇非常高质量的文章，而且也参加了 QCon、GMTC 等一些技术大会，同时开源了fish-redux、FlutterBoost等几个开源库，Flutter 也一下成为了闲鱼的技术品牌。

可以相信，在过去一年，闲鱼团队在共同攻坚 Flutter 一个又一个难题的过程中，无论是团队的士气还是团队技术和非技术上的能力，都会有非常大的进步。

2. 对于 Flutter，大家又是怎么看的

由于我还并没有在实际项目中使用 Flutter，所以在写今天的文章之前，我也请教了很多有实际应用经验的朋友，下面我们一起来看看他们对 Flutter 又是怎么看的。

总结

曾几何时，我们一直对 Chromium 庞大的代码无从入手。而 Flutter 是一个完整而且比 Webkit 简单很多的引擎，它内部涉及从 CPU 到 GPU，从上层到硬件的一系列知识，源码中有非常多值得我们挖掘去学习和研究的地方。

未来 Flutter 应用在小程序中也是一个非常有趣的课题，我们可以直接使用或者参考 Flutter 实现一个小程序渲染引擎。这样还可以带来另外一个好处，一个功能（例如微信的“附近的餐厅”）在不成熟的时候可以先以小程序的方式尝试，等到这个功能稳定之后，我们又可以无成本的转化为应用内的代码。

Dart 语言从 2011 年启动以来，一直想以高性能为卖点，试图取代 JS，但是长期以来在 Google 外部使用的并不多。那在 Flutter 的这个契机下，它未来是否可以实现弯道超车呢？这件事给我最大的感触是，机会可能随时会出现，但是需要我们时刻准备好。

“打铁还需自身硬”，我们还是要坚持修炼内功。对于是否要学习 Flutter，我的答案是“多说无益，实践至上”。

课后作业

对于 Flutter，你有什么看法？你是否准备在你的应用中跟进？欢迎留言跟我和其他同学一起讨论。

Flutter 作为今年最为火热的技术，里面有非常多的机遇，可以帮我们打造自己的技术品牌（例如撰写文章、参加技术会议、开源你的项目等）。对于 Flutter 的学习，你可以参考下面的一些资料。

万物之中，源码最美

Flutter 官方文档

闲鱼的 Flutter 相关文章

各大应用的使用总结：闲鱼 App、美团外卖、今日头条 App、爱奇艺开播助手、网易新闻客户端、京东JDFlutter、马蜂窝旅游 App

阿里 Flutter 开发者帮助 App：flutter-go

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

42 | Android开发高手课学习心得

你好，我是张绍文。专栏更新至今，转眼间最后一个模块“架构演进”也已经 更新完了。从“Android 开发高手课”筹备到现在将近的八个月里，非常感谢我们的学习委员孙鹏飞，不管是文章答疑还是练习 Sample，鹏飞为这个专栏付出了太多太多，这里再次衷心地跟他说一声谢谢。

无论是我还是鹏飞，在写这个专栏的同时，也是重塑自身知识架构的过程。在专栏的最后一篇文章，我特意邀请鹏飞跟大家分享他的学习心得，也算是跟大家做一个小小的告别。鹏飞对于 Android 系统框架、虚拟机、Linux 等底层知识非常熟悉，那他是如何做到的呢？不得不说的是，鹏飞是一个非常自律（+ 宅）的人，拥有坚持每天早上 7 点钟起床学习一个半小时的可怕技能。

最近鹏飞也准备奔向新的工作岗位，在此也祝愿他在新岗位上能够发挥所长。接下来我们一起来听听鹏飞学习专栏的心得和思考。

大家好，我是孙鹏飞。“Android 开发高手课”接近尾声，今天我来从一个学习者的角度，对专栏做一下总结。专栏涵盖了 Android 开发的方方面面的知识，有技术干货、心得体会、问题答疑和部分科普性质的文章，从内容上来说和平时大家看到的教程不太一样，没有过多介绍 Android 基础，更多的是提供新的思路和分享经验。很多同学感觉学习比较吃力，其实我也是如此，在开始准备这门课程的时候，很多东西也是从零开始学起。对于一个新的概念，从模糊到清晰的过程不会那么容易，它可能涉及 Android 和 Linux 很多细枝末节的知识点，需要阅读大量资料给予支撑才能理解。

下面我就总结一下我学习专栏的过程和一些思考，希望对你有所启发。

关于“高手课”的思考

最近我了解到一些同学对 C++ 和 Linux 感觉不知道如何入手，这里说一下我的学习过程以及相关的学习资料。你可以跟着《C++ Primier Plus》学习一下基本语法和标准库里的函数使用，比如字符串操作、内存分配、I/O 操作等，这里有一个很好的网站学习 C/C++ 语言，包括最新的特性都有介绍。

Linux 应用开发的部分，可以一步步的参考《UNIX 环境高级编程》这本书，熟悉一遍标准 I/O 库、系统数据文件和信息、进程环境、进程控制、进程关系、信号、线程、线程控制、守护进程，以及各种 I/O、进程间通信、网络 IPC 的使用。同时也可以参考《Linux/UNIX 系统编程手册》，这本书相对介绍得更详细一些。在了解了 Linux 应用开发相关内容后，可以深入了解一下 Linux 内核相关的知识，我推荐《Linux 技术内幕》，这本书是国人写的，语言相对于一些翻译书要通顺一些，而且还有大量的图解；缺点是由于篇幅限制，有一部分内容没有介绍。

从专栏中你可以看到有些内容涉及 ARM 的汇编，ARM 的汇编资料相对少一些，而且实践起来不是很方便，这里推荐一个网站Writing ARM Assembly，可以学习到基本的汇编操作。关于实践可以使用VisUAL工具来进行实验，这个工具可以逐步执行并可以实时观察寄存器和内存里的值。关于汇编相关的内容，我认为更多的时候只需要看懂即可，在实际中使用的地方可能并不是很多。

Android 的更新迭代是很快的，在快速的更新中我们需要保持对 Android 新特性的了解，还要掌握兼容问题的处理方法，。现在官方文档更新的速度还是很快的，中文文档更新速度也很快，大部分功能和 API 新特性的描述都可以在 Android 官网上找到很详细的文档，比如Android Q 功能和 API ，并且每个版本官方都会提供很详细的API 差异报告，这个报告将修改、新增的 API 都统计在一起，我们可以在这个表里发现一些新功能。

关于虚拟机方面的更新，我更多是参考官方的提交记录来查看。具体可以看Android Gerrit，这里有每一条提交记录，并且可以查看很详细的 Code Diff，提交记录的描述一般都很清晰，所以可以很好的帮助我们理解代码，也可以发现一些新的工具和功能。

专栏学习心得

我经常听到很多同学说一个技术很难，其实我觉得大多时候不是技术难，而是应该说复杂。技术大多都是演进而来，其实这种演进和我们业务需求迭代的过程很类似。比如内存管理，从固定分区、动态分区机制到分段机制，然后发展成现在的分页机制，硬件也随之演进，都算是一个迭代过程。理解分页机制其实不难，但这个知识点所涉及的内容就很繁琐和复杂了，比如了解系统如何管理内存需要了解内存布局，就会涉及内存划分；内存划分又涉及 CPU 的运行模式，然后是物理内存和虚拟内存如何映射，会涉及页表的翻译、物理页面的分配和释放、伙伴系统算法；为了解决伙伴系统的内存碎片化问题，又衍生出迁移类型等。

这一系列涉及的内容就非常复杂，但每一项单拆出来去看，一层层的去学习和补充，就会感觉容易很多。这一点其实在业务开发上也有体现，我们刚接手一个复杂的业务，代码庞大，注释和文档都很少，但在一段时间后你还是会对整个业务有或多或少的认识，在接到新的业务的时候也没有觉得难到无从下手，顶多是觉得复杂。底层的系统、框架也是如此，这是一个由点到面的过程。

忙碌状态下的学习

鸿洋写过一篇文章《嗷嗷加班，如何保持学习能力》，讲在繁忙的工作状态下如何保持学习，我看过之后也很有感触。在平时零散的时间里我们看到一篇技术文章，并不是阅读收藏后就结束了，这样你可能会在很短的时间里就忘掉了文章的内容。他将阅读一篇文章分成以下几个步骤：提取这篇文章要解决的问题；然后概括一下涉及的技术点；提取重点内容，比如问题发生的缘由、有哪几种解决方法。总体来说，这个方法是为了在短时间内提取出重点内容，然后记录下来后面再进行复习。所以我们都需要多记录、多复习，可以培养使用一些工具来帮助自己养成习惯。

逃离舒适区

并不是说换一个更忙碌的工作就是逃离舒适区，而是在平时工作和学习过程中保持一种焦虑感，这种焦虑感不是迷茫和恐慌，而是清晰的认识到自己的不足，然后在工作和业余的时间里填补自己，当你集中注意力攻克一个难点的时候，你会发现这是一件有趣的事。我身边有很多同学都在持续的学习，每个人都有不同的目标，比如学到什么程度、应用到什么地方等，需要一定的压力才能产生比较好的效果，否则很容易迷失丢掉重点。我以前所在的团队有一个学习计划表，每个同学调研一个方向，每周周会的时候都会抽出一定时间去做技术分享，我觉的这是一个很好的方法，人在有一定紧张情绪的情况下注意力会相当集中，这个和考试前、面试前学习效率会很高的道理是一样的。不过我们也不能太过焦虑，我经常会有焦虑感，我的解决方法是定制计划，半年或者一年，在一段时间专注完成一件事，你会看到自己成长了很多。

刻意练习

也许我们在公司里平时做的业务需求都是缝缝补补，并没有涉及很复杂的内容，大量重复的工作会让人觉得无法提升自己的技术。而自己平时自己学到的知识在一定时间之后可能就生疏了，而且有些技术可能从原理上看相对简单，但在实现的过程中会遇到各种问题，比如插件化和热修复，这样的技术如果不上手去实践就只能停留在理论层面。对于这种情况，可以采用一种刻意练习的方法，在知晓原理后自己尝试去实现一个类似的框架，在这个过程中你会得到比阅读源码和文章更多的实践经验，可以大大加深对一个知识的理解，也可以锻炼自己的框架设计能力。

阅读源码

别人写的源码分析文章我一般看的比较少，除非是自己遇到了很难理解的部分，大部分的内容都可以直接在代码里获取到。而且很多分析的文章很少，比如 Inline Hook ，但是框架实现的比较多，那么就只能从代码里获取相关内容。针对一个功能的框架可以去找一些相同功能的多个框架进行对比，看一下实现方式是否有不同之处，同时比较一下每个实现方式的优缺点，并记录一下每个框架所使用的技术。在理解一个框架的实现后，还是建议你去自己写一个类似的框架，因为在自己的实现过程中会遇到各种问题，可以从其他框架里寻找一下方案，然后自己总结一下，这样可以了解这个框架的优势之处，如果在面试的时候被问到一个框架的优缺点，这样也可以有自己的理解，而不是网上帖子的统一描述。

全栈发展

在所谓的互联网寒冬下，需要持续关注其他方向的技术，提升自身竞争力。在大前端的趋势热度和各个公司的发展方向中，大前端已经成为必要的技能，这也可以从各大公司的招聘方向中看出来。因此我们在平时学习过程中，可以更多的关注一下前端、跨平台、Flutter 相关的内容。现在很多公司和部门讲究中台化和前后端闭环管理，对于后台和前端的技术都要大概有所了解才能掌握整个业务的动态，只关注一块技术在中国的互联网环境下不太利于自身的发展。换句话讲，我们关注的越多，机会也就更多一些。

个人能力的提升

这里的能力更多是指软实力的提升，一个是技术视野，也就是一个业务系统的全局把控，将一个自底向上的思维方式发展为从上到下的抽象能力。再有就是以技术价值为导向。以前我总是深入一些技术细节，只是觉得比较有趣，但很少考虑这个技术点能带来什么“价值”。其实在工作晋升和面试的过程中，通常关注更多的是“价值“，我们一般总说业务迭代、模块开发，但很少谈及所做功能的价值，只是觉得技术实现比较简单并没有什么可以讲的，其实我们可以从以下几个方面进行总结。

首先，我们开发了一个新功能、做了一些改进、引入了一些技术等，可能我们大多在做这些工作，也就是实现了一个业务需求，保证了业务功能的使用，这是功能产生的价值。在做这个需求功能的时候，我们有没有考虑过扩展性、重用性、维护性、性能、稳定性、高可用性等呢？性能的提升给用户带来体验上的价值；可扩展性、重用性带来开发效率的提升；稳定性减少了维护的成本等，这些都是技术产出的价值。我们可以更进一步从业务的角度上看，比如完成这个业务给用户体验提升了多少？促进了多少用户增长、提高了多少用户活跃度、降低了多少成本？由于我们在每个业务开发的时候，都会有一些数据统计的埋点，因此在平时的时候我们要多关注这种业务相关的数据。一般产品同学都会有各种数据报表，我们可以将他们总结起来作为自己完成一个业务所产出的价值。

写在最后

最后感谢各位同学能一直跟随“Android 开发高手课“学习到最后，相信你一定也从专栏里学到了对自己有价值的新知识。我同样也是从专栏上线，随着专栏更新一点点学习到现在，从专栏里学到了很多思路和方法，也巩固了很多基础知识。但更多的基础知识专栏无法详细呈现，所以还需要我们以此为起点，自己在课下扩展开来，多去思考、多做总结。

最最后我想说，每个人在突破自己技术瓶颈时都会经历一段痛苦的时光，只有我们具有坚定的信念，并努力坚持下去，相信我等你回过头来再看曾经认为难以理解的技术和知识时，你会有一种阔然开朗、融会贯通的感觉，这就是成长和进步所带来最大的成就感。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

练习Sample跑起来 | ASM插桩强化练习

你好，我是孙鹏飞。

专栏上一期，绍文讲了编译插桩的三种方法：AspectJ、ASM、ReDex，以及它们的应用场景。学完以后你是不是有些动心，想赶快把它们应用到实际工作中去。但我也还了解到，不少同学其实接触插桩并不多，在工作中更是很少使用。由于这项技术太重要了，可以实现很多功能，所以我还是希望你通过理论 + 实践的方式尽可能掌握它。因此今天我给你安排了一期“强化训练”，希望你可以趁热打铁，保持学习的连贯性，把上一期的理论知识，应用到今天插桩的练习上。

为了尽量降低上手的难度，我尽量给出详细的操作步骤，相信你只要照着做，并结合专栏上期内容的学习，你一定可以掌握插桩的精髓。

ASM 插桩强化练习

在上一期里，Eateeer 同学留言说得非常好，提到了一个工具，我也在使用这个工具帮助自己理解 ASM。安装“ASM Bytecode Outline”也非常简单，只需要在 Android Studio 中的 Plugin 搜索即可。

ASM Bytecode Outline 插件可以快速展示当前编辑类的字节码表示，也可以展示出生成这个类的 ASM 代码，你可以在 Android Studio 源码编译框内右键选择“Show Bytecode Outline“来查看，反编译后的字节码在右侧展示。

我以今天强化练习中的SampleApplication类为例，具体字节码如下图所示。

除了字节码模式，ASM Bytecode Outline 还有一种“ASMified”模式，你可以看到 SampleApplication 类应该如何用 ASM 代码构建。

下面我们通过两个例子的练习，加深对 ASM 使用的理解。

1. 通过 ASM 插桩统计方法耗时

今天我们的第一个练习是：通过 ASM 实现统计每个方法的耗时。怎么做呢？请你先不要着急，同样以 SampleApplication 类为例，如下图所示，你可以先手动写一下希望实现插桩前后的对比代码。

那这样“差异”代码怎么样转化了 ASM 代码呢？ASM Bytecode Outline 还有一个非常强大的功能，它可以展示相邻两次修改的代码差异，这样我们可以很清晰地看出修改的代码在字节码上的呈现。

“onCreate”方法在“ASMified”模式的前后差异代码，也就是我们需要添加的 ASM 代码。在真正动手去实现插桩之前，我们还是需要理解一下 ASM 源码中关于 Core API 里面 ClassReader、ClassWriter、ClassVisitor 等几个类的用法。

我们使用 ASM 需要先通过 ClassReader 读入 Class 文件的原始字节码，然后使用 ClassWriter 类基于不同的 Visitor 类进行修改，其中 COMPUTE_MAXS 和 EXPAND_FRAMES 都是需要特别注意的参数。

ClassReader classReader = new ClassReader(is);

//COMPUTE_MAXS 说明使用 ASM 自动计算本地变量表最大值和操作数栈的最大值

ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);

ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);

//EXPAND_FRAMES 说明在读取 class 的时候同时展开栈映射帧 (StackMap Frame)，在使用 AdviceAdapter 里这项是必须打开的

classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);

如果要统计每个方法的耗时，我们可以使用 AdviceAdapter 来实现。它提供了 onMethodEnter() 和 onMethodExit() 函数，非常适合实现方法的前后插桩。具体的实现，你可以参考今天强化练习中的TraceClassAdapter的实现：

private int timeLocalIndex = 0;

@Override

protected void onMethodEnter() {

    mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);

    timeLocalIndex = newLocal(Type.LONG_TYPE); // 这个是 LocalVariablesSorter 提供的功能，可以尽量复用以前的局部变量

    mv.visitVarInsn(LSTORE, timeLocalIndex);

}

@Override

protected void onMethodExit(int opcode) {

    mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);

    mv.visitVarInsn(LLOAD, timeLocalIndex);

    mv.visitInsn(LSUB);// 此处的值在栈顶

    mv.visitVarInsn(LSTORE, timeLocalIndex);// 因为后面要用到这个值所以先将其保存到本地变量表中

    int stringBuilderIndex = newLocal(Type.getType("java/lang/StringBuilder"));

    mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");

    mv.visitInsn(Opcodes.DUP);

    mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);

    mv.visitVarInsn(Opcodes.ASTORE, stringBuilderIndex);// 需要将栈顶的 stringbuilder 保存起来否则后面找不到了

    mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);

    mv.visitLdcInsn(className + "." + methodName + " time:");

    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);

    mv.visitInsn(Opcodes.POP);// 将 append 方法的返回值从栈里 pop 出去

    mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);

    mv.visitVarInsn(Opcodes.LLOAD, timeLocalIndex);

    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);

    mv.visitInsn(Opcodes.POP);// 将 append 方法的返回值从栈里 pop 出去

    mv.visitLdcInsn("Geek");

    mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);

    mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);

    mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);// 注意： Log.d 方法是有返回值的，需要 pop 出去

    mv.visitInsn(Opcodes.POP);// 插入字节码后要保证栈的清洁，不影响原来的逻辑，否则就会产生异常，也会对其他框架处理字节码造成影响

}

具体实现和我们在 ASM Bytecode Outline 看到的大同小异，但是这里需要注意局部变量的使用。在练习的例子中用到了 AdviceAdapter 的一个很重要的父类 LocalVariablesSorter，这个类提供了一个很好用的方法 newLocal，它可以分配一个本地变量的 index，而不用用户考虑本地变量的分配和覆盖问题。

另一个需要注意的情况是，我们在最后的时候需要判断一下插入的代码是否会在栈顶上遗留不使用的数据，如果有的话需要消耗掉或者 POP 出去，否则就会导致后续代码的异常。

这样我们就可以快速地将这一大段字节码完成了。

2. 替换项目中的所有的 new Thread

今天另一个练习是：替换项目中所有的 new Thread，换为自己项目的 CustomThread 类。在实践中，你可以通过这个方法，在 CustomThread 增加统计代码，从而实现统计每个线程运行的耗时。

不过这也是一个相对来说坑比较多的情况，你可以提前考虑一下可能会遇到什么状况。同样我们通过修改MainActivity的 startThread 方法里面的 Thread 对象改变成 CustomThread，通过 ASM Bytecode Outline 看看在字节码上面的差异：

InvokeVirtual 是根据 new 出来的对象来调用，所以我们只需要替换 new 对象的过程就可以了。这里需要处理两个指令：一个 new、一个 InvokeSpecial。在大多数情况下这两条指令是成对出现的，但是在一些特殊情况下，会遇到直接从其他位置传递过来一个已经存在的对象，并强制调用构造方法的情况。

而我们需要处理这种特殊情况，所以在例子里我们需要判断 new 和 InvokeSpecial 是否是成对出现的。

 private boolean findNew = false;// 标识是否遇到了 new 指令

        @Override

        public void visitTypeInsn(int opcode, String s) {

            if (opcode == Opcodes.NEW && "java/lang/Thread".equals(s)) {

                findNew = true;// 遇到 new 指令

                mv.visitTypeInsn(Opcodes.NEW, "com/sample/asm/CustomThread");// 替换 new 指令的类名

                return;

            }

            super.visitTypeInsn(opcode, s);

        }

        @Override

        public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {

            // 需要排查 CustomThread 自己

            if ("java/lang/Thread".equals(owner) && !className.equals("com/sample/asm/CustomThread") && opcode == Opcodes.INVOKESPECIAL && findNew) {

                findNew= false;

                mv.visitMethodInsn(opcode, "com/sample/asm/CustomThread", name, desc, itf);// 替换 INVOKESPECIAL 的类名，其他参数和原来保持一致

                return;

            }

            super.visitMethodInsn(opcode, owner, name, desc, itf);

        }

new 指令的形态相对特殊，比如我们可能会遇到下面的情况：

new A(new B(2));

字节码如下，你会发现两个 new 指令连在一起。

NEW A

    DUP

    NEW B

    DUP

    ICONST_2

    INVOKESPECIAL B.<init> (I)V

    INVOKESPECIAL A.<init> (LB;)V

虽然 ASM Bytecode Outline 工具可以帮助我们完成很多场景下的 ASM 需求，但是在处理字节码的时候还是需要考虑很多种可能出现的情况，这点需要你注意一下每个指令的特征。所以说在稍微复杂一些的情况下，我们依然需要对 ASM 字节码以及 ASM 源码中的一些工具类有所了解，并且需要很多次的实践，毕竟实践是最重要的。

最后再留给你一个思考题，如何给某个方法增加一个 try catch 呢？你可以尝试一下在今天强化练习的代码里根据我提供的插件示例实现一下。

强化练习的代码：https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM

福利彩蛋

学到这里相信你肯定会认同成为一个 Android 开发高手的确不容易，能够坚持学习和练习，并整理输出分享更是不易。但是也确实有同学坚持下来了。

还记得在专栏导读里我们的承诺吗？我们会选出坚持参与学习并分享心得的同学，送出 2019 年 GMTC 大会的门票。今天我们就来兑现承诺，送出价值 4800 元的 GMTC 门票一张。获得这个“大礼包”的同学是@唯鹿，他不仅提交了作业，更是在博客里分享了每个练习 Sample 实现的过程和心得，并且一直在坚持。我在文稿里贴了他的练习心得文章链接，如果你对于之前的练习 Sample 还有不明白的地方，可以参考唯鹿同学的实现过程。

Android 开发高手课 课后练习（1 ~ 5）

Android 开发高手课 课后练习（6 ~ 8，12，17，19）

专栏第 4 期完成作业

专栏第 19 期完成作业

GMTC 门票还有剩余，给自己一个进阶的机会，从现在开始一切都还来得及。

小程序、Flutter、移动 AI、工程化、性能优化…大前端的下一站在哪里？GMTC 2019 全球大前端技术大会将于 6 月北京盛大开幕，来自 Google、BAT、美团、京东、滴滴等一线前端大牛将与你面对面共话前端那些事，聊聊大前端的最新技术趋势和最佳实践案例。
目前大会最低价 7 折购票火热进行中，讲师和议题也持续招募中，点击下方图片了解更多大会详情！

练习Sample跑起来 | 唯鹿同学的练习手记 第1辑

你好，我是张绍文，今天我要跟你分享唯鹿同学完成专栏课后练习作业的“手记”。专栏承诺会为坚持完成练习作业的同学送出 GMTC 大会门票，唯鹿同学通过自己的努力和坚持，为自己赢得了 GMTC 大会的门票。

如果你还没开始练习，我强烈建议你花一些时间在练习上，因为每个练习的 Sample 都是我和学习委员花费很多精力精心准备的，为的是让你在学习完后可以有机会上手实践，帮你尽快消化专栏里的知识并为自己所用。

大家好，我是唯鹿，来自西安，从事 Android 开发也有近 5 年的时间了，目前在做智慧社区方面的业务。我自己坚持写博客已经有三年多的时间了，希望分享自己在工作、学习中的收获。

先说说我学习专栏的方法，专栏更新当天我就会去学习，但是难度真的不小。我对自己的要求并不是看一遍就要搞明白，而是遇见不懂的地方立马查阅资料，要做到大体了解整篇内容。之后在周末的时候我会集中去做 Sample 练习，一边复习本周发布的内容，一边用写博客的方式记录练习的结果。

后面我计划专栏结束后再多看、多练习几遍，不断查漏补缺。说真的，我很喜欢《Android 开发高手课》的难度，让我在完成练习作业时有种翻越高山的快感。最后，希望同学们一起坚持，享受翻越高山带来的成就感。

最近在学习张绍文老师的《Android 开发高手课》。课后作业可不是一般的难，最近几天抽空练习了一下，结合老师给的步骤和其他同学的经验，完成了前 5 课的内容。

我整理总结了一下，分享出来，希望可以帮到一起学习的同学（当然希望大家尽量靠自己解决问题）。

Chapter01

例子里集成了 Breakpad 来获取发生 Native Crash 时候的系统信息和线程堆栈信息。通过一个简单的 Native 崩溃捕获过程，完成 minidump 文件的生成和解析，在实践中加深对 Breakpad 工作机制的认识。

直接运行项目，按照 README.md 的步骤操作就行。

中间有个问题，老师提供的 minidump_stackwalker 工具在 macOS 10.14 以上无法成功执行，因为没有 libstdc++.6.dylib 库，所以我就下载 Breakpad 源码重新编译了一遍。

使用 minidump_stackwalker 工具来根据 minidump 文件生成堆栈跟踪 log，得到的 crashLog.txt 文件如下：

Operating system: Android

                  0.0.0 Linux 4.9.112-perf-gb92eddd #1 SMP PREEMPT Tue Jan 1 21:35:06 CST 2019 aarch64

CPU: arm64  // 注意点 1

     8 CPUs

GPU: UNKNOWN

Crash reason:  SIGSEGV /SEGV_MAPERR

Crash address: 0x0

Process uptime: not available

Thread 0 (crashed)

 0  libcrash-lib.so + 0x600 // 注意点 2

     x0 = 0x00000078e0ce8460    x1 = 0x0000007fd4000314

     x2 = 0x0000007fd40003b0    x3 = 0x00000078e0237134

     x4 = 0x0000007fd40005d0    x5 = 0x00000078dca14200

     x6 = 0x0000007fd4000160    x7 = 0x00000078c8987e18

     x8 = 0x0000000000000000    x9 = 0x0000000000000001

    x10 = 0x0000000000430000   x11 = 0x00000078e05ef688

    x12 = 0x00000079664ab050   x13 = 0x0ad046ab5a65bfdf

    x14 = 0x000000796650c000   x15 = 0xffffffffffffffff

    x16 = 0x00000078c83defe8   x17 = 0x00000078c83ce5ec

    x18 = 0x0000000000000001   x19 = 0x00000078e0c14c00

    x20 = 0x0000000000000000   x21 = 0x00000078e0c14c00

    x22 = 0x0000007fd40005e0   x23 = 0x00000078c89fa661

    x24 = 0x0000000000000004   x25 = 0x00000079666cc5e0

    x26 = 0x00000078e0c14ca0   x27 = 0x0000000000000001

    x28 = 0x0000007fd4000310    fp = 0x0000007fd40002e0

     lr = 0x00000078c83ce624    sp = 0x0000007fd40002c0

     pc = 0x00000078c83ce600

    Found by: given as instruction pointer in context

 1  libcrash-lib.so + 0x620

     fp = 0x0000007fd4000310    lr = 0x00000078e051c7e4

     sp = 0x0000007fd40002f0    pc = 0x00000078c83ce624

    Found by: previous frame's frame pointer

 2  libart.so + 0x55f7e0

     fp = 0x130c0cf800000001    lr = 0x00000079666cc5e0

     sp = 0x0000007fd4000320    pc = 0x00000078e051c7e4

    Found by: previous frame's frame pointer

......

下来是符号解析，可以使用 NDK 中提供的addr2line来根据地址进行一个符号反解的过程，该工具在$NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line。

注意：此处要注意一下平台，如果是 ARM 64 位的 so，解析是需要使用 aarch64-linux-android-4.9 下的工具链。

因为我的是 ARM 64 位的 so。所以使用 aarch64-linux-android-4.9，libcrash-lib.so 在app/build/intermediates/cmake/debug/obj/arm64-v8a下，0x600为错误位置符号。

aarch64-linux-android-addr2line -f -C -e libcrash-lib.so 0x600

输出结果如下：

Crash()

/Users/weilu/Downloads/Chapter01-master/sample/.externalNativeBuild/cmake/debug/arm64-v8a/../../../../src/main/cpp/crash.cpp:10

可以看到输出结果与下图错误位置一致（第 10 行）。

Chapter02

该例子主要演示了如何通过关闭 FinalizerWatchdogDaemon 来减少 TimeoutException 的触发。

在我的上一篇博客：安卓开发中遇到的奇奇怪怪的问题（三）中有说明，就不重复赘述了。

Chapter03

项目使用了 Inline Hook 来拦截内存对象分配时候的 RecordAllocation 函数，通过拦截该接口可以快速获取到当时分配对象的类名和分配的内存大小。

在初始化的时候我们设置了一个分配对象数量的最大值，如果从 start 开始对象分配数量超过最大值就会触发内存 dump，然后清空 alloc 对象列表，重新计算。该功能和 Android Studio 里的 Allocation Tracker 类似，只不过可以在代码级别更细粒度的进行控制。可以精确到方法级别。

项目直接跑起来后，点击开始记录，然后点击 5 次生成 1000 对象按钮。生成对象代码如下：

for (int i = 0; i < 1000; i++) {

     Message msg = new Message();

     msg.what = i;

}

因为代码从点击开始记录开始，触发到 5000 的数据就 dump 到文件中，点击 5 次后就会在sdcard/crashDump下生成一个时间戳命名的文件。项目根目录下调用命令：

java -jar tools/DumpPrinter-1.0.jar dump 文件路径 > dump_log.txt

然后就可以在 dump_log.txt 中看到解析出来的数据：

Found 5000 records:

....

tid=4509 android.graphics.drawable.RippleForeground (112 bytes)

    android.graphics.drawable.RippleDrawable.tryRippleEnter (RippleDrawable.java:569)

    android.graphics.drawable.RippleDrawable.setRippleActive (RippleDrawable.java:276)

    android.graphics.drawable.RippleDrawable.onStateChange (RippleDrawable.java:266)

    android.graphics.drawable.Drawable.setState (Drawable.java:778)

    android.view.View.drawableStateChanged (View.java:21137)

    android.widget.TextView.drawableStateChanged (TextView.java:5289)

    android.support.v7.widget.AppCompatButton.drawableStateChanged (AppCompatButton.java:155)

    android.view.View.refreshDrawableState (View.java:21214)

    android.view.View.setPressed (View.java:10583)

    android.view.View.setPressed (View.java:10561)

    android.view.View.onTouchEvent (View.java:13865)

    android.widget.TextView.onTouchEvent (TextView.java:10070)

    android.view.View.dispatchTouchEvent (View.java:12533)

    android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)

    android.view.ViewGroup.dispatchTouchEvent (ViewGroup.java:2662)

    android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)

tid=4515 int[] (104 bytes)

tid=4509 android.os.BaseLooper$MessageMonitorInfo (88 bytes)

    android.os.Message.<init> (Message.java:123)

    com.dodola.alloctrack.MainActivity$4.onClick (MainActivity.java:70)

    android.view.View.performClick (View.java:6614)

    android.view.View.performClickInternal (View.java:6591)

    android.view.View.access$3100 (View.java:786)

    android.view.View$PerformClick.run (View.java:25948)

    android.os.Handler.handleCallback (Handler.java:873)

    android.os.Handler.dispatchMessage (Handler.java:99)

    android.os.Looper.loop (Looper.java:201)

    android.app.ActivityThread.main (ActivityThread.java:6806)

    java.lang.reflect.Method.invoke (Native method)

    com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:547)

    com.android.internal.os.ZygoteInit.main (ZygoteInit.java:873)

......

我们用 Android Profiler 查找一个 Message 对象对比一下，一模一样。

简单看一下 Hook 代码：

void hookFunc() {

    LOGI("start hookFunc");

    void *handle = ndk_dlopen("libart.so", RTLD_LAZY | RTLD_GLOBAL);

    if (!handle) {

        LOGE("libart.so open fail");

        return;

    }

    void *hookRecordAllocation26 = ndk_dlsym(handle,

                                             "_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");

    void *hookRecordAllocation24 = ndk_dlsym(handle,

                                             "_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");

    void *hookRecordAllocation23 = ndk_dlsym(handle,

                                             "_ZN3art3Dbg16RecordAllocationEPNS_6ThreadEPNS_6mirror5ClassEj");

    void *hookRecordAllocation22 = ndk_dlsym(handle,

                                             "_ZN3art3Dbg16RecordAllocationEPNS_6mirror5ClassEj");

    if (hookRecordAllocation26 != nullptr) {

        LOGI("Finish get symbol26");

        MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,

                       (void **) &oldArtRecordAllocation26);

    } else if (hookRecordAllocation24 != nullptr) {

        LOGI("Finish get symbol24");

        MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,

                       (void **) &oldArtRecordAllocation26);

    } else if (hookRecordAllocation23 != NULL) {

        LOGI("Finish get symbol23");

        MSHookFunction(hookRecordAllocation23, (void *) &newArtRecordAllocation23,

                       (void **) &oldArtRecordAllocation23);

    } else {

        LOGI("Finish get symbol22");

        if (hookRecordAllocation22 == NULL) {

            LOGI("error find hookRecordAllocation22");

            return;

        } else {

            MSHookFunction(hookRecordAllocation22, (void *) &newArtRecordAllocation22,

                           (void **) &oldArtRecordAllocation22);

        }

    }

    dlclose(handle);

}

使用了 Inline Hook 方案 Substrate 来拦截内存对象分配时候 libart.so 的 RecordAllocation 函数。首先如果我们要 hook 一个函数，需要知道这个函数的地址。我们也看到了代码中这个地址判断了四种不同系统。这里有一个网页版的解析工具可以快速获取。下面以 8.0 为例。

我在 8.0 的源码中找到了对应的方法：

7.0 方法就明显不同：

我也同时参看了 9.0 的代码，发现没有变化，所以我的测试机是 9.0 的也没有问题。

Hook 新内存对象分配处理代码：

static bool newArtRecordAllocationDoing24(Class *type, size_t byte_count) {

    allocObjectCount++;

	// 根据 class 获取类名

    char *typeName = GetDescriptor(type, &a);

    // 达到 max

    if (allocObjectCount > setAllocRecordMax) {

        CMyLock lock(g_Lock);// 此处需要 loc 因为对象分配的时候不知道在哪个线程，不 lock 会导致重复 dump

        allocObjectCount = 0;

        // dump alloc 里的对象转换成 byte 数据

        jbyteArray allocData = getARTAllocationData();

        // 将 alloc 数据写入文件

        SaveAllocationData saveData{allocData};

        saveARTAllocationData(saveData);

        resetARTAllocRecord();

        LOGI("===========CLEAR ALLOC MAPS=============");

        lock.Unlock();

    }

    return true;

}

Chapter04

通过分析内存文件 hprof 快速判断内存中是否存在重复的图片，并且将这些重复图片的 PNG、堆栈等信息输出。

首先是获取我们需要分析的 hprof 文件，我们加载两张相同的图片：

Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);

 Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);

 imageView1.setImageBitmap(bitmap1);

 imageView2.setImageBitmap(bitmap2);

生成 hprof 文件

// 手动触发 GC

 Runtime.getRuntime().gc();

 System.runFinalization();

 Debug.dumpHprofData(file.getAbsolutePath());

接下来就是利用HAHA 库进行文件分析的核心代码：

// 打开 hprof 文件

final HeapSnapshot heapSnapshot = new HeapSnapshot(hprofFile);

// 获得 snapshot

final Snapshot snapshot = heapSnapshot.getSnapshot();

// 获得 Bitmap Class

final ClassObj bitmapClass = snapshot.findClass("android.graphics.Bitmap");

// 获得 heap, 只需要分析 app 和 default heap 即可

Collection<Heap> heaps = snapshot.getHeaps();

for (Heap heap : heaps) {

    // 只需要分析 app 和 default heap 即可

    if (!heap.getName().equals("app") && !heap.getName().equals("default")) {

        continue;

    }

    for (ClassObj clazz : bitmapClasses) {

        // 从 heap 中获得所有的 Bitmap 实例

        List<Instance> bitmapInstances = clazz.getHeapInstances(heap.getId());

		// 从 Bitmap 实例中获得 buffer 数组, 宽高信息等。

        ArrayInstance buffer = HahaHelper.fieldValue(((ClassInstance) bitmapInstance).getValues(), "mBuffer");

        int bitmapHeight = fieldValue(bitmapInstance, "mHeight");

        int bitmapWidth = fieldValue(bitmapInstance, "mWidth");

        // 引用链信息

        while (bitmapInstance.getNextInstanceToGcRoot() != null) {

            print(instance.getNextInstanceToGcRoot());

            instance = instance.getNextInstanceToGcRoot();

        }

        // 根据 hashcode 来进行重复判断

    }

}

最终的输出结果：

我们用 Studio 打开 hprof 文件对比一下：

可以看到信息是一摸一样的。对于更优处理引用链的信息，可以参看LeakCanary源码的实现。

我已经将上面的代码打成 JAR 包，可以直接调用：

// 调用方法：

java -jar tools/DuplicatedBitmapAnalyzer-1.0.jar hprof 文件路径

详细的代码我提交到了Github，供大家参考。

Chapter05

尝试模仿ProcessCpuTracker.java拿到一段时间内各个线程的耗时占比。

usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):

 System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle

 CPU Core: 8

 Load Average: 8.74 / 7.74 / 7.36

 Process:com.sample.app

   50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965

 Threads:

   43% 23493/singleThread(R): 6.5% user + 36% kernel faults：3094

   3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults：329

   0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults：6

   0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults：982

  ...

因为了解 Linux 不多，所以看这个有点懵逼。好在课代表孙鹏飞同学解答了相关问题，看懂了上面信息，同时学习到了一些 Linux 知识。

private void testIO() {

        Thread thread = new Thread(new Runnable() {

            @Override

            public void run() {

                File f = new File(getFilesDir(), "aee.txt");

				FileOutputStream fos = new FileOutputStream(f);

				byte[] data = new byte[1024 * 4 * 3000];// 此处分配一个 12mb 大小的 byte 数组

				for (int i = 0; i < 30; i++) {// 由于 IO cache 机制的原因所以此处写入多次 cache，触发 dirty writeback 到磁盘中

    				Arrays.fill(data, (byte) i);// 当执行到此处的时候产生 minor fault，并且产生 User cpu useage

    				fos.write(data);

				}

				fos.flush();

				fos.close();

            }

        });

        thread.setName("SingleThread");

        thread.start();

    }

上述代码就是导致的问题罪魁祸首，这种密集 I/O 操作集中在 SingleThread 线程中处理，导致发生了 3094 次 faults、36% kernel，完全没有很好利用到 8 核 CPU。

最后，通过检测 CPU 的使用率，可以更好地避免卡顿现象，防止 ANR 的发生。

前前后后用了两三天的时间，远远没有当初想的顺利，感觉身体被掏空。中间也爬了不少坑，虽然没有太深入实现代码，但是中间的体验过程也是收获不小。所以总不能因为难就放弃了，先做到力所能及的部分，让自己动起来！

参考

练习 Sample 跑起来 | 热点问题答疑第 1 期

练习 Sample 跑起来 | 热点问题答疑第 2 期

练习Sample跑起来 | 唯鹿同学的练习手记 第2辑

你好，我是唯鹿。

接着上篇练习手记，今天练习 6～8、12、17、19 这六期内容（主要针对有课后 Sample 练习的），相比 1～5 期轻松了很多。

Chapter06

该项目展示了使用 PLT Hook 技术来获取 Atrace 的日志，可以学习到 systrace 的一些底层机制。

没有什么问题，项目直接可以运行起来。运行项目后点击开启 Atrace 日志，然后就可以在 Logcat 日志中查看到捕获的日志，如下：

11:40:07.031 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= install systrace hoook =========

11:40:07.034 8537-8537/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|Record View#draw()

11:40:07.034 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|DrawFrame

11:40:07.035 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|syncFrameState

    ========= B|8537|prepareTree

    ========= E

    ========= E

    ========= B|8537|eglBeginFrame

    ========= E

    ========= B|8537|computeOrdering

    ========= E

    ========= B|8537|flush drawing commands

    ========= E

11:40:07.036 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|eglSwapBuffersWithDamageKHR

    ========= B|8537|setSurfaceDamage

    ========= E

11:40:07.042 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|queueBuffer

    ========= E

11:40:07.043 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|dequeueBuffer

    ========= E

    ========= E

    ========= E

通过 B|事件和 E|事件是成对出现的，这样就可以计算出应用执行每个事件使用的时间。那么上面的 Log 中 View 的 draw() 方法显示使用了 9ms。

这里实现方法是使用了Profilo的 PLT Hook 来 hook libc.so 的write与__write_chk方法。libc 是 C 的基础库函数，为什么要 hook 这些方法，需要我们补充 C、Linux 相关知识。

同理Chapter06-plus展示了如何使用 PLT Hook 技术来获取线程创建的堆栈，README 有详细的实现步骤介绍，我就不赘述了。

Chapter07

这个 Sample 是学习如何给代码加入 Trace Tag，大家可以将这个代码运用到自己的项目中，然后利用 systrace 查看结果。这就是所谓的 systrace + 函数插桩。

操作步骤：

使用 Android Studio 打开工程 Chapter07。

运行 Gradle Task :systrace-gradle-plugin:buildAndPublishToLocalMaven编译 plugin 插件。

使用 Android Studio 单独打开工程 systrace-sample-android。

编译运行 App（插桩后的 class 文件在目录Chapter07/systrace-sample-android/app/build/systrace_output/classes中查看）。

对比一下插桩效果，插桩前：

插桩后:

可以看到在方法执行前后插入了 TraceTag，这样的话beginSection方法和endSection方法之间的代码就会被追踪。

public class TraceTag {

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)

    public static void i(String name) {

        Trace.beginSection(name);

    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)

    public static void o() {

        Trace.endSection();

    }

其实 Support-Compat 库中也有类似的一个TraceCompat，项目中可以直接使用。

然后运行项目，打开 systrace：

python $ANDROID_HOME/platform-tools/systrace/systrace.py gfx view wm am pm ss dalvik app sched -b 90960 -a com.sample.systrace  -o test.log.html

最后打开生成的 test.log.html 文件就可以查看 systrace 记录：

当然，这一步我们也可以使用 SDK 中的 Monitor，效果是一样的。

使用 systrace + 函数插桩的方式，我们就可以很方便地观察每个方法的耗时，从而针对耗时的方法进行优化，尤其是 Application 的启动优化。

Chapter08

该项目展示了关闭掉虚拟机的 class verify 后对性能的影响。

在加载类的过程有一个 verify class 的步骤，它需要校验方法的每一个指令，是一个比较耗时的操作。这个例子就是通过 Hook 去掉 verify 这个步骤。该例子尽量在 Dalvik 下执行，在 ART 下的效果并不明显。

去除校验代码（可以参看阿里的Atlas）：

    AndroidRuntime runtime = AndroidRuntime.getInstance();

    runtime.init(this.getApplicationContext(), true);

    runtime.setVerificationEnabled(false);

具体运行效果这里我就不展示了，直接运行体验就可以了。

Chapter12

通过复写 Application 的getSharedPreferences替换系统SharedPreferences的实现，核心的优化在于修改了 Apply 的实现，将多个 Apply 方法在内存中合并，而不是多次提交。

修改SharedPreferencesImpl的 Apply 部分如下：

       public void apply() {

			// 先调用 commitToMemory()

            final MemoryCommitResult mcr = commitToMemory();

            boolean hasDiskWritesInFlight = false;

            synchronized (SharedPreferencesImpl.this) {

            	// mDiskWritesInFlight 大于 0 说明之前已经有调用过 commitToMemory() 了

                hasDiskWritesInFlight = mDiskWritesInFlight > 0;

            }

            // 源码没有这层判断，直接提交。

            if (!hasDiskWritesInFlight) {

                final Runnable awaitCommit = new Runnable() {

                    public void run() {

                        try {

                            mcr.writtenToDiskLatch.await();

                        } catch (InterruptedException ignored) {

                        }

                    }

                };

                QueuedWork.add(awaitCommit);

                Runnable postWriteRunnable = new Runnable() {

                    public void run() {

                        awaitCommit.run();

                        QueuedWork.remove(awaitCommit);

                    }

                };

                SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

            }

            // Okay to notify the listeners before it's hit disk

            // because the listeners should always get the same

            // SharedPreferences instance back, which has the

            // changes reflected in memory.

            notifyListeners(mcr);

Chapter14

这个是全面解析 SQLite 的资料，有兴趣的可以下载看看。

Chapter17

该项目展示了如何使用 PLT Hook 技术来获取网络请求相关信息。

通过 PLT Hook，代理 Socket 相关的几个重要函数：

/**

* 直接 hook 内存中的所有 so，但是需要排除掉 socket 相关方法本身定义的 libc(不然会出现循坏)

* plt hook

*/

void hookLoadedLibs() {

    ALOG("hook_plt_method");

    hook_plt_method_all_lib("libc.so", "send", (hook_func) &socket_send_hook);

    hook_plt_method_all_lib("libc.so", "recv", (hook_func) &socket_recv_hook);

    hook_plt_method_all_lib("libc.so", "sendto", (hook_func) &socket_sendto_hook);

    hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &socket_recvfrom_hook);

    hook_plt_method_all_lib("libc.so", "connect", (hook_func) &socket_connect_hook);

}

int hook_plt_method_all_lib(const char* exclueLibname, const char* name, hook_func hook) {

  if (refresh_shared_libs()) {

    // Could not properly refresh the cache of shared library data

    return -1;

  }

  int failures = 0;

  for (auto const& lib : allSharedLibs()) {

      if (strcmp(lib.first.c_str(), exclueLibname) != 0) {

        failures += hook_plt_method(lib.first.c_str(), name, hook);

      }

  }

  return failures;

}

运行项目，访问百度的域名https://www.baidu.com，输出如下：

17:08:28.347 12145-12163/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 10

17:08:28.349 12145-12163/com.dodola.socket E/HOOOOOOOOK: stack:com.dodola.socket.SocketHook.getStack(SocketHook.java:13)

    java.net.PlainSocketImpl.socketConnect(Native Method)

    java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:334)

    java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:196)

    java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:178)

    java.net.SocksSocketImpl.connect(SocksSocketImpl.java:356)

    java.net.Socket.connect(Socket.java:586)

    com.android.okhttp.internal.Platform.connectSocket(Platform.java:113)

    com.android.okhttp.Connection.connectSocket(Connection.java:196)

    com.android.okhttp.Connection.connect(Connection.java:172)

    com.android.okhttp.Connection.connectAndSetOwner(Connection.java:367)

    com.android.okhttp.OkHttpClient$1.connectAndSetOwner(OkHttpClient.java:130)

    com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:329)

    com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:246)

    com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnection

    AF_INET6 ipv6 IP===>183.232.231.173:443

    socket_connect_hook sa_family: 1

    Ignore local socket connect

02-07 17:08:28.637 12145-12163/com.dodola.socket E/HOOOOOOOOK: respond:﻿<!DOCTYPE html>

    <html><!--STATUS OK--><head><meta charset="utf-8"><title> 百度一下, 你就知道 </title>

可以看到我们获取到了网络请求的相关信息。

最后，我们可以通过 Connect 函数的 hook，实现很多需求，例如：

禁用应用网络访问

过滤广告 IP

禁用定位功能

Chapter19

使用 Java Hook 实现 Alarm、WakeLock 与 GPS 的耗电监控。

实现原理

根据老师提供的提示信息，动态代理对应的PowerManager、AlarmManager、LocationManager的mService实现，要拦截的方法在PowerManagerService、AlarmManagerService、LocationManagerService中。

实现核心代码：

        Object oldObj = mHostContext.getSystemService(Context.XXX_SERVICE);

	Class<?> clazz = oldObj.getClass();

	Field field = clazz.getDeclaredField("mService");

	field.setAccessible(true);

	final Object mService = field.get(oldObj);

	setProxyObj(mService);

	Object newObj = Proxy.newProxyInstance(this.getClass().getClassLoader(), mService.getClass().getInterfaces(), this);

	field.set(oldObj, newObj)

写了几个调用方法去触发，通过判断对应的方法名来做堆栈信息的输出。

输出的堆栈信息如下：

当然，强大的 Studio 在 3.2 后也有了强大的耗电量分析器，同样可以监测到这些信息，如下图所示（我使用的 Studio 版本为 3.3）。

实现不足之处：

可能兼容性上不是特别完善（期待老师的标准答案）。

没有按照耗电监控的规则去做一些业务处理。

心得体会：

本身并不复杂，只是为了找到 Hook 点，看了对应的 Service 源码耗费了一些时间，对于它们的工作流程有了更深的认识。

平时也很少使用动态代理，这回查漏补缺，一次用了个爽。

这个作业前前后后用了一天时间，之前作业还有一些同学提供 PR，所以相对轻松些，但这次没有参考，走了点弯路，不过收获也是巨大的。我就不细说了，感兴趣的话可以参考我的实现。完整代码参见GitHub，仅供参考。

参考

练习 Sample 跑起来 | 热点问题答疑第 3 期

练习 Sample 跑起来 | 热点问题答疑第 4 期

练习Sample跑起来 | 唯鹿同学的练习手记 第3辑

没想到之前的写的练习心得得到了老师的认可，看来我要更加认真努力练习了。今天来练习第 22、27、ASM 这三课的 Sample。

Chapter22

尝试使用 Facebook ReDex 库来优化我们的安装包。

准备工作

首先是下载 ReDex：

git clone https://github.com/facebook/redex.git

cd redex

接着是安装：

autoreconf -ivf && ./configure && make -j4

sudo make install

在安装时执行到这里，报出下图错误：

其实就是没有安装 Boost，所以执行下面的命令安装它。

brew install boost jsoncpp

安装 Boost 完成后，再等待十几分钟时间安装 ReDex。

下来就是编译我们的 Sample，得到的安装包信息如下。

可以看到有三个 Dex 文件，APK 大小为 13.7MB。

通过 ReDex 命令优化

为了让我们可以更加清楚流程，你可以输出 ReDex 的日志。

export TRACE=2

去除 Debuginfo 的方法，需要在项目根目录执行：

redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/stripdebuginfo.config -P ReDexSample/proguard-rules.pro  -o redex-test/strip_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk

上面这段很长的命令，其实可以拆解为几部分：

--sign 签名信息

-s（keystore）签名文件路径

-a（keyalias）签名的别名

-p（keypass）签名的密码

-c 指定 ReDex 的配置文件路径

-P ProGuard 规则文件路径

-o 输出的文件路径

最后是要处理 APK 文件的路径

但在使用时，我遇到了下图的问题：

这里是找不到Zipalign，所以需要我们配置 Android SDK 的根目录路径，添加在原命令前面：

ANDROID_SDK=/path/to/android/sdk redex [... arguments ...]

结果如下：

实际的优化效果是，原 Debug 包为 14.21MB，去除 Debuginfo 的方法后为 12.91MB，效果还是不错的。去除的内容就是一些调试信息及堆栈行号。

不过老师在 Sample 的 proguard-rules.pro 中添加了-keepattributes SourceFile,LineNumberTable保留了行号信息。

所以处理后的包安装后进入首页，还是可以看到堆栈信息的行号。

Dex 重分包的方法

redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/interdex.config -P ReDexSample/proguard-rules.pro  -o redex-test/interdex_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk

和之前的命令一样，只是-c使用的配置文件为 interdex.config。

输出信息：

优化效果为，原 Debug 包为 14.21MB、3 个 Dex，优化后为 13.34MB、2 个 Dex。

根据老师的介绍，如果你的应用有 4 个以上的 Dex，这个体积优化至少有 10%。 看来效果还是很棒棒的。至于其他问题，比如在 Windows 环境使用 ReDex，可以参看 ReDex 的使用文档。

Chapter27

利用 AspectJ 实现插桩的例子。

效果和Chapter07是一样的，只是 Chapter07 使用的是 ASM 方式实现的，这次是 AspectJ 实现。ASM 与 AspectJ 都是 Java 字节码处理框架，相比较来说 AspectJ 使用更加简单，同样的功能实现只需下面这点代码，但是 ASM 比 AspectJ 更加高效和灵活。

AspectJ 实现代码：

@Aspect

public class TraceTagAspectj {

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)

    @Before("execution(* **(..))")

    public void before(JoinPoint joinPoint) {

        Trace.beginSection(joinPoint.getSignature().toString());

    }

    /**

     * hook method when it's called out.

     */

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)

    @After("execution(* **(..))")

    public void after() {

        Trace.endSection();

    }

简单介绍下上面代码的意思：

@Aspect：在编译时 AspectJ 会查找被@Aspect注解的类，然后执行我们的 AOP 实现。

@Before：可以简单理解为方法执行前。

@After：可以简单理解为方法执行后。

execution：方法执行。

* **(..)：第一个星号代表任意返回类型，第二个星号代表任意类，第三个代表任意方法，括号内为方法参数无限制。星号和括号内都是可以替换为具体值，比如 String TestClass.test(String)。

知道了相关注解的含义，那么实现的代码含义就是，所有方法在执行前后插入相应指定操作。

效果对比如下：

下来实现给 MainActivity 的onResume方法增加 try catch。

@Aspect

public class TryCatchAspect {

    @Pointcut("execution(* com.sample.systrace.MainActivity.onResume())") // <- 指定类与方法

    public void methodTryCatch() {

    }

    @Around("methodTryCatch()")

    public void aroundTryJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {

         // try catch

         try {

             joinPoint.proceed(); // <- 调用原方法

         } catch (Exception e) {

              e.printStackTrace();

         }

    }

}

上面用到了两个新注解：

@Around：用于替换以前的代码，使用 joinPoint.proceed() 可以调用原方法。

@Pointcut：指定一个切入点。

实现就是指定一个切入点，利用替换原方法的思路包裹一层 try catch。

效果对比如下：

当然 AspectJ 还有很多用法，Sample 中包含有《AspectJ 程序设计指南》，便于我们具体了解和学习 AspectJ。

Chapter-ASM

Sample 利用 ASM 实现了统计方法耗时和替换项目中所有的 new Thread。

运行项目首先要注掉 ASMSample build.gradle 的apply plugin: 'com.geektime.asm-plugin'和根目录 build.gradle 的classpath ("com.geektime.asm:asm-gradle-plugin:1.0") { changing = true }。

运行gradle task ":asm-gradle-plugin:buildAndPublishToLocalMaven"编译 plugin 插件，编译的插件在本地.m2\repository目录下

打开第一步注掉的内容就可以运行了。

实现的大致过程是，先利用 Transform 遍历所有文件，再通过 ASM 的visitMethod遍历所有方法，最后通过 AdviceAdapter 实现最终的修改字节码。具体实现可以看代码和《练习 Sample 跑起来 | ASM 插桩强化练习》。

效果对比：

下面是两个练习：

1. 给某个方法增加 try catch

这里我就给 MainActivity 的mm方法进行 try catch。实现很简单，直接修改 ASMCode 的 TraceMethodAdapter。

public static class TraceMethodAdapter extends AdviceAdapter {

        private final String methodName;

        private final String className;

        private final Label tryStart = new Label();

        private final Label tryEnd = new Label();

        private final Label catchStart = new Label();

        private final Label catchEnd = new Label();

        protected TraceMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {

            super(api, mv, access, name, desc);

            this.className = className;

            this.methodName = name;

        }

        @Override

        protected void onMethodEnter() {

            if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {

                mv.visitTryCatchBlock(tryStart, tryEnd, catchStart, "java/lang/Exception");

                mv.visitLabel(tryStart);

            }

        }

        @Override

        protected void onMethodExit(int opcode) {

            if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {

                mv.visitLabel(tryEnd);

                mv.visitJumpInsn(GOTO, catchEnd);

                mv.visitLabel(catchStart);

                mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/RuntimeException", "printStackTrace", "()V", false);

                mv.visitInsn(Opcodes.RETURN);

                mv.visitLabel(catchEnd);

            }

        }

visitTryCatchBlock方法：前三个参数均是 Label 实例，其中一、二表示 try 块的范围，三则是 catch 块的开始位置，第四个参数是异常类型。其他的方法及参数就不细说了，具体你可以参考ASM 文档。

实现类似 AspectJ，在方法执行开始及结束时插入我们的代码。

效果我就不截图了，代码如下：

public void mm() {

        try {

            A a = new A(new B(2));

        } catch (Exception e) {

            e.printStackTrace();

        }

    }

2. 查看代码中谁获取了 IMEI

这个就更简单了，直接寻找谁使用了 TelephonyManager 的getDeviceId方法，并且在 Sample 中有答案。

public class IMEIMethodAdapter extends AdviceAdapter {

    private final String methodName;

    private final String className;

    protected IMEIMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {

        super(api, mv, access, name, desc);

        this.className = className;

        this.methodName = name;

    }

    @Override

    public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {

        super.visitMethodInsn(opcode, owner, name, desc, itf);

        if (owner.equals("android/telephony/TelephonyManager") && name.equals("getDeviceId") && desc.equals("()Ljava/lang/String;")) {

            Log.e("asmcode", "get imei className:%s, method:%s, name:%s", className, methodName, name);

        }

    }

}

Build 后输出如下：

总体来说 ASM 的上手难度还是高于 AspectJ，需要我们了解编译后的字节码，这里所使用的功能也只是冰山一角。课代表鹏飞同学推荐的 ASM Bytecode Outline 插件是个好帮手！最后我将我练习的代码也上传到了GitHub，里面还包括一份中文版的 ASM 文档，有兴趣的同学可以下载看看。

参考

练习 Sample 跑起来 | ASM 插桩强化练

ASM 文档

练习Sample跑起来 | 热点问题答疑第1期

你好，我是专栏的“学习委员”孙鹏飞。

专栏上线以来很多同学反馈，说在运行练习 Sample 的时候遇到问题。由于这些 Sample 多是采用 C/C++ 来完成的，所以在编译运行上会比传统的纯 Java 项目稍微复杂一些。今天我就针对第 1 期～第 4 期中，同学们集中遇到的问题做一期答疑。设置练习的目的，也是希望你在学习完专栏的内容后，可以快速上手试验一下专栏所讲的工具或方法，帮你加快掌握技术的精髓。所以希望各位同学可以多参与进来，有任何问题也可以在留言区给我们反馈，后面我还会不定期针对练习再做答疑。

编译环境配置

首先是同学们问得比较多的运行环境问题。

前几期的练习 Sample 大多是使用 C/C++ 开发的，所以要运行起来需要先配置好 SDK 和 NDK，SDK 我们一般都是配置好的，NDK 环境的配置有一些特殊的地方，一般我们的 Sample 都会使用最新的 NDK 版本，代码可能会使用 C++11/14 的语法进行编写，并且使用 CMake 进行编译，我这里给出 NDK 环境的配置项。

首先需要去 NDK 官网下载最新版本，下载后可以解压到合适的地方，一般 macOS 可以存放在 ANDROID_SDK_HOME/ndk_bundle 目录下，Android Studio 可以默认找到该目录。如果放到别的目录，可能需要自己指定一下。

指定 NDK 目录的方法一般有下面两种。

1. 在练习 Sample 根目录下都会有一个 local.properties 文件，修改其中的 ndk.dir 路径即可。

ndk.dir=/Users/sample/Library/Android/sdk/ndk-bundle

 sdk.dir=/Users/sample/Library/Android/sdk

2. 可以在 Android Studio 里进行配置，打开 File -> Project Structure -> SDK Location 进行修改。

上面两种修改方法效果是一致的。

有些 Sample 需要降级 NDK 编译使用，可能需要下载旧版本的 NDK，可以从官网下载。

之后需要安装 CMake 和 LLDB。

CMake：一款外部构建工具，可与 Gradle 搭配使用来构建原生库。

LLDB：一种调试程序，Android Studio 使用它来调试原生代码。

这两项都可以在 Tools > Android > SDK Manager 里进行安装。

这样我们编译所需要的环境就配置好了。

热点问题答疑

01 | 崩溃优化（上）：关于“崩溃”那些事儿

关于第 1 期的 Sample，同学们遇到的最多的问题是使用模拟器运行无法获取 Crash 日志的问题。

引起这个问题的缘由比较深层，最直观的原因是使用 Clang 来编译 x86 平台下的 Breakpad 会导致运行出现异常，从而导致无法抓取日志。想要解决这个问题，我们需要先来了解一下 NDK 集成的编译器。

NDK 集成了两套编译器：GCC 和 Clang。从 NDK r11 开始，官方就建议使用 Clang，详情可以看ChangeLog，并且标记 GCC 为 Deprecated，并且从 GCC 4.8 升级到 4.9 以后就不再进行更新了。NDK r13 开始，默认使用 Clang。NDK r16b 以后的版本貌似强制开启 GCC 会引起错误，并将 libc++ 作为默认的 STL，而 NDK r18 干脆就完全删除了 GCC。

由于 Clang 的编译会引起 x86 的 Breakpad 执行异常，所以我们需要切换到 GCC 下进行编译，步骤如下。

1. 首先将 NDK 切换到 r16b，你可以从这里下载，在里面找到对应你操作系统平台的 NDK 版本。

2. 在 Android Studio 里设置 NDK 路径为 ndk-16b 的路径。

3. 在练习例子源码的 sample 和 breakpad-build 的 build.gradle 配置里进行如下配置。

externalNativeBuild {

   cmake {

     cppFlags "-std=c++11"

     arguments "-DANDROID_TOOLCHAIN=gcc"

   }

}

第二个问题是日志解析工具如何获取。

解析 Minidump 日志主要是使用minidump_stackwalk工具，配合使用的工具是dump_syms，这个工具可以获取一个 so 文件的符号表。

这两项工具需要通过编译 Breakpad 来获取，有部分同学查到的文章是采用 Chrome 团队的depot_tools来进行工具的源码下载、编译操作。depot_tools 是个很好用的工具，但是在国内其服务器是无法访问的，所以我们采用直接下载源码编译的方式相对来说比较方便。

编译 Breakpad 有一些需要注意的地方，由于 Android 平台的内核是 Linux，Android 里的动态链接库的符号表导出工具dump_syms需要运行在 Linux 下（暂时没有找到交叉编译在别的平台上的办法），所以下面的步骤都是在 Linux 环境（Ubuntu 18.04）下进行的，步骤如下。

1. 先下载源码。

2. 由于源码里没有附带上一些第三方的库，所以现在编译会出现异常，我们需要下载 lss 库到 Breakpad 源码目录 src/third_party 下面。

git clone https://chromium.googlesource.com/linux-syscall-support

3. 然后在源码目录下执行。

./configure && make

make install

这样我们就可以直接调用minidump_stackwalk、dump_syms工具了。

第三个问题是如何解析抓取下来的 Minidump 日志。

生成的 Crash 信息，如果授予 Sdcard 权限会优先存放在 /sdcard/crashDump 下，便于我们做进一步的分析。反之会放到目录 /data/data/com.dodola.breakpad/files/crashDump 下。

你可以通过 adb pull 命令拉取日志文件。

adb pull /sdcard/crashDump/

1. 首先我们需要从产生 Crash 的动态库中提取出符号表，以第 1 期的 Sample 为例，产生 Crash 的动态库 obj 路径在Chapter01/sample/build/intermediates/cmake/debug/obj 下。

此处需要注意一下手机平台，按照运行 Sample 时的平台取出 libcrash-lib.so 库进行符号表的 dump，然后调用 dump_syms 工具获取符号表。

dump_syms libcrash-lib.so > libcrash-lib.so.sym

2. 建立符号表目录结构。首先打开刚才生成的 libcrash-lib.so.syms，找到如下编码。

MODULE Linux arm64 322FCC26DA8ED4D7676BD9A174C299630 libcrash-lib.so

然后建立如下结构的目录 Symbol/libcrash-lib.so/322FCC26DA8ED4D7676BD9A174C299630/，将 libcrash-lib.so.sym 文件复制到该文件夹中。注意，目录结构不能有错，否则会导致符号表对应失败。

3. 完成上面的步骤后，就可以来解析 Crash 日志了，执行 minidump_stackwalk 命令。

minidump_stackwalk crash.dmp ./Symbol > dump.txt

4. 这样我们获取的 crash 日志就会有符号表了，对应一下之前没有符号表时候的日志记录。

5. 如果我们没有原始的 obj，那么需要通过 libcrash-lib.so 的导出符号来进行解析，这里用到的工具是 addr2line 工具，这个工具存放在 $NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line 下。你要注意一下平台，如果是解析 64 位的动态库，需要使用 aarch64-linux-android-4.9 下的 addr2line（此处是 64 位的）。

aarch64-linux-android-addr2line -f -C -e  libcrash-lib.so 0x5f8

Java_com_dodola_breakpad_MainActivity_crash

6. 可以使用 GDB 来根据 Minidump 调试出问题的动态库，这里就不展开了，你可以参考这里。

03 | 内存优化（上）：4GB 内存时代，再谈内存优化

针对这一期的 Sample，很多同学询问 Sample 中经常使用的 Hook 框架的原理。

Sample 中使用的 Hook 框架有两种，一种是 Inline Hook 方案（Substrate和HookZz），一种是 PLT Hook 方案（Facebook Hook），这两种方案各有优缺点，根据要实现功能的不同采取不同的框架。

PLT Hook 相对 Inline Hook 的方案要稳定很多，但是它操作的范围只是针对出现在 PLT 表中的动态链接函数，而 Inline Hook 可以 hook 整个 so 里的所有代码。Inline Hook 由于要针对各个平台进行指令修复操作，所以稳定性和兼容性要比 PLT Hook 差很多。

关于 PLT Hook 的内容，你可以看一下《程序员的自我修养：链接、装载与库》这本书，而 Inline Hook 则需要对 ARM、x86 汇编，以及各个平台下的过程调用标准（Procedure Call Standard）有很深入的了解。

第 3 期里，还有部分同学询问 Sample 中的函数符号是如何来的。

首先如果我们要 hook 一个函数，需要知道这个函数的地址。在 Linux 下我们获取函数的地址可以通过dlsym函数来根据名字获取，动态库里的函数名称一般都会通过 Name Mangling 技术来生成一个符号名称（具体细节可以看这篇文章），所以第 3 期的 Sample 里出现了很多经过转换的函数名。

void *hookRecordAllocation26 = ndk_dlsym(handle,

"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");

    void *hookRecordAllocation24 = ndk_dlsym(handle,                                        "_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");

这样的函数可以通过c++filt工具来进行反解，我在这里给你提供一个网页版的解析工具。

我们需要阅读系统源码来寻找 Hook 点，比如第 3 期里 Hook 的方法都是虚拟机内存分配相关的函数。需要注意的一点是，要先确认是否存在该函数的符号，很多时候由于强制 Inline 的函数或者过于短小的函数可能没有对应的符号，这时候就需要使用 objdump、readelf、nm 或者各种 disassembly 工具进行查看，根据类名、函数名查找一下有没有对应的符号。

总结

第 1 期的 Breakpad 的 Sample 主要是展示 Native Crash 的日志如何获取和解读。根据业务的不同，我们平时接触的很多都是 Java 的异常，在业务不断稳定、代码异常处理逐渐完善的情况下，Java 异常的量会逐渐减少，而 Native Crash 的问题会逐步的显现出来。一般比较大型的应用，都会或多或少包含一些 Native 库，比如加密、地图、日志、Push 等模块，由于多方面的原因，这些代码会产生一些异常，我们需要了解 Crash 日志来排查解决，又或者说绕过这些异常，进而提高应用的稳定性。

通过 Breakpad 的源码，以帮你了解到信号捕获、ptrace 的使用、进程 fork/clone 机制、主进程子进程通信、unwind stack、system info 的获取、memory maps info 的获取、symbol 的 dump，以及 symbol 反解等，通过源码我们可以学习到很多东西。

第 2 期的 Sample 提供了解决系统异常的一种思路，使用反射或者代理机制来解决系统代码中的异常。需要说明的是 FinalizerWatchdog 机制并不是系统异常，而是系统的一种防护机制。很多时候我们会遇到一些系统 Framework 的 bug 产生的 Crash，比如很常见的 Toast 异常等，这些异常虽然不属于本应用产生的，但也会影响用户的使用，解决这种异常可以考虑一下这个 Sample 中的思路。

第 3 期的 Sample 描述了一个简单的 Memory Allocation Trace 监控模块，这个模块主要是配合自动性能分析体系来自动发现问题，比如大对象的分配数量监控、分配对象的调用栈分析等。它可以做的事很多，同学们可以根据这个思路，根据自己的业务来开发适合自己的工具。

从第 3 期的 Sample 的代码，你可以学习到 Inline Hook Substrate 框架的使用，使用 ndk_dlopen 来绕过 Android Classloader-Namespace Restriction 机制，以及 C++ 里的线程同步等。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

练习Sample跑起来 | 热点问题答疑第2期

你好，我是孙鹏飞。今天我们基于专栏第 5 期的练习 Sample 以及热点问题，我来给你做答疑。有关上一期答疑，你可以点击这里查看。

为了让同学们可以进行更多的实践，专栏第 5 期 Sample 采用了让你自己实现部分功能的形式，希望可以让你把专栏里讲的原理可以真正用起来。

前面几期已经有同学通过 Pull request 提交了练习作业，这里要给每位参与练习、提交作业的同学点个赞。

第 5 期的作业是根据系统源码来完成一个 CPU 数据的采集工具，并且在结尾我们提供了一个案例让你进行分析。我已经将例子的实现提交到了GitHub上，你可以参考一下。

在文中提到，“当发生 ANR 的时候，Android 系统会打印 CPU 相关的信息到日志中，使用的是ProcessCpuTracker.java”。ProcessCpuTracker 的实现主要依赖于 Linux 里的 /proc 伪文件系统（in-memory pseudo-file system），主要使用到了 /proc/stat、/proc/loadavg、/proc/[pid]/stat、/proc/[pid]/task 相关的文件来读取数据。在 Linux 中有很多程序都依赖 /proc 下的数据，比如 top、netstat、ifconfig 等，Android 里常用的 procrank、librank、procmem 等也都以此作为数据来源。关于 /proc 目录的结构在 Linux Man Pages 里有很详细的说明，在《Linux/Unix 系统编程手册》这本书里，也有相关的中文说明。

关于 proc 有一些需要说明的地方，在不同的 Linux 内核中，该目录下的内容可能会有所不同，所以如果要使用该目录下的数据，可能需要做一些版本上的兼容处理。并且由于 Linux 内核更新速度较快，文档的更新可能还没有跟上，这就会导致一些数据和文档中说明的不一致，尤其是大量的以空格隔开的数字数据。这些文件其实并不是真正的文件，你用 ls 查看会发现它们的大小都是 0，这些文件都是系统虚拟出来的，读取这些文件并不会涉及文件系统的一系列操作，只有很小的性能开销，而现阶段并没有类似文件系统监听文件修改的回调，所以需要采用轮询的方式来进行数据采集。

下面我们来看一下专栏文章结尾的案例分析。下面是这个示例的日志数据，我会通过分析数据来猜测一下是什么原因引起，并用代码还原这个情景。

usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):

System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle

CPU Core: 8

Load Average: 8.74 / 7.74 / 7.36

Process:com.sample.app

  50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965

Threads:

  43% 23493/singleThread(R): 6.5% user + 36% kernel faults：3094

  3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults：329

  0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults：6

  0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults：982

  \.\.\.

上面的示例展示了一段在 5 秒时间内 CPU 的 usage 的情况。初看这个日志，你可以收集到几个重要信息。

1. 在 System Total 部分 user 占用不多，CPU idle 很高，消耗多在 kernel 和 iowait。

2.CPU 是 8 核的，Load Average 大约也是 8，表示 CPU 并不处于高负载情况。

3. 在 Process 里展示了这段时间内 sample app 的 CPU 使用情况：user 低，kernel 高，并且有 4965 次 page faults。

4. 在 Threads 里展示了每个线程的 usage 情况，当前只有 singleThread 处于 R 状态，并且当前线程产生了 3096 次 page faults，其他的线程包括主线程（Sample 日志里可见的）都是处于 S 状态。

根据内核中的线程状态的宏的名字和缩写的对应，R 值代表线程处于 Running 或者 Runnable 状态。Running 状态说明线程当前被某个 Core 执行，Runnable 状态说明线程当前正在处于等待队列中等待某个 Core 空闲下来去执行。从内核里看两个状态没有区别，线程都会持续执行。日志中的其他线程都处于 S 状态，S 状态代表TASK_INTERRUPTIBLE，发生这种状态是线程主动让出了 CPU，如果线程调用了 sleep 或者其他情况导致了自愿式的上下文切换（Voluntary Context Switches）就会处于 S 状态。常见的发生 S 状态的原因，可能是要等待一个相对较长时间的 I/O 操作或者一个 IPC 操作，如果一个 I/O 要获取的数据不在 Buffer Cache 或者 Page Cache 里，就需要从更慢的存储设备上读取，此时系统会把线程挂起，并放入一个等待 I/O 完成的队列里面，在 I/O 操作完成后产生中断，线程重新回到调度序列中。但只根据文中这个日志，并不能判定是何原因所引起的。

还有就是 SingleThread 的各项指标都相对处于一个很高的情况，而且产生了一些 faults。page faluts 分为三种：minor page fault、major page fault 和 invalid page fault，下面我们来具体分析。

minor page fault 是内核在分配内存的时候采用一种 Lazy 的方式，申请内存的时候并不进行物理内存的分配，直到内存页被使用或者写入数据的时候，内核会收到一个 MMU 抛出的 page fault，此时内核才进行物理内存分配操作，MMU 会将虚拟地址和物理地址进行映射，这种情况产生的 page fault 就是 minor page fault。

major page fault 产生的原因是访问的内存不在虚拟地址空间，也不在物理内存中，需要从慢速设备载入，或者从 Swap 分区读取到物理内存中。需要注意的是，如果系统不支持zRAM来充当 Swap 分区，可以默认 Android 是没有 Swap 分区的，因为在 Android 里不会因为读取 Swap 而发生 major page fault 的情况。另一种情况是 mmap 一个文件后，虚拟内存区域、文件磁盘地址和物理内存做一个映射，在通过地址访问文件数据的时候发现内存中并没有文件数据，进而产生了 major page fault 的错误。

根据 page fault 发生的场景，虚拟页面可能有四种状态：

第一种，未分配；

第二种，已经分配但是未映射到物理内存；

第三种，已经分配并且已经映射到物理内存；

第四种，已经分配并映射到 Swap 分区（在 Android 中此种情况基本不存在）。

通过上面的讲解并结合 page fault 数据，你可以看到 SingleThread 你一共发生了 3094 次 fault，根据每个页大小为 4KB，可以知道在这个过程中 SingleThread 总共分配了大概 12MB 的空间。

下面我们来分析 iowait 数据。既然有 iowait 的占比，就说明在 5 秒内肯定进行了 I/O 操作，并且 iowait 占比还是比较大的，说明当时可能进行了大量的 I/O 操作，或者当时由于其他原因导致 I/O 操作缓慢。

从上面的分析可以猜测一下具体实现，并且在读和写的时候都有可能发生。由于我的手机写的性能要低一些，比较容易复现，所以下面的代码基于写操作实现。

File f = new File(getFilesDir(), "aee.txt");

FileOutputStream fos = new FileOutputStream(f);

byte[] data = new byte[1024 * 4 * 3000];// 此处分配一个 12mb 大小的 byte 数组

for (int i = 0; i < 30; i++) {// 由于 IO cache 机制的原因所以此处写入多次 cache，触发 dirty writeback 到磁盘中

    Arrays.fill(data, (byte) i);// 当执行到此处的时候产生 minor fault，并且产生 User cpu useage

    fos.write(data);

}

fos.flush();

fos.close();

上面的代码抓取到的 CPU 数据如下。

E/ProcessCpuTracker: CPU usage from 5187ms to 121ms ago (2018-12-28 08:28:27.186 to 2018-12-28 08:28:32.252):

    40% 24155/com.sample.processtracker(R): 14% user + 26% kernel / faults: 5286 minor

    thread stats:

    35% 24184/SingleThread(S): 11% user + 24% kernel / faults: 3055 minor

    2.1% 24174/RenderThread(S): 1.3% user + 0.7% kernel / faults: 384 minor

    1.5% 24155/.processtracker(R): 1.1% user + 0.3% kernel / faults: 95 minor

    0.1% 24166/HeapTaskDaemon(S): 0.1% user + 0% kernel / faults: 1070 minor

    100% TOTAL(): 3.8% user + 7.8% kernel + 11% iowait + 0.1% irq + 0% softirq + 76% idle

    Load: 6.31 / 6.52 / 6.66

可以对比 Sample 中给出的数据，基本一致。

通过上面的说明，你可以如法炮制去分析 ANR 日志中相关的数据来查找性能瓶颈，比如，如果产生大量的 major page fault 其实是不太正常的，或者 iowait 过高就需要关注是否有很密集的 I/O 操作。

相关资料

低内存配置

iowait 的形成原因和内核分析

page fault 带来的性能问题

Linux 工具快速教程

Android: memory management insights, part I

Linux 2.6 调度系统分析

《性能之巅》

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

练习Sample跑起来 | 热点问题答疑第3期

你好，我是孙鹏飞。又到了答疑的时间，今天我将围绕卡顿优化这个主题，和你探讨一下专栏第 6 期和补充篇的两个 Sample 的实现。

专栏第 6 期的 Sample 完全来自于 Facebook 的性能分析框架Profilo，主要功能是收集线上用户的 atrace 日志。关于 atrace 相信我们都比较熟悉了，平时经常使用的 systrace 工具就是封装了 atrace 命令来开启 ftrace 事件，并读取 ftrace 缓冲区生成可视化的 HTML 日志。这里多说一句，ftrace 是 Linux 下常用的内核跟踪调试工具，如果你不熟悉的话可以返回第 6 期文稿最后查看 ftrace 的介绍。Android 下的 atrace 扩展了一些自己使用的 categories 和 tag，这个 Sample 获取的就是通过 atrace 的同步事件。

Sample 的实现思路其实也很简单，有两种方案。

第一种方案：hook 掉 atrace 写日志时的一系列方法。以 Android 9.0 的代码为例写入 ftrace 日志的代码在trace-dev.cpp里，由于每个版本的代码有些区别，所以需要根据系统版本做一些区分。

第二种方案：也是 Sample 里所使用的方案，由于所有的 atrace event 写入都是通过/sys/kernel/debug/tracing/trace_marker，atrace 在初始化的时候会将该路径 fd 的值写入atrace_marker_fd全局变量中，我们可以通过 dlsym 轻易获取到这个 fd 的值。关于 trace_maker 这个文件我需要说明一下，这个文件涉及 ftrace 的一些内容，ftrace 原来是内核的事件 trace 工具，并且 ftrace 文档的开头已经写道

Ftrace is an internal tracer designed to help out developers and designers of systems to find what is going on inside the kernel.

从文档中可以看出来，ftrace 工具主要是用来探查 outside of user-space 的性能问题。不过在很多场景下，我们需要知道 user space 的事件调用和 kernel 事件的一个先后关系，所以 ftrace 也提供了一个解决方法，也就是提供了一个文件 trace_marker，往该文件中写入内容可以产生一条 ftrace 记录，这样我们的事件就可以和 kernel 的日志拼在一起。但是这样的设计有一个不好的地方，在往文件写入内容的时候会发生 system call 调用，有系统调用就会产生用户态到内核态的切换。这种方式虽然没有内核直接写入那么高效，但在很多时候 ftrace 工具还是很有用处的。

由此可知，用户态的事件数据都是通过 trace_marker 写入的，更进一步说是通过 write 接口写入的，那么我们只需要 hook 住 write 接口并过滤出写入这个 fd 下的内容就可以了。这个方案通用性比较高，而且使用 PLT Hook 即可完成。

下一步会遇到的问题是，想要获取 atrace 的日志，就需要设置好 atrace 的 category tag 才能获取到。我们从源码中可以得知，判断 tag 是否开启，是通过 atrace_enabled_tags & tag 来计算的，如果大于 0 则认为开启，等于 0 则认为关闭。下面我贴出了部分 atrace_tag 的值，你可以看到，判定一个 tag 是否是开启的，只需要 tag 值的左偏移数的位值和 atrace_enabled_tags 在相同偏移数的位值是否同为 1。其实也就是说，我将 atrace_enabled_tags 的所有位都设置为 1，那么在计算时候就能匹配到任何的 atrace tag。

#define ATRACE_TAG_NEVER            0

#define ATRACE_TAG_ALWAYS           (1<<0)

#define ATRACE_TAG_GRAPHICS         (1<<1)

#define ATRACE_TAG_INPUT            (1<<2)

#define ATRACE_TAG_VIEW             (1<<3)

#define ATRACE_TAG_WEBVIEW          (1<<4)

#define ATRACE_TAG_WINDOW_MANAGER   (1<<5)

#define ATRACE_TAG_ACTIVITY_MANAGER (1<<6)

#define ATRACE_TAG_SYNC_MANAGER     (1<<7)

#define ATRACE_TAG_AUDIO            (1<<8)

#define ATRACE_TAG_VIDEO            (1<<9)

#define ATRACE_TAG_CAMERA           (1<<10)

#define ATRACE_TAG_HAL              (1<<11)

#define ATRACE_TAG_APP              (1<<12)

下面是我用 atrace 抓下来的部分日志。

看到这里有同学会问，Begin 和 End 是如何对应上的呢？要回答这个问题，首先要先了解一下这种记录产生的场景。这个日志在 Java 端是由 Trace.traceBegin 和 Trace.traceEnd 产生的，在使用上有一些硬性要求：这两个方法必须成对出现，否则就会造成日志的异常。请看下面的系统代码示例。

void assignWindowLayers(boolean setLayoutNeeded) {

2401        Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "assignWindowLayers");// 关注此处事件开始代码

2402        assignChildLayers(getPendingTransaction());

2403        if (setLayoutNeeded) {

2404            setLayoutNeeded();

2405        }

2406

2411        scheduleAnimation();

2412        Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);// 事件结束

2413    }

2414

所以我们可以认为 B 下面紧跟的 E 就是事件的结束标志，但很多情况下我们会遇到上面日志中所看到的两个 B 连在一起，紧跟的两个 E 我们不知道分别对应哪个 B。此时我们需要看一下产生事件的 CPU 是哪个，并且看一下产生事件的 task_pid 是哪个，也就是最前面的 InputDispatcher-1944，这样我们就可以对应出来了。

接下来我们一起来看看补充篇的 Sample，它的目的是希望让你练习一下如何监控线程创建，并且打印出创建线程的 Java 方法。Sample 的实现比较简单，主要还是依赖 PLT Hook 来 hook 线程创建时使用的主要函数 pthread_create。想要完成这个 Sample 你需要知道 Java 线程是如何创建出来的，并且还要理解 Java 线程的执行方式。需要特别说明的是，其实这个 Sample 也存在一个缺陷。从虚拟机的角度看，线程其实又分为两种，一种是 Attached 线程，我习惯按照.Net 的叫法称其为托管线程；一种是 Unattached 线程，为非托管线程。但底层都是依赖 POSIX Thread 来实现的，从 pthread_create 里无法区分该线程是否是托管线程，也有可能是 Native 直接开启的线程，所以有可能并不能对应到创建线程时候的 Java Stack。

关于线程，我们在日常监控中可能并不太关心线程创建时候的状况，而区分线程可以通过提前设置 Thread Name 来实现。举个例子，比如在出现 OOM 时发现是发生在 pthread_create 执行的时候，说明当前线程数可能过多，一般我们会在 OOM 的时候采集当前线程数和线程堆栈信息，可以看一下是哪个线程创建过多，如果指定了线程名称则很快就能查找出问题所在。

对于移动端的线程来说，我们大多时候更关心的是主线程的执行状态。因为主线程的任何耗时操作都会影响操作界面的流畅度，所以我们经常把看起来比较耗时的操作统统都往子线程里面丢，虽然这种操作虽然有时候可能很有效，但还可能会产生一些我们平时很少遇到的异常情况。比如我曾经遇到过，由于用户手机的 I/O 性能很低，大量的线程都在 wait io；或者线程开启的太多，导致线程 Context switch 过高；又或者是一个方法执行过慢，导致持有锁的时间过长，其他线程无法获取到锁等一系列异常的情况，

虽然线程的监控很不容易，但并不是不能实现，只是实现起来比较复杂并且要考虑兼容性。比如我们可能比较关心一个 Lock 当前有多少线程在等待锁释放，就需要先获取到这个 Object 的 MirrorObject，然后构造一个 MonitorInfo，之后获取到 waiters 的列表，而这个列表里就存储了等待锁释放的线程。你看其实过程也并不复杂，只是在计算地址偏移量的时候需要做一些处理。

当然还有更细致的优化，比如我们都知道 Java 里是有轻量级锁和重量级锁的一个转换过程，在 ART 虚拟机里被称为 ThinLocked 和 FatLocked，而转换过程是通过 Monitor::Inflate 和 Monitor::Deflate 函数来实现的。此时我们可以监控 Monitor::Inflate 调用时 monitor 指向的 Object，来判断是哪段代码产生了“瘦锁”到“胖锁”转换的过程，从而去做一些优化。接下来要做优化，需要先知晓 ART 虚拟机锁转换的机制，如果当前锁是瘦锁，持有该锁的线程再一次获取这个锁只递增了 lock count，并未改变锁的状态。但是 lock count 超过 4096 则会产生瘦锁到胖锁的转换，如果当前持有该锁的线程和进入 MontorEnter 的线程不是同一个的情况下就会产生锁争用的情况。ART 虚拟机为了减少胖锁的产生做了一些优化，虚拟机先通过sched_yield让出当前线程的执行权，操作系统在后面的某个时间再次调度该线程执行，从调用 sched_yield 到再次执行的时候计算时间差，在这个时间差里占用该锁的线程可能会释放对锁的占用，那么调用线程会再次尝试获取锁，如果获取锁成功的话则会从 Unlocked 状态直接转换为 ThinLocked 状态，不会产生 FatLocked 状态。这个过程持续 50 次，如果在 50 次循环内无法获取到锁则会将瘦锁转为胖锁。如果我们对某部分的多线程代码性能敏感，则希望锁尽量持续在瘦锁的状态，我们可以减少同步块代码的粒度，尽量减少很多线程同时争抢锁，可以监控 Inflate 函数调用情况来判断优化效果。

最后，还有同学对在 Crash 状态下获取 Java 线程堆栈的方法比较感兴趣，我在这里简单讲一下，后面会有专门的文章介绍这部分内容。

一种方案是使用 ThreadList::ForEach 接口间接实现，具体的逻辑可以看这里。另一种方案是 Profilo 里的Unwinder机制，这种实现方式就是模拟StackVisitor的逻辑来实现。

这两期反馈的问题不多，答疑的内容也可以算作对正文的补充，如果有同学想多了解虚拟机的机制或者其他性能相关的问题，欢迎你给我留言，我也会在后面的文章和你聊聊这些话题，比如有同学问到的 ART 下 GC 的详细逻辑之类的问题。

相关资料

ftrace kernel doc

ftrace 的使用

A look at ftrace

福利彩蛋

今天为认真提交作业完成练习的同学，送出第二波“学习加油礼包”。@Seven同学提交了第 5 期的作业，送出“极客周历”一本，其他同学如果完成了练习千万别忘了通过 Pull request 提交哦。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

练习Sample跑起来 | 热点问题答疑第4期

你好，我是孙鹏飞。今天我们回到专栏第 7 期和第 8 期，来看看课后练习 Sample 的运行需要注意哪些问题。另外我结合同学们留言的疑问，也来谈谈文件顺序对 I/O 的影响，以及关于 Linux 学习我的一些方法和建议。

专栏第 7 期的 Sample 借助于 systrace 工具，通过字节码处理框架对函数插桩来获取方法执行的 trace。这个 Sample 实现相当完整，你在日常工作也可以使用它。

这个 Sample 使用起来虽然非常简单，但其内部的实现相对来说是比较复杂的。它的实现涉及 Gradle Transform、Task 实现、增量处理、ASM 字节码处理、mapping 文件使用，以及 systrace 工具的使用等。

对于 Gradle 来说，我们应该比较熟悉，它是 Android 平台下的构建工具。对于平时使用来说，我们大多时候只需要关注 Android Gradle Plugin 的一些参数配置就可以实现很多功能了，官方文档已经提供了很详细的参数设置说明。对于一些需要侵入打包流程的操作，就需要我们实现自己的 Task 或者 Transform 代码来完成，比如处理 Class 和 JAR 包、对资源做一些处理等。

Gradle 学习的困难更多来自于 Android Gradle Plugin 对 Gradle 做的一些封装扩展，而这部分 Google 并没有提供很完善的文档，并且每个版本都有一些接口上的变动。对于这部分内容的学习，我主要是去阅读别人实现的 Gradle 工具代码和Android Gradle Plugin 代码。

关于这期的 Sample 实现，有几个可能产生疑问的地方我们来探讨一下。

这个 Sample 的 Gradle 插件是发布到本地 Maven 库的，所以如果没有执行发布直接编译需要先发布插件库到本地 Maven 中才能执行编译成功。

另一个可能遇到问题的是，如果你想把 Sample 使用到其他项目，需要自己将 SampleApp 中其 p 的 e.systrace.TraceTag”类移植到自己的项目中，否则会产生编译错误。

对于字节码处理，在 Sample 中主要使用了 ASM 框架来处理。市面上关于字节码处理的框架有很多，常见的有ASM 和 Javassist 框架，其他的框架你可以使用“Java bytecode manipulation”关键字在 Google 上搜索。使用字节码处理框架需要对字节码有比较深入的了解，要提醒你的是这里的字节码不是 Dalvik bytecode 而是 Java bytecode。对于字节码的学习，你可以参考官方文档和《Java 虚拟机规范》，里面对字节码的执行规则和指令说明都有很详细的描述。并且还可以配合 javap 命令查看反编译的字节码对应的源码，这样学习下来会有很好的效果。字节码处理是一个很细微的操作，稍有失误就会产生编译错误、执行错误或者 Crash 的情况，里面需要注意的地方也非常多，比如 Try Catch Block 对操作数栈的影响、插入的代码对本地变量表和操作数栈的影响等。

实现 AOP 的另一种方是可以接操作 Dex 文件进行 Dalvik bytecode 字节码注入，关于这种实现方式可以使用dexer库来完成，在 Facebook 的Redex中也提供了针对 dex 的 AOP 功能。

下面我们来看专栏第 8 期。我从文章留言里看到，有同学关于数据重排序对 I/O 性能的影响有些疑问，不太清楚优化的原理。其实这个优化原理理解起来是很容易的，在进行文件读取的操作过程中，系统会读取比预期更多的文件内容并缓存在 Page Cache 中，这样下一次读请求到来时，部分页面直接从 Page Cache 读取，而不用再从磁盘中获取数据，这样就加速了读取的操作。在《支付宝 App 构建优化解析》里“原理”一节中已经有比较详细的描述，我就不多赘述了。如果你对“预读”感兴趣的话，我给你提供一些资料，可以深入了解一下。

预读（readhead）机制的系统源码在readhead.c文件中。需要说明的是，预读机制可能在不同系统版本中有所变化，所以下面我提供的资料大多是基于 Linux 2.6.x 的内核，在这以后的系统版本可能对 readhead 机制有修改，你需要留意一下。

关于预读机制详细的算法说明可以看《Linux readahead: less tricks for more》和《Sequential File Prefetching In Linux》、《Linux 内核的文件预读（readahead）》 这三篇文档。

从专栏前几篇的正文看，很多优化的内容是从 Linux 的机制入手的，如果你对 Linux 的机制和优化不了解的话，是不太容易想到这些方案的。举个例子，专栏文章提到的小文件系统是运行在用户态的代码，底层依然依赖现存文件系统提供的功能，因此需要深入了解 Linux VFS、ext4 的实现，以及它们的优缺点和原理，这样我们才能发现为什么大量的小文件依赖现存的文件系统管理是存在性能缺陷的，以及下一步如何填补这些性能缺陷。

作为 Android 开发工程师，我们该何学习 Linux 呢？我其实不建议上来就直接阅读系统源码分析相关的书，我建议是从理解操作系统概念开始，推荐两本操作系统相关的书：《深入理解计算机系统》和《计算机系统 系统架构与操作系统的高度集成》。Linux 的系统实现其实和传统的操作系统概念在细节上会有不小的差别，再推荐一本解析 Linux 操作系统的书《操作系统之编程观察》，这本书结合源码对 Linux 的各方面机制都进行和很详细的分析。

对于从事 Android 开发的同学来说，确实很有必要深入了解 Linux 系统相关的知识，因为 Android 里很多特性都是依赖底层基础系统的，就比如我刚刚提到的“预读”机制，不光可以用在 Android 的资源加载上，也可以拓展到 Flutter 的资源加载上。假如我们以后面对一个不是 Linux 内核的系统，比如 Fuchsia OS，也可以根据已经掌握的系统知识套用到现有的操作系统上，因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的，在迁移到新的系统上的时候可以有一个全局的学习视角，帮助我们快速上手。对于操作系统内容，我的学习路线是先熟悉系统机制，然后熟悉系统提供的各个方向的接口，比如 I/O 操作、进程创建、信号中断处理、线程使用、epoll、通信机制等，按照《UNIX 环境高级编程》这本书的内容一步步的走就可以完成这一步骤，熟悉之后可以按照自己的节奏，再去学习自己比较感兴趣的模块。此时可以找一本源码分析的书再去阅读，比如想了解 fork 机制的实现、I/O 操作的 read 和 write 在内核态的调度执行，像这些问题就需要有目的性的进行挖掘。

上面这个学习路线是我在学习过程中不断踩坑总结出来的一些经验，对于操作系统我也只是个初学者，也欢迎你留言说说自己学习的经验和问题，一起切磋进步。

最后送出 3 本“极客周历”给用户故事“专栏学得苦？可能是方法没找对”留言点赞数前三的同学，分别是@坚持远方、@蜗牛、@JIA，感谢同学们的参与。

Android JVM TI机制详解（内含福利彩蛋）

你好，我是孙鹏飞。

在专栏卡顿优化的分析中，绍文提到可以利用 JVM TI 机制获得更加非常丰富的顿现场信息，包括内存申请、线程创建、类加载、GC 信息等。

JVM TI 机制究竟是什么？它为什么如此的强大？怎么样将它应用到我们的工作中？今天我们一起来解开它神秘的面纱。

JVM TI 介绍

JVM TI 全名是Java Virtual Machine Tool Interface，是开发虚拟机监控工具使用的编程接口，它可以监控 JVM 内部事件的执行，也可以控制 JVM 的某些行为，可以实现调试、监控、线程分析、覆盖率分析工具等。

JVM TI 属于Java Platform Debugger Architecture中的一员，在 Debugger Architecture 上 JVM TI 可以算作一个 back-end，通过 JDWP 和 front-end JDI 去做交互。需要注意的是，Android 内的 JDWP 并不是基于 JVM TI 开发的。

从 Java SE 5 开始，Java 平台调试体系就使用 JVM TI 替代了之前的 JVMPI 和 JVMDI。如果你对这部分背景还不熟悉，强烈推荐先阅读下面这几篇文章：

深入 Java 调试体系：第 1 部分，JPDA 体系概览

深入 Java 调试体系：第 2 部分，JVMTI 和 Agent 实现

深入 Java 调试体系：第 3 部分，JDWP 协议及实现

深入 Java 调试体系：第 4 部分，Java 调试接口（JDI）

虽然 Java 已经使用了 JVM TI 很多年，但从源码上看在 Android 8.0 才集成了 JVM TI v1.2，主要是需要在 Runtime 中支持修改内存中的 Dex 和监控全局的事件。有了 JVM TI 的支持，我们可以实现很多调试工具没有实现的功能，或者定制我们自己的 Debug 工具来获取我们关心的数据。

现阶段已经有工具使用 JVM TI 技术，比如 Android Studio 的 Profilo 工具和 Linkedin 的dexmaker-mockito-inline工具。Android Studio 使用 JVM TI 机制实现了实时的内存监控，对象分配切片、GC 事件、Memory Alloc Diff 功能，非常强大；dexmaker 使用该机制实现 Mock final methods 和 static methods。

1. JVM TI 支持的功能

在介绍 JVM TI 的实现原理之前，我们先来看一下 JVM TI 提供了什么功能？我们可以利用这些功能做些什么？

线程相关事件 -> 监控线程创建堆栈、锁信息

ThreadStart ：线程在执行方法前产生线程启动事件。

ThreadEnd：线程结束事件。

MonitorWait：wait 方法调用后。

MonitorWaited：wait 方法完成等待。

MonitorContendedEnter：当线程试图获取一个已经被其他线程持有的对象锁时。

MonitorContendedEntered：当线程获取到对象锁继续执行时。

类加载准备事件 -> 监控类加载

ClassFileLoadHook：在类加载之前触发。

ClassLoad：某个类首次被加载。

ClassPrepare：某个类的准备阶段完成。

异常事件 -> 监控异常信息

Exception：有异常抛出的时候。

ExceptionCatch：当捕获到一个异常时候。

调试相关

SingleStep：步进事件，可以实现相当细粒度的字节码执行序列，这个功能可以探查多线程下的字节码执行序列。

Breakpoint：当线程执行到一个带断点的位置，断点可以通过 JVMTI SetBreakpoint 方法来设置。

方法执行

FramePop：当方法执行到 retrun 指令或者出现异常时候产生，手动调用 NofityFramePop JVM TI 函数也可产生该事件。

MethodEntry：当开始执行一个 Java 方法的时候。

MethodExit：当方法执行完成后，产生异常退出时。

FieldAccess：当访问了设置了观察点的属性时产生事件，观察点使用 SetFieldAccessWatch 函数设置。

FieldModification：当设置了观察点的属性值被修改后，观察点使用 SetFieldModificationWatch 设置。

GC -> 监控 GC 事件与时间

GarbageCollectionStart：GC 启动时。

GarbageCollectionFinish：GC 结束后。

对象事件 -> 监控内存分配

ObjectFree：GC 释放一个对象时。

VMObjectAlloc：虚拟机分配一个对象的时候。

其他

NativeMethodBind：在首次调用本地方法时或者调用 JNI RegisterNatives 的时候产生该事件，通过该回调可以将一个 JNI 调用切换到指定的方法上。

通过上面的事件描述可以大概了解到 JVM TI 支持什么功能，详细的回调函数参数可以从 JVM TI规范文档里获取到，我们可以通过这些功能实们定制的性能监控、数据采集、行为修改等工具。

2. JVM TI 实现原理

JVM TI Agent 的启动需要虚拟机的支持，我们的 Agent 和虚拟机运行在同一个进程中，虚拟机通过 dlopen 打开我们的 Agent 动态链接库，然后通过 Agent_OnAttach 方法来调用我们定义的初始化逻辑。

JVM TI 的原理其实很简单，以 VmObjectAlloc 事件为例，当我们通过 SetEventNotificationMode 函数设置 JVMTI_EVENT_VM_OBJECT_ALLOC 回调的时候，最终会调用到 art::Runtime::Current() -> GetHeap() -> SetAllocationListener(listener);

在这个方法中，listener 是 JVM TI 实现的一个虚拟机提供的 art::gc::AllocationListener 回调，当虚拟机分配对象内存的时候会调用该回调，源码可见heap-inl.h#194，同时在该回调函数里也会调用我们之前设置的 callback 方法，这样事件和相关的数据就会透传到我们的 Agent 里，来实现完成事件的监听。

类似 atrace 和 StrictMode，JVM TI 的每个事件都需要在源码中埋点支持。感兴趣的同学，可以挑选一些事件在源码中进一步跟踪。

JVM TI Agent 开发

JVM TI Agent 程序使用 C/C++ 语言开发，也可以使用其他支持 C 语言调用语言开发，比如 Rust。

JVM TI 所涉及的常量、函数、事件、数据类型都定义在 jvmti.h 文件中，我们需要下载该文件到项目中引用使用，你可以从 Android 项目里下载它的头文件。

JVM TI Agent 的产出是一个 so 文件，在 Android 里通过系统提供的Debug.attachJvmtiAgent方法来启动一个 JVM TI Agent 程序。

static fun attachJvmtiAgent(library: String, options: String?, classLoader: ClassLoader?): Unit

library 是 so 文件的绝对地址。需要注意的是 API Level 为 28，而且需要应用开启了android:debuggable才可以使用，不过我们可以通过强制开启 debug 来在 release 版里启动 JVM TI 功能。

Android 下的 JVM TI Agent 在被虚拟机加载后会及时调用 Agent_OnAttach 方法，这个方法可以当作是 Agent 程序的 main 函数，所以我们需要在程序里实现下面的函数。

extern "C" JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options,void *reserved)

你可以在这个方法里进行初始化操作。

通过 JavaVM::GetEnv 函数拿到 jvmtiEnv* 环境指针（Environment Pointer），通过该指针可以访问 JVM TI 提供的函数。

jvmtiEnv *jvmti_env;jint result = vm->GetEnv((void **) &jvmti_env, JVMTI_VERSION_1_2);

通过 AddCapabilities 函数来开启需要的功能，也可以通过下面的方法开启所有的功能，不过开启所有的功能对虚拟机的性能有所影响。

void SetAllCapabilities(jvmtiEnv *jvmti) {

    jvmtiCapabilities caps;

    jvmtiError error;

    error = jvmti->GetPotentialCapabilities(&caps);

    error = jvmti->AddCapabilities(&caps);

}

GetPotentialCapabilities 函数可以获取当前环境支持的功能集合，通过 jvmtiCapabilities 结构体返回，该结构体里标明了支持的所有功能，可以通过jvmti.h来查看，大概内容如下。

typedef struct {

    unsigned int can_tag_objects : 1;

    unsigned int can_generate_field_modification_events : 1;

    unsigned int can_generate_field_access_events : 1;

    unsigned int can_get_bytecodes : 1;

    unsigned int can_get_synthetic_attribute : 1;

    unsigned int can_get_owned_monitor_info : 1;

......

} jvmtiCapabilities;

然后通过 AddCapabilities 方法来启动需要的功能，如果需要单独添加功能，则可以通过如下方法。

 jvmtiCapabilities caps;

    memset(&caps, 0, sizeof(caps));

    caps.can_tag_objects = 1;

到此 JVM TI 的初始化操作就已经完成了。

所有的函数和数据结构类型说明可以在这里找到。下面我来介绍一些常用的功能和函数。

1. JVM TI 事件监控

JVM TI 的一大功能就是可以收到虚拟机执行时候的各种事件通知。

首先通过 SetEventCallbacks 方法来设置目标事件的回调函数，如果 callbacks 传入 nullptr 则清除掉所有的回调函数。

  jvmtiEventCallbacks callbacks;

    memset(&callbacks, 0, sizeof(callbacks));

    callbacks.GarbageCollectionStart = &GCStartCallback;

    callbacks.GarbageCollectionFinish = &GCFinishCallback;

    int error = jvmti_env->SetEventCallbacks(&callbacks, sizeof(callbacks));

设置了回调函数后，如果要收到目标事件的话需要通过 SetEventNotificationMode，这个函数有个需要注意的地方是 event_thread，如果参数 event_thread 参数为 nullptr，则会全局启用改目标事件回调，否则只在指定的线程内生效，比如很多时候对于一些事件我们只关心主线程。

jvmtiError SetEventNotificationMode(jvmtiEventMode mode,

          jvmtiEvent event_type,

          jthread event_thread,

           ...);

typedef enum {

    JVMTI_ENABLE = 1,// 开启

    JVMTI_DISABLE = 0 .// 关闭

} jvmtiEventMode;

以上面的 GC 事件为例，上面设置了 GC 事件的回调函数，如果想要在回调方法里接收到事件则需要使用 SetEventNotificationMode 开启事件，需要说明的是 SetEventNotificationMode 和 SetEventCallbacks 方法调用没有先后顺序。

jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr);

jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_FINISH, nullptr);

通过上面的步骤就可以在虚拟机产生 GC 事件后在回调函数里获取到对应的函数了，这个 Sample 需要注意的是在 gc callback 里禁止使用 JNI 和 JVM TI 函数，因为虚拟机处于停止状态。

void GCStartCallback(jvmtiEnv *jvmti) {

    LOGI("========== 触发 GCStart=======");

}

void GCFinishCallback(jvmtiEnv *jvmti) {

    LOGI("========== 触发 GCFinish=======");

}

Sample 效果如下。

com.dodola.jvmti I/jvmti: ========== 触发 GCStart=======

com.dodola.jvmti I/jvmti: ========== 触发 GCFinish=======

2. JVM TI 字节码增强

JVM TI 可以在虚拟机运行的状态下对字节码进行修改，可以通过下面三种方式修改字节码。

Static：在虚拟机加载 Class 文件之前，对字节码修改。该方式一般不采用。

Load-Time：在虚拟机加载某个 Class 时，可以通过 JVM TI 回调拿到该类的字节码，会触发 ClassFileLoadHook 回调函数，该方法由于 ClassLoader 机制只会触发一次，由于我们 Attach Agent 的时候经常是在虚拟机执行一段时间之后，所以并不能修改已经加载的 Class 比如 Object，所以需要根据 Class 的加载时机选择该方法。

Dynamic：对于已经载入的 Class 文件也可以通过 JVM TI 机制修改，当系统调用函数 RetransformClasses 时会触发 ClassFileLoadHook，此时可以对字节码进行修改，该方法最为实用。

传统的 JVM 操作的是 Java Bytecode，Android 里的字节码操作的是Dalvik Bytecode，Dalvik Bytecode 是寄存器实现的，操作起来相对 JavaBytecode 来说要相对容易一些，可以不用处理本地变量和操作数栈的交互。

使用这个功能需要开启 JVM TI 字节码增强功能。

jvmtiCapabilities.can_generate_all_class_hook_events=1 // 开启 class hook 功能标记

jvmtiCapabilities.can_retransform_any_class=1 // 开启对任意类进行 retransform 操作

然后注册 ClassFileLoadHook 事件回调。

jvmtiEventCallbacks callbacks;s

callbacks.ClassFileLoadHook = &ClassTransform;

这里说明一下 ClassFileLoadHook 的函数原型，后面会讲解如何重新修改现有字节码。

static void ClassTransform(

               jvmtiEnv *jvmti_env,//jvmtiEnv 环境指针

               JNIEnv *env,//jniEnv 环境指针

               jclass classBeingRedefined,// 被重新定义的 class 信息

               jobject loader,// 加载该 class 的 classloader，如果该项为 nullptr 则说明是 BootClassLoader 加载的

               const char *name,// 目标类的限定名

               jobject protectionDomain,// 载入类的保护域

               jint classDataLen,//class 字节码的长度

               const unsigned char *classData,//class 字节码的数据

               jint *newClassDataLen,// 新的类数据的长度

               unsigned char **newClassData) // 新类的字节码数据

然后开启事件，完整的初始化逻辑可参考 Sample 中的代码。

SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, NULL)

下面以 Sample 代码作为示例来讲解如何在 Activity 类的 onCreate 方法中插入一行日志调用代码。

通过上面的步骤后就可以在虚拟机第一次加载类的时候和在调用 RetransformClasses 或者 RedefineClasses 时，在 ClassFileLoadHook 回调方法里会接收到事件回调。我们目标类是 Activity，它在启动应用的时候就已经触发了类加载的过程，由于这个 Sample 开启事件的时机很靠后，所以此时并不会收到加载 Activity 类的事件回调，所以需要调用 RetransformClasses 来触发事件回调，这个方法用于对已经载入的类进行修改，传入一个要修改类的 Class 数组和数组长度。

jvmtiError RetransformClasses(jint class_count, const jclass* classes)

调用该方法后会在 ClassFileLoadHook 设置的回调，也就是上面的 ClassTran sform 方法中接收到回调，在这个回调方法中我们通过字节码处理工具来修改原始类的字节码。

类的修改会触发虚拟机使用新的方法，旧的方法将不再被调用，如果有一个方法正在栈帧上，则这个方法会继续运行旧的方法的字节码。RetransformClasses 的修改不会导致类的初始化，也就是不会重新调用方法，类的静态变量的值和实例变量的值不会产生变化，但目标类的断点会失效。

处理类有一些限制，我们可以改变方法的实现和属性，但不能添加删除重命名方法，不能改变方法签名、参数、修饰符，不能改变类的继承关系，如果产生上面的行为会导致修改失败。修改之后会触发类的校验，而且如果虚拟机里有多个相同的 Class ，我们需要注意一下取到的 Class 需要是当前生效的 Class，按照 ClassLoader 加载机制也就是说优先使用提前加载的类。

Sample 中实现的效果是在 Activity.onCreate 方法中增加一行日志输出。

修改前：

protected void onCreate(@Nullable Bundle savedInstanceState) {

.......

}

修改后：

protected void onCreate(@Nullable Bundle savedInstanceState) {

      com.dodola.jvmtilib.JVMTIHelper.printEnter(this,"....");

....

}

我使用的 Dalvik 字节码修改库是 Android 系统源码里提供的一套修改框架dexter，虽然使用起来十分灵活但比较繁琐，也可以使用dexmaker框架来实现。本例还是使用 dexter，框架使用 C++ 开发，可以直接读取 classdata 然后进行操作，可以类比到 ASM 框架。下面的代码是核心的操作代码，完整的代码参考本期 Sample。

ir::Type* stringT = b.GetType("Ljava/lang/String;");

ir::Type* jvmtiHelperT=b.GetType("Lcom/dodola/jvmtilib/JVMTIHelper;");

lir::Instruction *fi = *(c.instructions.begin());

VReg* v0 = c.Alloc<VReg>(0);

addInstr(c, fi, OP_CONST_STRING,

         {v0, c.Alloc<String>(methodDesc, methodDesc->orig_index)});

addCall(b, c, fi, OP_INVOKE_STATIC, jvmtiHelperT, "printEnter", voidT, {stringT}, {0});

c.Assemble();

必须通过 JVM TI 函数 Allocate 为要修改的类数据分配内存，将 new_class_data 指向修改后的类 bytecode 数组，将 new_class_data_len 置为修改后的类 bytecode 数组的长度。若是不修改类文件，则不设置 new_class_data 即可。若是加载了多个 JVM TI Agent 都启用了该事件，则设置的 new_class_data 会成为下一个 JVM TI Agent 的 class_data。

此时我们生成的 onCreate 方法里已经加上了我们添加的日志方法调用。开启新的 Activity 会使用新的类字节码执行，同时会使用 ClassLoader 加载我们注入的 com.dodola.jvmtilib.JVMTIHelper 类。我在前面说过，Activity 是使用 BootClassLoader 进行加载的，然而我们的类明显不在 BootClassLoader 里，此时就会产生 Crash。

java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace available

所以需要想办法将 JVMTIHelper 类添加到 BootClassLoader 里，这里可以使用 JVM TI 提供的 AddToBootstrapClassLoaderSearch 方法来添加 Dex 或者 APK 到 Class 搜索目录里。Sample 里是将 getPackageCodePath 添加进去就可以了。

总结

今天我主要讲解了 JVM TI 的概念和原理，以及它可以实现的功能。通过 JVM TI 可以完成很多平时可能需要很多“黑科技”才可以获取到的数据，比如Thread Park Start/Finish事件、获取一个锁的 waiters 等。

可能在 Android 圈里了解 JVM TI 的人不多，对它的研究还没有非常深入。目前 JVM TI 的功能已经十分强大，后续的 Android 版本也会进一步增加更多的功能支持，这样它可以做的事情将会越来越多。我相信在未来，它将会是本地自动化测试，甚至是线上远程诊断的一大“杀器”。

在本期的Sample里，我们提供了一些简单的用法，你可以在这个基础之上完成扩展，实现自己想要的功能。

相关资料

1.深入 Java 调试体系：第 1 部分，JPDA 体系概览

2.深入 Java 调试体系：第 2 部分，JVMTI 和 Agent 实现

3.深入 Java 调试体系：第 3 部分，JDWP 协议及实现

4.深入 Java 调试体系：第 4 部分，Java 调试接口（JDI）

5.JVM TI 官方文档：https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html

6. 源码是最好的资料：http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/

福利彩蛋

根据专栏导读里我们约定的，我和绍文会选出一些认真提交作业完成练习的同学，送出一份“学习加油礼包”。专栏更新到现在，很多同学留下了自己的思考和总结，我们选出了@Owen、@志伟、@许圣明、@小洁、@SunnyBird，送出“极客时间周历”一份，希望更多同学可以加入到学习和讨论中来，与我们一起进步。

@Owen 学习总结：https://github.com/devzhan/Breakpad

@许圣明、@小洁、@SunnyBird 通过 Pull Requests 提交了练习作业https://github.com/AndroidAdvanceWithGeektime/Chapter04/pulls。

极客时间小助手会在 24 小时内与获奖用户取得联系，注意查看短信哦～

Android工程师的“面试指南”

你好，我是孙鹏飞。又到了传统的“金三银四”换工作的高峰期，在互联网寒冬下，抓住机会就显得尤为重要了。那作为 Android 工程师我们应该从哪些方面去准备呢？例如，不太熟悉的技能要不要写在简历上、要复习哪些 Android 组件的知识、刷算法题目有没有用，可能在面试前你都会仔细考虑这些问题。下面我就结合自身的经验和理解，帮你梳理一下关于简历、面试和算法方面需要准备的内容，分享一些我的心得体会。

简历

简历在面试过程会起到至关重要的作用，我们需要非常注意简历的撰写。

在面试的过程中，面试官通常会非常关注你简历中的工作经历、项目介绍、技能特长这三部分的内容，如果你面试的公司没有固定题目的话，那很多问题都会围绕你简历里这三部分内容去问。这里需要注意的一点是相关技能的书写，首先你要让面试官明确你面试的定级是什么。很多时候一个职位对应了很多个职级，在投简历的时候，你的简历需要让面试官给你一个比较明确的定级，否则面试过程会比较被动，也会影响面试官对你的判断。因此这部分的内容需要突出自己的特长，也要写一些现在公司相对关心的问题，比如你对插件化、热修复、组件化、性能优化等很熟悉，就可以明确的写上，但如果不是很熟悉那么尽量不要去写。如果你对 Android 某部分内容很熟悉就可以写得相对详细一些，比如你对 Handler、Binder 机制很熟悉，就可以写“熟悉 Android 常见机制，比如 Handler、Binder 机制等”。而看到你很熟悉这部分内容，面试官可能在问问题时一层层深入，因此你肯定需要提前准备一下这部分内容如何讲解，基本可以从机制的优点、重点、难点三方面去说明。

关于项目介绍主要体现你在这个项目或者这个团队中的作用，突出自己的贡献和项目的难点。很多同学可能在公司一直做需求的开发，会觉得自己的项目经验没有亮点，难度也没有那么大，会觉得在这部分内容上比较吃亏。其实每个需求下来的时候你肯定会对这个需求有一个自己的设计，在这个过程中你会考虑如何对现有代码的影响最小，如何快捷清晰的实现功能，以及在开发过程中对组件、控件的封装，考虑如何优化性能，有没有新的技术可以帮助开发这个需求的…我想我们每个需求都是通过一系列的考量和设计后才实施的，你可以回去翻一下自己的代码，然后考虑一下如何把你的这些设计和思考体现在简历上，同样也是个不错的说明。

面试

对于 Android 工程师来说，面试开始的时候都会问一些算法和 Android、Java 的基础知识。针对 Java 的基础知识，我建议你看一下《码出高效：Java 开发手册》《深入理解 Java 虚拟机》《Java 并发编程的艺术》这三本书。对于 Android 的面试题，大多都是跟系统原理有关的内容，但也有很多没有准确答案的问题，比如四大组件的原理这样的题目，需要你从一个宏观的角度去解释一下四大组件，或者你也可以拆分开一个个去讲解。

在面试前你需要提前准备一下，避免回答问题的时候没有条理，导致面试官对你的逻辑思维能力和语言表达能力产生不好的判断。一些 Android 经常使用到的组件一定要理解清楚，比如 Handler.postDelay 的机制、触摸事件机制、自定义 View、如何计算 View 大小、容器控件如何对子控件进行布局、数据库基本操作、Binder 机制、LMK 机制等。还有面试官也可能会问一些开源框架的原理，我建议你也要多了解一些优秀的网络框架、图片加载框架、日志记录框架、EventBus、AAC 框架的原理。对于相对复杂的插件化和热修复来说，热修复可以去看一下《深入探索 Android 热修复》这本书，插件化可以去看《Android 插件化原理解析》这个系列的文章。还有性能优化，最近几年公司对性能优化关注很多，有的同学可能做过专门的性能优化或者自己开发过一些工具总结过一些方法论，这样比较好答一些。但是大部分同学可能平时都在关注业务需求开发，性能优化的实战可能并不是很多。我建议你可以从业务开发过程中找一些点来说，比如在做一些公共的业务组件时需要在启动时初始化，那么就需要注意初始化过程中的性能；又或者在做一个列表页面的时候，在复杂的列表 View 下如何保证滑动性能。相信你在平时开发过程中都会有自己的思考，可以结合具体的情景讲出来。

面试的后面大多都会从项目入手，你需要在面试之前针对你的项目做详细的准备。比如面试官会让你介绍一下你的项目，你需要体现出这个项目的难点、你在项目中的贡献、项目的具体实现等，有可能还会问到一些具体的细节，所以建议是实事求是去讲，但一定要对自己的模块非常清晰。除了技术面试以外，有时还有可能会考察一些软技能，比如面试官会考察你跨部门协作能力、沟通能力、时间管理、任务分配和职业规划等。

面试更多的还是要靠平时的积累和临场的发挥，做总结是很重要的，因为很多内容不经常使用的话过一段时间之后就会忘掉，这样就会出现原本自己做过的东西，因为忘记了细节，结果在面试过程中没法很好的展现出来。就比如插件化、热修复这样的技术，其实原理相对来说比较简单，但是在开发的过程中会遇到很多很多的坑，如果没有一些关键点的文字记录，过一段时间之后可能就忘记了某段代码是用于什么目的。所以在做完一次需求之后尽量多总结项目中的难点，使用到的框架以及这个框架的原理，以及其中花费时间最长的地方。另外 Bug 最多的地方也要做总结一下原因，这样在面试前就不用把代码再翻一遍，了解自己的项目细节就可以做到游刃有余了。

对于复习，首先要对自己做一次自我了解，我是通过画脑图来进行这个过程的，我会整体默想一遍大概的知识体系，画成类似下图。回想每个知识点可能考到的内容，记录下自己模糊的地方，然后去看网上同学们总结的面试题，再对每个题目都做一下回答。这是一个迭代过程。在你预想的问题都可以回答上来的时候，就需要深入挖掘一下技术细节和深度了，比如我工作中开发了一个 PLT Hook 工具，这个工具可能是我参考开源项目并封装修改过来的，但对其中的细节并没有很了解，这个时候你就要对这个开源项目所涉及的内容做一次系统学习了。

另一方面是需要在面试过程中提高面试官对自己的级别评价，比如大部分人回答 GC 的问题都是按照《深入理解 Java 虚拟机》里的内容复述一遍，这种回答基本也是可以的。不过毕竟 Java 虚拟机和 Android 虚拟机的 GC 还是有些差别的，如果自己阅读过 Android 虚拟机 GC 相关资料或者自己分析过源码的话，可以从 Android 虚拟机的角度解释 GC，比如 Android 虚拟机里 MarkSweep 算法的增量回收、并行回收等，后台 GC 和前台 GC、VisitRoot 的执行过程、GC 的触发方式、TLAB 的处理、ConcurrentGC 的原理、堆的 Trim 过程、内存碎片的解决、Reference 的处理、finalize 函数调用等展开讲。如果你对一个机制很熟悉的话，可以把话题引到这上面去，然后一层层对这个知识点深入讲解，这样可以提升面试官对你的等级评价。

算法

算法是一定要复习的，在很多面试的过程中都会穿插算法题。面试的算法题一般不会很难，可以分为基础的数据结构，比如数组、链表、栈、队列、二叉树、堆的使用，这几种常见的数据结构的基础操作一定要很熟悉，比如链表逆置、删除、获取第 K 个元素、判断是否有环等，二叉树翻转、深度遍历、层级遍历、求树深度、公共父节点等。另一种是常见的搜索、排序算法，这两类算法出现频率很高，一定要知道它们常见的几种实现方式，比如排序方式有冒泡、快排、插入、归并、堆排序等。注意这里一定不要简单的去记忆算法实现，因为面试的时候可能不会直接让你写出对应的算法，会出一些使用搜索或者排序算法来实现的题目，这类题目你可以去 LeetCode 上通过标签过滤出来。

另一部分的算法题可能集中在贪心、动态规划、分治算法、深搜广搜等，这一类的算法相对需要一些技巧性。但面试算法题通常不需要太多行代码就能完成，一般都是在几十行内就能完成的，所以你可以优先去找一些经典题目来做，比如爬楼梯、最大子序和等。但也会有一些相对复杂的题目是几种算法结合在一起的，比如二叉树的最大路径和就是深度搜索和动态规划一起使用的题目。除此之外，也可能会遇到通过其他问题引申出的一些算法题目，比如 HashMap 可能会引申出红黑树的实现等。这些都有可能需要准备，虽然它可能不会成为你整个面试的一个绊脚石，但有可能成为你获取一个高评价的筹码。

“临时抱佛脚”对于算法的学习和积累作用不是很大，因此需要我们在平时繁忙的工作中抽出一些时间来复习，你也可以去 LeetCode、LintCode 上刷刷题。另外，虽然大部分面试的算法题目都是 LeetCode 上的简单题目，但你同样也需要关注一些中等和困难难度的经典题目。

总结

今天我并没有涉及太多具体的面试题，更多侧重的如何准备面试，而面试的准备其实是在我们平时工作过程中一点一滴积累的，复习只是作为一种在面试前巩固知识的手段。复习的过程主要是我们对知识点的整理和总结，你可以想一下在面试的时候可能会遇到的问题，以及该如何去表达。但是我想说，虽然“临时抱佛脚”的准备可能有时有用，但是在短时间内靠“突击”是很难理解到某个知识点更加深度层次的内容，而且知识面的广度也是需要时间和经验去积累的。所以不管你是否需要面试，在平时工作过程中都需要多思考、多训练、多总结，在有需要的时候可以厚积薄发。

最后，如果你也在准备面试，可以在留言区分享一下你的准备情况和面试心得。当然你也可以留下你遇到的面试问题，把它分享给其他同学。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”，期待与你一起切磋进步哦。

Native下如何获取调用栈？

你好，我是 simsun，曾在微信从事 Android 开发，也是开源爱好者、Rust 语言“铁粉”。应绍文邀请，很高兴可以在“高手课”里和你分享一些编译方面的底层知识。

当我们在调试 Native 崩溃或者在做 profiling 的时候是十分依赖 backtrace 的，高质量的 backtrace 可以大大减少我们修复崩溃的时间。但你是否了解系统是如何生成 backtrace 的呢？今天我们就来探索一下 backtrace 背后的故事。

下面是一个常见的 Native 崩溃。通常崩溃本身并没有任何 backtrace 信息，可以直接获得的就是当前寄存器的值，但显然 backtrace 才是能够帮助我们修复 Bug 的关键。

pid: 4637, tid: 4637, name: crasher  >>> crasher <<<

signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------

Abort message: 'some_file.c:123: some_function: assertion "false" failed'

    r0  00000000  r1  0000121d  r2  00000006  r3  00000008

    r4  0000121d  r5  0000121d  r6  ffb44a1c  r7  0000010c

    r8  00000000  r9  00000000  r10 00000000  r11 00000000

    ip  ffb44c20  sp  ffb44a08  lr  eace2b0b  pc  eace2b16

backtrace:

    #00 pc 0001cb16  /system/lib/libc.so (abort+57)

    #01 pc 0001cd8f  /system/lib/libc.so (__assert2+22)

    #02 pc 00001531  /system/bin/crasher (do_action+764)

    #03 pc 00002301  /system/bin/crasher (main+68)

    #04 pc 0008a809  /system/lib/libc.so (__libc_init+48)

    #05 pc 00001097  /system/bin/crasher (_start_main+38)

在阅读后面的内容之前，你可以先给自己 2 分钟时间，思考一下系统是如何生成 backtrace 的呢？我们通常把生成 backtrace 的过程叫作 unwind，unwind 看似和我们平时开发并没有什么关系，但其实很多功能都是依赖 unwind 的。举个例子，比如你要绘制火焰图或者是在崩溃发生时得到 backtrace，都需要依赖 unwind。

书本中的 unwind

1. 函数调用过程

如果你在大学时期修过汇编原理这门课程，相信你会对下面的内容还有印象。下图就是一个非常标准的函数调用的过程。

首先假设我们处于函数 main() 并准备调用函数 foo()，调用方会按倒序压入参数。此时第一个参数会在调用栈栈顶。

调用 invoke foo() 伪指令，压入当前寄存器 EIP 的值到栈，然后载入函数 foo() 的地址到 EIP。

此时，由于我们已经更改了 EIP 的值（为 foo() 的地址），相当于我们已经进入了函数 foo()。在执行一个函数之前，编译器都会给每个函数写一段序言（prologue），这里会压入旧的 EBP 值，并赋予当前 EBP 和 ESP 新的值，从而形成新的一个函数栈。

下一步就行执行函数 foo() 本身的代码了。

结束执行函数 foo() 并准备返回，这里编译器也会给每个函数插入一段尾声（epilogue）用于恢复调用方的 ESP 和 EBP 来重建之前函数的栈和恢复寄存器。

执行返回指令（ret），被调用函数的尾声（epilogue）已经恢复了 EBP 和 ESP，然后我们可以从被恢复的栈中依次 pop 出 EIP、所有的参数以及被暂存的寄存器的值。

读到这里，相信如果没有学过汇编原理的同学肯定会有一些懵，我来解释一下上面提到的寄存器缩写的具体含义，上述命名均使用了 x86 的命名方式。讲这些是希望你对函数调用有一个初步的理解，其中有很多细节在不同体系结构、不同编译器上的行为都有所区别，所以请你放松心情，跟我一起继续向后看。

EBP：基址指针寄存器，指向栈帧的底部。
在 ARM 体系结构中，R11（ARM code）或者 R7（Thumb code）起到了类似的作用。在 ARM64 中，此寄存器为 X29。
ESP：栈指针寄存器，指向栈帧的栈顶 ， 在 ARM 下寄存器为 R13。
EIP：指令寄存器，存储的是 CPU 下次要执行的指令的地址，ARM 下为 PC，寄存器为 R15。

2. 恢复调用帧

如果我们把上述过程缩小，站在更高一层视角去看，所有的函数调用栈都会形成调用帧（stack frame），每一个帧中都保存了足够的信息可以恢复调用函数的栈帧。

我们这里忽略掉其他不相关的细节，重点关注一下 EBP、ESP 和 EIP。你可以看到 EBP 和 ESP 分别指向执行函数栈的栈底和栈顶。每次函数调用都会保存 EBP 和 EIP 用于在返回时恢复函数栈帧。这里所有被保存的 EBP 就像一个链表指针，不断地指向调用函数的 EBP。 这样我们就可以此为线索，十分优雅地恢复整个调用栈。

这里我们可以用下面的伪代码来恢复调用栈：

void debugger::print_backtrace() {

    auto curr_func = get_func_from_pc(get_pc());

    output_frame(curr_func);

    auto frame_pointer = get_register_value(m_pid, reg::rbp);

    auto return_address = read_mem(frame_pointer+8);

    while (dwarf::at_name(curr_func) != "main") {

        curr_func = get_func_from_pc(ret_addr);

        output_frame(curr_func);

        frame_pointer = read_mem(frame_pointer);

        return_address = read_mem(frame_pointer+8);

    }

但是在 ARM 体系结构中，出于性能的考虑，天才的开发者为了节约 R7/R11 寄存器，使其可以作为通用寄存器来使用，因此无法保证保存足够的信息来形成上述调用栈的（即使你向编译器传入了“-fno-omit-frame-pointer”）。比如下面两种情况，ARM 就会不遵循一般意义上的序言（prologue），感兴趣的同学可以具体查看APCS Doc。

函数为叶函数，即在函数体内再没有任何函数调用。

函数体非常小。

Android 中的 unwind

我们知道大部分 Android 手机使用的都是 ARM 体系结构，那在 Android 中需要如何进行 unwind 呢？我们需要分两种情况分别讨论。

1. Debug 版本 unwind

如果是 Debug 版本，我们可以通过“.debug_frame”（有兴趣的同学可以了解一下DWARF）来帮助我们进行 unwind。这种方法十分高效也十分准确，但缺点是调试信息本身很大，甚至会比程序段（.TEXT 段）更大，所以我们是无法在 Release 版本中包含这个信息的。

DWARF 是一种标准调试信息格式。DWARF 最早与 ELF 文件格式一起设计, 但 DWARF 本身是独立的一种对象文件格式。本身 DAWRF 和 ELF 的名字并没有任何意义（侏儒、精灵，是不是像魔兽世界的角色 :)），后续为了方便宣传，才命名为 Debugging With Attributed Record Formats。引自wiki

2. Release 版本 unwind

对于 Release 版本，系统使用了一种类似“.debug_frame”的段落，是更加紧凑的方法，我们可以称之为 unwind table，具体来说在 x86 和 ARM64 平台上是“.eh_frame”和“.eh_frame_hdr”，在 ARM32 平台上为“.ARM.extab”和“.ARM.exidx”。

由于 ARM32 的标准早于 DWARF 的方法，所有 ARM 使用了自己的实现，不过它们的原理十分接近，后续我们只讨论“.eh_frame”，如果你对 ARM32 的实现特别感兴趣，可以参考ARM-Unwinding-Tutorial。

“.eh_frame section”也是遵循 DWARF 的格式的，但 DWARF 本身十分琐碎也十分复杂，这里我们就不深入进去了，只涉及一些比较浅显的内容，你只需要了解 DAWRF 使用了一个被称为 DI（Debugging Information Entry）的数据结构，去表示每个变量、变量类型和函数等在 debug 程序时需要用到的内容。

“.eh_frame”使用了一种很聪明的方法构成了一个非常大的表，表中包含了每个程序段的地址对应的相应寄存器的值以及返回地址等相关信息，下面就是这张表的示例（你可以使用readelf --debug-dump=frames-interp去查看相应的信息，Release 版中会精简一些信息，但所有帮助我们 unwind 的寄存器信息都会被保留）。

“.eh_frame section”至少包含了一个 CFI（Call Frame Information）。每个 CFI 都包含了两个条目：独立的 CIE（Common Information Entry）和至少一个 FDE（Frame Description Entry）。通常来讲 CFI 都对应一个对象文件，FDE 则描述一个函数。

“.eh_frame_hdr section”包含了一系列属性，除了一些基础的 meta 信息，还包含了一列有序信息（初始地址，指向“.eh_frame”中 FDE 的指针），这些信息按照 function 排序，从而可以使用二分查找加速搜索。

总结

总的来说，unwind 第一个栈帧是最难的，由于 ARM 无法保证会压基址指针寄存器（EBP）进栈，所以我们需要借助一些额外的信息（.eh_frame）来帮助我们得到相应的基址指针寄存器的值。即使如此，生产环境还是会有各种栈破坏，所以还是有许多工作需要做，比如不同的调试器（GDB、LLDB）或者 breakpad 都实现了一些搜索算法去寻找潜在的栈帧，这里我们就不展开讨论了，感兴趣的同学可以查阅相关代码。

扩展阅读

下面给你一些外部链接，你可以阅读 GCC 中实现 unwind 的关键函数，有兴趣的同学可以在调试器中实现自己的 unwinder。

_Unwind_Backtrace

uw_frame_state_for

uw_update_context

uw_update_context_1

专栏学得苦？可能你还需要一份配套学习书单

你好，我是张绍文。专栏已经发布了一段时间，很多同学在学习专栏时问我，想补充一些基础知识可以参考哪些图书。今天我就结合专栏的编排，给你推荐几本我看过并且对我帮助很大的图书。推荐的书单不在于数量，而在于希望尽可能覆盖 Android 开发工程师进阶学习的路径，只有掌握牢固的基础知识，才能在进阶的道路上走得平稳。专栏把进阶的各个主题由点到线串联起来，但这背后必然少不了一些基础的、底层的知识进行支撑，而这些经典的图书涵盖的知识点比较全面，即使遇到问题时放在手边也是很好的参考书。

作为一名 Android 开发工程师，你需要学习一些 Linux 的基础知识，在做优化时可以有更好的思路。

关于 Linux 学习，我推荐：

性能之巅

最强 Android 书：架构大剖析

戳此购买

极客时间专栏：Linux 性能优化实战

如果想更好地学习虚拟机以及 Hook 相关的知识，你需要对 C++ 以及编译原理有一定的了解。

关于虚拟机，我推荐：

程序员的自我修养——链接、装载与库

垃圾回收算法手册

戳此购买

关于编程语言，我推荐：

More Effective C++

戳此购买

Effective Java 中文版（第 3 版）

戳此购买

其他的知识，例如网络、数据库的一些细分领域，我推荐：

Web 性能权威指南

戳此购买

UNIX 网络编程 卷 1：套接字联网 API（第 3 版）

戳此购买

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

专栏学得苦？可能是方法没找对

各位同学，我是《Android 开发高手课》的编辑 Shawn，很高兴可以和你分享专栏里同学们自己的故事。小说家马塞尔·普鲁斯特说过：“真正的发现之旅，不在于寻找新的风景，而在于有新的视角”。专栏便是给了你一个“高手”的视角，让你重新审视自己工作中处理问题的方法、掌握新的技能，进而让自己也成为真正的高手。这个过程一定是艰辛的，但只要坚持下去，一定能大有收获。

我是 Kenny，来自广州，加入到《Android 开发高手课》，是希望自己可以深入学习一些 Android 的高级知识。

学习的过程总是枯燥的，我的方法是保持兴趣和新鲜感。你可以把专栏的课程当作是一本武功秘籍，每一期就相当于一种招式，这样就会很有期待。我基本会在专栏更新第一时间就学习一遍，然后再结合 Google 查找跟专栏相关的知识点，把它们融会贯通。另外，专栏里提供的练习我也会先自己思考，然后再尝试去写 demo 验证，最后跟老师给的 Sample 进行对照，对比分析我和老师给的方案的优劣。

如果你正处学习的迷茫期，我建议学习前先带着问题，比如专栏更新后，先想想自己在工作中是否遇到过和这期主题相关的技术问题。然后就是兴趣了，对技术始终保持兴趣，学习的动力也会更充沛，在学习过程中也会产生更多有价值的思考。

我目前学习最大的收获是思考问题、处理问题的思维方式的变化，在对待“黑科技”也会更慎重了。最主要的是思维得到了发散，解决问题时也会多考虑如何举一反三。

我的 2019 年“小目标”：目前在从事产品性能优化的工作，期待 2019 能把产品做成竞品间各项性能指标第一！

我是 ZYW，目前在从事移动端开发，从 2008 年开始到今天已经 10 个年头了。

我主要是在更新当天晚上看专栏的内容，看完后再做练习，遇到不会的内容会去搜索相关的知识点。

在当下，不论是工作还是学习，焦虑肯定是有的。坚持，学习从来就不是一蹴而就的事情。我毕业 10 年了，IT 编码也 10 年了，现在还在坚持。IT 技术更新很快，一定要在合适的时间做合适的事情，不要怕困难，更不要放弃。

我的 2019 年“小目标”：好好的工作，好好照顾家人，能在 Android 的道路一直走下去，坚持。

我是 Owen，工作 3 年做过 OA 和手机的项目，目前在深圳一家上市公司负责海外工具类开发。订阅这个专栏主要是听过江湖上绍文老师的“传说”，再加上我对讲的内容也比较感兴趣。

一般专栏更新之后，我会选择第一时间听一遍音频，或是在上下班的地铁上，或是在睡觉前。我会利用零碎时间反复听音频，把整片的时间用来反复阅读课程文章，然后根据文章的 Sample 自己亲自实践。因为在实践的过程中会有很多坑要爬，需要自己补充老师提到的知识点，专栏的篇幅也有限，有些知识点就需要自己去查找资料来学习。所以一篇文章我会反复阅读，反复查看相关知识点，反复实践。遇到不懂的就问绍文老师、同学和同事，然后形成自己的理解和总结，争取自己也能写一点总结分享出来。

坚持学习的过程，我感觉自己拓宽了知识面，对知识点的体会也加深了。移动开发领域大佬、高手如云，必须孜孜不倦地学习和交流，才能做到真正的理解并且达到一定的高度。

这个专栏整体来说适合爬坡进阶，如果坚持学完会有很多收获。有些知识点我平时没太注意，或者有些知识点平时干脆都没听过，学完给我的感觉我们并不是简单的应用开发，还有太多可以精深的东西。

大家好，我是 Seven，来自四川成都。现在在某家医疗公司担任 Android 开发，主要做的是摄像头开发、实时分析相机预览数据这块，之前还做过一年的视频点播和直播的底层开发。在这两年的工作中，一直想把自己的半吊子基础给打实。

说来也巧，曾经的项目中用到了 Tinker，也就顺理成章进入了 Tinker 交流群，也在群内交流了一段时间。某一天，绍文老师在群里推荐了他的极客时间专栏；与此同时，众多著名 Android 大佬和公众号也争相推广，我已经站不住了，技术的浪潮已经向我扑来，我非接不可，同时也希望自己能够得到一次深度成长的机会，所以我选择了订阅，这也是对自己负责吧。

刚开始时我是很用心的每天都听，然后课后 Sample 也在认真做，但当我发现 Sample 搞不定的时候，我就改变了学习态度：专栏更新，我也及时阅读，Sample 做不出来就放在那里，反正保持同步阅读专栏，有时也在专栏里留些无关痛痒的话，以为这样就是学习。直到某一天，专栏出了一篇文章《让 Sample 跑起来 | 热点问题答疑第 1 期》，我才恍然大悟，自己当前的学习状态不对，不应该这样学习，应该一丝不苟，脚踏实地才对。那时的我只是看上去在学习而已，实际根本没有任何收获。于是我改正了自己的态度，重新打开第 1 期重新学习，认真阅读并理解文中的每一个字，遇到不懂的地方就看文中给的链接或者自己查资料慢慢摸索。后来发现这样学习虽然慢，但是我心里很踏实，而且学到的东西其实不止是专栏中的内容，因为在查资料的时候，总会查到别的东西，可以顺带学习，并且做了笔记。我现在的学习状态大概就是这样，我还是很满意自己做出的改变。

专栏从开始看到现在这么久了，最主要提升的是我的自觉性，看到不懂的知识点我并不会害怕，而是想着要怎么去弄懂它，也不会消极的对待所谓“高深”的知识了。现在遇到一个问题，我脑袋里面想的是要去解决它，先试着自己去解决，解决不了的话说明知识不到位，需要深入学习。

如果你觉得专栏里的知识很难，也不要消极对待，很多大的知识点都是由许多小知识点组成的，只要分而治之，就可以很快建立自信。并且这些小的知识点排列组合会产生无数种知识，所以这里也建议同学们认真对待基础知识，比如专栏里提到的 Linux 有关的知识，这些都是保质期很长的知识，希望我们可以一起坚持下来。

我的 2019 年“小目标”：2018 年已经过去，希望 2019 年的自己，能够对移动开发的基础知识有更深刻的理解，希望自己能够在 Android 源码分析研究这一块大有长进，一起加油吧！

我是小洁，是一名工作接近 4 年的 Android 开发，坐标广东东莞，公司是做儿童电话手表的，我在团队中负责性能相关优化以及组件化优化方面工作。由于目前工作的任务专栏有所涉及，加上身边同事的推荐，所以想从专栏中借鉴学习老师的各种经验。

由于工作原因，我一般的学习时间主要是在晚上。我会根据自己对当前内容是否感兴趣，以及是否感觉重要而去学习并完成课后的练习。通过专栏我收获了一些老师的经验之谈和对某些方案的思考，能力也有所提升吧。

我认为学习还是靠个人毅力，每当学习感到枯燥或者遇到障碍时，可以沉下来问问自己开始学习的目的，重新调整自己再一步步看下去，终会有豁然开朗的时候。

我的 2019 年“小目标”：希望自己能实现一个性能监控分析。

我是希夷，我订阅专栏的目的是想提高自己的眼界和能力。

我主要是利用空闲的时间学习专栏，并且会做些笔记，但专栏的作业和练习我坚持得不够（捂脸）。我觉得学习专栏时感觉难是正常的，如果不觉得难就说明这个专栏对你提升有限。我认为还是贵在坚持，一遍不懂就多看几遍、多练几遍，勤能补拙，我们学习都会有这个过程，也需要我们给自己打打气。

同样我觉得 Android 开发，基础真的很重要，我感觉自己有很多基础的东西要补。通过这个专栏，我看到了大公司在移动开发这块使用的比较新的技术，确实拓展了自己的视野。

我的 2019 年“小目标”：希望把 Kotlin 和 Flutter 用到项目中，把从专栏中学到的东西落地到项目实践上，也希望自己有更高的收入。

我是志伟，从 12 年开始在芯片企业从事 Android ROM 开发，15 年开始在移动互联网企业做 Android 开发。我希望可以从专栏里更加全面地了解 Android 开发的技术，领略绍文面对亿级 App 各种技术问题的思考方式和处理方法。

专栏更新当天晚上我都会认真学习，通过文章里附带的外部资料、自己查询网上其他资料和专栏提供的 Sample 进行练习，从中提炼自己需要的知识。我还会结合自己的实际工作、以前的经历，推敲一下自己在面对专栏里的问题时会如何思考，我会怎么去做。

专栏涉及技术面广，又有深度，花时间深入钻研，肯定有很大的收获。比如我自己，通过学习扩展了自己的知识面，对一些技术的理解也更加深入，很多问题也有了解决方案。专栏定位 “高手”，肯定有觉得难或者不懂的知识点，这就更需要坚持，因为这正是成长的机会。另外，还可以直接在专栏留言区跟作者进行交流，从中我也获益良多。

绍文经历过多个 App 日活过亿的成长，期间面临的各种技术难题也是其他 App 共同存在的，但我们不是每个人都有这样的机会去经历。通过专栏，可以以高度“浓缩”的方式经历一遍，这也是十分宝贵的收获。

我的 2019 年“小目标”：朝架构师的方向“一路走到黑”。

听完身边同学的故事，你有没有想和其他同学分享的故事呢？欢迎你在留言区也写写自己学习专栏的方法和体会，我们会在下一期答疑文章发布时，选出留言点赞数最高的 3 位，送出“学习加油礼包”，期待你的分享。

程序员修炼之路 | 设计能力的提升途径

你好，我是张绍文，今天我要和你分享我的朋友长元的一篇文章，主题是设计能力的提升途径。专栏已经进入架构演进模块，由于每个人对架构的理解都不同，在工作中也会遇到各种各样的架构设计问题，很多时候我们的架构设计能力都是靠不断的理论学习和在设计实践中不断摸索提高的，因此在成为设计高手的道路上，我们肯定或多或少有些自己的经验和体会，当然也少不了踩坑。今天长元分享的设计能力提升路径，希望可以把他的经验分享给你，你可以参考他的提升路径来强化自己的设计能力，在高手的修炼之路上少走弯路。

每当我做完一次内部设计培训以后，经常有同学来问我：如何才能快速提升自己的设计能力？我觉得这个问题非常有代表性，代表了一大波程序员在艰辛修炼路上的心声。今天我就来分享一下我所理解的程序员设计能力的提升路径，也欢迎你留言写写你的思考与体会。

1. 编码历练

代码行经验是个非常重要的东西，当你还没有 1 万行代码经验的时候，如果你来问我如何提升设计能力这个问题，我只能告诉你不要太纠结，看看理论就好，老老实实先写代码吧。

一个程序员平均每天码代码的速度是 200～300 行。你可能会说，我一天怎么也要写上 1000 行吧？别忘了，当你码完代码后，你还需要测试、调试、优化、Bug Fix，这些时间你没法一直码代码的。

编码规范就不多说了，如果你的代码还是杂乱无章的状态，就先别谈什么设计与架构了，先把基础的工作做好再谈其他的吧。

另外，作为“代码洁癖患者”，推荐你不要在写完代码后，再做批量格式化处理，或者手工再去整理代码，而是应该每敲一个字符，都是符合规范的。习惯真的很重要，有时在招聘面试的时候，我真想添加一个环节，现场编写程序完成一个简单但容易出错的任务，考察一下你的代码基本功。

2. 理论学习

简单说就是看书、看博客，学习你能得到的所有资源，但前提是内容质量要高。例如图书，我推荐：《重构 改善既有代码的设计》《敏捷软件开发：原则、模式与实践》《UML 和模式应用》《设计模式》等，其他你还需要学习面向对象设计原则（五大原则）。

《设计模式》是本很古老的书了，只有短短 200 页，但是可能这是最难看懂的一本书了，可能一个月都看不完（看小说的话，200 页 3 个小时也许就看完了吧）。而且就算看完了，也不会全看懂，很可能看懂的内容不超过 30%。我想说的是，看不懂没关系，认真看了就行，不用太纠结，因为这不能说明什么问题。

另外，我想说一下，多线程技术是程序员必须掌握的，而且需要理解透彻。现在的高级技术例如 GCD，会掩盖你对多线程理解不足的问题，因为使用起来实在太简单了。另外，别说你没写过多线程依然完成了复杂的项目，更别说你随手写出的多线程代码好像也没出什么问题啊，你可以试试把你的代码给技术好的同事看看，分分钟写个 Demo 让它出错乃至崩溃。

3. 实践

现在，你已经具备了一定的编码经验，而且已经学习了足够的理论知识，接下来就是真正练手的时候了。好好反复思考你学习的这些理论知识，要如何运用到项目中去，通过身体力行的实践，一定要把那些理论搞清楚，用于指导你的实践。在实践的过程中，你要收起从前的自信，首先否定自己以前的做法，保证每次做出的东西相比以前是有进步、有改进的。

4. 重温理论

你已经能看到自己的进步了，发现比以前做得更好了，但是总感觉还不够，好像有瓶颈似的，恭喜你，已经可以看到你未来的潜力了。

重新拿起书本，重温一遍之前看的那些似懂非懂的东西，你会发现之前没弄懂的内容，现在豁然开朗了，不再有那种难于理解的晦涩感了。而且就算是以前你觉得自己已经理解的内容，再看一遍的话，通常也会有新的收获。

5. 再实践

这个阶段，你已经掌握了较多的知识，不但实践经验丰富，各种理论也能手到擒来了。但是，你发现你的设计依然不够专业，而且回过头去看以前写的代码，你会惊讶：天啊，这是谁写的代码，怎么能这样干！然后，就不多说了…此时，你已经进入了自省的阶段，掌握了适合自己的学习方法，之后再学习什么新东西，都不会再难住你了。

6. 总结

先别太得意（不信？那你去给团队分享一次讲座试试），你还需要总结，总结自己的学习方法、总结项目经验、总结设计理论的知识。

如果你能有自己独到的理解，而不是停留在只会使用成熟的设计模式什么的，能根据自己的经验教训总结出一些设计原则，那自然是极好的。

7. 分享

分享是最好的学习催化剂，当你要准备一次培训分享的时候，你会发现先前以为已经理解的东西其实并没有完全理解透彻，因为你无法把它讲清楚，实际上还是研究得不够透彻。这时会迫使你再重新深入学习，做到融汇贯通，然后你才敢走上讲台。否则，当别人提问的时候，你根本回答不上来。

以上，便是我认为的程序员修炼道路的必经阶段。接下来，我再分享几点其他对设计能力提升非常重要的方法。

养成先设计，再编码的习惯。

几乎所有的程序员，一开始都不太愿意写文档，也不太愿意去精心设计，拿到需求总是忍不住那双躁动的手，总觉得敲在键盘上，把一行一行的代码飙出来，才有成就感，才是正确的工作姿势。

我的建议是，没讨论清楚不要编码，不然你一定会返工。

设计重于编码，接口重于实现。

制定接口的过程，本身就是设计过程，接口一定要反复推敲，尽量做减法而不是加法，在能满足需求的情况下越简单越好。

另外，不要一个人冥思苦想。可以先简单做一个雏形出来，然后去找使用方沟通，直到对方满意为止。不要完全根据使用需求去设计接口，参考 MVVM，ViewModel 就是根据 View 的需要而对 Model 进行的再封装，不能将这些接口直接设计到 Model 中。

不盲从设计模式。

设计模式只是一种解决问题的套路方法，你也可以有自己的方法，当然设计模式如果用好了，会让你的设计显得专业、优雅，毕竟前辈们的心血结晶是非常有价值的。但是如果滥用的话，也会导致更严重的问题，甚至可能成为灾难。我觉得面向对象设计原则更加重要，有些原则是必须遵守的（如单向依赖、SRP 等），而设计模式本身都是遵守这些原则的，有些模式就是为了遵循某原则而设计出来的。

抽象不是万能的，在适当的地方使用，需要仔细推敲。当有更好的方案不用抽象就能解决问题时，尽量避免抽象。我见过太多抽象过火、过度设计的案例了，增加了太多维护成本，还不如按照最自然的方式去写。

空杯心态，向身边的同学学习，站在巨人的肩上，站在别人的肩上。

有人提意见，先收下它（无论接受与否）。

很多程序员都有个“毛病”，就是觉得自己技术牛的不行，不愿意接受别人的意见，尤其是否定意见（文人相轻）。 但是无论是理论的学习，还是编码实践，向身边的同学学习是对自己影响最大的（三人行，必有我师）。

我自己就经常在跟团队同学讨论中获益，当百思不得其解的时候，把问题抛出来讨论一下，通常都能得到一个最佳方案。

另外，跟团队其他人讨论还有一个好处，就是当你的设计有妥协或有些不专业的时候，别人看到代码也不会产生质疑，因为他也参与了讨论，你不用花那么多时间去做解释。

设计期间一定要找其他人一起讨论，我一直比较反对一个人把设计做完、把文档写完，然后才找大家开个评审会那种模式，虽然也有效果，但是效果达不到极致。因为大家没有参与到设计中，通过一次会议的时间理解不一定有那么深，最关键的是，如果在会上发现设计有些问题，但不是致命问题的时候，通常并不会打回重新设计。

相反，如果前期讨论足够，大家都知道你的思路与方案，而且最后也有设计文档，当其他人阅读你的代码的时候，根本无需你再指引，这样今后在工作交接时都会很顺利，何乐而不为呢？

最后，我想呼吁一下，当你去修改维护别人的代码时，最好找模块负责人深入讨论沟通一下，让他明白你的需求以及你的方案，请他帮忙评估方案是否可行，是否会踩坑、埋坑等。如果你恰好是模块的负责人，请行使你的权力，拒绝有问题的不符合要求的代码提交入库。

欢迎你点击“请朋友读”，把今天的内容分享给好友，邀请他一起学习。

聊聊Framework的学习方法

大家好，我是陆晓明，现在在一家互联网手机公司担任 Android 系统开发工程师。很高兴可以在极客时间 Android 开发高手课专栏里，分享一些我在手机行业 9 年的经验以及学习 Android 的方法。

今天我要跟你分享的是 Framework 的学习和调试的方法。

首先，Android 是一种基于 Linux 的开放源代码软件栈，为广泛的设备和机型而创建。下图是 Android 平台的主要组件。

从图中你可以看到主要有以下几部分组成：

Linux 内核

Android Runtime

原生 C/C++ 库

Java API 框架（后面我称之为 Framework 框架层）

系统应用

我们在各个应用市场看到的，大多是第三方应用，也就是安装在 data 区域的应用，它们可以卸载，并且权限也受到一些限制，比如不能直接设置时间日期，需要调用到系统应用设置里面再进行操作。

而我们在应用开发过程中使用的四大组件，便是在 Framework 框架层进行实现，应用通过约定俗成的规则，在 AndroidMainfest.xml 中进行配置，然后继承对应的基类进行复写。系统在启动过程中解析 AndroidMainfest.xml，将应用的信息存储下来，随后根据用户的操作，或者系统的广播触发，启动对应的应用。

那么，我们先来看看 Framework 框架层都有哪些东西。

Framework 框架层是应用开发过程中，调用的系统方法的内部实现，比如我们使用的 TextView、Button 控件，都是在这里实现的。再举几个例子，我们调用 ActivityManager 的 getRunningAppProcesses 方法查看当前运行的进程列表，还有我们使用 NotificationManager 的 notify 发送一个系统通知。

让我们来看看 Framework 相关的代码路径。

如何快速地学习、梳理 Framework 知识体系呢？常见的学习方法有下面几种：

阅读书籍（方便梳理知识体系，但对于解决问题只能提供方向）。

直接阅读源码（效率低，挑战难度大）。

打 Log 和打堆栈 （效率有所提升，但需要反复编译，添加 Log 和堆栈代码）。

直接联调，实时便捷（需要调试版本）。

首先可以通过购买相关的书籍进行学习，其中主要的知识体系有 Linux 操作系统，比如进程、线程、进程间通信、虚拟内存，建立起自己的软件架构。在此基础上学习 Android 的启动过程、服务进程 SystemServer 的创建、各个服务线程（AMS/PMS 等）的创建过程，以及 Launcher 的启动过程。熟悉了这些之后，你还要了解 ART 虚拟机的主要工作原理，以及 init 和 Zygote 的主要工作原理。之后随着在工作和实践过程中你会发现，Framework 主要是围绕应用启动、显示、广播消息、按键传递、添加服务等开展，这些代码的实现主要使用的是 Java 和 C++ 这两种语言。

通过书籍或者网络资料学习一段时间后，你会发现很多问题都没有现成的解决方案，而此时就需要我们深入源码中进行挖掘和学习。但是除了阅读官方文档外，别忘了调试 Framework 也是一把利刃，可以让你游刃有余快速定位和分析源码。

下面我们来看看调试 Framework 的 Java 部分，关于 C++ 的部分，需要使用 GDB 进行调试，你可以在课下实践一下，调试的过程可以参考《深入 Android 源码系列（一）》。

我们这里使用 Android Studio 进行调试，在调试前我们要先掌握一些知识。Java 代码的调试，主要依据两个因素，一个是你要调试的进程；一个是调试的类对应的包名路径，同时还要保证你所运行的手机环境和你要调试的代码是匹配的。只要这两个信息匹配，编译不通过也是可以进行调试的。

我们调试的系统服务是在 SystemServer 进程中，可以使用下面的命令验证（我这里使用 Genymotion 上安装 Android 对应版本镜像的环境演示）。

ps -A |grep system_server  查看系统服务进程 pid

cat /proc/pid/maps |grep services 通过 cat 查看此进程的内存映射，看看是否 services 映射到内存里面。

这里我们看到信息：/system/framework/oat/x86/services.odex 。

odex 是 Android 系统对于 dex 的进一步优化，目的是为了提升执行效率。从这个信息便可以确定，我们的 services.jar 确实是跑到这里了，也就是我们的系统服务相关联的代码，可以通过调试 SystemServer 进程进行跟踪。

下来我们来建立调试环境。

打开 Genymotion，选择下载好 Android 9.0 的镜像文件，启动模拟器。

找到模拟器对应的 ActivityManagerService.java 代码。 我是从http://androidxref.com/下载 Android 9.0 对应的代码。

打开 Android Studio，File -> New -> New Project 然后直接 Next 直到完成就行。

新建一个包名，从 ActivityManagerService.java 文件中找到它，这里为com.android.server.am，然后把 ActivityManagerService.java 放到里面即可。

在 ActivityManagerService.java 的 startActivity 方法上面设置断点，然后找到菜单的 Run -> Attach debugger to Android process 勾选 Show all process，选中 SystemServer 进程确定。

这时候我们点击 Genymotion 模拟器中桌面的一个图标，启动新的界面。

会发现这时候我们设定的断点已经生效。

你可以看到断下来的堆栈信息，以及一些变量值，然后我们可以一步步调试下去，跟踪启动的流程。

对于学习系统服务线程来讲，通过调试可以快速掌握流程，再结合阅读源码，便可以快速学习，掌握系统框架的整个逻辑，从而节省学习的时间成本。

以上我们验证了系统服务 AMS 服务代码的调试，其他服务调试方法也是一样，具体的线程信息，可以使用下面的命令查看。

ps -T 353

这里 353 是使用 ps -A |grep SystemServer 查出 SystemServer 的进程号

在上面图中，PID = TID 的只有第一行这一行，如果 PID = TID 的话，也就是这个线程是主线程。下面是我们平时使用 Logcat 查看输出的信息。

03-10 09:33:01.804   240   240 I hostapd : type=1400 audit(0.0:1123): avc: de

03-10 09:33:37.320   353  1213 D WificondControl: Scan result ready event

03-10 09:34:00.045   404   491 D hwcomposer: hw_composer sent 6 syncs in 60s

这里我还框了一个 ActivityManager 的线程，这个是线程的名称，通过查看这行的 TID（368）就知道下面的 Log 就是这个线程输出的。

03-10 08:47:33.574   353   368 I ActivityManager: Force stopping com.android.providers

学习完上面的知识，相信你应该学会了系统服务的调试。通过调试分析，我们便可以将系统服务框架进行庖丁解牛般的学习，面对大量庞杂的代码掌握起来也可以轻松一些。

我们回过头来，再次在终端中输入ps -A，看看下面这一段信息。

你可以看到这里的第一列，代表的是当前的用户，这里有 system root 和 u0_axx，不同的用户有不同的权限。我们当前关注的是第二列和第三列，第二列代表的是 PID，也就是进程 ID；第三列代表的是 PPID，也就是父进程 ID。

你发现我这里框住的都是同一个父进程，那么我们来找下这个 323 进程，看看它到底是谁。

root 323 1 1089040 127540 poll_schedule_timeout f16fcbc9 S zygote

这个名字在学习 Android 系统的时候，总被反复提及，因为它是我们 Android 世界的孵化器，每一个上层应用的创建，都是通过 Zygote 调用 fork 创建的子进程，而子进程可以快速继承父进程已经加载的资源库，这里主要指的是应用所需的 JAR 包，比如 /system/framework/framework.jar，因为我们应用所需的基础控件都在这里，像 View、TextView、ImageView。

接下来我来讲解下一个调试，也就是对 TextView 的调试（其他 Button 调试方式一样）。如前面所说，这个代码被编译到 /system/framework/framework.jar，那么我们通过 ps 命令和 cat /proc/pid/maps 命令在 Zygote 中找到它，同时它能够被每一个由 Zygote 创建的子进程找到，比如我们当前要调试 Gallery 的主界面 TextView。

我们验证下，使用ps -A |grep gallery3d查到 Gallery 对应的进程 PID，使用 cat /proc/pid/maps |grep framework.jar 看到如下信息：

efcd5000-efcd6000 r--s 00000000 08:06 684                                /system/framework/framework.jar

这说明我们要调试的应用进程在内存映射中确实存在，那么我们就需要在 gallery3d 进程中下断点了。

下来我们建立调试环境：

打开 Genymotion，选择下载好 Android 9.0 的镜像文件，启动模拟器，然后在桌面上启动 Gallery 图库应用。

找到模拟器对应的 TextView.java 代码。

打开 Android Studio，File -> New -> New Project 然后直接 Next 直到完成就行。

新建一个包名，从 TextView.java 文件中找到它的包名，这里为 android.widget，然后把 TextView.java 放到里面即可。

在 TextView.java 的 onDraw 方法上面设置断点，然后找到菜单的 Run -> Attach debugger to Android process 勾选 Show all process，选中 com.android.gallery3d 进程（我们已知这个主界面有 TextView 控件）确定。

然后我们点击下这个界面左上角的菜单，随便选择一个点击，发现断点已生效，具体如下图所示。

然后我们可以使用界面上的调试按钮（或者快捷键）进行调试代码。

今天我讲解了如何调试 Framework 中的系统服务进程的 AMS 服务线程，其他 PMS、WMS 的调试方法跟 AMS 一样。并且我也讲解了如何调试一个应用里面的 TextView 控件，其他的比如 Button、ImageView 调试方法跟 TextView 也是一样的。

通过今天的学习，我希望能够给你一个学习系统框架最便捷的路径。在解决系统问题的时候，你可以方便的使用调试分析，从而快速定位、修复问题。

结束语 | 移动开发的今天和明天

不知不觉，“Android 开发高手课”上线更新到现在，已经陪伴了大家 140 多天，转眼间到了不得不说“再见”的时候。回想半年前开始筹备这个专栏时，当时焦虑和浮躁是移动开发者比较普遍的心态，而作为移动开发者的一份子，希望通过这个专栏可以分享一些我的经验，帮你克服焦虑，拥抱未来无限可能。

我的专栏希望通过质量、效率以及架构三个角度来说明一个移动开发高手应该具备的特质。“高质量开发”模块，希望让你知道 Android 开发并不是那么简单，一个 Android 高手需要具备从上层到底层“通关的”能力。Flutter就是一个非常好的例子，这里建议你回顾一下《想成为 Android 高手，你需要搞定这三个问题》。

稳定性、内存、网络、渲染、I/O…你可以选择其中一两个点深入往底层走走，打造自身的技术壁垒，当团队内部如果遇到相关问题（比如虚拟机、渲染、数据库）的时候，第一时间就会想到你来解决。

而“高效开发”模块希望告诉你一个开发高手不仅仅有“深度”，还需要有“广度”。移动开发早已经过了“单兵作战”的年代了，服务化、前后端一体化越来越重要，这里也推荐你回顾一下《做一名有高度的移动开发工程师》。

这里要求我们学会跳出客户端的范畴，具备全链路优化的思维。一个真正的开发高手应该不仅能把性能优化多少倍，而且还能带动整个团队提升开发效率。

最后一个模块是“架构演进”，我希望告诉你架构是为性能和效率服务的。无论是 H5、React Native/Weex、小程序、Flutter，还是类似游戏、音视频、AI 这些移动开发“新大陆”，它们都是殊途同归。

对于移动开发的今日，在学完专栏以后，我希望你能体会到下面这两点：

移动开发并不简单。想成为一名移动开发高手并不简单，它需要非常深入的学习。当然学完专栏几十篇文章，你也不可能立刻就成为了一名高手，俗话说“师傅领进门，修行靠个人”，在专栏以外你还需要进一步深入学习更多的知识，积累更丰富的经验。

移动开发生命力依然顽强。移动开发很有前途，而且这个领域不仅局限于 Android 开发。我们的出路其实有很多很多，只要我们肯拿出折腾 Android 的热情，用从上到下“通关”的钻研精神，无论是转向 iOS，还是游戏、前端等其他的领域，我相信绝对都是“降维打击”。

但是不可否认，如今的移动开发已经不再是“风口上的猪”，对于初级移动开发者的岗位越来越少，大厂的要求也随之变得越来越严格。类似微信、头条、阿里，他们都对候选人在算法和底层基础知识考究得非常深入。

但是我相信只要你认真学习完这个专栏，对面试大厂会有非常大的帮助。专栏里提到的很多知识点，目前其实很多大厂也还做得不够完善。正如前面所说，你可以找其中一两个点作为进入大厂的突破口。

而对于 Android 的未来，个人认为会有下面两个变化：

合规和用户隐私越发重要。无论被称为史上最严厉的欧盟 GDPR 法令，还是 Android Q 用户唯一标识、权限等最新限制，都可以清楚看到未来对用户隐私和权限更严格限制的趋势。

中国的声音会越来越大。以往我们总说 Copy to China，现在更多的是 Copy from China。无论是 Google 还是苹果，都无法忽视我们 13 亿人的巨大市场。比如说 Android Q 就为了中国的热修复框架，专门提供新的接口。

对于移动开发的未来，可能未来的新设备将颠覆手机，也许是嵌入式设备、智能眼镜、车载设备等，但这些设备仍然还是移动设备。从当下的技术发展来看，我很期待今年的 Facebook F8和 Google I/O 大会（我今年也会去现场观摩，欢迎面基～）。比如说 Facebook 今年计划开源很多好东西，例如 React Native 新版本、启动优化测量等。

“三星凉了，宝洁药丸，诺基亚衰落，苹果下凡，国外品牌都挺惨…”，但现实情况却是三星依然在全球称霸。在我看来，“Android 开发要完，移动开发要完”也是这个道理。我们不应该焦虑，也不需要浮躁，没有不过时的技术，不过时的只有学习能力和态度。

“唯有学习，不可辜负”。Android 开发的路很长也很宽，我们既可以往底层走，也能往大中台走，还可以去看看 IoT、游戏、AI。未来的机会还有很多很多，只要我们找准方向，积极拥抱变化、迎接挑战，每个人都可以走出一条自己的“光明大道”。

不过在工作学习之余，我也奉劝各位同学一定要锻炼好身体，无论什么时候身体都是第一位。我们的职业生涯非常漫长，脑力、体力、心力三者缺一不可。

非常感谢订阅专栏和我一路走来的同学们，因为你们的鼓励让我充满激情，驱使我呈现出最好的内容。最后祝愿你们未来无论是从事哪方面的工作，都能成为一名真正的高手。

到了说再见的时候了，各位潜水的同学都来冒个泡吧，我希望听听你这几个月学习的心得和收获，自己在工作和生活有什么变化，2019 年的 Flag 完成得如何了？我们可以在专栏里畅所欲言、把酒言欢。