This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<audio id="audio" title="01 | 崩溃优化(上):关于“崩溃”那些事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/59/f2aa5994e64d237331a822748d20d559.mp3"></audio>
在各种场合遇到其他产品的开发人员时,大家总忍不住想在技术上切磋两招。第一句问的通常都是“你们产品的崩溃率是多少?”
程序员A自豪地说 “百分之一。”
旁边的程序员B鄙视地看了一眼然后喊到 “千分之一!”
“万分之一” 程序员C说完之后全场变得安静起来。
崩溃率是衡量一个应用质量高低的基本指标,这一点是你我都比较认可的。不过你说的“万分之一”就一定要比我说的“百分之一” 更好吗?我觉得,这个问题其实并不仅仅是比较两个数值这么简单。
今天我们就来聊一聊有关“崩溃”的那些事我会从Android的两种崩溃类型谈起再和你进一步讨论到底该怎样客观地衡量崩溃这个指标以及又该如何看待和崩溃相关的稳定性。
## Android 的两种崩溃
我们都知道Android崩溃分为Java崩溃和Native崩溃。
简单来说,**Java崩溃就是在Java代码中出现了未捕获异常导致程序异常退出**。那Native崩溃又是怎么产生的呢**一般都是因为在Native代码中访问非法地址也可能是地址对齐出现了问题或者发生了程序主动abort这些都会产生相应的signal信号导致程序异常退出**。
所以“崩溃”就是程序出现异常而一个产品的崩溃率跟我们如何捕获、处理这些异常有比较大的关系。Java崩溃的捕获比较简单但是很多同学对于如何捕获Native崩溃还是一知半解下面我就重点介绍Native崩溃的捕获流程和难点。
1.Native崩溃的捕获流程
如果你对Native崩溃机制的一些基本知识还不是很熟悉建议你阅读一下[《Android平台Native代码的崩溃捕获机制及实现》](http://mp.weixin.qq.com/s/g-WzYF3wWAljok1XjPoo7w)。这里我着重给你讲讲一个完整的Native崩溃从捕获到解析要经历哪些流程。
<li>
编译端。编译C/C++代码时,需要将带符号信息的文件保留下来。
</li>
<li>
客户端。捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。
</li>
<li>
服务端。读取客户端上报的日志文件寻找适合的符号文件生成可读的C/C++调用栈。
</li>
<img src="https://static001.geekbang.org/resource/image/95/11/95d9733860e3a52c6c3b5976ca25b711.jpg" alt="">
2.Native崩溃捕获的难点
Chromium的[Breakpad](http://chromium.googlesource.com/breakpad/breakpad/+/master)是目前Native崩溃捕获中最成熟的方案但很多人都觉得Breakpad过于复杂。其实我认为Native崩溃捕获这个事情本来就不容易跟当初设计Tinker的时候一样如果只想在90%的情况可靠那大部分的代码的确可以砍掉但如果想达到99%,在各种恶劣条件下依然可靠,后面付出的努力会远远高于前期。
所以在上面的三个流程中,**最核心的是怎么样保证客户端在各种极端情况下依然可以生成崩溃日志**。因为在崩溃时,程序会处于一个不安全的状态,如果处理不当,非常容易发生二次崩溃。
那么,生成崩溃日志时会有哪些比较棘手的情况呢?
**情况一:文件句柄泄漏,导致创建日志文件失败,怎么办?**
应对方式我们需要提前申请文件句柄fd预留防止出现这种情况。
**情况二:因为栈溢出了,导致日志生成失败,怎么办?**
应对方式为了防止栈溢出导致进程没有空间创建调用栈执行处理函数我们通常会使用常见的signalstack。在一些特殊情况我们可能还需要直接替换当前栈所以这里也需要在堆中预留部分空间。
**情况三:整个堆的内存都耗尽了,导致日志生成失败,怎么办?**
应对方式这个时候我们无法安全地分配内存也不敢使用stl或者libc的函数因为它们内部实现会分配堆内存。这个时候如果继续分配内存会导致出现堆破坏或者二次崩溃的情况。Breakpad做的比较彻底重新封装了[Linux Syscall Support](https://chromium.googlesource.com/linux-syscall-support/)来避免直接调用libc。
**情况四:堆破坏或二次崩溃导致日志生成失败,怎么办?**
应对方式Breakpad会从原进程fork出子进程去收集崩溃现场此外涉及与Java相关的一般也会用子进程去操作。这样即使出现二次崩溃只是这部分的信息丢失我们的父进程后面还可以继续获取其他的信息。在一些特殊的情况我们还可能需要从子进程fork出孙进程。
当然Breakpad也存在着一些问题例如生成的minidump文件是二进制格式的包含了太多不重要的信息导致文件很容易达到几MB。但是minidump也不是毫无用处它有一些比较高级的特性比如[使用gdb调试](https://www.chromium.org/chromium-os/packages/crash-reporting/debugging-a-minidump)、可以看到传入参数等。Chromium未来计划使用Crashpad全面替代Breakpad但目前来说还是 “too early to mobile”。
我们有时候想遵循Android的文本格式并且添加更多我们认为重要的信息这个时候就要去改造Breakpad的实现。**比较常见的例如增加Logcat信息、Java调用栈信息以及崩溃时的其他一些有用信息在下一节我们会有更加详细的介绍。**
如果想彻底弄清楚Native崩溃捕获需要我们对虚拟机运行、汇编这些内功有一定造诣。做一个高可用的崩溃收集SDK真的不是那么容易它需要经过多年的技术积累要考虑的细节也非常多每一个失败路径或者二次崩溃场景都要有应对措施或备用方案。
3.选择合适的崩溃服务
对于很多中小型公司来说,我并不建议自己去实现一套如此复杂的系统,可以选择一些第三方的服务。目前各种平台也是百花齐放,包括腾讯的[Bugly](https://bugly.qq.com/v2/)、阿里的[啄木鸟平台](http://wpk.uc.cn/)、网易云捕、Google的Firebase等等。
当然在平台的选择方面我认为从产品化跟社区维护来说Bugly在国内做的最好从技术深度跟捕获能力来说阿里UC浏览器内核团队打造的啄木鸟平台最佳。
## 如何客观地衡量崩溃
对崩溃有了更多了解以后,我们怎样才能客观地衡量崩溃呢?
要衡量一个指标,首先要统一计算口径。如果想评估崩溃造成的用户影响范围,我们会先去看**UV崩溃率**。
```
UV 崩溃率 = 发生崩溃的 UV / 登录 UV
```
只要用户出现过一次崩溃就会被计算到所以UV崩溃率的高低会跟应用的使用时长有比较大的关系这也是微信UV崩溃率在业界不算低的原因强行甩锅。当然这个时候我们还可以去看应用PV崩溃率、启动崩溃率、重复崩溃率这些指标计算方法都大同小异。
这里为什么要单独统计启动崩溃率呢?因为启动崩溃对用户带来的伤害最大,应用无法启动往往通过热修复也无法拯救。闪屏广告、运营活动,很多应用启动过程异常复杂,又涉及各种资源、配置下发,极其容易出现问题。微信读书、蘑菇街、淘宝、天猫这些“重运营”的应用都有使用一种叫作[“安全模式”](https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&amp;mid=2247488429&amp;idx=1&amp;sn=448b414a0424d06855359b3eb2ba8569&amp;source=41#wechat_redirect)的技术来保障客户端的启动流程,在监控到客户端启动失败后,给用户自救的机会。
现在回到文章开头程序员“华山论剑”的小故事,我来揭秘他们解决崩溃率的“独家秘笈”。
程序员B对所有线程、任务都封装了一层try catch“消化”掉了所有Java崩溃。至于程序是否会出现其他异常表现这是上帝要管的事情反正我是实现了“千分之一”的目标。
程序员C认为Native崩溃太难解决所以他想了一个“好方法”就是不采集所有的Native崩溃美滋滋地跟老板汇报“万分之一”的工作成果。
了解了美好数字产生的“秘笈”后不知道你有何感想其实程序员B和C都是真实的案例而且他们的用户体量都还不算小。技术指标过于KPI化是国内比较明显的一个现象。崩溃率只是一个数字我们的出发点应该是让用户有更好的体验。
## 如何客观地衡量稳定性
到此我们讨论了崩溃是怎么回事儿以及怎么客观地衡量崩溃。那崩溃率是不是就能完全等价于应用的稳定性呢答案是肯定不行。处理了崩溃我们还会经常遇到ANRApplication Not Responding程序没有响应这个问题。
出现ANR的时候系统还会弹出对话框打断用户的操作这是用户非常不能忍受的。这又带来另外一个问题我们怎么去发现应用中的ANR异常呢总结一下通常有两种做法。
**1. 使用FileObserver监听/data/anr/traces.txt 的变化**。非常不幸的是很多高版本的ROM已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径海外可以使用Google Play服务而国内微信利用[Hardcoder](https://mp.weixin.qq.com/s/9Z8j3Dv_5jgf7LDQHKA0NQ?)框架HC框架是一套独立于安卓系统实现的通信框架它让App和厂商ROM能够实时“对话”了目标就是充分调度系统资源来提升App的运行速度和画质切实提高大家的手机使用体验向厂商获取了更大的权限。
**2. 监控消息队列的运行时间**。这个方案无法准确地判断是否真正出现了ANR异常也无法得到完整的ANR日志。在我看来更应该放到卡顿的性能范畴。
回想我当时在设计Tinker的时候为了保证热修复不会影响应用的启动Tinker在补丁的加载流程也设计了简单的“安全模式”在启动时会检查上次应用的退出类型如果检查连续三次异常退出将会自动清除补丁。所以除了常见的崩溃还有一些会导致应用异常退出的情况。
在讨论什么是异常退出之前,我们先看看都有哪些应用退出的情形。
<li>
主动自杀。`Process.killProcess()``exit()` 等。
</li>
<li>
崩溃。出现了Java或Native崩溃。
</li>
<li>
系统重启;系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小。
</li>
<li>
被系统杀死。被low memory killer杀掉、从系统的任务管理器中划掉等。
</li>
<li>
ANR。
</li>
我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。对应上面的五种退出场景,我们排除掉主动自杀和崩溃**(崩溃会单独的统计)**这两种场景希望可以监控到剩下三种的异常退出理论上这个异常捕获机制是可以达到100%覆盖的。
通过这个异常退出的检测可以反映如ANR、low memory killer、系统强杀、死机、断电等其他无法正常捕获到的问题。当然异常率会存在一些误报比如用户从系统的任务管理器中划掉应用。对于线上的大数据来说还是可以帮助我们发现代码中的一些隐藏问题。
所以就得到了一个新的指标来衡量应用的稳定性,即**异常率**。
```
UV 异常率 = 发生异常退出或崩溃的 UV / 登录 UV
```
前不久我们的一个应用灰度版本发现异常退出的比例增长不少最后排查发现由于视频播放存在一个巨大bug会导致可能有用户手机卡死甚至重启这是传统崩溃收集很难发现的问题。
根据应用的前后台状态,我们可以把异常退出分为前台异常退出和后台异常退出。“被系统杀死”是后台异常退出的主要原因,当然我们会**更关注前台的异常退出**的情况这会跟ANR、OOM等异常情况有更大的关联。
通过异常率我们可以比较全面的评估应用的稳定性对于线上监控还需要完善崩溃的报警机制。在微信我们可以做到5分钟级别的崩溃预警确保能在第一时间发现线上重大问题尽快决定是通过发版还是动态热修复解决问题。
## 总结
今天我讲了Android的两种崩溃重点介绍了Native崩溃的捕获流程和一些难点。做一个高可用的崩溃收集SDK并不容易它背后涉及Linux信号处理以及内存分配、汇编等知识当你内功修炼得越深厚学习这些底层知识就越得心应手。
接着我们讨论了崩溃率应该如何去计算崩溃率的高低跟应用时长、复杂度、收集SDK有关。不仅仅是崩溃率我们还学习了目前ANR采集的方式以及遇到的问题最后提出了异常率这一个新的稳定性监控指标。
作为技术人员我们不应该盲目追求崩溃率这一个数字应该以用户体验为先如果强行去掩盖一些问题往往更加适得其反。我们不应该随意使用try catch去隐藏真正的问题要从源头入手了解崩溃的本质原因保证后面的运行流程。在解决崩溃的过程也要做到由点到面不能只针对这个崩溃去解决而应该要考虑这一类崩溃怎么解决和预防。
崩溃的治理是一个长期的过程,在专栏下一期我会重点讲一些分析应用崩溃的方法论。另外,你如果细心的话,可以发现,在这篇文章里,我放了很多的超链接,后面的文章里也会有类似的情况。所以,这就要求你在读完文章之后,或者读的过程中,如果对相关的背景信息或者概念不理解,就需要花些时间阅读周边文章。当然,如果看完还是没有明白,你也可以在留言区给我留言。
## 课后作业
[Breakpad](https://chromium.googlesource.com/breakpad/breakpad/+/master)是一个跨平台的开源项目今天的课后作业是使用Breakpad来捕获一个Native崩溃并在留言区写下你今天学习和练习后的总结与思考。
当然我在专栏GitHub的[Group](https://github.com/AndroidAdvanceWithGeektime)里也为你提供了一个[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter01)方便你练习如果你没使用过Breakpad的话只需要直接编译即可。希望你可以通过一个简单的Native崩溃捕获过程完成minidump文件的生成和解析在实践中加深对Breakpad工作机制的认识。
我要再次敲黑板划重点了,请你一定要坚持参与我们的课后练习,从最开始就养成学完后立马动手操作的好习惯,这样才能让学习效率最大化,一步步接近“成为高手”的目标。当然了,认真提交作业的同学还有机会获得学习加油礼包。接下来,就看你的了!
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“[学习加油礼包](http://time.geekbang.org/column/article/70250)”,期待与你一起切磋进步哦。
<img src="https://static001.geekbang.org/resource/image/24/c0/24c190870d71c3daa203a939d67358c0.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/30/aa/306ef8892cc985a19fdd36534e7c5daa.png" alt="">

View File

@@ -0,0 +1,249 @@
<audio id="audio" title="02 | 崩溃优化(下):应用崩溃了,你应该如何去分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/e8/7d238599f2f6299ec1add3877805a4e8.mp3"></audio>
在侦探漫画《名侦探柯南》中,无论柯南走到哪里都会遇到新的“案件”,这也很像程序员的“日常”,我们每天工作也会遇到各种各样的疑难问题,“崩溃”就是其中比较常见的一种问题。
解决崩溃跟破案一样需要经验,我们分析的问题越多越熟练,定位问题就会越快越准。当然这里也有很多套路,比如对于“案发现场”我们应该留意哪些信息?怎样找到更多的“证人”和“线索”?“侦查案件”的一般流程是什么?对不同类型的“案件”分别应该使用什么样的调查方式?
“真相永远只有一个”,崩溃也并不可怕。通过今天的学习,希望你能成为代码届的名侦探柯南。
## 崩溃现场
崩溃现场是我们的“第一案发现场”,它保留着很多有价值的线索。在这里我们挖掘到的信息越多,下一步分析的方向就越清晰,而不是去靠盲目猜测。
操作系统是整个崩溃过程的“旁观者”,也是我们最重要的“证人”。一个好的崩溃捕获工具知道应该采集哪些系统信息,也知道在什么场景要深入挖掘哪些内容,从而可以更好地帮助我们解决问题。
接下来我们具体来看看在崩溃现场应该采集哪些信息。
1.崩溃信息
从崩溃的基本信息,我们可以对崩溃有初步的判断。
<li>
进程名、线程名。崩溃的进程是前台进程还是后台进程崩溃是不是发生在UI线程。
</li>
<li>
崩溃堆栈和类型。崩溃是属于Java崩溃、Native崩溃还是ANR对于不同类型的崩溃我们关注的点也不太一样。特别需要看崩溃堆栈的栈顶看具体崩溃在系统的代码还是我们自己的代码里面。
</li>
```
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 被杀以及原因
```
<li>
机型、系统、厂商、CPU、ABI、Linux版本等。我们会采集多达几十个维度这对后面讲到寻找共性问题会很有帮助。
</li>
<li>
设备状态是否root、是否是模拟器。一些问题是由Xposed或多开软件造成对这部分问题我们要区别对待。
</li>
3.内存信息
OOM、ANR、虚拟内存耗尽等很多崩溃都跟内存有直接关系。如果我们把用户的手机内存分为“2GB以下”和“2GB以上”两个桶会发现“2GB以下”用户的崩溃率是“2GB以上”用户的几倍。
<li>
系统剩余内存。关于系统内存状态,可以直接读取文件/proc/meminfo。当系统可用内存很小低于MemTotal的 10%OOM、大量GC、系统频繁自杀拉起等问题都非常容易出现。
</li>
<li>
应用使用内存。包括Java内存、RSSResident Set Size、PSSProportional Set Size我们可以得出应用本身内存的占用大小和分布。PSS和RSS通过/proc/self/smap计算可以进一步得到例如apk、dex、so等更加详细的分类统计。
</li>
<li>
虚拟内存。虚拟内存可以通过/proc/self/status得到通过/proc/self/maps文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存但是很多类似OOM、tgkill等问题都是虚拟内存不足导致的。
</li>
```
Name: com.sample.name // 进程名
FDSize: 800 // 当前进程申请的文件句柄个数
VmPeak: 3004628 kB // 当前进程的虚拟内存峰值大小
VmSize: 2997032 kB // 当前进程的虚拟内存大小
Threads: 600 // 当前进程包含的线程个数
```
一般来说对于32位进程如果是32位的CPU虚拟内存达到3GB就可能会引起内存申请失败的问题。如果是64位的CPU虚拟内存一般在34GB之间。当然如果我们支持64位进程虚拟内存就不会成为问题。Google Play要求 2019年8月一定要支持64位在国内虽然支持64位的设备已经在90%以上了但是商店都不支持区分CPU架构类型发布普及起来需要更长的时间。
4.资源信息
有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。
- 文件句柄fd。文件句柄的限制可以通过/proc/self/limits获得一般单个进程允许打开的最大文件句柄个数为1024。但是如果文件句柄超过800个就比较危险需要将所有的fd以及对应的文件名输出到日志中进一步排查是否出现了有文件或者线程的泄漏。
```
opened files count 812:
0 -&gt; /dev/null
1 -&gt; /dev/log/main4
2 -&gt; /dev/binder
3 -&gt; /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.应用信息
除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。
<li>
崩溃场景。崩溃发生在哪个Activity或Fragment发生在哪个业务中。
</li>
<li>
关键操作路径。不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。
</li>
<li>
其他自定义信息。不同的应用关心的重点可能不太一样比如网易云音乐会关注当前播放的音乐QQ浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。
</li>
除了上面这些通用的信息外,针对特定的一些崩溃,我们可能还需要获取类似磁盘空间、电量、网络使用等特定信息。所以说一个好的崩溃捕获工具,会根据场景为我们采集足够多的信息,让我们有更多的线索去分析和定位问题。当然数据的采集需要注意用户隐私,做到足够强度的加密和脱敏。
## 崩溃分析
有了这么多现场信息之后,我们可以开始真正的“破案”之旅了。绝大部分的“案件”只要我们肯花功夫,最后都能真相大白。不要畏惧问题,经过耐心和细心地分析,总能敏锐地发现一些异常或关键点,并且还要敢于怀疑和验证。下面我重点给你介绍崩溃分析“三部曲”。
第一步:确定重点
确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
**1. 确认严重程度**。解决崩溃也要看性价比我们优先解决Top崩溃或者对业务有重大影响例如启动、支付过程的崩溃。我曾经有一次辛苦了几天解决了一个大的崩溃但下个版本产品就把整个功能都删除了这令我很崩溃。
**2. 崩溃基本信息**。确定崩溃的类型以及异常描述,对崩溃有大致的判断。一般来说,大部分的简单崩溃经过这一步已经可以得到结论。
<li>
Java崩溃。Java崩溃类型比较明显比如NullPointerException是空指针OutOfMemoryError是资源不足这个时候需要去进一步查看日志中的 “内存信息”和“资源信息”。
</li>
<li>
Native崩溃。需要观察signal、code、fault addr等内容以及崩溃时Java的堆栈。关于各signal含义的介绍你可以查看[崩溃信号介绍](http://www.mkssoftware.com/docs/man5/siginfo_t.5.asp)。比较常见的是有SIGSEGV和SIGABRT前者一般是由于空指针、非法指针造成后者主要因为ANR和调用abort() 退出所导致。
</li>
<li>
**ANR**。我的经验是先看看主线程的堆栈是否是因为锁等待导致。接着看看ANR日志中iowait、CPU、GC、system server等信息进一步确定是I/O问题或是CPU竞争问题还是由于大量GC导致卡死。
</li>
**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](http://github.com/AndroidAdvanceWithGeektime/Chapter02)提供了一种“完全解决”TimeoutException的方法主要是希望你可以更好地学习解决系统崩溃的套路。
1.通过源码分析。我们发现TimeoutException是由系统的FinalizerWatchdogDaemon抛出来的。
2.寻找可以规避的方法。尝试调用了它的Stop()方法但是线上发现在Android 6.0之前会有线程同步问题。
3.寻找其他可以Hook的点。通过代码的依赖关系发现一个取巧的Hook点。
最终代码你可以参考Sample的实现但是建议只在灰度中使用。这里需要提的是虽然有一些黑科技可以帮助我们解决某些问题但对于黑科技的使用我们需要慎重比如有的黑科技对保活进程频率没有做限制可能会导致系统卡死。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“[学习加油礼包](http://time.geekbang.org/column/article/70250)”,期待与你一起切磋进步哦。
<img src="https://static001.geekbang.org/resource/image/24/c0/24c190870d71c3daa203a939d67358c0.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/30/aa/306ef8892cc985a19fdd36534e7c5daa.png" alt="">

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="03 | 内存优化4GB内存时代再谈内存优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/03/8342235cc6e012147b7c2cf6a692f303.mp3"></audio>
在写今天这篇文章前我又翻了翻三年前我在WeMobileDev公众号写过的[《Android内存优化杂谈》](http://mp.weixin.qq.com/s/Z7oMv0IgKWNkhLon_hFakg)今天再看对里面的一句话更有感触“我们并不能将内存优化中用到的所有技巧都一一说明而且随着Android版本的更替可能很多方法都会变的过时”。
三年过去了4GB内存的手机都变成了主流。那内存优化是不是变得不重要了如今有哪些技巧已经淘汰而我们又要升级什么技能呢
今天在4GB内存时代下我就再来谈谈“内存优化”这个话题。
## 移动设备发展
Facebook有一个叫[device-year-class](http://github.com/facebook/device-year-class)的开源库它会用年份来区分设备的性能。可以看到2008年的手机只有可怜的140MB内存而今年的华为Mate 20 Pro手机的内存已经达到了8GB。
<img src="https://static001.geekbang.org/resource/image/8d/f1/8d1367526799c38d525910bfb5a618f1.png" alt="">
内存看起来好像是我们都非常熟悉的概念那请问问自己手机内存和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。
<img src="https://static001.geekbang.org/resource/image/2f/44/2f26e93ac941f30bb4037648640aca44.png" alt="">
目前市面上的手机主流的运行内存有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.两个问题
<img src="https://static001.geekbang.org/resource/image/c2/ce/c26a9351868bb82abd7ada028275f5ce.png" alt="">
内存造成的第一个问题是**异常**。在前面的崩溃分析我提到过“异常率”异常包括OOM、内存分配失败这些崩溃也包括因为整体内存不足导致应用被杀死、设备重启等问题。不知道你平时是否在工作中注意过如果我们把用户设备的内存分成2GB以下和2GB以上两部分你可以试试分别计算他们的异常率或者崩溃率看看差距会有多大。
内存造成的第二个问题是**卡顿**。Java内存不足会导致频繁GC这个问题在Dalvik虚拟机会更加明显。而ART虚拟机在内存管理跟回收策略上都做大量优化内存分配和GC效率相比提升了510倍。如果想具体测试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这样一个绝对的数值。当系统内存充足的时候我们可以多用一些获得更好的性能。当系统内存不足的时候希望可以做到**“用时分配,及时释放”**,就像下面这张图一样,当系统内存出现压力时,能够迅速释放各种缓存来减少系统压力。
<img src="https://static001.geekbang.org/resource/image/07/97/0739a98bfafdd9f59539ddbbf403f097.png" alt="">
现在手机已经有6GB和8GB的内存出现了Android系统也希望去提升内存的利用率因此我们有必要简单回顾一下Android Bitmap内存分配的变化。
<li>
在Android 3.0之前Bitmap对象放在Java堆而像素数据是放在Native内存中。如果不手动调用recycleBitmap Native内存的回收完全依赖finalize函数回调熟悉Java的同学应该知道这个时机不太可控。
</li>
<li>
Android 3.0Android 7.0将Bitmap对象和像素数据统一放到Java堆中这样就算我们不调用recycleBitmap内存也会随着对象一起被回收。不过Bitmap是内存消耗的大户把它的内存放到Java堆中似乎不是那么美妙。即使是最新的华为Mate 20最大的Java堆限制也才到512MB可能我的物理内存还有5GB但是应用还是会因为Java堆内存不足导致OOM。Bitmap放到Java堆的另外一个问题会引起大量的GC对系统内存也没有完全利用起来。
</li>
<li>
有没有一种实现可以将Bitmap内存放到Native中也可以做到和对象一起快速释放同时GC的时候也能考虑这些内存防止被滥用NativeAllocationRegistry可以一次满足你这三个要求Android 8.0正是使用这个辅助回收Native内存的机制来实现像素数据放到Native内存中。Android 8.0还新增了硬件位图Hardware Bitmap它可以减少图片内存并提升绘制效率。
</li>
**误区二Native内存不用管**
虽然Android 8.0重新将Bitmap内存放回到Native中那么我们是不是就可以随心所欲地使用图片呢
答案当然是否定的。正如前面所说当系统物理内存不足时lmk开始杀进程从后台、桌面、服务、前台直到手机重启。系统构想的场景就像下面这张图描述的一样大家有条不絮的按照优先级排队等着被kill。
<img src="https://static001.geekbang.org/resource/image/b8/98/b8d160f8d487bcb377e0c38ff9a0ac98.png" alt="">
low memory killer的设计是假定我们都遵守Android规范但并没有考虑到中国国情。国内很多应用就像是打不死的小强杀死一个拉起五个。频繁的杀死、拉起进程又会导致system server卡死。当然在Android 8.0以后应用保活变得困难很多,但依然有一些方法可以突破。
既然讲到了将图片的内存放到Native中我们比较熟悉的是Fresco图片库在Dalvik会把图片放到Native内存中。事实上在Android 5.0Android 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使用情况》。](http://developer.android.com/studio/profile/investigate-ram?hl=zh-cn)
```
adb shell dumpsys meminfo &lt;package_name|pid&gt; [-d]
```
**1. Java内存分配**
有些时候我们希望跟踪Java堆内存的使用情况这个时候最常用的有Allocation Tracker和MAT这两个工具。
在我曾经写过的[《Android内存申请分析》](http://mp.weixin.qq.com/s/b_lFfL1mDrNVKj_VAcA2ZA)里提到过Allocation Tracker的三个缺点。
<li>
获取的信息过于分散,中间夹杂着不少其他的信息,很多信息不是应用申请的,可能需要进行不少查找才能定位到具体的问题。
</li>
<li>
跟Traceview一样无法做到自动化分析每次都需要开发者手工开始/结束,这对于某些问题的分析可能会造成不便,而且对于批量分析来说也比较困难。
</li>
<li>
虽然在Allocation Tracking的时候不会对手机本身的运行造成过多的性能影响但是在停止的时候直到把数据dump出来之前经常会把手机完全卡死如果时间过长甚至会直接ANR。
</li>
因此我们希望可以做到脱离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](http://source.android.com/devices/tech/debug/asan.html) 。遵循**“谁最痛,谁最需要,谁优化”**所以Chromium出品了一大堆Native相关的工具。Android之前对AddressSanitize支持的不太好需要root和一大堆的操作但在Android 8.0之后,我们可以根据这个[指南](http://github.com/google/sanitizers/wiki/AddressSanitizerOnAndroid)来使用AddressSanitize。目前AddressSanitize内存泄漏检测只支持x86_64 Linux和OS X系统不过相信Google很快就可以支持直接在Android上进行检测了。
那我们有没有类似Allocation Tracker那样的Native内存分配工具呢在这方面Android目前的支持还不是太好但Android Developer近来也补充了一些相关的文档你可以参考[《调试本地内存使用》](http://source.android.com/devices/tech/debug/native-memory)。关于Native内存的问题有两种方法分别是**Malloc调试**和**Malloc钩子**。
[Malloc调试](http://android.googlesource.com/platform/bionic/+/master/libc/malloc_debug/README.md)可以帮助我们去调试Native内存的一些使用问题例如堆破坏、内存泄漏、非法地址等。Android 8.0之后支持在非root的设备做Native内存调试不过跟AddressSanitize一样需要通过[wrap.sh](http://developer.android.com/ndk/guides/wrap-script.html)做包装。
```
adb shell setprop wrap.&lt;APP&gt; '&quot;LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper&quot;'
```
[Malloc钩子](http://android.googlesource.com/platform/bionic/+/master/libc/malloc_hooks/README.md)是在Android P之后Android的libc支持拦截在程序执行期间发生的所有分配/释放调用,这样我们就可以构建出自定义的内存检测工具。
```
adb shell setprop wrap.&lt;APP&gt; '&quot;LIBC_HOOKS_ENABLE=1&quot;'
```
但是在使用“Malloc调试”时感觉整个App都会变卡有时候还会产生ANR。如何在Android上对应用Native内存分配和泄漏做自动化分析也是我最近想做的事情。据我了解微信最近几个月在Native内存泄漏监控上也做了一些尝试我会在专栏下一期具体讲讲。
## 总结
LPDDR5将在明年进入量产阶段移动内存一直向着更大容量、更低功耗、更高带宽的方向发展。伴随内存的发展内存优化的挑战和解决方案也不断变化。而内存优化又是性能优化重要的一部分今天我讲到了很多的异常和卡顿都是因为内存不足引起的并在最后讲述了如何在日常开发中分析和测量内存的使用情况。
一个好的开发者并不满足于做完需求,我们在设计方案的时候,还需要考虑要使用多少的内存,应该怎么去管理这些内存。在需求完成之后,我们也应该去回归需求的内存情况,是否存在使用不当的地方,是否出现内存泄漏。
## 课后作业
内存优化是一个非常“古老”的话题大家在工作中也会遇到各种各样内存相关的问题。今天的课后作业是分享一下你在工作中遇到的内存问题总结一下通过Sample的练习有什么收获。
在今天文章里我提到希望可以脱离Android Studio实现一个自定义的Allocation Tracker这样就可以将它用到自动化分析中。本期的[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter03)就提供了一个自定义的Allocation Tracker实现的示例目前已经兼容到Android 8.1。你可以用它练习实现自动化的内存分析有哪些对象占用了大量内存以及它们是如何导致GC等。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“[学习加油礼包](http://time.geekbang.org/column/article/70250)”,期待与你一起切磋进步哦。
<img src="https://static001.geekbang.org/resource/image/24/c0/24c190870d71c3daa203a939d67358c0.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/30/aa/306ef8892cc985a19fdd36534e7c5daa.png" alt="">

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="04 | 内存优化(下):内存优化这件事,应该从哪里着手?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/4b/a1053fbf7b3a7343279896ea7c20774b.mp3"></audio>
在掌握内存相关的背景知识后,下一步你肯定想着手开始优化内存的问题了。不过在真正开始做内存优化之前,需要先评估内存对应用性能的影响,我们可以通过崩溃中“异常退出” 和OOM的比例进行评估。另一方面低内存设备更容易出现内存不足引起的异常和卡顿我们也可以通过查看应用中用户的手机内存在2GB以下所占的比例来评估。
所以在优化前要先定好自己的目标这一点非常关键。比如针对512MB的设备和针对2GB以上的设备完全是两种不同的优化思路。如果我们面向东南亚、非洲用户那对内存优化的标准就要变得更苛刻一些。
铺垫了这么多,下面我们就来看看内存优化都有哪些方法吧。
## 内存优化探讨
那要进行内存优化应该从哪里着手呢我通常会从设备分级、Bitmap优化和内存泄漏这三个方面入手。
**1. 设备分级**
相信你肯定遇到过同一个应用在4GB内存的手机运行得非常流畅但在1GB内存的手机就不一定可以做到而且在系统空闲和繁忙的时候表现也不太一样。
**内存优化首先需要根据设备环境来综合考虑**,专栏上一期我提到过很多同学陷入的一个误区:“内存占用越少越好”。其实我们可以让高端设备使用更多的内存,做到针对设备性能的好坏使用不同的内存分配和回收策略。
当然这需要有一个良好的架构设计支撑,在架构设计时需要做到以下几点。
- 设备分级。使用类似device-year-class的策略对设备分级对于低端机用户可以关闭复杂的动画或者是某些功能使用565格式的图片使用更小的缓存内存等。在现实环境下不是每个用户的设备都跟我们的测试机一样高端在开发过程我们要学会思考功能要不要对低端机开启、在系统资源吃紧的时候能不能做降级。
下面我举一个例子。我们知道device-year-class会根据手机的内存、CPU核心数和频率等信息决定设备属于哪一个年份这个示例表示对于2013年之后的设备可以使用复杂的动画对于2010年之前的低端设备则不添加任何动画。
```
if (year &gt;= 2013) {
// Do advanced animation
} else if (year &gt;= 2010) {
// Do simple animation
} else {
// Phone too slow, don't do any animations
}
```
<li>
缓存管理。我们需要有一套统一的缓存管理机制可以适当地使用内存当“系统有难”时也要义不容辞地归还。我们可以使用OnTrimMemory回调根据不同的状态决定释放多少内存。对于大项目来说可能存在几十上百个模块统一缓存管理可以更好地监控每个模块的缓存大小。
</li>
<li>
**进程模型**。一个空的进程也会占用10MB的内存而有些应用启动就有十几个进程甚至有些应用已经从双进程保活升级到四进程保活所以减少应用启动的进程数、减少常驻进程、有节操的保活对低端机内存优化非常重要。
</li>
<li>
安装包大小。安装包中的代码、资源、图片以及so库的体积跟它们占用的内存有很大的关系。一个80MB的应用很难在512MB内存的手机上流畅运行。这种情况我们需要考虑针对低端机用户推出4MB的轻量版本例如Facebook Lite、今日头条极速版都是这个思路。
</li>
安装包中的代码、图片、资源以及so库的大小跟内存究竟有哪些关系你可以参考下面的这个表格。
<img src="https://static001.geekbang.org/resource/image/0b/a9/0bbcbc6862d0d5f86b8e42d25231b5a9.png" alt="">
**2. Bitmap优化**
Bitmap内存一般占应用总内存很大一部分所以做内存优化永远无法避开图片内存这个“永恒主题”。
即使把所有的Bitmap都放到Native内存并不代表图片内存问题就完全解决了这样做只是提升了系统内存利用率减少了GC带来的一些问题而已。
那我们回过头来看看,到底该如何优化图片内存呢?我给你介绍两种方法。
**方法一,统一图片库。**
图片内存优化的前提是收拢图片的调用这样我们可以做整体的控制策略。例如低端机使用565格式、更加严格的缩放算法可以使用Glide、Fresco或者采取自研都可以。而且需要进一步将所有Bitmap.createBitmap、BitmapFactory相关的接口也一并收拢。
**方法二,统一监控。**
在统一图片库后就非常容易监控Bitmap的使用情况了这里主要有三点需要注意。
<li>
大图片监控。我们需要注意某张图片内存占用是否过大例如长宽远远大于View甚至是屏幕的长宽。在开发过程中如果检测到不合规的图片使用应该立即弹出对话框提示图片所在的Activity和堆栈让开发同学更快发现并解决问题。在灰度和线上环境下可以将异常信息上报到后台我们可以计算有多少比例的图片会超过屏幕的大小也就是图片的**“超宽率”**。
</li>
<li>
重复图片监控。重复图片指的是Bitmap的像素数据完全一致但是有多个不同的对象存在。这个监控不需要太多的样本量一般只在内部使用。**之前我实现过一个内存Hprof的分析工具它可以自动将重复Bitmap的图片和引用链输出**。下图是一个简单的例子你可以看到两张图片的内容完全一样通过解决这张重复图片可以节省1MB内存。
</li>
<img src="https://static001.geekbang.org/resource/image/bb/ae/bbeb46e2a974f2cdf7fb3b8fdbf5afae.png" alt="">
- 图片总内存。通过收拢图片使用,我们还可以统计应用所有图片占用的内存,这样在线上就可以按不同的系统、屏幕分辨率等维度去分析图片内存的占用情况。**在OOM崩溃的时候也可以把图片占用的总内存、Top N图片的内存都写到崩溃日志中帮助我们排查问题**。
讲完设备分级和Bitmap优化我们发现架构和监控需要两手抓一个好的架构可以减少甚至避免我们犯错而一个好的监控可以帮助我们及时发现问题。
**3. 内存泄漏**
内存泄漏简单来说就是没有回收不再使用的内存,排查和解决内存泄漏也是内存优化无法避开的工作之一。
内存泄漏主要分两种情况,一种是同一个对象泄漏,还有一种情况更加糟糕,就是每次都会泄漏新的对象,可能会出现几百上千个无用的对象。
很多内存泄漏都是框架设计不合理所导致各种各样的单例满天飞MVC中Controller的生命周期远远大于View。优秀的框架设计可以减少甚至避免程序员犯错当然这不是一件容易的事情所以我们还需要对内存泄漏建立持续的监控。
- **Java内存泄漏**。建立类似LeakCanary自动化检测方案至少做到Activity和Fragment的泄漏检测。在开发过程我们希望出现泄漏时可以弹出对话框让开发者更加容易去发现和解决问题。内存泄漏监控放到线上并不容易我们可以对生成的Hprof内存快照文件做一些优化裁剪大部分图片对应的byte数组减少文件大小。**比如一个100MB的文件裁剪后一般只剩下30MB左右使用7zip压缩最后小于10MB增加了文件上传的成功率**。
<img src="https://static001.geekbang.org/resource/image/3d/85/3d917345c8c8462f8a419568c7d73085.png" alt="">
<li>
**OOM监控**。美团有一个Android内存泄露自动化链路分析组件[Probe](http://ppt.geekbang.org/slide/download/876/593bc30c21689.pdf/19)它在发生OOM的时候生成Hprof内存快照然后通过单独进程对这个文件做进一步的分析。不过在线上使用这个工具风险还是比较大在崩溃的时候生成内存快照**有可能会导致二次崩溃**而且部分手机生成Hprof快照可能会耗时几分钟这对用户造成的体验影响会比较大。另外部分OOM是因为虚拟内存不足导致这块需要具体问题具体分析。
</li>
<li>
**Native内存泄漏监控**。上一期我讲到Malloc调试Malloc Debug和Malloc钩子Malloc Hook似乎还不是那么稳定。在WeMobileDev最近的一篇文章[《微信Android终端内存优化实践》](http://mp.weixin.qq.com/s/KtGfi5th-4YHOZsEmTOsjg)中,微信也做了一些其他方案上面的尝试。
</li>
<li>
**针对无法重编so的情况**使用了PLT Hook拦截库的内存分配函数其中PLT Hook是Native Hook的一种方案后面我们还会讲到。然后重定向到我们自己的实现后记录分配的内存地址、大小、来源so库路径等信息定期扫描分配与释放是否配对对于不配对的分配输出我们记录的信息。
</li>
<li>
**针对可重编的so情况**通过GCC的“-finstrument-functions”参数给所有函数插桩桩中模拟调用栈入栈出栈操作通过ld的“wrap”参数拦截内存分配和释放函数重定向到我们自己的实现后记录分配的内存地址、大小、来源so以及插桩记录的调用栈此刻的内容定期扫描分配与释放是否配对对于不配对的分配输出我们记录的信息。
</li>
开发过程中内存泄漏排查可以使用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内存、**平均图片占用**这些指标,它们可以反映内存的平均情况。通过平均内存和分区间内存占用这些指标,我们可以通过版本对比来监控有没有新增内存相关的问题。
<img src="https://static001.geekbang.org/resource/image/65/ec/65e0b02933c1f7fe181b83d69587e7ec.jpg" alt="">
因为上报了前台时间,我们还可以按照时间维度看应用内存的变化曲线。比如可以观察一下我们的应用是不是真正做到了**“用时分配,及时释放”**。如果需要,我们还可以实现按照场景来对比内存的占用。
**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(&quot;art.gc.gc-count&quot;);
// GC使用的总耗时单位是毫秒
Debug.getRuntimeStat(&quot;art.gc.gc-time&quot;);
// 阻塞式GC的次数
Debug.getRuntimeStat(&quot;art.gc.blocking-gc-count&quot;);
// 阻塞式GC的总耗时
Debug.getRuntimeStat(&quot;art.gc.blocking-gc-time&quot;);
```
需要特别注意阻塞式GC的次数和耗时因为它会暂停应用线程可能导致应用发生卡顿。我们也可以更加细粒度地分应用场景统计例如启动、进入朋友圈、进入聊天页面等关键场景。
## 总结
在具体进行内容优化前,我们首先要问清楚自己几个问题,比如我们要优化到什么目标、内存对我们造成了多少异常和卡顿。只有在明确了应用的现状和优化目标后,我们才能去进行下一步的操作。
在探讨了内存优化的思路时针对不同的设备、设备不同的情况我们希望可以给用户不同的体验。这里我主要讲到了关于Bitmap内存优化和内存泄漏排查、监控的一些方法。最后我提到了怎样在线上监控内存的异常情况通常内存异常率、触顶率这些指标对我们很有帮助。
**目前我们在Native泄漏分析上做的还不是那么完善不过做优化工作的时候我特别喜欢用演进的思路来看问题。用演进的思路来看即使是Google 在时机不成熟时也会做一些权衡和妥协。换到我们个人身上,等到时机成熟或者我们的能力达到了,就需要及时去还这些“技术债务”。**
## 课后作业
看完我分享的内存优化的方法后,相信你也肯定还有很多好的思路和方法,今天的课后作业是分享一下你的内存优化“必杀技”,在留言区分享一下今天学习、练习的收获与心得。
在文中我提到Hprof文件裁剪和重复图片监控这是很多应用目前都没有做的而这两个功能也是微信的APM框架Matrix中内存监控的一部分。Matrix是我一年多前在微信负责的最后一个项目也付出了不少心血最近听说终于准备开源了。
那今天我们就先来练练手尝试使用HAHA库快速判断内存中是否存在重复的图片并且将这些重复图片的PNG、堆栈等信息输出。最终的实现可以通过向[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter04)发送Pull Request。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“[学习加油礼包](http://time.geekbang.org/column/article/70250)”,期待与你一起切磋进步哦。
<img src="https://static001.geekbang.org/resource/image/24/c0/24c190870d71c3daa203a939d67358c0.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/30/aa/306ef8892cc985a19fdd36534e7c5daa.png" alt="">

View File

@@ -0,0 +1,267 @@
<audio id="audio" title="05 | 卡顿优化(上):你要掌握的卡顿分析方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/24/6519091567168124a551f359b736aa24.mp3"></audio>
“我的后羿怎么动不了!”,在玩《王者荣耀》的时候最怕遇到团战时卡得跟幻灯片一样。对于应用也是这样,我们经常会听到用户抱怨:“这个应用启动怎么那么慢?”“滑动的时候怎么那么卡?”。
对用户来说内存占用高、耗费电量、耗费流量可能不容易被发现但是用户对卡顿特别敏感很容易直观感受到。另一方面对于开发者来说卡顿问题又非常难以排查定位产生的原因错综复杂跟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还配备了专门为神经网络计算打造的NPUNeural 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占用率》](http://www.samirchen.com/linux-cpu-performance/)和[Linux文档](http://man7.org/linux/man-pages/man5/proc.5.html)。其中比较重要的字段有:
```
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_countIO 等待的次数
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繁忙的时候线程调度会对执行效率有非常大的影响。
<img src="https://static001.geekbang.org/resource/image/52/0b/526d72f3dbc70ef45c00e7c0e7bdd80b.png" alt="">
关于线程优先级,你需要注意**是否存在高优先级的线程空等低优先级线程,例如主线程等待某个后台线程的锁**。从应用程序的角度来看无论是用户时间、系统时间还是等待CPU的调度都是程序运行花费的时间。
## Android卡顿排查工具
可能你会觉得按照上面各种Linux命令组合来排查问题太麻烦了有没有更简单的、图形化的操作界面呢Traceview和systrace都是我们比较熟悉的排查卡顿的工具从实现上这些工具分为两个流派。
第一个流派是instrument。获取一段时间内所有函数的调用过程可以通过分析这段时间内的函数调用流程再进一步分析待优化的点。
第二个流派是sample。有选择性或者采用抽样的方式观察某些函数调用过程可以通过这些有限的信息推测出流程中的可疑点然后再继续细化分析。
这两种流派有什么差异?我们在什么场景应该选择哪种合适的工具呢?还有没有其他有用的工具可以使用呢?下面我们一一来看。
**1. Traceview**
[Traceview](http://developer.android.com/studio/profile/generate-trace-logs)是我第一个使用的性能分析工具也是吐槽的比较多的工具。它利用Android Runtime函数调用的event事件将函数运行的耗时和调用关系写入trace文件中。
由此可见Traceview属于instrument类型它可以用来查看整个过程有哪些函数调用但是工具本身带来的性能开销过大有时无法反映真实的情况。比如一个函数本身的耗时是1秒开启Traceview后可能会变成5秒而且这些函数的耗时变化并不是成比例放大。
在Android 5.0之后,新增了`startMethodTracingSampling`方法可以使用基于样本的方式进行分析以减少分析对运行时的性能影响。新增了sample类型后就需要我们在开销和信息丰富度之间做好权衡。
<img src="https://static001.geekbang.org/resource/image/4b/f5/4b0b688b5248aa2e018f7841b5834cf5.png" alt="">
无论是哪种的Traceview对release包支持的都不太好例如无法反混淆。其实trace文件的格式十分简单之前曾经写个一个小工具支持通过mapping文件反混淆trace。
**2. Nanoscope**
那在instrument类型的性能分析工具里有没有性能损耗比较小的呢
答案是有的Uber开源的[Nanoscope](http://github.com/uber/nanoscope)就能达到这个效果。它的实现原理是直接修改Android虚拟机源码`ArtMethod`执行入口和执行结束位置增加埋点代码将所有的信息先写到内存等到trace结束后才统一生成结果文件。
在使用过程可以明显感觉到应用不会因为开启Nanoscope而感到卡顿但是trace结束生成结果文件这一步需要的时间比较长。**另一方面它可以支持分析任意一个应用,可用于做竞品分析。**
但是它也有不少限制:
<li>
需要自己刷ROM并且当前只支持Nexus 6P或者采用其提供的x86架构的模拟器。
</li>
<li>
默认只支持主线程采集,其他线程需要[代码手动设置](http://github.com/uber/nanoscope/wiki/Architecture%3A-Nanoscope-ROM#java-api)。考虑到内存大小的限制每个线程的内存数组只能支持大约20秒左右的时间段。
</li>
Uber写了一系列自动化脚本协助整个流程使用起来还算简单。Nanoscope作为基本没有性能损耗的instrument工具它非常适合做启动耗时的自动化分析。
Nanoscope生成的是符合Chrome tracing规范的HTML文件。我们可以通过脚本来实现两个功能
第一个是反混淆。通过mapping自动反混淆结果文件。
第二个是自动化分析。传入相同的起点和终点实现两个结果文件的diff自动分析差异点。
这样我们可以每天定期去跑自动化启动测试,查看是否存在新增的耗时点。**我们有时候为了实现更多定制化功能或者拿到更加丰富的信息这个时候不得不使用定制ROM的方式。而Nanoscope恰恰是一个很好的工具可以让我们更方便地实现定制ROM在后面启动和I/O优化里我还会提到更多类似的案例。**
**3. systrace**
[systrace](http://source.android.com/devices/tech/debug/systrace?hl=zh-cn)是Android 4.1新增的性能分析工具。我通常使用systrace跟踪系统的I/O操作、CPU负载、Surface渲染、GC等事件。
systrace利用了Linux的[ftrace](http://source.android.com/devices/tech/debug/ftrace)调试工具相当于在系统各个关键位置都添加了一些性能探针也就是在代码里加了一些性能监控的埋点。Android在ftrace的基础上封装了[atrace](http://android.googlesource.com/platform/frameworks/native/+/master/cmds/atrace/atrace.cpp)并增加了更多特有的探针例如Graphics、Activity Manager、Dalvik VM、System Server等。
systrace工具只能监控特定系统调用的耗时情况所以它是属于sample类型而且性能开销非常低。但是它不支持应用程序代码的耗时分析所以在使用时有一些局限性。
由于系统预留了`Trace.beginSection`接口来监听应用程序的调用耗时那我们有没有办法在systrace上面自动增加应用程序的耗时分析呢
划重点了,我们可以通过**编译时给每个函数插桩**的方式来实现,也就是在重要函数的入口和出口分别增加`Trace.beginSection``Trace.endSection`。当然出于性能的考虑我们会过滤大部分指令数比较少的函数这样就实现了在systrace基础上增加应用程序耗时的监控。通过这样方式的好处有
<li>
可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用例如渲染耗时、线程锁GC耗时等。
</li>
<li>
性能损耗可以接受。由于过滤了大部分的短函数而且没有放大I/O所以整个运行耗时不到原来的两倍基本可以反映真实情况。
</li>
systrace生成的也是HTML格式的结果我们利用跟Nanoscope相似方式实现对反混淆的支持。
<img src="https://static001.geekbang.org/resource/image/12/15/127526ef09381587f48fb16187b91715.jpg" alt="">
**4. Simpleperf**
那如果我们想分析Native函数的调用上面的三个工具都不能满足这个需求。
Android 5.0新增了[Simpleperf](http://android.googlesource.com/platform/system/extras/+/master/simpleperf/doc/README.md)性能分析工具它利用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类型它的性能开销非常低使用火焰图展示分析结果。
<img src="https://static001.geekbang.org/resource/image/de/5f/de8b2064c4fee25166602781fbff915f.jpg" alt="">
目前除了Nanoscope之外的三个工具都只支持debugable的应用程序如果想测试release包需要将测试机器root。对于这个限制我们在实践中会专门打出debugable的测试包然后自己实现针对mapping的反混淆功能。**其中Simpleperf的反混淆比较难实现因为在函数聚合后会抛弃参数无法直接对生成的HTML文件做处理**。当然我们也可以根据各个工具的实现思路自己重新打造一套支持非debugable的自动化测试工具。
**选择哪种工具需要看具体的场景。我来汇总一下如果需要分析Native代码的耗时可以选择Simpleperf如果想分析系统调用可以选择systrace如果想分析整个程序执行流程的耗时可以选择Traceview或者插桩版本的systrace。**
## 可视化方法
随着Android版本的演进Google不仅提供了更多的性能分析工具而且也在慢慢优化现有工具的体验使功能更强大、使用门槛更低。而Android Studio则肩负另外一个重任那就是让开发者使用起来更加简单的图形界面也更加直观。
在Android Studio 3.2的Profiler中直接集成了几种性能分析工具其中
<li>
Sample Java Methods的功能类似于Traceview的sample类型。
</li>
<li>
Trace Java Methods的功能类似于Traceview的instrument类型。
</li>
<li>
Trace System Calls的功能类似于systrace。
</li>
<li>
SampleNative (API Level 26+) 的功能类似于Simpleperf。
</li>
坦白来说Profiler界面在某些方面不如这些工具自带的界面支持配置的参数也不如命令行不过Profiler的确大大降低了开发者的使用门槛。
另外一个比较大的变化是分析结果的展示方式这些分析工具都支持了Call Chart和Flame Chart两种展示方式。下面我来讲讲这两种展示方式适合的场景。
**1. Call Chart**
Call Chart是Traceview和systrace默认使用的展示方式。它按照应用程序的函数执行顺序来展示适合用于分析整个流程的调用。举一个最简单的例子A函数调用B函数B函数调用C函数循环三次就得到了下面的Call Chart。
<img src="https://static001.geekbang.org/resource/image/db/3e/db3612f661d29efe59854df2e6c2383e.jpg" alt="">
Call Chart就像给应用程序做一个心电图我们可以看到在这一段时间内各个线程的具体工作比如是否存在线程间的锁、主线程是否存在长时间的I/O操作、是否存在空闲等。
**2. Flame Chart**
Flame Chart也就是大名鼎鼎的[火焰图](http://www.brendangregg.com/flamegraphs.html)。它跟Call Chart不同的是Flame Chart以一个全局的视野来看待一段时间的调用分布它就像给应用程序拍X光片可以很自然地把时间和空间两个维度上的信息融合在一张图上。上面函数调用的例子换成火焰图的展示结果如下。
<img src="https://static001.geekbang.org/resource/image/6c/01/6ca232173daf9e71f06ac22252d65d01.jpg" alt="">
当我们不想知道应用程序的整个调用流程只想直观看出哪些代码路径花费的CPU时间较多时火焰图就是一个非常好的选择。例如之前我的一个反序列化实现非常耗时通过火焰图发现耗时最多的是大量Java字符串的创建和拷贝通过将核心实现转为Native最终使性能提升了很多倍。
火焰图还可以使用在各种各样的维度例如内存、I/O的分析。有些内存可能非常缓慢地泄漏通过一个内存的火焰图我们就知道哪些路径申请的内存最多有了火焰图我们根本不需要分析源代码也不需要分析整个流程。
最后我想说,每个工具都可以生成不同的展示方式,我们需要根据不同的使用场景选择合适的方式。
## 总结
在写今天的文章也就是分析卡顿的基础知识和四种Android卡顿排查工具时我越发觉得底层基础知识的重要性。Android底层基于Linux内核像systrace、Simpleperf也是利用Linux提供的机制实现因此学习一些Linux的基础知识对于理解这些工具的工作原理以及排查性能问题都有很大帮助。
另一方面,虽然很多大厂有专门的性能优化团队,但我觉得鼓励和培养团队里的每一个人都去关注性能问题更加重要。我们在使用性能工具的同时,要学会思考,应该知道它们的原理和局限性。更进一步来说,你还可以尝试去为这些工具做一些优化,从而实现更加完善的方案。
## 课后作业
当发生ANR的时候Android系统会打印CPU相关的信息到日志中使用的是[ProcessCpuTracker.java](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/com/android/internal/os/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 faults3094
  3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults329
  0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults6
  0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults982
...
```
今天的课后作业是,请你在留言区解读一下上面的信息,分享一下你认为这个示例的瓶颈在什么地方。之后能不能更进一步,自己动手写一个工具,得到一段时间内上面的这些统计信息。同样最终的实现可以通过向[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter05)发送Pull Request。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,180 @@
<audio id="audio" title="06 | 卡顿优化(下):如何监控应用卡顿?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/b7/350cee0d832ea047ea4e7944bd4a20b7.mp3"></audio>
“我在秒杀iPhone XS的支付页面卡了3秒最后没抢到”用户嘶声力竭地反馈了一个卡顿问题。
“莫慌莫慌”等我打开Android Studio 用上一讲学到的几个工具分析一下就知道原因了。
“咦在我这里整个支付过程丝滑般流畅”。这个经历让我明白卡顿跟崩溃一样需要“现场信息”。因为卡顿的产生也是依赖很多因素比如用户的系统版本、CPU负载、网络环境、应用数据等。
脱离这个现场,我们本地难以复现,也就很难去解决问题。但是卡顿又非常影响用户体验的,特别是发生在启动、聊天、支付这些关键场景,那我们应该如何去监控线上的卡顿问题,并且保留足够多的现场信息协助我们排查解决问题呢?
## 卡顿监控
前面我讲过监控ANR的方法不过也提到两个问题一个是高版本的系统没有权限读取系统的ANR日志另一个是ANR太依赖系统实现我们无法灵活控制参数例如我觉得主线程卡顿3秒用户已经不太能忍受而默认参数只能监控至少5秒以上的卡顿。
所以现实情况就要求我们需要采用其他的方式来监控是否出现卡顿问题,并且针对特定场景还要监控其他特定的指标。
**1. 消息队列**
我设计的第一套监控卡顿的方案是**基于消息队列实现**通过替换Looper的Printer实现。在2013年的时候我写过一个名为WxPerformanceTool的性能监控工具其中耗时监控就使用了这个方法。后面这个工具在腾讯公共组件做了内部开源还获得了2013年的年度十佳组件。
<img src="https://static001.geekbang.org/resource/image/10/b3/10a39c4253447b3e9d0a5045795d47b3.png" alt="">
还没庆祝完很快就有同事跟我吐槽一个问题线上开启了这个监控模块快速滑动时平均帧率起码降低5帧。我通过Traceview一看发现是因为上面图中所示的大量字符串拼接导致性能损耗严重。
后来很快又想到了另外一个方案可以通过一个监控线程每隔1秒向主线程消息队列的头部插入一条空消息。假设1秒后这个消息并没有被主线程消费掉说明阻塞消息运行的时间在01秒之间。换句话说如果我们需要监控3秒卡顿那在第4次轮询中头部消息依然没有被消费的话就可以确定主线程出现了一次3秒以上的卡顿。
<img src="https://static001.geekbang.org/resource/image/b0/56/b06d5aa439e8bb75885a338df9a25f56.png" alt="">
这个方案也存在一定的误差,那就是发送空消息的间隔时间。但这个间隔时间也不能太小,因为监控线程和主线程处理空消息都会带来一些性能损耗,但基本影响不大。
**2. 插桩**
不过在使用了一段时间之后,我感觉还是有那么一点不爽。基于消息队列的卡顿监控并不准确,正在运行的函数有可能并不是真正耗时的函数。这是为什么呢?
我画张图解释起来就清楚了。我们假设一个消息循环里面顺序执行了A、B、C三个函数当整个消息执行超过3秒时因为函数A和B已经执行完毕我们只能得到的正在执行的函数C的堆栈事实上它可能并不耗时。
<img src="https://static001.geekbang.org/resource/image/8e/bf/8ee841e21f4a40f2835fe846be143dbf.png" alt="">
**不过对于线上大数据来说因为函数A和B相对比较耗时所以抓取到它们的概率会更大一些通过后台聚合后捕获到函数A和B的卡顿日志会更多一些。**
这也是我们线上目前依然使用基于消息队列的方法但是肯定希望可以做到跟Traceview一样可以拿到整个卡顿过程所有运行函数的耗时就像下面图中的结果可以明确知道其实函数A和B才是造成卡顿的主要原因。
<img src="https://static001.geekbang.org/resource/image/0c/b5/0c6d97f12e0a5342626b11d683c227b5.png" alt="">
既然这样那我们能否直接利用Android Runtime函数调用的回调事件做一个自定义的Traceview++呢?
答案是可以的但是需要使用Inline Hook技术。我们可以实现类似Nanoscope先写内存的方案但考虑到兼容性问题这套方案并没有用到线上。
对于大体量的应用稳定性是第一考虑因素。那如果在编译过程插桩兼容性问题肯定是OK的。上一讲讲到systrace可以通过插桩自动生成Trace Tag我们一样也可以在函数入口和出口加入耗时监控的代码但是需要考虑的细节有很多。
<li>
**避免方法数暴增**。在函数的入口和出口应该插入相同的函数在编译时提前给代码中每个方法分配一个独立的ID作为参数。
</li>
<li>
**过滤简单的函数**。过滤一些类似直接return、i++这样的简单函数,并且支持黑名单配置。对一些调用非常频繁的函数,需要添加到黑名单中来降低整个方案对性能的损耗。
</li>
<img src="https://static001.geekbang.org/resource/image/55/aa/5554d062dd45d6d927a08be4a39926aa.png" alt="">
基于性能的考虑线上只会监控主线程的耗时。微信的Matrix使用的就是这个方案因为做了大量的优化所以最终安装包体积只增大12%平均帧率下降也在2帧以内。虽然插桩方案对性能的影响总体还可以接受但只会在灰度包使用。
插桩方案看起来美好,它也有自己的短板,那就是只能监控应用内自身的函数耗时,无法监控系统的函数调用,整个堆栈看起来好像“缺失了”一部分。
**3. Profilo**
2018年3月Facebook开源了一个叫[Profilo](http://github.com/facebookincubator/profilo)的库,它收集了各大方案的优点,令我眼前一亮。具体来说有以下几点:
**第一集成atrace功能**。ftrace所有性能埋点数据都会通过trace_marker文件写入内核缓冲区Profilo通过PLT Hook拦截了写入操作选择部分关心的事件做分析。这样所有systrace的探针我们都可以拿到例如四大组件生命周期、锁等待时间、类校验、GC时间等。
**不过大部分的atrace事件都比较笼统从事件“B|pid|activityStart”我们并不知道具体是哪个Activity的创建**。同样我们可以统计GC相关事件的耗时但是也不知道为什么发生了这次GC。
<img src="https://static001.geekbang.org/resource/image/7f/fc/7f4abeb31fbc50546b0481435e7a7bfc.jpg" alt="">
**第二快速获取Java堆栈**。**很多同学有一个误区,觉得在某个线程不断地获取主线程堆栈是不耗时的。但是事实上获取堆栈的代价是巨大的,它要暂停主线程的运行**。
Profilo的实现非常精妙它实现类似Native崩溃捕捉的方式快速获取Java堆栈通过间隔发送SIGPROF信号整个过程如下图所示。
<img src="https://static001.geekbang.org/resource/image/2f/b7/2f00261346ba4c85c9ae522766cf05b7.jpg" alt="">
Signal Handler捕获到信号后拿取到当前正在执行的Thread通过Thread对象可以获取当前线程的ManagedStackManagedStack是一个单链表它保存了当前的ShadowFrame或者QuickFrame栈指针先依次遍历ManagedStack链表然后遍历其内部的ShadowFrame或者QuickFrame还原一个可读的调用栈从而unwind出当前的Java堆栈。通过这种方式可以实现线程一边继续跑步我们还可以帮它做检查而且耗时基本忽略不计。代码可以参照[Profilo::unwind](http://github.com/facebookincubator/profilo/blob/master/cpp/profiler/unwindc/android_712/arm/unwinder.h)和[StackVisitor::WalkStack](http://androidxref.com/7.1.1_r6/xref/art/runtime/stack.cc#772)。
不用插桩、性能基本没有影响、捕捉信息还全那Profilo不就是完美的化身吗当然由于它利用了大量的黑科技兼容性是需要注意的问题。它内部实现有大量函数的Hookunwind也需要强依赖Android Runtime实现。Facebook已经将Profilo投入到线上使用但由于目前Profilo快速获取堆栈功能依然不支持Android 8.0和Android 9.0,鉴于稳定性问题,建议采取抽样部分用户的方式来开启该功能。
**先小结一下不管我们使用哪种卡顿监控方法最后我们都可以得到卡顿时的堆栈和当时CPU运行的一些信息。大部分的卡顿问题都比较好定位例如主线程执行一个耗时任务、读一个非常大的文件或者是执行网络请求等。**
## 其他监控
除了主线程的耗时过长之外,我们还有哪些卡顿问题需要关注呢?
Android Vitals是Google Play官方的性能监控服务涉及卡顿相关的监控有ANR、启动、帧率三个。尤其是ANR监控我们应该经常的来看看主要是Google自己是有权限可以准确监控和上报ANR。
对于启动和帧率Android Vitals只是上报了应用的区间分布但是不能归纳出问题。这也是我们做性能优化时比较迷惑的一点即使发现整体的帧率比过去降低了5帧也并不知道是哪里造成的还是要花很大的力气去做二次排查。
<img src="https://static001.geekbang.org/resource/image/fc/fa/fc93805b240cccbcb8474968a2bfb9fa.png" alt="">
能不能做到跟崩溃、卡顿一样直接给我一个堆栈告诉我就是因为这里写的不好导致帧率下降了5帧。退一步说如果做不到直接告诉我堆栈能不能告诉我是因为聊天这个页面导致的帧率下降让我缩小二次排查的范围。
**1. 帧率**
业界都使用Choreographer来监控应用的帧率。跟卡顿不同的是需要排除掉页面没有操作的情况我们应该只在**界面存在绘制**的时候才做统计。
那么如何监听界面是否存在绘制行为呢可以通过addOnDrawListener实现。
```
getWindow().getDecorView().getViewTreeObserver().addOnDrawListener
```
我们经常用平均帧率来衡量界面流畅度但事实上电影的帧率才24帧用户对于应用的平均帧率是40帧还是50帧并不一定可以感受出来。对于用户来说感觉最明显的是连续丢帧情况Android Vitals将连续丢帧超过700毫秒定义为冻帧也就是连续丢帧42帧以上。
因此,我们可以统计更有价值的冻帧率。**冻帧率就是计算发生冻帧时间在所有时间的占比**。出现丢帧的时候我们可以获取当前的页面信息、View信息和操作路径上报后台降低二次排查的难度。
正如下图一样我们还可以按照Activity、Fragment或者某个操作定义场景通过细化不同场景的平均帧率和冻帧率进一步细化问题排查的范围。
<img src="https://static001.geekbang.org/resource/image/73/ba/73b185f989a20e868886d10c864c43ba.png" alt="">
**2. 生命周期监控**
Activity、Service、Receiver组件生命周期的耗时和调用次数也是我们重点关注的性能问题。例如Activity的onCreate()不应该超过1秒不然会影响用户看到页面的时间。Service和Receiver虽然是后台组件不过它们生命周期也是占用主线程的也是我们需要关注的问题。
对于组件生命周期我们应该采用更严格地监控,可以全量上报。在后台我们可以看到各个组件各个生命周期的启动时间和启动次数。
<img src="https://static001.geekbang.org/resource/image/d4/67/d4e8abcb054793168dff716c7956ae67.png" alt="">
有一次我们发现有两个Service的启动次数是其他的10倍经过排查发现是因为频繁的互相拉起导致。Receiver也是这样而且它们都需要经过System Server。曾经有一个日志上报模块通过Broadcast来做跨进程通信每秒发送几千次请求导致系统System Server卡死。所以说每个组件各个生命周期的调用次数也是非常有参考价值的指标。
除了四大组件的生命周期,我们还需要监控各个进程生命周期的启动次数和耗时。通过下面的数据,我们可以看出某些进程是否频繁地拉起。
<img src="https://static001.geekbang.org/resource/image/53/0c/534e422d44eb4b08ebdac2181b87f70c.png" alt="">
对于生命周期的监控实现我们可以利用插件化技术Hook的方式。但是Android P之后我还是不太推荐你使用这种方式。我更推荐使用编译时插桩的方式**后面我会讲到Aspect、ASM和ReDex三种插桩技术的实现敬请期待。**
**3. 线程监控**
Java线程管理是很多应用非常头痛的事情应用启动过程就已经创建了几十上百个线程。而且大部分的线程都没有经过线程池管理都在自由自在地狂奔着。
另外一方面某些线程优先级或者活跃度比较高占用了过多的CPU。这会降低主线程UI响应能力我们需要特别针对这些线程做重点的优化。
对于Java线程总的来说我会监控以下两点。
<li>
线程数量。需要监控线程数量的多少以及创建线程的方式。例如有没有使用我们特有的线程池这块可以通过got hook线程的nativeCreate()函数。主要用于进行线程收敛,也就是减少线程数量。
</li>
<li>
线程时间。监控线程的用户时间utime、系统时间stime和优先级。主要是看哪些线程utime+stime时间比较多占用了过多的CPU。**正如上一期“每课一练”所提到的,可能有一些线程因为生命周期很短导致很难发现,这里我们需要结合线程创建监控。**
</li>
<img src="https://static001.geekbang.org/resource/image/52/53/52a236b3d3af37869a4eaf087b4ddf53.png" alt="">
**看到这里可能有同学会比较困惑卡顿优化的主题就是监控吗导致卡顿的原因会有很多比如函数非常耗时、I/O非常慢、线程间的竞争或者锁等。其实很多时候卡顿问题并不难解决相较解决来说更困难的是如何快速发现这些卡顿点以及通过更多的辅助信息找到真正的卡顿原因。**
就跟在本地使用各种卡顿分析工具一样卡顿优化的难点在于如何把它们移植到线上以最少的性能代价获得更加丰富的卡顿信息。当然某些卡顿问题可能是I/O、存储或者网络引发的后面会还有专门的内容来讲这些问题的优化方法。
## 总结
今天我们学习了卡顿监控的几种方法。随着技术的深入,我们发现了旧方案的一些缺点,通过不断地迭代和演进,寻找更好的方案。
Facebook的Profilo实现了快速获取Java堆栈其实它参考的是JVM的AsyncGetCallTrace思路然后适配Android Runtime的实现。systrace使用的是Linux的ftraceSimpleperf参考了Linux的perf工具。还是熟悉的配方还是熟悉的味道我们很多创新性的东西其实还是基于Java和Linux十年前的产物。
还是回到我在专栏开篇词说过的,切记不要浮躁,多了解和学习一些底层的技术,对我们的成长会有很大帮助。**日常开发中我们也不能只满足于完成需求就可以了,在实现上应该学会多去思考内存、卡顿这些影响性能的点,我们比别人多想一些、多做一些,自己的进步自然也会更快一些。**
## 课后作业
看完我分享的卡顿优化的方法后,相信你也肯定还有很多好的思路和方法,今天的课后作业是分享一下你的卡顿优化的“必杀技”,在留言区分享一下今天学习、练习的收获与心得。
## 课后练习
我在上一期中提到过Linux的ftrace机制而systrace正是利用这个系统机制实现的。而Profilo更是通过一些黑科技实现了一个可以用于线上的“systrace”。那它究竟是怎么实现的呢
通过今天这个[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter06),你可以学习到它的实现思路。当你对这些底层机制足够熟悉的时候,可能就不局限在本地使用,而是可以将它们搬到线上了。
当然为了能更好地理解这个Sample可能你还需要补充一些ftrace和atrace相关的背景知识。你会发现这些的确都是Linux十年前的一些知识但时至今日它们依然非常有用。
1.[ftrace 简介](http://www.ibm.com/developerworks/cn/linux/l-cn-ftrace/index.html)、[ftrace使用](http://www.ibm.com/developerworks/cn/linux/l-cn-ftrace1/index.html)、[frace使用](http://www.ibm.com/developerworks/cn/linux/l-cn-ftrace2/index.html)。
2.[atrace介绍](http://source.android.com/devices/tech/debug/ftrace)、[atrace实现](http://android.googlesource.com/platform/frameworks/native/+/master/cmds/atrace/atrace.cpp)。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,258 @@
<audio id="audio" title="06补充篇 | 卡顿优化:卡顿现场与卡顿分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/b0/67ee78197a35a1bc2c4da6bbd6e5a9b0.mp3"></audio>
我们使用上一期所讲的插桩或者Profilo的方案可以得到卡顿过程所有运行函数的耗时。在大部分情况下这几种方案的确非常好用可以让我们更加明确真正的卡顿点在哪里。
但是你肯定还遇到过很多莫名其妙的卡顿比如读取1KB的文件、读取很小的asset资源或者只是简单的创建一个目录。
为什么看起来这么简单的操作也会耗费那么长的时间呢?那我们如何通过收集更加丰富的卡顿现场信息,进一步定位并排查问题呢?
## 卡顿现场
我先来举一个线上曾经发现的卡顿例子,下面是它的具体耗时信息。
<img src="https://static001.geekbang.org/resource/image/23/46/2398281c40faaa3620f48e1d23da9046.png" alt="">
从图上看Activity的onCreate函数耗时达到3秒而其中Lottie动画中[openNonAsset](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/android/content/res/AssetManager.java#852)函数耗时竟然将近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) { // 情况一:在这里卡住 --&gt; BLOCKED
object.wait(); // 情况二:在这里卡住 --&gt; WAITING
}
```
不过当一个线程进入WAITING状态时它不仅会释放CPU资源还会将持有的object锁也同时释放。对Java各个线程状态的定义以及转换等更多介绍你可以参考[Thread.State](http://developer.android.com/reference/java/lang/Thread.State)和[《Java线程Dump分析》](http://juejin.im/post/5b31b510e51d4558a426f7e9)。
**步骤二:获得所有线程堆栈**
接着我们在Java层通过Thread.getAllStackTraces()进一步拿所有线程的堆栈希望知道具体是因为哪个线程导致主线程的BLOCKED。
需要注意的是在Android 7.0getAllStackTraces是不会返回主线程的堆栈的。通过分析收集上来的卡顿日志我们发现跟AssetManager相关的线程有下面这个。
```
&quot;BackgroundHandler&quot; RUNNABLE
at android.content.res.AssetManager.list
at com.sample.business.init.listZipFiles
```
通过查看[AssetManager.list](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/android/content/res/AssetManager.java#788)的确发现是使用了同一个synchronized锁而list函数需要遍历整个目录耗时会比较久。
```
public String[] list(String path) throws IOException {
synchronized (this) {
ensureValidLocked();
return nativeList(mObject, path);
}
}
```
**另外一方面“BackgroundHandler”线程属于低优先级后台线程这也是我们前面文章提到的不良现象也就是主线程等待低优先级的后台线程。**
**2. SIGQUIT信号实现**
Java实现的方案看起来非常不错也帮助我们发现了卡顿的原因。不过在我们印象中似乎[ANR日志](http://developer.android.com/topic/performance/vitals/anr)的信息更加丰富那我们能不能直接用ANR日志呢
比如下面的例子它的信息的确非常全所有线程的状态、CPU时间片、优先级、堆栈和锁的信息应有尽有。其中utm代表utimeHZ代表CPU的时钟频率将utime转换为毫秒的公式是“time * 1000/HZ”。例子中utm=218也就是218*1000/100=2180毫秒。
```
// 线程名称; 优先级; 线程id; 线程状态
&quot;main&quot; prio=5 tid=1 Suspended
// 线程组; 线程suspend计数; 线程debug suspend计数;
| group=&quot;main&quot; 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](http://androidxref.com/9.0.0_r3/xref/art/runtime/thread_state.h#24)和[Thread_nativeGetStatus](http://androidxref.com/9.0.0_r3/xref/art/runtime/native/java_lang_Thread.cc#64)。
<img src="https://static001.geekbang.org/resource/image/af/4b/af6485856d47626b13433f96ec48d44b.png" alt="">
**我们可以看到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 &lt;0x41ddc798&gt; (android.content.res.AssetManager) held by tid=66 (BackgroundHandler)
at android.content.res.AssetManager.open(AssetManager.java:289)
```
线程间的死锁和热锁分析是一个非常有意思的话题很多情况分析起来也比较困难例如我们只能拿到Java代码中使用的锁而且有部分类型锁的持有并不会表现在堆栈上面。对这部分内容感兴趣想再深入一下的同学可以认真看一下这两篇文章[《Java线程Dump分析》](http://juejin.im/post/5b31b510e51d4558a426f7e9)、[《手Q Android线程死锁监控与自动化分析实践》](http://cloud.tencent.com/developer/article/1064396)。
**3. Hook实现**
用SIGQUIT信号量获取ANR日志从而拿到所有线程的各种信息这套方案看起来很美好。但事实上它存在这几个问题
<li>
**可行性**。正如我在崩溃分析所说的一样,很多高版本系统已经没有权限读取/data/anr/traces.txt文件。
</li>
<li>
**性能**。获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。
</li>
那有什么方法既可以拿到ANR日志整个过程又不会影响用户的体验呢
再回想一下,在[崩溃分析](http://time.geekbang.org/column/article/70602)的时候我们就讲过一种获得所有线程堆栈的方法。它通过下面几个步骤实现。
<li>
通过`libart.so``dlsym`调用[ThreadList::ForEach](http://androidxref.com/9.0.0_r3/xref/art/runtime/thread_list.cc#1501)方法拿到所有的Native线程对象。
</li>
<li>
遍历线程对象列表,调用[Thread::DumpState](http://androidxref.com/9.0.0_r3/xref/art/runtime/thread.cc#1615)方法。
</li>
它基本模拟了系统打印ANR日志的流程但是因为整个过程使用了一些黑科技可能会造成线上崩溃。
为了兼容性考虑我们会通过fork子进程方式实现这样即使子进程崩溃了也不会影响我们主进程的运行。**这样还可以带来另外一个非常大的好处,获取所有线程堆栈这个过程可以做到完全不卡我们主进程。**
但使用fork进程会导致进程号改变源码中通过/proc/self方式获取的一些信息都会失败**错误的拿了子进程的信息,而子进程只有一个线程**例如state、schedstat、utm、stm、core等。不过问题也不大这些信息可以通过指定/proc/[父进程id]的方式重新获取。
```
&quot;main&quot; prio=7 tid=1 Native
| group=&quot;&quot; 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日志更加丰富我们可以进一步增加这些信息
<li>
**CPU使用率和调度信息**。参考第5期的课后练习我们可以得到系统CPU使用率、负载、各线程的CPU使用率以及I/O调度等信息。
</li>
<li>
**内存相关信息**。我们可以添加系统总内存、可用内存以及应用各个进程的内存等信息。如果开启了Debug.startAllocCounting或者atrace还可以增加GC相关的信息。
</li>
<li>
**I/O和网络相关**。我们还可以把卡顿期间所有的I/O和网络操作的详细信息也一并收集这部分内容会在后面进一步展开。
</li>
在Android 8.0后Android虚拟机终于支持了JVM的[JVMTI](http://www.ibm.com/developerworks/cn/java/j-lo-jpda2/index.html)机制。Profiler中内存采集等很多模块也切换到这个机制中实现后面我会邀请“学习委员”鹏飞给你讲讲JVMTI机制与应用。使用它可以获得的信息非常丰富包括内存申请、线程创建、类加载、GC等有大量的应用场景。
最后我们还可以利用崩溃分析中的一些思路例如添加用户操作路径等信息这样我们可以得到一份比系统ANR更加丰富的卡顿日志这对我们解决某些疑难的卡顿问题会更有帮助。
## 卡顿分析
在客户端捕获卡顿之后,最后数据需要上传到后台统一分析。我们可以对数据做什么样的处理?应该关注哪些指标?
**1. 卡顿率**
如果把主线程卡顿超过3秒定义为一个卡顿问题类似崩溃我们会先评估卡顿问题的影响面也就是UV卡顿率。
```
UV 卡顿率 = 发生过卡顿 UV / 开启卡顿采集 UV
```
因为卡顿问题一般都是抽样上报,采样规则跟内存相似,都应该按照人来抽样。一个用户如果命中采集,那么在一天内都会持续的采集数据。
UV卡顿率可以评估卡顿的影响范围但对于低端机器来说比较难去优化卡顿的问题。如果想评估卡顿的严重程度我们可以使用PV卡顿率。
```
PV 卡顿率 = 发生过卡顿 PV / 启动采集 PV
```
需要注意的是对于命中采集PV卡顿率的用户每次启动都需要上报作为分母。
**2. 卡顿树**
发生卡顿时我们会把CPU使用率和负载相关信息也添加到卡顿日志中。虽然采取了抽样策略但每天的日志量还是达到十万级别。这么大的日志量如果简单采用堆栈聚合日志会发现有几百上千种卡顿类型很难看出重点。
我们能不能实现卡顿的火焰图,在一张图里就可以看到卡顿的整体信息?
这里我非常推荐卡顿树的做法对于超过3秒的卡顿具体是4秒还是10秒这涉及手机性能和当时的环境。我们决定抛弃具体的耗时只按照相同堆栈出现的比例来聚合。这样我们从一棵树上面就可以看到哪些堆栈出现的卡顿问题最多它下面又存在的哪些分支。
<img src="https://static001.geekbang.org/resource/image/ca/5d/ca54f510455317ce487476cbe9cd285d.png" alt="">
我们的精力是有限的一般会优先去解决Top的卡顿问题。采用卡顿树的聚合方式可以从全盘的角度看到Top卡顿问题的各个分支情况帮助我们快速找到关键的卡顿点。
## 总结
今天我们从一个简单的卡顿问题出发一步一步演进出解决这个问题的三种思路。其中Java实现的方案是大部分同学首先想到的方案它虽然简单稳定不过存在信息不全、性能差等问题。
可能很多同学认为问题可以解决就算万事大吉了,但我并不这样认为。我们应该继续敲问自己,如果再出现类似的问题,我们是否也可以采用相同的方法去解决?这个方案的代价对用户会带来多大的影响,是否还有优化的空间?
只有这样,才会出现文中的方案二和方案三,解决方案才会一直向前演进,做得越来越好。也只有这样,我们才能在追求卓越的过程中快速进步。
## 课后作业
线程等待、死锁和热锁在应用中都是非常普遍的,今天的课后作业是分享一下你的产品中是否出现过这些问题,又是如何解决的?请你在留言区分享一下今天学习、练习的收获与心得。
我在评论中发现很多同学对监控Thread的创建比较感兴趣今天我们的[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter06-plus)是如何监控线程的创建。在实践前,给你一些可以参考的链接。
<li>
[Android线程的创建过程](http://www.jianshu.com/p/a26d11502ec8)
</li>
<li>
[java_lang_Thread.cc](http://androidxref.com/9.0.0_r3/xref/art/runtime/native/java_lang_Thread.cc#43)
</li>
<li>
[thread.cc](http://androidxref.com/9.0.0_r3/xref/art/runtime/thread.cc)
</li>
<li>
[编译脚本Android.bp](http://androidxref.com/9.0.0_r3/xref/art/runtime/Android.bp)
</li>
对于PLT Hook和Inline Hook的具体实现原理与差别我在后面会详细讲到。这里我们可以把它们先隐藏掉直接利用开源的实现即可。通过这个Sample我希望你可以学会通过分析源码寻找合理的Hook函数与具体的so库。我相信当你熟悉这些方法之后一定会惊喜地发现实现起来其实真的不难。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="07 | 启动优化(上):从启动过程看启动速度优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/05/b0dc8c860ec1140e8d8e1a9e55bb9a05.mp3"></audio>
>
在超市排队结账,扫码支付启动十几秒都还没完成,只能换一个工具支付?
>
想买本书充实一下,页面刷出来时候十几秒都不能操作,那就换一个应用购买?
用户如果想打开一个应用,就一定要经过“启动”这个步骤。启动时间的长短,不只是用户体验的问题,对于淘宝、京东这些应用来说,会直接影响留存和转化等核心数据。对研发人员来说,启动速度是我们的“门面”,它清清楚楚可以被所有人看到,我们都希望自己应用的启动速度可以秒杀所有竞争对手。
那启动过程究竟会出现哪些问题?我们应该怎么去优化和监控应用的启动速度呢?今天我们一起来看看这些问题该如何解决。
## 启动分析
在真正动手开始优化之前,我们应该先搞清楚从用户点击图标开始,整个启动过程经过哪几个关键阶段,又会给用户带来哪些体验问题。
**1. 启动过程分析**
<img src="https://static001.geekbang.org/resource/image/0d/43/0da2051f1f8d182a531063eb202abf43.png" alt="">
我以微信为例用户从桌面点击图标开始会经过4个关键阶段。
<li>
**T1预览窗口显示**。系统在拉起微信进程之前会先根据微信的Theme属性创建预览窗口。当然如果我们禁用预览窗口或者将预览窗口指定为透明用户在这段时间依然看到的是桌面。
</li>
<li>
**T2闪屏显示**。在微信进程和闪屏窗口页面创建完毕并且完成一系列inflate view、onmeasure、onlayout等准备工作后用户终于可以看到熟悉的“小地球”。
</li>
<li>
**T3主页显示**。在完成主窗口创建和页面显示的准备工作后,用户可以看到微信的主界面。
</li>
<li>
**T4界面可操作**。在启动完成后,微信会有比较多的工作需要继续执行,例如聊天和朋友圈界面的预加载、小程序框架和进程的准备等。在这些工作完成后,用户才可以真正开始愉快地聊天。
</li>
**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(&quot;Test.test()&quot;);
//原来的工作
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可以看到锁等待的事件我们需要排查这些等待是否可以优化特别是防止主线程出现长时间的空转。
<img src="https://static001.geekbang.org/resource/image/36/b2/36316813548502f6cf241189e2a73cb2.png" alt="">
特别是现在有很多启动框架会使用Pipeline机制根据业务优先级规定业务初始化时机。比如微信内部使用的[mmkernel](http://mp.weixin.qq.com/s/6Q818XA5FaHd7jJMFBG60w)、阿里最近开源的[Alpha](http://github.com/alibaba/alpha)启动框架它们为各个任务建立依赖关系最终构成一个有向无环图。对于可以并发的任务会通过线程池最大程度提升启动速度。如果任务的依赖关系没有配置好很容易出现下图这种情况即主线程会一直等待taskC结束空转2950毫秒。
<img src="https://static001.geekbang.org/resource/image/86/db/868a5f4c47224be920e97b82d03905db.png" alt="">
- GC优化
在启动过程要尽量减少GC的次数避免造成主线程长时间的卡顿特别是对Dalvik来说我们可以通过systrace单独查看整个启动过程GC的时间。
```
python systrace.py dalvik -b 90960 -a com.sample.gc
```
对于GC各个事件的具体含义你可以参考[《调查RAM使用情况》](http://developer.android.com/studio/profile/investigate-ram?hl=zh-cn)。
<img src="https://static001.geekbang.org/resource/image/d9/93/d9b93eb8de70426f9a487b006d335093.png" alt="">
不知道你是否还记得我在“内存优化”中提到Debug.startAllocCounting我们也可以使用它来监控启动过程总GC的耗时情况特别是阻塞式同步GC的总次数和耗时。
```
// GC使用的总耗时单位是毫秒
Debug.getRuntimeStat(&quot;art.gc.gc-time&quot;);
// 阻塞式GC的总耗时
Debug.getRuntimeStat(&quot;art.gc.blocking-gc-time&quot;);
```
如果我们发现主线程出现比较多的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](http://github.com/AndroidAdvanceWithGeektime/Chapter07)我们一起来看一下它是如何实现的。需要注意的是Sample选择了ASM插桩的方式感兴趣的同学可以课后学习一下它的使用方法在后续我们也会有关于插桩的专门课程。
我们可以将Sample运用到自己的应用中虽然它过滤了大部分的函数但是我们还是需要注意白名单的配置。例如log、加解密等在底层非常频繁调用的函数都要在白名单中配置过滤掉不然可能会出现类似下面这样大量的毛刺。
<img src="https://static001.geekbang.org/resource/image/6e/92/6e217938a569f32adffa84ff4eedc492.png" alt="">
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,230 @@
<audio id="audio" title="08 | 启动优化(下):优化启动速度的进阶方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/50/a7fb450524c3797f7e5a0dfa3ee15a50.mp3"></audio>
专栏上一期我们一起梳理了应用启动的整个过程和问题也讲了一些启动优化方法可以说是完成了启动优化工作最难的一部分。还可以通过删掉或延后一些不必要的业务来实现相关具体业务的优化。你学会了这些工具和方法是不是觉得效果非常不错然后美滋滋地向老大汇报工作成果“启动速度提升30%,秒杀所有竞品好几条街”。
“还有什么方法可以做进一步优化吗?怎么证明你秒杀所有的竞品?如何在线上衡量启动优化的效果?怎么保障和监控启动速度是否变慢?”,老大一口气问了四个问题。
面对这四个问题,你可不能一脸懵。我们的应用启动是不是真的已经做到了极致?如何保证启动优化成果是长期有效的?让我们通过今天的学习,一起来回答老大这些问题吧。
## 启动进阶方法
除了上期讲的常规的优化方法,我还有一些与业务无关的“压箱底”方法可以帮助加快应用的启动速度。当然有些方法会用到一些黑科技,它就像一把双刃剑,需要你做深入的评估和测试。
**1. I/O 优化**
在负载过高的时候I/O性能下降得会比较快。特别是对于低端机同样的I/O操作耗时可能是高端机器的几十倍。**启动过程不建议出现网络I/O**相比之下磁盘I/O是启动优化一定要抠的点。首先我们要清楚启动过程读了什么文件、多少个字节、Buffer是多大、使用了多长时间、在什么线程等一系列信息。
<img src="https://static001.geekbang.org/resource/image/b9/d7/b901216f4231f43475ca1227f25b6ed7.png" alt="">
那么如何实现I/O的监控呢我今天先卖个关子下一期我会详细和你聊聊I/O方面的知识。
通过上面的数据我们发现chat.db的大小竟然达到500MB。我们经常发现本地启动明明非常快为什么线上有些用户就那么慢这可能是一些用户本地积累了非常多的数据我们也发现有些微信的重度用户他的DB文件竟然会超过1GB。所以**重度用户是启动优化一定要覆盖的群体**,我们要做一些特殊的优化策略。
还有一个是数据结构的选择问题我们在启动过程只需要读取Setting.sp的几项数据不过SharedPreference在初始化的时候还是要全部数据一起解析。**如果它的数据量超过1000条启动过程解析时间可能就超过100毫秒**。如果只解析启动过程用到的数据项则会很大程度减少解析时间,启动过程适合使用随机读写的数据结构。
<img src="https://static001.geekbang.org/resource/image/84/73/84c061b93f84e6b20b36d2ee004b7473.png" alt="">
可以将ArrayMap改造成支持随机读写、延时解析的数据存储方式。同样我们今天也不再展开这部分内容这些知识会在存储优化的相关章节进一步展开。
**2. 数据重排**
在上面的表格里面我们读取test.io文件中1KB数据因为Buffer不小心写成了1 byte总共要读取1000次。那系统是不是真的会读1000次磁盘呢
事实上1000次读操作只是我们发起的次数并不是真正的磁盘I/O次数。你可以参考下面Linux文件I/O流程。
<img src="https://static001.geekbang.org/resource/image/30/b4/30a46524d9c91b8b137c73b2c87654b4.png" alt="">
Linux文件系统从磁盘读文件的时候会以block为单位去磁盘读取一般block大小是4KB。也就是说一次磁盘读写大小至少是4KB然后会把4KB数据放到页缓存Page Cache中。如果下次读取文件数据已经在页缓存中那就不会发生真实的磁盘I/O而是直接从页缓存中读取大大提升了读的速度。所以上面的例子我们虽然读了1000次但事实上只会发生一次磁盘I/O其他的数据都会在页缓存中得到。
Dex文件用的到的类和安装包APK里面各种资源文件一般都比较小但是读取非常频繁。我们可以利用系统这个机制将它们按照读取顺序重新排列减少真实的磁盘I/O次数。
**类重排**
启动过程类加载顺序可以通过复写ClassLoader得到。
```
class GetClassLoader extends PathClassLoader {
public Class&lt;?&gt; findClass(String name) {
// 将 name 记录到文件
writeToFile(name&quot;coldstart_classes.txt&quot;);
return super.findClass(name);
}
}
```
然后通过ReDex的[Interdex](https://github.com/facebook/redex/blob/master/docs/Interdex.md)调整类在Dex中的排列顺序最后可以利用010 Editor查看修改后的效果。
<img src="https://static001.geekbang.org/resource/image/5f/04/5f118a4064ada2fc6b978f4025d57404.png" alt="">
我多次提到的[ReDex](https://github.com/facebook/redex)是Facebook开源的Dex优化工具它里面有非常多好用的东西后续我们会有更详细的介绍。
**资源文件重排**
Facebook在比较早的时候就使用“资源热图”来实现资源文件的重排最近支付宝在[《通过安装包重排布优化Android端启动性能》](https://mp.weixin.qq.com/s/79tAFx6zi3JRG-ewoapIVQ)中也详细讲述了资源重排的原理和落地方法。
在实现上它们都是通过修改Kernel源码单独编译了一个特殊的ROM。这样做的目的有三个
<li>
统计。统计应用启动过程加载了安装包中哪些资源文件比如assets、drawable、layout等。跟类重排一样我们可以得到一个资源加载的顺序列表。
</li>
<li>
度量。在完成资源顺序重排后我们需要确定是否真正生效。比如有哪些资源文件加载了它是发生真实的磁盘I/O还是命中了Page Cache。
</li>
<li>
自动化。任何代码提交都有可能改变启动过程中类和资源的加载顺序如果完全依靠人工手动处理这个事情很难持续下去。通过定制ROM的一些埋点和配合的工具我们可以将它们放到自动化流程当中。
</li>
跟前面提到的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(&quot;file:&quot;+a)
return this.loadDrawableForCookie(a,b,c,d,e)
}
```
调整安装包文件排列需要修改7zip源码实现支持传入文件列表顺序同样最后可以利用010 Editor查看修改后的效果。
<img src="https://static001.geekbang.org/resource/image/ea/b1/eaef116a5c0d6be1f3687b159fd214b1.png" alt="">
这两个优化可能会带来100200毫秒的提高**我们还可以大大减少启动过程I/O的时间波动**。特别是对于中低端机器来说经常发现启动时间波动非常大这个波动跟CPU调度相关但更多时候是跟I/O相关。
可能有同学会问这些优化思路究竟是怎么样想出来的呢其实利用文件系统和磁盘读取机制的优化思路在服务端和Windows上早已经不是什么新鲜事。**所谓的创新,不一定是创造前所未有的东西。我们将已有的方案移植到新的平台,并且很好地结合该平台的特性将其落地,就是一个很大的创新。**
**3. 类的加载**
在WeMobileDev公众号发布的[《微信Android热补丁实践演进之路》](https://mp.weixin.qq.com/s/-NmkSwZu83HAmzKPawdTqQ)中我提过在加载类的过程有一个verify class的步骤它需要校验方法的每一个指令是一个比较耗时的操作。
<img src="https://static001.geekbang.org/resource/image/d2/d2/d2dbf21396e16c0cd53bbc3c0be405d2.png" alt="">
我们可以通过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](https://github.com/alibaba/atlas/blob/master/atlas-core/libs/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](https://www.geekpark.net/news/233791)方案。根据OPPO的数据对于手机QQ、淘宝、微信启动场景会直接有20%以上的优化。
有的时候你问为什么微信可以保活?为什么它可以运行的那么流畅?这里可能不仅仅是技术上的问题,当应用体量足够大,就可以倒逼厂商去专门为它们做优化。
**第二,插件化和热修复**
从2012年开始淘宝、微信尝试做插件化的探索。到了2015年淘宝的Dexposed、支付宝的AndFix以及微信的Tinker等热修复技术开始“百花齐放”。
它们真的那么好吗事实上大部分的框架在设计上都存在大量的Hook和私有API调用带来的缺点主要有两个
<li>
稳定性。虽然大家都号称兼容100%的机型由于厂商的兼容性、安装失败、dex2oat失败等原因还是会有那么一些代码和资源的异常。Android P推出的non-sdk-interface调用限制以后适配只会越来越难成本越来越高。
</li>
<li>
性能。Android Runtime每个版本都有很多的优化因为插件化和热修复用到的一些黑科技导致底层Runtime的优化我们是享受不到的。Tinker框架在加载补丁后应用启动速度会降低5%10%。
</li>
应用加固对启动速度来说简直是灾难,有时候我们需要做一些权衡和选择。为了提升启动速度,支付宝也提出一种[GC抑制](https://mp.weixin.qq.com/s/ePjxcyF3N1vLYvD5dPIjUw)的方案。不过首先Android 5.0以下的系统占比已经不高,其次这也会带来一些兼容性问题。我们还是更希望通过手段可以真正优化整个耗时,而不是一些取巧的方式。
总的来说,对于黑科技我们需要慎重,当你足够了解它们内部的机制以后,可以选择性的使用。
## 启动监控
终于千辛万苦的优化好了,我们还要找一套合理、准确的方法来度量优化的成果。同时还要对它做全方位的监控,以免被人破坏劳动果实。
**1. 实验室监控**
如果想客观地反映启动的耗时,视频录制会是一个非常好的选择。特别是我们很难拿到竞品的线上数据,所以实验室监控也非常适合做竞品的对比测试。
它的难点在于如何让实验系统准确地找到启动结束的点,这里可以通过下面两种方式。
<li>
80%绘制。当页面绘制超过80%的时候认为是启动完成,不过可能会把闪屏当成启动结束的点,不一定是我们所期望的。
</li>
<li>
图像识别。手动输入一张启动结束的图片当实验系统认为当前截屏页面有80%以上相似度时,就认为是启动结束。这种方法更加灵活可控,但是实现难度会稍微高一点。
</li>
<img src="https://static001.geekbang.org/resource/image/ac/fd/acbbb4f9b147d68bfe9ab519642616fd.png" alt="">
启动的实验室监控可以定期自动去跑,需要注意的是,我们应该覆盖高、中、低端机不同的场景。但是使用录屏的方式也有一个缺陷,就是出现问题时我们需要人工二次定位具体是什么代码所导致的。
**2. 线上监控**
实验室覆盖的场景和机型还是有限的,是驴是马我们还是要发布到线上进行验证。针对线上,启动监控会更加复杂一些。[Android Vitals](https://developer.android.google.cn/topic/performance/vitals/launch-time)可以对应用冷启动、温启动时间做监控。
<img src="https://static001.geekbang.org/resource/image/27/84/27f427f00755a723f8f9b2ada3540f84.png" alt="">
事实上,每个应用启动的流程都非常复杂,上面的图并不能真实反映每个应用的启动耗时。启动耗时的计算需要考虑非常多的细节,比如:
<li>
启动结束的统计时机。是否是使用用户真正可以操作的时间作为启动结束的时间。
</li>
<li>
启动时间扣除的逻辑。闪屏、广告和新手引导这些时间都应该从启动时间里扣除。
</li>
<li>
启动排除逻辑。Broadcast、Server拉起启动过程进入后台这些都需要排除出统计。
</li>
经过精密的扣除和排除逻辑,我们最终可以得到用户的线上启动耗时。**正如我在上一期所说的,准确的启动耗时统计是非常重要的。有很多优化在实验室完成之后,还需要在线上灰度验证效果。这个前提是启动统计是准确的,整个效果评估是真实的。**
那我们一般使用什么指标来衡量启动速度的快慢呢?
很多应用采用平均启动时间,不过这个指标其实并不太好,一些体验很差的用户很有可能是被平均了。我更建议使用类似下面的指标:
<li>
快开慢开比。例如2秒快开比、5秒慢开比我们可以看到有多少比例的用户体验非常好多少比例的用户比较槽糕。
</li>
<li>
90%用户的启动时间。如果90%的用户启动时间都小于5秒那么我们90%区间启动耗时就是5秒。
</li>
此外我们还要区分启动的类型。这里要统计首次安装启动、覆盖安装启动、冷启动和温启动这些类型,一般我们都使用普通的**冷启动时间**作为指标。另一方面热启动的占比也可以反映出我们程序的活跃或保活能力。
除了指标的监控启动的线上堆栈监控更加困难。Facebook会利用Profilo工具对启动的整个流程耗时做监控并且在后台直接对不同的版本做自动化对比监控新版本是否有新增耗时的函数。
## 总结
今天我们学习了一些与业务无关的启动优化方法可以进一步减少启动耗时特别是减少磁盘I/O可能带来的波动。然后我们探讨了一些黑科技对启动的影响对于黑科技我们需要两面看在选择时也要慎重。最后我们探讨了如何在实验室和线上更好地测量和监控启动速度。
启动优化需要耐得住寂寞,把整个流程摸清摸透,一点点把时间抠出来,特别是对于低端机和系统繁忙的场景。而数据重排的优化,对我有非常大的启发,帮助我开发了一个新的方向。也让我明白了,当我们足够熟悉底层的知识时,可以利用系统的特性去做更加深层次的优化。
不管怎么说你都需要谨记一点对于启动优化要警惕KPI化**我们要解决的不是一个数字,而是用户真正的体验问题**。
看完我分享的启动优化的方法后,相信你肯定也还有很多好的思路和方法。今天的课后作业是分享一下你“压箱底”的启动优化“秘籍”,在留言区分享一下今天学习、练习的收获与心得。
## 课后练习
今天我们的[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter08)是如何在Dalvik去掉verify你可以顺着这个思路尝试去分析Dalvik虚拟机加载Dex和类的流程。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,258 @@
<audio id="audio" title="09 | I/O优化开发工程师必备的I/O优化知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/48/07f1825ad920d2518c1975b68dc2ef48.mp3"></audio>
>
250GB容量512MB DDR4缓存连续读取不超过550MB/s连续写入不超过520MB/s。
“双十一”在天猫看到一款固态硬盘有上面的这些介绍,这些数字分别代表了什么意思?
在专栏前面卡顿和启动优化里我也经常提到I/O优化。可能很多同学觉得I/O优化不就是不在主线程读写大文件吗真的只有这么简单吗那你是否考虑过从应用程序调用read()方法内核和硬件会做什么样的处理整个流程可能会出现什么问题今天请你带着这些疑问我们一起来看看I/O优化需要的知识。
## I/O的基本知识
在工作中我发现很多工程师对I/O的认识其实比较模糊认为I/O就是应用程序执行read()、write()这样的一些操作,并不清楚这些操作背后的整个流程是怎样的。
<img src="https://static001.geekbang.org/resource/image/60/d4/60928bc51c0d04b1c39b24282e8126d4.jpg" alt="">
我画了一张简图你可以看到整个文件I/O操作由应用程序、文件系统和磁盘共同完成。首先应用程序将I/O命令发送给文件系统然后文件系统会在合适的时机把I/O操作发给磁盘。
这就好比CPU、内存、磁盘三个小伙伴一起完成接力跑最终跑完的时间很大程度上取决于最慢的小伙伴。我们知道CPU和内存相比磁盘是高速设备整个流程的瓶颈在于磁盘I/O的性能。所以很多时候文件系统性能比磁盘性能更加重要为了降低磁盘对应用程序的影响文件系统需要通过各种各样的手段进行优化。那么接下来我们首先来看文件系统。
**1. 文件系统**
文件系统简单来说就是存储和组织数据的方式。比如在iOS 10.3系统以后苹果使用APFSApple File System替代之前旧的文件系统HFS+。对于Android来说现在普遍使用的是Linux常用的ext4文件系统。
关于文件系统还需要多说两句华为在EMUI 5.0以后就使用F2FS取代ext4Google也在最新的旗舰手机Pixel 3使用了F2FS文件系统。Flash-Friendly File System是三星是专门为NAND闪存芯片开发的文件系统也做了大量针对闪存的优化。根据华为的测试数据F2FS文件系统在小文件的随机读写方面比ext4更快例如随机写可以优化60%不足之处在于可靠性方面出现过一些问题。我想说的是随着Google、华为的投入和规模化使用F2FS系统应该是未来Android的主流文件系统。
还是回到文件系统的I/O。应用程序调用read()方法系统会通过中断从用户空间进入内核处理流程然后经过VFSVirtual File System虚拟文件系统、具体文件系统、页缓存Page Cache。下面是Linux一个通用的I/O架构模型。
<img src="https://static001.geekbang.org/resource/image/fb/4b/fb11cbe604eb6c0fc2ba5825275f104b.png" alt="">
<li>
虚拟文件系统VFS。它主要用于实现屏蔽具体的文件系统为应用程序的操作提供一个统一的接口。这样保证就算厂商把文件系统从ext4切换到F2FS应用程序也不用做任何修改。
</li>
<li>
文件系统File System。ext4、F2FS都是具体文件系统实现文件元数据如何组织、目录和索引结构如何设计、怎么分配和清理数据这些都是设计一个文件系统必须要考虑的。**每个文件系统都有适合自己的应用场景我们不能说F2FS就一定比ext4要好。**F2FS在连续读取大文件上并没有优势而且会占用更大的空间。只是对一般应用程序来说随机I/O会更加频繁特别是在启动的场景。你可以在/proc/filesystems看到系统可以识别的所有文件系统的列表。
</li>
<li>
页缓存Page Cache。在启动优化中我已经讲过Page Cache这个概念了在读文件的时候会先看它是不是已经在Page Cache中如果命中就不会去读取磁盘。在Linux 2.4.10之前还有一个单独的Buffer Cache后来它也合并到Page Cache中的Buffer Page了。
</li>
具体来说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调度层、设备驱动层最后才会交给具体的硬件设备处理。
<img src="https://static001.geekbang.org/resource/image/13/18/13c06810c88632db1050ab3e56139a18.png" alt="">
<li>
通用块层。系统中能够随机访问固定大小数据块block的设备称为块设备CD、硬盘和SSD这些都属于块设备。通用块层主要作用是接收上层发出的磁盘请求并最终发出I/O请求。它跟VFS的作用类似让上层不需要关心底层硬件设备的具体实现。
</li>
<li>
I/O调度层。磁盘I/O那么慢为了降低真正的磁盘I/O我们不能接收到磁盘请求就立刻交给驱动层处理。所以我们增加了I/O调度层它会根据设置的调度算法对请求合并和排序。这里比较关键的参数有两个一个是队列长度一个是具体的调度算法。我们可以通过下面的文件可以查看对应块设备的队列长度和使用的调度算法。
</li>
```
/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固态硬盘还要快同时也大大缩小了和内存的速度差距。不过这些都是厂商提供的一些测试数据特别是对于随机读写的性能相比内存还是差了很多。
<img src="https://static001.geekbang.org/resource/image/f3/b1/f3bcc6974bf879f35a842ecd8ee086b1.png" alt="">
上面的数字好像有点抽象,直白地说闪存的性能会影响我们打开微信、游戏加载以及连续自拍的速度。当然闪存性能不仅仅由硬件决定,它跟采用的标准、文件系统的实现也有很大的关系。
**2. 两个疑问**
看到这里可能有些同学会问知道文件读写的流程、文件系统和磁盘这些基础知识对我们实际开发有什么作用呢下面我举两个简单的例子可能你平时也思考过不过如果不熟悉I/O的内部机制你肯定是一知半解。
**疑问一:文件为什么会损坏?**
先说两个客观数据微信聊天记录使用的SQLite数据库大概有几万分之一的损坏率系统SharedPreference如果频繁跨进程读写也会有万分之一的损坏率。
在回答文件为什么会损坏前首先需要先明确一下什么是文件损坏。一个文件的格式或者内容如果没有按照应用程序写入时的结果都属于文件损坏。它不只是文件格式错误文件内容丢失可能才是最常出现的SharedPreference跨进程读写就非常容易出现数据丢失的情况。
再来探讨文件为什么会损坏,我们可以从应用程序、文件系统和磁盘三个角度来审视这个问题。
<li>
应用程序。大部分的I/O方法都不是原子操作文件的跨进程或者多线程写入、使用一个已经关闭的文件描述符fd来操作文件它们都有可能导致数据被覆盖或者删除。事实上大部分的文件损坏都是因为应用程序代码设计考虑不当导致的并不是文件系统或者磁盘的问题。
</li>
<li>
文件系统。虽说内核崩溃或者系统突然断电都有可能导致文件系统损坏不过文件系统也做了很多的保护措施。例如system分区保证只读不可写增加异常检查和恢复机制ext4的fsck、f2fs的fsck.f2fs和checkpoint机制等。
</li>
在文件系统这一层更多是因为断电而导致的写入丢失。为了提升I/O性能文件系统把数据写入到Page Cache中然后等待合适的时机才会真正的写入磁盘。当然我们也可以通过fsync、msync这些接口强制写入磁盘在下一其我会详细介绍直接I/O和缓存I/O。
- 磁盘。手机上使用的闪存是电子式的存储设备所以在资料传输过程可能会发生电子遗失等现象导致数据错误。不过闪存也会使用ECC、多级编码等多种方式增加数据的可靠性一般来说出现这种情况的可能性也比较小。
闪存寿命也可能会导致数据错误由于闪存的内部结构和特征导致它写过的地址必须擦除才能再次写入而每个块擦除又有次数限制次数限制是根据采用的存储颗粒从十万次到几千都有SLC&gt;MLC&gt;TLC
下图是闪存Flash Memory的结构图其中比较重要的是FTLFlash Translation Layer它负责物理地址的分配和管理。它需要考虑到每个块的擦除寿命将擦除次数均衡到所有块上去。当某个块空间不够的时候它还要通过垃圾回收算法将数据迁移。FTL决定了闪存的使用寿命、性能和可靠性是闪存技术中最为重要的核心技术之一。
<img src="https://static001.geekbang.org/resource/image/97/96/97c18602e462d5724d26660fc5115e96.png" alt="">
对于手机来说假设我们的存储大小是128GB即使闪存的最大擦除次数只有1000次那也可以写入128TB但一般来说比较难达到。
**疑问二I/O有时候为什么会突然很慢**
手机厂商的数据通常都是出厂数据我们在使用Android手机的时候也会发现刚买的时候“如丝般顺滑”的手机在使用一年之后就会变得卡顿无比。
这是为什么呢在一些低端机上面我发现大量跟I/O相关的卡顿。I/O有时候为什么会突然变慢可能有下面几个原因。
<li>
内存不足。当手机内存不足的时候系统会回收Page Cache和Buffer Cache的内存大部分的写操作会直接落盘导致性能低下。
</li>
<li>
写入放大。上面我说到闪存重复写入需要先进行擦除操作但这个擦除操作的基本单元是block块一个page页的写入操作将会引起整个块数据的迁移这就是典型的写入放大现象。低端机或者使用比较久的设备由于磁盘碎片多、剩余空间少非常容易出现写入放大的现象。具体来说闪存读操作最快在20us左右。写操作慢于读操作在200us左右。而擦除操作非常耗时在1ms左右的数量级。当出现写入放大时因为涉及移动数据这个时间会更长。
</li>
<li>
由于低端机的CPU和闪存的性能相对也较差在高负载的情况下容易出现瓶颈。例如eMMC闪存不支持读写并发当出现写入放大现象时读操作也会受影响。
</li>
系统为了缓解磁盘碎片问题可以引入fstrim/TRIM机制在锁屏、充电等一些时机会触发磁盘碎片整理。
## I/O的性能评估
正如下图你所看到的整个I/O的流程涉及的链路非常长。我们在应用程序中通过打点发现一个文件读取需要300ms。但是下面每一层可能都有自己的策略和调度算法因此很难真正的得到每一层的耗时。
<img src="https://static001.geekbang.org/resource/image/2d/60/2d2dffd5b2a95363c100875be6cae360.png" alt="">
在前面的启动优化内容中我讲过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_countIO 等待的次数
se.statistics.iowait_sum IO 等待的时间
```
如果是root的机器我们可以开启内核的I/O监控将所有block读写dump到日志文件中这样可以通过dmesg命令来查看。
```
echo 1 &gt; /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, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000447&gt;
read(53, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000084&gt;
read(53, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000059&gt;
```
通过上面的日志你可以看到应用程序在读取文件操作符为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耗时的百分比**。假设一个任务执行了10sI/O花了9s那么I/O耗时百分比就是90%。这种情况下I/O就是我们任务很大的瓶颈需要去做进一步的优化。
**第三种方法使用vmstat。**
vmstat的各个字段说明可以参考[《vmstat监视内存使用情况》](https://linuxtools-rst.readthedocs.io/zh_CN/latest/tool/vmstat.html)其中Memory中的buff和cacheI/O中的bi和boSystem中的cs以及CPU中的sy和wa这些字段的数值都与I/O行为有关。
<img src="https://static001.geekbang.org/resource/image/5f/a2/5fcc14c666f9c5c6d0cfb803634b2ba2.png" alt="">
我们可以配合[dd命令](https://www.cnblogs.com/kongzhongqijing/articles/9049336.html)来配合测试观察vmstat的输出数据变化。**不过需要注意的是Android里面的dd命令似乎并不支持conv和flag参数。**
```
//清除Buffer和Cache内存缓存
echo 3 &gt; /proc/sys/vm/drop_caches
//每隔1秒输出1组vmstat数据
vmstat 1
//测试写入速度,写入文件/data/data/testbuffer大小为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那些事](https://tech.meituan.com/about_desk_io.html)
2.[Linux 内核的文件 Cache 管理机制介绍](https://www.ibm.com/developerworks/cn/linux/l-cache/index.html)
3.[The Linux Kernel/Storage](https://en.wikibooks.org/wiki/The_Linux_Kernel/Storage)
4.[选eMMC、UFS还是NVMe 手机ROM存储传输协议解析](https://www.sohu.com/a/196510603_616364)
5.[聊聊Linux IO](http://0xffffff.org/2017/05/01/41-linux-io/)
6.[采用NAND Flash设计存储设备的挑战在哪里?](http://blog.51cto.com/alanwu/1425566)
“实践出真知”你也可以尝试使用strace和block_dump来观察自己应用的I/O情况不过有些实验会要求有root的机器。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="10 | I/O优化不同I/O方式的使用场景是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/c0/af43a6eba61ec66fa4339e926ab967c0.mp3"></audio>
今天是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方式的关键点以及在实际应用中需要注意的地方。
<img src="https://static001.geekbang.org/resource/image/3e/38/3e295519291c337bb394fb5fdbd7d538.png" alt="">
**1. 标准I/O**
我们应用程序平时用到read/write操作都属于标准I/O也就是缓存I/OBuffered I/O。它的关键特性有
<li>
对于读操作来说,当应用程序读取某块数据的时候,如果这块数据已经存放在页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。
</li>
<li>
对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用写操作的机制。默认系统采用的是延迟写机制,应用程序只需要将数据写到页缓存中去就可以了,完全不需要等数据全部被写回到磁盘,系统会负责定期地将放在页缓存中的数据刷到磁盘上。
</li>
从中可以看出来缓存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等系统调用时内核都会立刻将相应的数据写回到磁盘。
<img src="https://static001.geekbang.org/resource/image/43/bd/431742f4ad3aefcc90334c905a729abd.png" alt="">
上图中我以read()操作为例它会导致数据先从磁盘拷贝到Page Cache中然后再从Page Cache拷贝到应用程序的用户空间这样就会多一次内存拷贝。系统这样设计主要是因为内存相对磁盘是高速设备即使多拷贝100次内存也比真正读一次硬盘要快。
**2. 直接I/O**
很多数据库自己已经做了数据和索引的缓存管理,对页缓存的依赖反而没那么强烈。它们希望可以绕开页缓存机制,这样可以减少一次数据拷贝,这些数据也不会污染页缓存。
<img src="https://static001.geekbang.org/resource/image/d7/e0/d7a24f0fe50d56df0b4f315b12d065e0.png" alt="">
从图中你可以看到直接I/O访问文件方式减少了一次数据拷贝和一些系统调用的耗时很大程度降低了CPU的使用率以及内存的占用。
不过直接I/O有时候也会对性能产生负面影响。
<li>
对于读操作来说,读数据操作会造成磁盘的同步读,导致进程需要较长的时间才能执行完。
</li>
<li>
对于写操作来说使用直接I/O也需要同步执行也会导致应用程序等待。
</li>
Android并没有提供Java的DirectByteBuffer直接I/O需要在open()文件的时候需要指定O_DIRECT参数更多的资料可以参考[《Linux 中直接 I/O 机制的介绍》](https://www.ibm.com/developerworks/cn/linux/l-cn-directio/index.html)。在使用直接I/O之前一定要对应用程序有一个很清醒的认识只有在确定缓冲I/O的开销非常巨大的情况以后才可以考虑使用直接I/O。
**3. mmap**
Android系统启动加载Dex的时候不会把整个文件一次性读到内存中而是采用mmap的方式。微信的[高性能日志xlog](https://mp.weixin.qq.com/s/cnhuEodJGIbdodh0IxNeXQ)也是使用mmap来保证性能和可靠性。
mmap究竟是何方神圣它是不是真的可以做到不丢失数据、性能还非常好其实它是通过把文件映射到进程的地址空间**而网上很多文章都说mmap完全绕开了页缓存机制其实这并不正确**。我们最终映射的物理内存依然在页缓存中,它可以带来的好处有:
<li>
减少系统调用。我们只需要一次mmap() 系统调用后续所有的调用像操作内存一样而不会出现大量的read/write系统调用。
</li>
<li>
减少数据拷贝。普通的read()调用,数据需要经过两次拷贝;**而mmap只需要从磁盘拷贝一次就可以了**,并且由于做过内存映射,也不需要再拷贝回用户空间。
</li>
<li>
可靠性高。mmap把数据写入页缓存后跟缓存I/O的延迟写机制一样可以依靠内核线程定期写回磁盘。**但是需要提的是mmap在内核崩溃、突然断电的情况下也一样有可能引起内容丢失当然我们也可以使用msync来强制同步写。**
</li>
<img src="https://static001.geekbang.org/resource/image/11/3e/116ada829f5017f3d40bf2f78d4f4c3e.png" alt="">
从上面的图看来我们使用mmap仅仅只需要一次数据拷贝。看起来mmap的确可以秒杀普通的文件读写那我们为什么不全都使用mmap呢事实上它也存在一些缺点
<li>
虚拟内存增大。mmap会导致虚拟内存增大我们的APK、Dex、so都是通过mmap读取。而目前大部分的应用还没支持64位除去内核使用的地址空间一般我们可以使用的虚拟内存空间只有3GB左右。如果mmap一个1GB的文件应用很容易会出现虚拟内存不足所导致的OOM。
</li>
<li>
磁盘延迟。mmap通过缺页中断向磁盘发起真正的磁盘I/O所以如果我们当前的问题是在于磁盘I/O的高延迟那么用mmap()消除小小的系统调用开销是杯水车薪的。**启动优化中讲到的类重排技术就是将Dex中的类按照启动顺序重新排列主要为了减少缺页中断造成的磁盘I/O延迟。**
</li>
在Android中可以将文件通过[MemoryFile](https://developer.android.com/reference/android/os/MemoryFile)或者[MappedByteBuffer](https://developer.android.com/reference/java/nio/MappedByteBuffer)映射到内存,然后进行读写,使用这种方式对于小文件和频繁读写操作的文件还是有一定优势的。我通过简单代码测试,测试结果如下。
<img src="https://static001.geekbang.org/resource/image/f0/3a/f03df7c8d500c9e129bc853cff87bd3a.png" alt="">
从上面的数据看起来mmap好像的确跟写内存的性能差不多但是这并不正确因为我们并没有计算文件系统异步落盘的耗时。在低端机或者系统资源严重不足的时候mmap也一样会出现频繁写入磁盘这个时候性能就会出现快速下降。
mmap比较适合于对同一块区域频繁读写的情况推荐也使用线程来操作。用户日志、数据上报都满足这种场景另外需要跨进程同步的时候mmap也是一个不错的选择。Android跨进程通信有自己独有的Binder机制它内部也是使用mmap实现。
<img src="https://static001.geekbang.org/resource/image/12/f2/12c2c64bd77fc58d414dfcdb8cfd91f2.png" alt="">
利用mmapBinder在跨进程通信只需要一次数据拷贝比传统的Socket、管道等跨进程通信方式会少一次数据拷贝过程。
<img src="https://static001.geekbang.org/resource/image/26/9f/265862d451441b94e6205e07ab58879f.png" alt="">
## 多线程阻塞I/O和NIO
我在上一期说过由于写入放大的现象特别是在低端机中有时候I/O操作可能会非常慢。
所以I/O操作应该尽量放到线程中不过很多同学可能都有这样一个疑问如果同时读10个文件我们应该用单线程还是10个线程并发读
**1. 多线程阻塞I/O**
我们来做一个实验使用Nexus 6P读取30个大小为40MB的文件分别使用不同的线程数量做测试。
<img src="https://static001.geekbang.org/resource/image/1d/b5/1dbf02b2f0b17f81a24f03b6adf1ccb5.png" alt="">
你可以发现多线程在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的工作模式
<img src="https://static001.geekbang.org/resource/image/3f/43/3f322506a458d60145cee27f55673743.png" alt="">
对应用程序来说磁盘I/O阻塞线程的总时间会更加合理它并不关心CPU是否真的在等待还是去执行其他工作了。**在实际开发工作中大部分时候都是读一些比较小的文件使用单独的I/O线程还是专门新开一个线程其实差别不大。**
**2. NIO**
多线程阻塞式I/O会增加系统开销那我们是否可以使用异步I/O呢当我们线程遇到I/O操作的时候不再以阻塞的方式等待I/O操作的完成而是将I/O请求发送给系统后继续往下执行。这个过程你可以参考下面的图。
<img src="https://static001.geekbang.org/resource/image/22/f9/22141f888cefc43219b0c3df3ab8d4f9.png" alt="">
非阻塞的NIO将I/O以事件的方式通知的确可以减少线程切换的开销。Chrome网络库是一个使用NIO提升性能很好的例子特别是在系统非常繁忙的时候。但是NIO的缺点也非常明显应用程序的实现会变得更复杂有的时候异步改造并不容易。
下面我们来看利用NIO的FileChannel来读写文件。FileChannel需要使用ByteBuffer来读写文件可以使用ByteBuffer.allocate(int size)分配空间或者通过ByteBuffer.wrap(byte[])包装byte数组直接生成。上面的示例使用NIO方式在CPU闲和CPU忙时耗时如下。
<img src="https://static001.geekbang.org/resource/image/25/1d/25740c1def909b3860b1dd5f12ef391d.png" alt="">
通过上面的数据你可以看到我们发现使用NIO整体性能跟非NIO差别并不大。这其实也是可以理解的在CPU闲的时候无论我们的线程是否继续做其他的工作当前瓶颈依然在磁盘整体耗时不会太大。在CPU忙的时候无论是否使用NIO单线程可以抢到的CPU时间片依然有限。
那NIO是不是完全没有作用呢**其实使用NIO的最大作用不是减少读取文件的耗时而是最大化提升应用整体的CPU利用率。**在CPU繁忙的时候我们可以将线程等待磁盘I/O的时间来做部分CPU操作。非常推荐Square的[Okio](https://github.com/square/okio)它支持同步和异步I/O也做了比较多优化你可以尝试使用。
## 小文件系统
对于文件系统来说目录查找的性能是非常重要的。比如微信朋友圈图片可能有几万张如果我们每张图片都是一个单独的文件那目录下就会有几万个小文件你想想这对I/O的性能会造成什么影响
文件的读取需要先找到存储的位置在文件系统上面我们使用inode来存储目录。读取一个文件的耗时可以拆分成下面两个部分。
```
文件读取的时间 = 找到文件的 inode 的时间 + 根据 inode 读取文件数据的时间
```
如果我们需要频繁读写几万个小文件查找inode的时间会变得非常可观。这个时间跟文件系统的实现有关。
<li>
对于FAT32系统来说FAT32系统是历史久远的产物在一些低端机的外置SD卡会使用这个系统。当目录文件数比较多的时候需要线性去查找一个exist()都非常容易出现ANR。
</li>
<li>
对于ext4系统来说ext4系统使用目录Hash索引的方式查找目录查找时间会大大缩短。但是如果需要频繁操作大量的小文件查找和打开文件的耗时也不能忽视。
</li>
大量的小文件合并为大文件后,我们还可以将能连续访问的小文件合并存储,将原本小文件间的随机访问变为了顺序访问,可以大大提高性能。同时合并存储能够有效减少小文件存储时所产生的磁盘碎片问题,提高磁盘的利用率。
业界中Google的GFS、淘宝开源的[TFS](http://tfs.taobao.org/)、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方式的使用场景。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,303 @@
<audio id="audio" title="11 | I/O优化如何监控线上I/O操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/07/14ce7d2bff28984d1e71e37cb0e6af07.mp3"></audio>
通过前面的学习相信你对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 -&gt; IoBridge.open -&gt; Libcore.os.open
-&gt; BlockGuardOs.open -&gt; Posix.open
```
在[Libcore.java](http://androidxref.com/6.0.1_r10/xref/libcore/luni/src/main/java/libcore/io/Libcore.java)中可以找到一个挺不错的Hook点那就是[BlockGuardOs](http://androidxref.com/6.0.1_r10/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java)这一个静态变量。如何可以快速找到合适的Hook点呢一方面需要靠经验但是耐心查看和分析源码是必不可少的工作。
```
public static Os os = new BlockGuardOs(new Posix());
// 反射获得静态变量
Class&lt;?&gt; clibcore = Class.forName(&quot;libcore.io.Libcore&quot;);
Field fos = clibcore.getDeclaredField(&quot;os&quot;);
```
我们可以通过动态代理的方式在所有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);
```
看起来这个方案好像挺不错的,但在实际使用中很快就发现这个方法有几个缺点。
<li>
性能极差。I/O操作调用非常频繁因为使用动态代理和Java的大量字符串操作导致性能比较差无法达到线上使用的标准。
</li>
<li>
无法监控Native代码。例如微信中有大量的I/O操作是在Native代码中使用Java Hook方案无法监控到。
</li>
<li>
兼容性差。Java Hook需要每个Android版本去兼容特别是Android P增加对非公开API限制。
</li>
**2. Native Hook**
如果Java Hook不能满足需求我们自然就会考虑Native Hook方案。Profilo使用到是PLT Hook方案它的性能比[GOT Hook](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-android-commons/src/main/cpp/elf_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](https://github.com/Tencent/matrix/blob/master/matrix/matrix-android/matrix-io-canary/src/main/cpp/io_canary_jni.cc#L161)。
不过我更推荐Profilo中[atrace.cpp](https://github.com/facebookincubator/profilo/blob/master/cpp/atrace/Atrace.cpp#L172)的做法它直接遍历所有已经加载的library一并替换。
```
void hookLoadedLibs() {
auto&amp; functionHooks = getFunctionHooks();
auto&amp; seenLibs = getSeenLibs();
facebook::profilo::hooks::hookLoadedLibs(functionHooks, seenLibs);
}
```
不同版本的Android系统实现有所不同在Android 7.0之后,我们还需要替换下面这三个方法。
```
open64
__read_chk
__write_chk
```
**3. 监控内容**
在实现I/O跟踪后我们需要进一步思考需要监控哪些I/O信息。假设读取一个文件我们希望知道这个文件的名字、原始大小、打开文件的堆栈、使用了什么线程这些基本信息。
接着我们还希望得到这一次操作一共使用了多长时间使用的Buffer是多大的。是一次连续读完的还是随机的读取。通过上面Hook的四个接口我们可以很容易的采集到这些信息。
<img src="https://static001.geekbang.org/resource/image/ba/3c/ba36f8e259427bde06bc44861905c63c.png" alt="">
下面是一次I/O操作的基本信息在主线程对一个大小为600KB的“test.db”文件。
<img src="https://static001.geekbang.org/resource/image/07/ae/0732644e3734490825c896fa559bcaae.png" alt="">
使用了4KB的Buffer连续读取150次一次性把整个文件读完整体的耗时是10ms。因为连读读写时间和打开文件的总时间相同我们可以判断出这次read()操作是一气呵成的,中间没有间断。
<img src="https://static001.geekbang.org/resource/image/aa/fb/aab6899b0b7a91f466e187333337dcfb.png" alt="">
因为I/O操作真的非常频繁采集如此多的信息对应用程序的性能会造成多大的影响呢我们可以看看是否使用Native Hook的耗时数据。
<img src="https://static001.geekbang.org/resource/image/f0/5f/f0394337bee26e8bf105cfd1eda37a5f.png" alt="">
你可以看到采用Native Hook的监控方法性能损耗基本可以忽略这套方案可以用于线上。
## 线上监控
通过Native Hook方式可以采集到所有的I/O相关的信息但是采集到的信息非常多我们不可能把所有信息都上报到后台进行分析。
对于I/O的线上监控我们需要进一步抽象出规则明确哪些情况可以定义为不良情况需要上报到后台进而推动开发去解决。
<img src="https://static001.geekbang.org/resource/image/9c/3a/9c408d0ec409771c2a036f0208cadf3a.png" alt="">
**1. 主线程I/O**
我不止一次说过有时候I/O的写入会突然放大即使是几百KB的数据还是尽量不要在主线程上操作。在线上也会经常发现一些I/O操作明明数据量不大但是最后还是ANR了。
当然如果把所有的主线程I/O都收集上来这个数据量会非常大所以我会添加“连续读写时间超过100毫秒”这个条件。之所以使用连续读写时间是因为发现有不少案例是打开了文件句柄但不是一次读写完的。
在上报问题到后台时为了能更好地定位解决问题我通常还会把CPU使用率、其他线程的信息以及内存信息一并上报辅助分析问题。
**2. 读写Buffer过小**
我们知道对于文件系统是以block为单位读写对于磁盘是以page为单位读写看起来即使我们在应用程序上面使用很小的Buffer在底层应该差别不大。那是不是这样呢
```
read(53, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000447&gt;
read(53, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000084&gt;
read(53, &quot;*****************&quot;\.\.\., 1024) = 1024 &lt;0.000059&gt;
```
虽然后面两次系统调用的时间的确会少一些但是也会有一定的耗时。如果我们的Buffer太小会导致多次无用的系统调用和内存拷贝导致read/write的次数增多从而影响了性能。
那应该选用多大的Buffer呢我们可以跟据文件保存所挂载的目录的block size来确认Buffer大小数据库中的[pagesize](http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/database/sqlite/SQLiteGlobal.java#61)就是这样确定的。
```
new StatFs(&quot;/data&quot;).getBlockSize()
```
所以我们最终选择的判断条件为:
<li>
buffer size小于block size这里一般为4KB。
</li>
<li>
read/write的次数超过一定的阈值例如5次这主要是为了减少上报量。
</li>
buffer size不应该小于4KB那它是不是越大越好呢你可以通过下面的命令做一个简单的测试读取测试应用的iotest文件它的大小是40M。其中bs就是buffer sizebs分别使用不同的值然后观察耗时。
```
// 每次测试之前需要手动释放缓存
echo 3 &gt; /proc/sys/vm/drop_caches
time dd if=/data/data/com.sample.io/files/iotest of=/dev/null bs=4096
```
<img src="https://static001.geekbang.org/resource/image/f8/7e/f8dc9ca5d2b45f278e881cc2cd50317e.png" alt="">
通过上面的数据大致可以看出来Buffer的大小对文件读写的耗时有非常大的影响。耗时的减少主要得益于系统调用与内存拷贝的优化Buffer的大小一般我推荐使用4KB以上。
在实际应用中ObjectOutputStream和ZipOutputStream都是一个非常经典的例子ObjectOutputStream使用的buffer size非常小。而ZipOutputStream会稍微复杂一些如果文件是Stored方式存储的它会使用上层传入的buffer size。如果文件是Deflater方式存储的它会使用DeflaterOutputStream的buffer size这个大小默认是512Byte。
**你可以看到如果使用BufferInputStream或者ByteArrayOutputStream后整体性能会有非常明显的提升。**
<img src="https://static001.geekbang.org/resource/image/81/f5/81fa5cac7b7c91e0687c11fb83e35df5.png" alt="">
正如我上一期所说的准确评估磁盘真实的读写次数是比较难的。磁盘内部也会有很多的策略例如预读。它可能发生超过你真正读的内容预读在有大量顺序读取磁盘的时候readahead可以大幅提高性能。但是大量读取碎片小文件的时候可能又会造成浪费。
你可以通过下面的这个文件查看预读的大小一般是128KB。
```
/sys/block/[disk]/queue/read_ahead_kb
```
一般来说,我们可以利用/proc/sys/vm/block_dump或者[/proc/diskstats](https://www.kernel.org/doc/Documentation/iostats.txt)的信息统计真正的磁盘读写次数。
```
/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 &gt; /proc/sys/vm/drop_caches
```
如果频繁地读取某个文件,并且这个文件一直没有被写入更新,我们可以通过缓存来提升性能。不过为了减少上报量,我会增加以下几个条件:
<li>
重复读取次数超过3次并且读取的内容相同。
</li>
<li>
读取期间文件内容没有被更新也就是没有发生过write。
</li>
加一层内存cache是最直接有效的办法比较典型的场景是配置文件等一些数据模块的加载如果没有内存cache那么性能影响就比较大了。
```
public String readConfig() {
if (Cache != null) {
return cache;
}
cache = read(&quot;configFile&quot;);
return cache;
}
```
**4. 资源泄漏**
在崩溃分析中我说过有部分的OOM是由于文件句柄泄漏导致。资源泄漏是指打开资源包括文件、Cursor等没有及时close从而引起泄露。这属于非常低级的编码错误但却非常普遍存在。
如何有效的监控资源泄漏这里我利用了Android框架中的StrictModeStrictMode利用[CloseGuard.java](http://androidxref.com/8.1.0_r33/xref/libcore/dalvik/src/main/java/dalvik/system/CloseGuard.java)类在很多系统代码已经预置了埋点。
到了这里接下来还是查看源码寻找可以利用的Hook点。这个过程非常简单CloseGuard中的REPORTER对象就是一个可以利用的点。具体步骤如下
<li>
利用反射把CloseGuard中的ENABLED值设为true。
</li>
<li>
利用动态代理把REPORTER替换成我们定义的proxy。
</li>
虽然在Android源码中StrictMode已经预埋了很多的资源埋点。不过肯定还有埋点是没有的比如MediaPlayer、程序内部的一些资源模块。所以在程序中也写了一个MyCloseGuard类对希望增加监控的资源可以手动增加埋点代码。
## I/O与启动优化
通过I/O跟踪可以拿到整个启动过程所有I/O操作的详细信息列表。我们需要更加的苛刻地检查每一处I/O调用检查清楚是否每一处I/O调用都是必不可少的特别是write()。
当然主线程I/O、读写Buffer、重复读以及资源泄漏是首先需要解决的特别是重复读比如cpuinfo、手机内存这些信息都应该缓存起来。
对于必不可少的I/O操作我们需要思考是否有其他方式做进一步的优化。
<li>
对大文件使用mmap或者NIO方式。[MappedByteBuffer](https://developer.android.com/reference/java/nio/MappedByteBuffer)就是Java NIO中的mmap封装正如上一期所说对于大文件的频繁读写会有比较大的优化。
</li>
<li>
安装包不压缩。对启动过程需要的文件我们可以指定在安装包中不压缩这样也会加快启动速度但带来的影响是安装包体积增大。事实上Google Play非常希望我们不要去压缩library、resource、resource.arsc这些文件这样对启动的内存和速度都会有很大帮助。而且不压缩文件带来只是安装包体积的增大对于用户来说Download size并没有增大。
</li>
<li>
Buffer复用。我们可以利用[Okio](https://github.com/square/okio)开源库它内部的ByteString和Buffer通过重用等技巧很大程度上减少CPU和内存的消耗。
</li>
<li>
存储结构和算法的优化。是否可以通过算法或者数据结构的优化让我们可以尽量的少I/O甚至完全没有I/O。比如一些配置文件从启动完全解析改成读取时才解析对应的项替换掉XML、JSON这些格式比较冗余、性能比较较差的数据结构当然在接下来我还会对数据存储这一块做更多的展开。
</li>
2013年我在做Multidex优化的时候发现代码中会先将classes2.dex从APK文件中解压出来然后再压缩到classes2.zip文件中。classes2.dex做了一次无用的解压和压缩其实根本没有必要。
<img src="https://static001.geekbang.org/resource/image/e7/7e/e73a4aa5919f991df734b7d39fec447e.png" alt="">
那个时候通过研究ZIP格式的源码我发现只要能构造出一个符合ZIP格式的文件那就可以直接将classses2.dex的压缩流搬到classes2.zip中。整个过程没有任何一次解压和压缩这个技术也同样应用到[Tinker的资源合成](https://github.com/Tencent/tinker/tree/master/third-party/tinker-ziputils)中。
## 总结
今天我们学习了如何在应用层面监控I/O的使用情况从实现上尝试了Java Hook和Native Hook两种方案最终考虑到性能和兼容性选择了Native Hook方案。
对于Hook方案的选择在同等条件下我会优先选择Java Hook方案。但无论采用哪种Hook方案我们都需要耐心地查看源码、分析调用流程从而寻找可以利用的地方。
一套监控方案是只用在实验室自动化测试还是直接交给用户线上使用这两者的要求是不同的后者需要99.9%稳定性,还要具备不影响用户体验的高性能才可以上线。从实验室到线上,需要大量的灰度测试以及反复的优化迭代过程。
## 课后练习
微信的性能监控分析工具[Matrix](https://github.com/Tencent/matrix)终于开源了,文中大部分内容都是基于[matrix-io-canary](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-io-canary)的分析。今天的课后作业是尝试接入I/O Canary查看一下自己的应用是否存在I/O相关的问题请你在留言区跟同学们分享交流一下你的经验。
是不是觉得非常简单?我还有一个进阶的课后练习。在[io_canary_jni.cc](https://github.com/Tencent/matrix/blob/master/matrix/matrix-android/matrix-io-canary/src/main/cpp/io_canary_jni.cc#L224)中发现目前Matrix只监控了主线程的I/O运行情况这主要为了解决多线程同步问题。
```
//todo 解决非主线程打开,主线程操作问题
int ProxyOpen(const char *pathname, int flags, mode_t mode) {
```
事实上其他线程使用I/O不当也会影响到应用的性能“todo = never do”今天就请你来尝试解决这个问题吧。但是考虑到性能的影响我们不能简单地直接加锁。针对这个case是否可以做到完全无锁的线程安全或者可以尽量降低锁的粒度呢我邀请你一起来研究这个问题给Matrix提交Pull request参与到开源的事业中吧。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,198 @@
<audio id="audio" title="12 | 存储优化(上):常见的数据存储方法有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/0b/3d284cacb92fc7a4a5bf0b16b093ab0b.mp3"></audio>
通过专栏前面我讲的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命令的运行结果。
<img src="https://static001.geekbang.org/resource/image/a1/1c/a1036cee7c76e900b146e0875587601c.png" alt="">
什么是分区呢分区简单来说就是将设备中的存储划分为一些互不重叠的部分每个部分都可以单独格式化用作不同的目的。这样系统就可以灵活的针对单独分区做不同的操作例如在系统还原recovery过程我们不希望会影响到用户存储的数据。
<img src="https://static001.geekbang.org/resource/image/e7/31/e7440b651fec65f40ac324065b3ed331.png" alt="">
从上面的表中你可以看到,每个分区非常独立,**不同的分区可以使用的不同的文件系统**。其中比较重要的有:
<li>
/system分区它是存放所有Google提供的Android组件的地方。这个分区只能以只读方式mount。这样主要基于稳定性和安全性考虑即使发生用户突然断电的情况也依然需要保证/system分区的内容不会受到破坏和篡改。
</li>
<li>
/data分区它是所有用户数据存放的地方。主要为了实现数据隔离即系统升级和恢复的时候会擦除整个/system分区但是却不会影响/data的用户数据。而恢复出厂设置只会擦除/data的数据。
</li>
<li>
/vendor分区它是存放厂商特殊系统修改的地方。特别是在Android 8.0以后,隆重推出了[“Treble”项目](https://source.android.com/devices/architecture)。厂商OTA时可以只更新自己的/vendor分区即可让厂商能够以更低的成本更轻松、更快速地将设备更新到新版Android系统。
</li>
**2. Android存储安全**
除了数据的分区隔离存储安全也是Android系统非常重要的一部分****存储安全首先考虑的是权限控制。
**第一,权限控制**
Android的每个应用都在自己的[应用沙盒](https://source.android.google.cn/security/app-sandbox)内运行,在 Android 4.3之前的版本中这些沙盒使用了标准Linux的保护机制通过为每个应用创建独一无二的Linux UID来定义。简单来说我们需要保证微信不能访问淘宝的数据并且在没有权限的情况下也不能访问系统的一些保护文件。
在Android 4.3引入了[SELinux](https://source.android.google.cn/security/selinux)Security Enhanced Linux机制进一步定义Android应用沙盒的边界。那它有什么特别的呢它的作用是即使我们进程有root权限也不能为所欲为如果想在SELinux系统中干任何事情都必须先在专门的安全策略配置文件中赋予权限。
**第二,数据加密**
除了权限的控制,用户还会担心在手机丢失或者被盗导致个人隐私数据泄露。加密或许是一个不错的选择,它可以保护丢失或被盗设备上的数据。
Android有两种[设备加密方法](https://source.android.google.cn/security/encryption):全盘加密和文件级加密。[全盘加密](https://source.android.google.cn/security/encryption/full-disk)是在Android 4.4中引入的并在Android 5.0中默认打开。它会将/data分区的用户数据操作加密/解密,对性能会有一定的影响,但是新版本的芯片都会在硬件中提供直接支持。
我们知道基于文件系统的加密如果设备被解锁了加密也就没有用了。所以Android 7.0增加了[基于文件的加密](https://source.android.google.cn/security/encryption/file-based)。在这种加密模式下将会给每个文件都分配一个必须用用户的passcode推导出来的密钥。特定的文件被屏幕锁屏之后直到用户下一次解锁屏幕期间都不能访问。
可能有些同学会问了Android的这两种设备加密方法跟应用的加密有什么不同我们在应用存储还需要单独的给敏感文件加密吗
我想说的是设备加密方法对应用程序来说是透明的它保证我们读取到的是解密后的数据。对于应用程序特别敏感的数据我们也需要采用RSA、AES、chacha20等常用方式做进一步的存储加密。
## 常见的数据存储方法
Android为我们提供了很多种持久化存储的方案在具体介绍它们之前你需要先问一下自己什么是存储
每个人可能都会有自己的答案在我看来存储就是把特定的数据结构转化成可以被记录和还原的格式这个数据格式可以是二进制的也可以是XML、JSON、Protocol Buffer这些格式。
对于闪存来说一切归根到底还是二进制的XML、JSON它们只是提供了一套通用的二进制编解码格式规范。既然有那么多存储的方案那我们在选择数据存储方法时一般需要考虑哪些关键要素呢
**1. 关键要素**
在选择数据存储方法时,我一般会想到下面这几点,我把它们总结给你。
<img src="https://static001.geekbang.org/resource/image/63/c3/63ef4665d77714c0704f11d82a5f0ac3.png" alt="">
那上面这些要素哪个最重要呢?数据存储方法不能脱离场景来考虑,我们不可能把这六个要素都做成最完美。
我来解释一下这句话。如果首要考虑的是正确性,那我们可能需要采用冗余、双写等方案,那就要容忍对时间开销产生的额外影响。同样如果非常在意安全,加解密环节的开销也必不可小。如果想针对启动场景,我们希望选择在初始化时间和读取时间更有优势的方案。
**2. 存储选项**
总的来说我们需要结合应用场景选择合适的数据存储方法。那Android为应用开发者提供了哪些存储数据的方法呢你可以参考[存储选项](https://developer.android.com/guide/topics/data/data-storage),综合来看,有下面几种方法。
<li>
SharedPreferences
</li>
<li>
ContentProvider
</li>
<li>
文件
</li>
<li>
数据库
</li>
今天我先来讲SharedPreferences和ContentProvider这两个存储方法文件和数据库将放到“存储优化”后面两期来讲。
**第一SharedPreferences的使用。**
[SharedPreferences](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/android/content/SharedPreferences.java)是Android中比较常用的存储方法它可以用来存储一些比较小的键值对集合。
虽然SharedPreferences使用非常简便但也是我们诟病比较多的存储方法。它的性能问题比较多我可以轻松地说出它的“七宗罪”。
<li>
跨进程不安全。由于没有使用跨进程的锁,就算使用[MODE_MULTI_PROCESS](https://developer.android.com/reference/android/content/Context)SharedPreferences在跨进程频繁读写有可能导致数据全部丢失。根据线上统计SP大约会有万分之一的损坏率。
</li>
<li>
加载缓慢。SharedPreferences文件的加载使用了异步线程而且加载线程并没有设置线程优先级如果这个时候主线程读取数据就需要等待文件加载线程的结束。**这就导致出现主线程等待低优先级线程锁的问题比如一个100KB的SP文件读取等待时间大约需要50~100ms我建议提前用异步线程预加载启动过程用到的SP文件。**
</li>
<li>
全量写入。无论是调用commit()还是apply()即使我们只改动其中的一个条目都会把整个内容全部写到文件。而且即使我们多次写入同一个文件SP也没有将多次修改合并为一次这也是性能差的重要原因之一。
</li>
<li>
卡顿。由于提供了异步落盘的apply机制在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播或者被调用onPause等一些时机系统会强制把所有的SharedPreferences对象数据落地到磁盘。如果没有落地完成这时候主线程会被一直阻塞。**这样非常容易造成卡顿甚至是ANR从线上数据来看SP卡顿占比一般会超过5%。**
</li>
讲到这里如果你对SharedPreferences机制还不熟悉的话可以参考[《彻底搞懂SharedPreferences》](https://juejin.im/entry/597446ed6fb9a06bac5bc630)。
坦白来讲,**系统提供的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](https://github.com/Tencent/MMKV)。
下面是MMKV对于SharedPreferences的“六要素”对比。
<img src="https://static001.geekbang.org/resource/image/f1/ea/f1aadf32f59291e428be7591a38668ea.png" alt="">
你可以参考MMKV的[实现原理](https://github.com/Tencent/MMKV/wiki/design)和[性能测试报告](https://github.com/Tencent/MMKV/wiki/android_benchmark_cn),里面有一些非常不错的思路。例如[利用文件锁保证跨进程的安全](https://github.com/Tencent/MMKV/wiki/android_ipc)、使用mmap保证数据不会丢失、选用性能和存储空间更好的Protocol Buffer代替XML、支持增量更新等。
根据I/O优化的分析对于频繁修改的配置使用mmap的确非常合适使用者不用去理解apply()和commit()的差别,也不用担心数据的丢失。同时,我们也不需要每次都提交整个文件,整体性能会有很大提升。
**第二ContentProvider的使用。**
为什么Android系统不把SharedPreferences设计成跨进程安全的呢那是因为Android系统更希望我们在这个场景选择使用ContentProvider作为存储方式。ContentProvider作为Android四大组件中的一种为我们提供了不同进程甚至是不同应用程序之间共享数据的机制。
Android系统中比如相册、日历、音频、视频、通讯录等模块都提供了ContentProvider的访问支持。它的使用十分简单你可以参考[官方文档](https://developer.android.com/guide/topics/providers/content-providers)。
当然,在使用过程也有下面几点需要注意。
- 启动性能
ContentProvider的生命周期默认在Application onCreate()之前而且都是在主线程创建的。我们自定义的ContentProvider类的构造函数、静态代码块、onCreate函数都尽量不要做耗时的操作会拖慢启动速度。
<img src="https://static001.geekbang.org/resource/image/b2/40/b2dcd6744561a4e219235c874491fa40.png" alt="">
可能很多同学都不知道ContentProvider还有一个多进程模式它可以和AndroidManifest中的multiprocess属性结合使用。这样调用进程会直接在自己进程里创建一个push进程的Provider实例就不需要跨进程调用了。需要注意的是这样也会带来Provider的多实例问题。
- 稳定性
ContentProvider在进行跨进程数据传递时利用了Android的Binder和匿名共享内存机制。简单来说就是通过Binder传递CursorWindow对象内部的匿名共享内存的文件描述符。这样在跨进程传输中结果数据并不需要跨进程传输而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存这样来实现不同进程访问相同数据的目的。
<img src="https://static001.geekbang.org/resource/image/1d/b4/1d516441b44e3a7fbd33aca6a96a64b4.png" alt="">
正如我前面I/O优化所讲的基于mmap的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候可能不一定划算。所以ContentProvider提供了一种call函数它会直接通过Binder来传输数据。
Android的Binder传输是有大小限制的一般来说限制是1~2MB。ContentProvider的接口调用参数和call函数调用并没有使用匿名共享机制比如要批量插入很多数据那么就会出现一个插入数据的数组如果这个数组太大了那么这个操作就可能会出现数据超大异常。
- 安全性
虽然ContentProvider为应用程序之间的数据共享提供了很好的安全机制但是如果ContentProvider是exported当支持执行SQL语句时就需要注意SQL注入的问题。另外如果我们传入的参数是一个文件路径然后返回文件的内容这个时候也要校验合法性不然整个应用的私有数据都有可能被别人拿到在intent传递参数的时候可能经常会犯这个错误。
最后我给你总结一下ContentProvider的“六要素”优缺点。
<img src="https://static001.geekbang.org/resource/image/67/26/677a736dfdcb8cc4e3d7157a58fe7726.png" alt="">
总的来说ContentProvider这套方案实现相对比较笨重适合传输大的数据。
## 总结
虽然SharedPreferences和ContentProvider都是我们日常经常使用的存储方法但是里面的确会有大大小小的暗坑。所以我们需要充分了解它们的优缺点这样在工作中可以更好地使用和优化。
如何在合适的场景选择合适的存储方法是存储优化的必修课,你应该学会通过正确性、时间开销、空间开销、安全、开发成本以及兼容性这六大关键要素来分解某个存储方法。
在设计某个存储方案的时候也是同样的道理,我们无法同时把所有的要素都做得最好,因此要学会取舍和选择,在存储的世界里不存在全局最优解,我们要找的是局部的最优解。这个时候更应明确自己的诉求,大胆牺牲部分关键点的指标,将自己场景最关心的要素点做到最好。
## 课后作业
下面是MMKV给出的性能测试报告你可以看到跟系统的SharedPreferences相比主要差距在于写的速度。
<img src="https://static001.geekbang.org/resource/image/5d/bd/5df2fdd4c21452641afacb0dfd02cabd.png" alt="">
没有实践就没有发言权今天我们一起来尝试测试对比MMKV与系统SharedPreferences的性能差异。请将你的测试结果和分析体会写在留言区跟同学们分享交流吧。
今天的练习[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter12)是通过复写Application的getSharedPreferences方法替换系统默认实现这种方式虽然不是最好的方法不过它主要的优点在于代码的侵入性比较低无需修改太多的代码。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,269 @@
<audio id="audio" title="13 | 存储优化(中):如何优化数据存储?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/2b/399e32d0b42c6c8187018583485db12b.mp3"></audio>
“将特定结构的数据转化为另一种能被记录和还原的格式”,这是我在上一期对存储下的一个定义。
再来复习一下数据存储的六个关键要素:正确性、时间开销、空间开销、安全、开发成本和兼容性。我们不可能同时把所有要素都做到最好,所谓数据存储优化就是根据自己的使用场景去把其中的一项或者几项做到最好。
更宽泛来讲,我认为数据存储不一定就是将数据存放到磁盘中,比如放到内存中、通过网络传输也可以算是存储的一种形式。或者我们也可以把这个过程叫作对象或者数据的序列化。
对于大部分的开发者来说我们不一定有精力去“创造”一种数据序列化的格式所以我今天主要来讲讲Android常用的序列化方法如何进行选择。
## 对象的序列化
应用程序中的对象存储在内存中,如果我们想把对象存储下来或者在网络上传输,这个时候就需要用到对象的序列化和反序列化。
对象序列化就是把一个Object对象所有的信息表示成一个字节序列这包括Class信息、继承关系信息、访问权限、变量类型以及数值信息等。
**1. Serializable**
Serializable是Java原生的序列化机制在Android中也有被广泛使用。我们可以通过Serializable将对象持久化存储也可以通过Bundle传递Serializable的序列化数据。
**Serializable的原理**
Serializable的原理是通过ObjectInputStream和ObjectOutputStream来实现的我们以Android 6.0的源码为例,你可以看到[ObjectOutputStream](http://androidxref.com/6.0.0_r1/xref/libcore/luni/src/main/java/java/io/ObjectOutputStream.java#927)的部分源码实现:
```
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 件事》](https://www.ibm.com/developerworks/cn/java/j-5things1/index.html)这篇文章。
<li>
writeObject和readObject方法。Serializable序列化支持替代默认流程它会先反射判断是否存在我们自己实现的序列化方法writeObject或反序列化方法readObject。**通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能。**
</li>
<li>
writeReplace和readResolve方法。这两个方法代理序列化的对象可以实现自定义返回的序列化实例。那它有什么用呢我们可以通过它们实现对象序列化的版本兼容例如通过readResolve方法可以把老版本的序列化对象转换成新版本的对象类型。
</li>
Serializable的序列化与反序列化的调用流程如下。
```
// 序列化
E/test:SerializableTestData writeReplace
E/test:SerializableTestData writeObject
// 反序列化
E/test:SerializableTestData readObject
E/test:SerializableTestData readResolve
```
**Serializable的注意事项**
Serializable虽然使用非常简单但是也有一些需要注意的事项字段。
<li>
不被序列化的字段。类的static变量以及被声明为transient的字段默认的序列化机制都会忽略该字段不会进行序列化存储。**当然我们也可以使用进阶的writeReplace和readResolve方法做自定义的序列化存储。**
</li>
<li>
serialVersionUID。在类实现了Serializable接口后我们需要添加一个Serial Version ID它相当于类的版本号。这个ID我们可以显式声明也可以让编译器自己计算。**通常我建议显式声明会更加稳妥**因为隐式声明假如类发生了一点点变化进行反序列化都会由于serialVersionUID改变而导致InvalidClassException异常。
</li>
<li>
构造方法。Serializable的反序列默认是不会执行构造函数的它是根据数据流中对Object的描述信息创建对象的。如果一些逻辑依赖构造函数就可能会出现问题例如一个静态变量只在构造函数中赋值当然我们也可以通过进阶方法做自定义的反序列化修改。
</li>
**2. Parcelable**
由于Java的Serializable的性能较低Android需要重新设计一套更加轻量且高效的对象序列化和反序列化机制。Parcelable正是在这个背景下产生的它核心的作用就是为了解决Android中大量跨进程通信的性能问题。
**Parcelable的永久存储**
Parcelable的原理十分简单它的核心实现都在[Parcel.cpp](http://androidxref.com/6.0.0_r1/xref/frameworks/native/libs/binder/Parcel.cpp)。
你可以发现Parcel序列化和Java的Serializable序列化差别还是比较大的Parcelable只会在内存中进行序列化操作并不会将数据存储到磁盘里。
当然我们也可以通过[Parcel.java](http://androidxref.com/6.0.0_r1/xref/frameworks/base/core/java/android/os/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的永久存储但是它也存在两个问题。
<li>
系统版本的兼容性。由于Parcelable设计本意是在内存中使用的我们无法保证所有Android版本的[Parcel.cpp](http://androidxref.com/6.0.0_r1/xref/frameworks/native/libs/binder/Parcel.cpp)实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。
</li>
<li>
数据前后兼容性。Parcelable并没有版本管理的设计如果我们类的版本出现升级写入的顺序及字段类型的兼容都需要格外注意这也带来了很大的维护成本。
</li>
一般来说如果需要持久化存储的话一般还是不得不选择性能更差的Serializable方案。
**3. Serial**
作为程序员,我们肯定会追求完美。那有没有性能更好的方案并且可以解决这些痛点呢?
事实上关于序列化基本每个大公司都会自己自研的一套方案我在专栏里推荐Twitter开源的高性能序列化方案[Serial](https://github.com/twitter/Serial/blob/master/README-CHINESE.rst/)。那它是否真的是高性能呢?我们可以将它和前面的两套方案做一个对比测试。
<img src="https://static001.geekbang.org/resource/image/71/df/71e3e58ed10ecc09646101ec22e360df.png" alt="">
从图中数据上看来Serial在序列化与反序列化耗时以及落地的文件大小都有很大的优势。
从实现原理上看Serial就像是把Parcelable和Serializable的优点集合在一起的方案。
<li>
由于没有使用反射,相比起传统的反射序列化方案更加高效,具体你可以参考上面的测试数据。
</li>
<li>
开发者对于序列化过程的控制较强可定义哪些Object、Field需要被序列化。
</li>
<li>
有很强的debug能力可以调试序列化的过程。
</li>
<li>
有很强的版本管理能力可以通过版本号和OptionalFieldException做兼容。
</li>
## 数据的序列化
Serial性能看起来还不错但是对象的序列化要记录的信息还是比较多在操作比较频繁的时候对应用的影响还是不少的这个时候我们可以选择使用数据的序列化。
**1. JSON**
JSON是一种轻量级的数据交互格式它被广泛使用在网络传输中很多应用与服务端的通信都是使用JSON格式进行交互。
JSON的确有很多得天独厚的优势主要有
<li>
相比对象序列化方案,速度更快,体积更小。
</li>
<li>
相比二进制的序列化方案,结果可读,易于排查问题。
</li>
<li>
使用方便,支持跨平台、跨语言,支持嵌套引用。
</li>
因为每个应用基本都会用到JSON所以每个大厂也基本都有自己的“轮子”。例如Android自带的JSON库、Google的[Gson](https://github.com/google/gson)、阿里巴巴的[Fastjson](https://github.com/alibaba/fastjson/wiki/Android%E7%89%88%E6%9C%AC)、美团的[MSON](https://tech.meituan.com/MSON.html)。
各个自研的JSON方案主要在下面两个方面进行优化
<li>
便利性。例如支持JSON转换成JavaBean对象支持注解支持更多的数据类型等。
</li>
<li>
性能。减少反射减少序列化过程内存与CPU的使用特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显。
</li>
<img src="https://static001.geekbang.org/resource/image/98/78/984bb5dc6300f25ba142c208bdca3178.png" alt="">
在数据量比较少的时候系统自带的JSON库还稍微有一些优势。但在数据量大了之后差距逐渐被拉开。总的来说Gson的兼容性最好一般情况下它的性能与Fastjson相当。但是在数据量极大的时候Fastjson的性能更好。
**2. Protocol Buffers**
相比对象序列化方案JSON的确速度更快、体积更小。不过为了保证JSON的中间结果是可读的它并没有做二进制的压缩也因此JSON的性能还没有达到极致。
如果应用的数据量非常大,又或者对性能有更高的要求,此时[Protocol Buffers](https://developers.google.com/protocol-buffers/docs/overview)是一个非常好的选择。它是Google开源的跨语言编码协议Google内部的几乎所有RPC都在使用这个协议。
下面我来总结一下它的优缺点。
<li>
性能。使用了二进制编码压缩相比JSON体积更小编解码速度也更快感兴趣的同学可以参考[protocol-buffers编码规则](https://developers.google.com/protocol-buffers/docs/encoding)。
</li>
<li>
兼容性。跨语言和前后兼容性都不错,也支持基本类型的自动转换,但是不支持继承与引用类型。
</li>
<li>
使用成本。Protocol Buffers的开发成本很高需要定义.proto文件并用工具生成对应的辅助类。辅助类特有一些序列化的辅助方法所有要序列化的对象都需要先转化为辅助类的对象这让序列化代码跟业务代码大量耦合是侵入性较强的一种方式。
</li>
对于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 体验》](https://www.race604.com/flatbuffers-intro/)。最后我再结合“六要素”帮你综合对比一下Serial、JSON、Protocol Buffers这三种序列化方案。
<img src="https://static001.geekbang.org/resource/image/1a/28/1afba11681441b6a8ab8f0d86337ea28.png" alt="">
## 存储监控
通过本地实验我们可以对比不同文件存储方法的性能,但是实验室环境不一定能真实反映用户实际的使用情况,所以我们同样需要对存储建立完善的监控。那么应该监控哪些内容呢?
**1. 性能监控**
正确性、时间开销、空间开销、安全、开发成本和兼容性,对于这六大关键要素来说,在线上我更关注:
- 正确性
在[专栏第9期](http://time.geekbang.org/column/article/74988)中我讲过,应用程序、文件系统或者磁盘都可以导致文件损坏。
在线上我希望可以监控存储模块的损坏率在专栏上一期中也提到SharedPreferences的损坏率大约在万分之一左右而我们内部自研的SharedPreferences的损耗率在十万分之一左右。如何界定一个文件是损坏的对于系统SP我们将损坏定义为文件大小变为0而自研的SP文件头部会有专门的校验字段比如文件长度、关键位置的CRC信息等可以识别出更多的文件损坏场景。在识别出文件损坏之后我们还可以进一步做数据修复等工作。
- 时间开销
存储模块的耗时也是我非常关心的,而线上的耗时监控分为初始化耗时与读写耗时。每个存储模块的侧重点可能不太一样,例如在启动过程中使用的存储模块我们可能希望初始化可以快一些。
同样以系统的SharedPreferences为例在初始化过程它需要读取并解析整个文件如果内容超过1000条初始化的时间可能就需要50100ms。我们内部另外一个支持随机读写的存储模块初始化时间并不会因为存储条数的数量而变化即使有几万条数据初始化时间也在1ms以内。
- 空间开销
空间的占用分为内存空间和ROM空间通常为了性能的提升会采用空间换时间的方式。内存空间需要考虑GC和峰值内存以及在一些数据量比较大的情况会不会出现OOM。ROM空间需要考虑做清理逻辑例如数据超过1000条或者10MB后会触发自动清理或者数据合并。
**2. ROM监控**
除了某个存储模块的监控我们也需要对应用整体的ROM空间做详细监控。为什么呢这是源于我发现有两个经常会遇到的问题。
以前经常会收到用户的负反馈微信的ROM空间为什么会占用2GB之多是因为数据库太大了吗还是其他什么原因那时候我们还真有点不知所措。曾经我们在线上发现一个bug会导致某个配置重复下载相同的内容一个用户可能会下载了几千次。
```
download_1 download_2 download_3 ....
```
线上我们有时候会发现在遍历某个文件夹时会出现卡顿或者ANR。在[专栏第10期](http://time.geekbang.org/column/article/75760)我也讲过文件遍历的耗时跟文件夹下的文件数量有关。曾经我们也出现过一次bug导致某个文件夹下面有几万个文件在遍历这个文件夹时用户手机直接重启了。**需要注意的是文件遍历在API level 26之后建议使用[FileVisitor](https://developer.android.com/reference/java/nio/file/FileVisitor)替代ListFiles整体的性能会好很多。**
ROM监控的两个核心指标是文件总大小与总文件数例如我们可以将文件总大小超过400MB的用户比例定义为**空间异常率**将文件数超过1000个的用户比例定义为**数量异常率**,这样我们就可以持续监控应用线上的存储情况。
当然监控只是第一步核心问题在于如何能快速发现问题。类似卡顿树我们也可以构造用户的存储树然后在后台做聚合。但是用户的整个存储树会非常非常大这里我们需要通过一些剪枝算法。例如只保留最大的3个文件夹每个文件夹保留5个文件但在这5个文件我们需要保留一定的随机性以免所有人都会上传相同的内容。
<img src="https://static001.geekbang.org/resource/image/e0/4e/e06937c99789f58f046b214a660a8a4e.png" alt="">
在监控的同时我们也要有远程控制的能力用户投诉时可以实时拉取这个用户的完整存储树。对线上发现的存储问题我们可以动态下发清理规则例如某个缓存文件夹超过200MB后自动清理、删除某些残留的历史文件等。
## 总结
对于优化存储来说,不同的应用关注点可能不太一样。对小应用来说,可能开发成本是最重要的,我们希望开发效率优先;对于成熟的应用来说,性能会更加重要。因此选择什么样的存储方案,需要结合应用所处的阶段以及使用场景具体问题具体分析。
无论是优化某个存储方案的性能还是应用整体的ROM存储我们可能对存储监控关注比较少。而如果这块出现问题对用户的体验影响还是非常大的。例如我们知道微信占用的ROM空间确实不小为了解决这个问题特别推出了空间清理的功能而且在ROM空间不足等场景会弹框提示用户操作。
## 课后作业
今天的课后作业是,你的应用选择了哪种对象序列化和数据序列化方案?对数据的存储你还有哪些体会?请你在留言区写写你的方案和想法,与其他同学一起交流。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="14 | 存储优化数据库SQLite的使用和优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/20/3f2516d097c71a3e435e0967ecb40220.mp3"></audio>
我们先来复习一下前面讲到的存储方法的使用场景少量的Key Value数据可以直接使用SharedPreferences稍微复杂一些的数据类型也可以通过序列化成JSON或者Protocol Buffers保存并且在开发中获取或者修改数据也很简单。
不过这几种方法可以覆盖所有的存储场景吗?数据量在几百上千条这个量级时它们的性能还可以接受,但如果是几万条的微信聊天记录呢?而且如何实现快速地对某几个联系人的数据做增删改查呢?
对于大数据的存储场景,我们需要考虑稳定性、性能和**可扩展性**这个时候就要轮到今天的“主角”数据库登场了。讲存储优化一定绕不开数据库而数据库这个主题又非常大我也知道不少同学学数据库的过程是从入门到放弃。那么考虑到我们大多是从事移动开发的工作今天我就来讲讲移动端数据库SQLite的使用和优化。
## SQLite的那些事儿
虽然市面上有很多的数据库但受限于库体积和存储空间适合移动端使用的还真不多。当然使用最广泛的还是我们今天的主角SQLite但同样还是有一些其他不错的选择例如创业团队的[Realm](https://github.com/realm/realm-java)、Google的[LevelDB](https://github.com/google/leveldb)等。
在国内那么多的移动团队中微信对SQLite的研究可以算是最深入的。这其实是业务诉求导向的用户聊天记录只会在本地保存一旦出现数据损坏或者丢失对用户来说都是不可挽回的。另一方面微信有很大一批的重度用户他们有几千个联系人、几千个群聊天曾经做过一个统计有几百万用户的数据库竟然大于1GB。对于这批用户如何保证他们可以正常地使用微信是一个非常大的挑战。
所以当时微信专门开展了一个重度用户优化的专项。一开始的时候我们集中在SQLite使用上的优化例如表结构、索引等。但很快就发现由于系统版本的不同SQLite的实现也有所差异经常会出现一些兼容性问题并且也考虑到加密的诉求我们决定单独引入自己的SQLite版本。
“源码在手天下我有”从此开启了一条研究数据库的“不归路”。那时我们投入了几个人专门去深入研究SQLite的源码从SQLite的PRAGMA编译选项、[Cursor实现优化](https://mp.weixin.qq.com/s?__biz=MzAwNDY1ODY2OQ==&amp;mid=2649286603&amp;idx=1&amp;sn=d243dd27f2c6614631241cd00570e853&amp;chksm=8334c349b4434a5fd81809d656bfad6072f075d098cb5663a85823e94fc2363edd28758ab882&amp;mpshare=1&amp;scene=1&amp;srcid=0609GLAeaGGmI4zCHTc2U9ZX#rd)到SQLite源码的优化最后打造出从实验室到线上的整个监控体系。
在2017年我们开源了内部使用的SQLite数据库[WCDB](https://github.com/Tencent/wcdb/wiki)。这里多说两句看一个开源项目是否靠谱就看这个项目对产品本身有多重要。微信开源坚持内部与外部使用同一个版本虽然我现在已经离开了微信团队但还是欢迎有需要的同学使用WCDB。
在开始学习前我要提醒你SQLite的优化同样也很难通过一两篇文章就把每个细节都讲清楚。今天的内容我选择了一些比较重要的知识点并且为你准备了大量的参考资料遇到陌生或者不懂的地方需要结合参考资料反复学习。
**1. ORM**
坦白说可能很多BAT的高级开发工程师都不完全了解SQLite的内部机制也不能正确地写出高效的SQL语句。大部分应用为了提高开发效率会引入ORM框架。ORMObject Relational Mapping也就是对象关系映射用面向对象的概念把数据库中表和对象关联起来可以让我们不用关心数据库底层的实现。
Android中最常用的ORM框架有开源[greenDAO](https://github.com/greenrobot/greenDAO)和Google官方的[Room](https://developer.android.com/training/data-storage/room/)那使用ORM框架会带来什么问题呢
使用ORM框架真的非常简单但是简易性是需要牺牲部分执行效率为代价的具体的损耗跟ORM框架写得好不好很有关系。但可能更大的问题是让很多的开发者的思维固化最后可能连简单的SQL语句都不会写了。
那我们的应用是否应该引入ORM框架呢可能程序员天生追求偷懒为了提高开发效率应用的确应该引入ORM框架。**但是这不能是我们可以不去学习数据库基础知识的理由,只有理解底层的一些机制,我们才能更加得心应手地解决疑难的问题**。
考虑到可以更好的与Android Jetpack的组件互动[WCDB选择Room作为ORM框架](https://github.com/Tencent/wcdb/wiki/Android-WCDB-%E4%BD%BF%E7%94%A8-Room-ORM-%E4%B8%8E%E6%95%B0%E6%8D%AE%E7%BB%91%E5%AE%9A)。
**2. 进程与线程并发**
如果我们在项目中有使用SQLite那么下面这个[SQLiteDatabaseLockedException](https://developer.android.com/reference/android/database/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个状态具体你可以参考下面的文章。
<li>
官方文档:[SQLite locking](https://www.sqlite.org/lockingv3.html)
</li>
<li>
SQLite源码分析[SQLite锁机制简介](http://huili.github.io/lockandimplement/machining.html)
</li>
<li>
[SQLite封锁机制](https://www.cnblogs.com/cchust/p/4761814.html)
</li>
简单来说多进程可以同时获取SHARED锁来读取数据但是只有一个进程可以获取EXCLUSIVE锁来写数据库。对于iOS来说可能没有多进程访问数据库的场景可以把locking_mode的默认值改为EXCLUSIVE。
```
PRAGMA locking_mode = EXCLUSIVE
```
在EXCLUSIVE模式下数据库连接在断开前都不会释放SQLite文件的锁从而避免不必要的冲突提高数据库访问的速度。
**多线程并发**
相比多进程多线程的数据库访问可能会更加常见。SQLite支持多线程并发模式需要开启下面的配置当然系统SQLite会默认开启多线程[Multi-thread模式](https://sqlite.org/threadsafe.html)。
```
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源码优化实践》](https://mp.weixin.qq.com/s/8FjDqPtXWWqOInsiV79Chg)这篇文章。
为了进一步提高并发性能,我们还可以打开[WAL](https://www.sqlite.org/wal.html)Write-Ahead Logging模式。WAL模式会将修改的数据单独写到一个WAL文件中同时也会引入了WAL日志文件锁。通过WAL模式读和写可以完全地并发执行不会互相阻塞。
```
PRAGMA schema.journal_mode = WAL
```
**但是需要注意的是,写之间是仍然不能并发**。如果出现多个写并发的情况依然有可能会出现SQLiteDatabaseLockedException。这个时候我们可以让应用中捕获这个异常然后等待一段时间再重试。
```
} catch (SQLiteDatabaseLockedException e) {
if (sqliteLockedExceptionTimes &lt; (tryTimes - 1)) {
try {
Thread.sleep(100);
} catch (InterruptedException e1) {
}
}
sqliteLockedExceptionTimes++
}
```
**总的来说通过连接池与WAL模式我们可以很大程度上增加SQLite的读写并发大大减少由于并发导致的等待耗时建议大家在应用中可以尝试开启。**
**3. 查询优化**
说到数据库的查询优化你第一个想到的肯定是建索引那我就先来讲讲SQLite的索引优化。
**索引优化**
正确使用索引在大部分的场景可以大大降低查询速度微信的数据库优化也是通过索引开始。下面是索引使用非常简单的一个例子我们先从索引表找到数据对应的rowid然后再从原数据表直接通过rowid查询结果。
<img src="https://static001.geekbang.org/resource/image/57/d9/57fd28e1464b7ffbb1ba2b5379b84ad9.gif" alt="">
关于SQLite索引的原理网上有很多文章在这里我推荐一些参考资料给你
<li>
[SQLite索引的原理](https://www.cnblogs.com/huahuahu/p/sqlite-suo-yin-de-yuan-li-ji-ying-yong.html)
</li>
<li>
官方文档:[Query Planning](https://www.sqlite.org/queryplanner.html#searching)
</li>
<li>
[MySQL索引背后的数据结构及算法原理](http://blog.codinglabs.org/articles/theory-of-mysql-index.html)
</li>
这里的关键在于如何正确的建立索引很多时候我们以为已经建立了索引但事实上并没有真正生效。例如使用了BETWEEN、LIKE、OR这些操作符、使用表达式或者case when等。更详细的规则可参考官方文档[The SQLite Query Optimizer Overview](http://www.sqlite.org/optoverview.html),下面是一个通过优化转换达到使用索引目的的例子。
```
BETWEENmyfiedl索引无法生效
SELECT * FROM mytable WHERE myfield BETWEEN 10 and 20;
转换成myfiedl索引可以生效
SELECT * FROM mytable WHERE myfield &gt;= 10 AND myfield &lt;= 20;
```
建立索引是有代价的,需要一直维护索引表的更新。比如对于一个很小的表来说就没必要建索引;如果一个表经常是执行插入更新操作,那么也需要节制的建立索引。总的来说有几个原则:
<li>
建立正确的索引。这里不仅需要确保索引在查询中真正生效我们还希望可以选择最高效的索引。如果一个表建立太多的索引那么在查询的时候SQLite可能不会选择最好的来执行。
</li>
<li>
单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过复合索引直接在索引表返回查询结果。
</li>
<li>
索引字段的选择。整型类型索引效率会远高于字符串索引而对于主键SQLite会默认帮我们建立索引所以主键尽量不要用复杂字段。
</li>
**总的来说索引优化是SQLite优化中最简单同时也是最有效的但是它并不是简单的建一个索引就可以了有的时候我们需要进一步调整查询语句甚至是表的结构这样才能达到最好的效果。**
**页大小与缓存大小**
在I/O文件系统中我讲过数据库就像一个小文件系统一样事实上它内部也有页和缓存的概念。
对于SQLite的DB文件来说page是最小的存储单位如下图所示每个表对应的数据在整个DB文件中都是通过一个一个的页存储属于同一个表不同的页以B树B-tree的方式组织索引每一个表都是一棵B树。
<img src="https://static001.geekbang.org/resource/image/c9/f3/c9b494da11233c98f2a54abfe2921ef3.png" alt="">
跟文件系统的页缓存Page Cache一样SQLite会将读过的页缓存起来用来加快下一次读取速度。页大小默认是1024Byte缓存大小默认是1000页。更多的编译参数你可以查看官方文档[PRAGMA Statements](https://sqlite.org/pragma.html#pragma_journal_mode)。
```
PRAGMA page_size = 1024
PRAGMA cache_size = 1000
```
每个页永远只存放一个表或者一组索引的数据即不可能同一个页存放多个表或索引的数据表在整个DB文件的第一个页就是这棵B树的根页。继续以上图为例如果想查询rowID为N+2的数据我们首先要从sqlite_master查找出table的root page的位置然后读取root page、page4这两个页所以一共会需要3次I/O。
<img src="https://static001.geekbang.org/resource/image/f2/1c/f232cbaff34236a1933182a02c685a1c.png" alt="">
从上表可以看到增大page size并不能不断地提升性能在拐点以后可能还会有副作用。我们可以通过PRAGMA改变默认page size的大小也可以再创建DB文件的时候进行设置。但是需要注意如果存在老的数据需要调用vacuum对数据表对应的节点重新计算分配大小。
在微信的内部测试中如果使用4KB的page size性能提升可以在5%10%。但是考虑到历史数据的迁移成本最终还是使用1024Byte。**所以这里建议大家在新建数据库的时候就提前选择4KB作为默认的page size以获得更好的性能。**
**其他优化**
关于SQLite的使用优化还有很多很多下面我简单提几个点。
<li>
慎用“`select*`”,需要使用多少列,就选取多少列。
</li>
<li>
正确地使用事务。
</li>
<li>
预编译与参数绑定缓存被编译后的SQL语句。
</li>
<li>
对于blob或超大的Text列可能会超出一个页的大小导致出现超大页。建议将这些列单独拆表或者放到表字段的后面。
</li>
<li>
定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。
</li>
在日常的开发中我们都应该对这些知识有所了解再来复习一下上面学到的SQLite优化方法。**通过引进ORM可以大大的提升我们的开发效率。通过WAL模式和连接池可以提高SQLite的并发性能。通过正确的建立索引可以提升SQLite的查询速度。通过调整默认的页大小和缓存大小可以提升SQLite的整体性能。**
## SQLite的其他特性
除了SQLite的优化经验我在微信的工作中还积累了很多使用的经验下面我挑选了几个比较重要的经验把它分享给你。
**1. 损坏与恢复**
微信中SQLite的损耗率在1/200001/10000左右虽然看起来很低不过意考虑到微信的体量这个问题还是不容忽视的。特别是如果某些大佬的聊天记录丢失我们团队都会承受超大的压力。
创新是为了解决焦虑技术都是逼出来的。对于SQLite损坏与恢复的研究可以说是微信投入比较大的一块。关于SQLite数据库的损耗与修复以及微信在这里的优化成果你可以参考下面这些资料。
<li>
[How To Corrupt An SQLite Database File](https://sqlite.org/howtocorrupt.html)
</li>
<li>
[微信 SQLite 数据库修复实践](https://mp.weixin.qq.com/s/N1tuHTyg3xVfbaSd4du-tw)
</li>
<li>
[微信移动端数据库组件WCDB系列 — 数据库修复三板斧](https://mp.weixin.qq.com/s/Ln7kNOn3zx589ACmn5ESQA)
</li>
<li>
[WCDB Android数据库修复](https://github.com/Tencent/wcdb/wiki/Android%E6%95%B0%E6%8D%AE%E5%BA%93%E4%BF%AE%E5%A4%8D)
</li>
**2. 加密与安全**
数据库的安全主要有两个方面一个是防注入一个是加密。防注入可以通过静态安全扫描的方式而加密一般会使用SQLCipher支持。
SQLite的加解密都是以页为单位默认会使用AES算法加密加/解密的耗时跟选用的密钥长度有关。下面是[WCDB Android Benchmark](https://github.com/Tencent/wcdb/wiki/Android-Benchmark)的数据详细的信息请查看链接里的说明从结论来说对Create来说影响会高达到10倍。
<img src="https://static001.geekbang.org/resource/image/5f/81/5f5ac545e346a45f1c3fcfa504300b81.png" alt="">
关于WCDB加解密的使用你可以参考[《微信移动数据库组件WCDB — Android 特性篇》](https://mp.weixin.qq.com/s/NFnYEXSxAaHBqpi7WofSPQ)。
**3. 全文搜索**
微信的全文搜索也是一个技术导向的项目,最开始的时候性能并不是很理想,经常会被人“批斗”。经过几个版本的优化迭代,目前看效果还是非常不错的。
<img src="https://static001.geekbang.org/resource/image/d2/d9/d2a09d040d0d915e78d7598457d6d1d9.png" alt="">
关于全文搜索,你可以参考这些资料:
<li>
[SQLite FTS3 and FTS4 Extensions](https://sqlite.org/fts3.html)
</li>
<li>
[微信全文搜索优化之路](https://mp.weixin.qq.com/s/AhYECT3HVyn1ikB0YQ-UVg)
</li>
<li>
[移动客户端多音字搜索](https://mp.weixin.qq.com/s/GCznwCtjJ2XUszyMcbNz8Q)
</li>
**关于SQLite的这些特性我们需要根据自己的项目情况综合考虑。假如某个数据库存储的数据并不重要这个时候万分之一的数据损坏率我们并不会关心。同样是否需要使用数据库加密也要根据存储的数据是不是敏感内容。**
## SQLite的监控
首先我想说正确使用索引正确使用事务。对于大型项目来说参与的开发人员可能有几十几百人开发人员水平参差不齐很难保证每个人都可以正确而高效地使用SQLite所以这次时候需要建立完善的监控体系。
**1. 本地测试**
作为一名靠谱的开发工程师我们每写一个SQL语句都应该先在本地测试。我们可以通过 EXPLAIN QUERY PLAN测试SQL语句的查询计划是全表扫描还是使用了索引以及具体使用了哪个索引等。
```
sqlite&gt; EXPLAIN QUERY PLAN SELECT * FROM t1 WHERE a=1 AND b&gt;2;
QUERY PLAN
|--SEARCH TABLE t1 USING INDEX i2 (a=? AND b&gt;?)
```
关于SQLite命令行与EXPLAIN QUERY PLAN的使用可以参考[Command Line Shell For SQLite](https://sqlite.org/cli.html)以及[EXPLAIN QUERY PLAN](https://sqlite.org/eqp.html)。
**2. 耗时监控**
本地测试过于依赖开发人员的自觉性所以很多时候我们依然需要建立线上大数据的监控。因为微信集成了自己的SQLite源码所以可以非常方便地增加自己想要的监控模块。
WCDB增加了[SQLiteTrace](https://tencent.github.io/wcdb/references/android/reference/com/tencent/wcdb/database/SQLiteTrace.html)的监控模块,有以下三个接口:
<img src="https://static001.geekbang.org/resource/image/f3/8a/f3f0b4b43e4911dc0655694fca90358a.png" alt="">
我们可以通过这些接口监控数据库busy、损耗以及执行耗时。针对耗时比较长的SQL语句需要进一步检查是SQL语句写得不好还是需要建立索引。
<img src="https://static001.geekbang.org/resource/image/12/5d/12c3d1b494fc4fb60fa0a429a8ff805d.png" alt="">
**3. 智能监控**
对于查询结果的监控只是我们监控演进的第二阶段,在这个阶段我们依然需要人工介入分析,而且需要比较有经验的人员负责。
我们希望SQL语句的分析可以做到智能化是完全不需要门槛的。微信开源的Matrix里面就有一个智能化分析SQLite语句的工具[Matrix SQLiteLint SQLite 使用质量检测](https://mp.weixin.qq.com/s/laUgOmAcMiZIOfM2sWrQgw)。**它根据分析SQL语句的语法树结合我们日常数据库使用的经验抽象出索引使用不当、`select*`等六大问题。**
<img src="https://static001.geekbang.org/resource/image/a4/81/a40411af3c91c9d58cac27f4f53aaa81.png" alt="">
可能有同学会感叹为什么微信的人可以想到这样的方式事实上这个思路在MySQL中是非常常见的做法。美团也开源了它们内部的SQL优化工具SQLAdvisor你可以参考这些资料
<li>
[SQL解析在美团的应用](https://tech.meituan.com/SQL_parser_used_in_mtdp.html)
</li>
<li>
[美团点评SQL优化工具SQLAdvisor开源](https://tech.meituan.com/sqladvisor_pr.html)
</li>
## 总结
数据库存储是一个开发人员的基本功清楚SQLite的底层机制对我们的工作会有很大的指导意义。
掌握了SQLite数据库并发的机制在某些时候我们可以更好地决策应该拆数据表还是拆数据库。新建一个数据库好处是可以隔离其他库并发或者损坏的情况而坏处是数据库初始化耗时以及更多内存的占用。一般来说单独的业务都会使用独立数据库例如专门的下载数据库、朋友圈数据库、聊天数据库。但是数据库也不宜太多我们可以有一个公共数据库用来存放一些相对不是太大的数据。
在了解SQLite数据库损坏的原理和概率以后我们可以根据数据的重要程度决定是否要引入恢复机制。我还讲了如何实现数据库加密以及对性能的影响我们可以根据数据的敏感程度决定是否要引入加密。
最后我再强调一下SQLite优化真的是一个很大的话题在课后你还需要结合参考资料再进一步反复学习才能把今天的内容理解透彻。
## 课后作业
在你的应用中是否使用数据库存储呢使用了哪种数据库是否使用ORM在使用数据库过程中你有哪些疑问或者经验呢欢迎留言跟我和其他同学一起讨论。
如果你的应用也在使用SQLite存储今天的课后练习是尝试接入WCDB对比测试系统默认SQLite的性能。尝试接入[Matrix SQLiteLint](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-sqlite-lint)查看是否存在不合理的SQLite使用。
除了今天文章中的参考资料,我还给希望进阶的同学准备了下面的资料,欢迎有兴趣的同学继续深入学习。
<li>
[SQLite官方文档](https://sqlite.org/docs.html)
</li>
<li>
[SQLite源码分析](http://huili.github.io/sqlite/sqliteintro.html)
</li>
<li>
[全面解析SQLite](https://github.com/AndroidAdvanceWithGeektime/Chapter14/blob/master/%E5%85%A8%E9%9D%A2%E8%A7%A3%E6%9E%90SQLite.pdf)
</li>
<li>
图书《SQLite权威指南第2版
</li>
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="15 | 网络优化(上):移动开发工程师必备的网络优化知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/29/d37c7f3599f9526095c306a3e0a98829.mp3"></audio>
专栏前面我们已经学习过文件I/O和存储优化相信你已经掌握了文件I/O和存储的性能分析以及优化思路。今天我们就再接再厉继续学习系统中另外一种常见的I/O——网络I/O。
我在写今天的文章时回想了一下大学期间学的那本几百页厚的《计算机网络》当时学得也是云里雾里网络的确涉及了方方面面太多的知识。那我们作为移动开发者来说都需要掌握哪些必备的网络知识呢文件I/O跟网络I/O又有哪些差异呢
今天我们不谈“经典巨著”,一起来解决移动开发工程师面对的网络问题。
## 网络基础知识
现在已经很难找到一款完全不需要网络的应用,即使是单机应用,也会存在数据上报、广告等各种各样的网络请求。既然网络已经无处不在,我们必须要掌握哪些基础知识呢?
**1. 无线网络**
在过去十年,移动互联网的高速增长离不开无线网络的普及。无线网络多种多样,而且各有各的特点,并且适合使用的场景也不同。
下图是iPhone XS支持的无线网络类型你可以看到WiFi、蜂窝网络、蓝牙、NFC这些都是我们日常经常使用的无线网络类型。
<img src="https://static001.geekbang.org/resource/image/36/87/36fb4b91a718766075c2fae70c08ba87.png" alt="">
“千兆级LTE”指的是蜂窝网络在理论上速度可以达到光纤级别的1Gbps125MB/s。虽然基于4G标准但通过[MIMO](https://zh.wikipedia.org/wiki/MIMO)(多输入多输出)、使用载波聚合的[LAA](https://www.qualcomm.cn/invention/technologies/lte/laa)等技术,现在已经发展到[千兆级LTE](http://rf.eefocus.com/article/id-332405)。2020年我们也即将迎来5G的商用它的理论传输速率可以达到20Gbps。目前5G的标准还没有完全release关于5G的原理我推荐你看看[这篇文章](https://mp.weixin.qq.com/s/bPNuEbwZZS9uS5bKmHskTw)。
<img src="https://static001.geekbang.org/resource/image/75/7f/7506643f3368cbf1737f8e2e7e44be7f.png" alt="">
“802.11ac无线网络”指的是我们经常使用的WiFi。WiFi由IEEE定义和进行标准化规范跟任何流行的技术一样IEEE也一直在积极地发布新的协议。目前最常用的是[802.11ac](https://zh.wikipedia.org/wiki/IEEE_802.11ac)标准它的理想速率可以达到866.7Mbps。
从硬件维度上来看所有的无线网络都通过基带芯片支持目前高通在基带芯片领域占据了比较大的优势。之前由于苹果和高通的专利诉讼iPhone XS选用了英特尔的基带芯片但同时也出现大量的用户投诉网络连接异常。
市面上有那么多的无线网络标准和制式还有双卡双待等各种特色功能因此基带芯片对技术的要求非常高。随着未来5G的商用与普及国内也会迎来新的一波换机潮。这对各大芯片厂商来说是机遇也是挑战目前高通、MTK、华为都已经发布了5G基带芯片。如果你对当前的5G格局感兴趣可以阅读[《全世界5G格局》](https://mp.weixin.qq.com/s?src=11&amp;timestamp=1580647446&amp;ver=2134&amp;signature=8h5fb0NUiU4OKOcr-GPNgb4yexcVWJ4OGy6ve8Mqb*ZkNEDFhWwotq*SSrIaktLcvwnxsgaItbDwHK1khe*c2FpwTvi3y8ySBcGNczBd8*REqgAeQyqrufMYvVAjgYD6&amp;new=1)。
**2. Link Turbo**
像5G这种新的标准可以极大地提升网络速度但缺点是它需要新的基站设备和手机设备支持这个过程起码需要几年的时间。
手机厂商为了提升用户的网络体验也会做各种各样的定制优化华为最近在荣耀V20推出的[Link Turbo 网络聚合加速技术](https://www.pingwest.com/a/181911)就是其中比较硬核的一种“黑科技”。
<img src="https://static001.geekbang.org/resource/image/85/0f/851e2bdcfe129a629aecc61828e27a0f.png" alt="">
从硬件角度来说WiFi和蜂窝网络属于基带芯片的不同模块我们可以简单的把它们理解为类似双网卡的情形。所谓的Link Turbo就是在使用WiFi的同时使用移动网络加速。
可能有人会疑惑我都已经连接WiFi了为什么还要使用收费的移动网络呢有这个疑问的人肯定没有试过使用公司网络打“王者”团战卡成狗的情形其实WiFi可能会因为下面的一些原因导致很不稳定。
<img src="https://static001.geekbang.org/resource/image/6a/d1/6aad8d120185866e098281e5546025d1.png" alt="">
事实上双通道的技术也并不是华为首发。类似iPhone的无线网络助理、小米和一加的自适应WLAN它们都能在侦测到WiFi网络不稳定时自动切换到移动网络。iPhone在连接WiFi的时候移动网络也是依然可以连接的。
而Link Turbo硬核的地方在于可以同时使用两条通道传输数据而且支持TCP与UDP。其中TCP支持使用的是开源的[MultiPath TCP](http://www.multipath-tcp.org)iOS 7也有引入而UDP则是华为自研的MultiPath UDP。
当然这个功能目前比较鸡肋主要是由于一是覆盖的用户比较少当前只有V20一台机器支持而且还需要用户手动开启二是改造成本双通道需要我们的后台服务器也做一些改造才能支持。
<img src="https://static001.geekbang.org/resource/image/87/a8/87b11d3e2ae67e4048e5164f9f73a2a8.png" alt="">
但是这项技术还是有一定的价值一方面流量越来越便宜很多用户不再那么care流量资费的问题。另一方面华为可以直接跟阿里云、华为云、腾讯云以及CDN服务商合作屏蔽应用后台服务器的改造成本。目前爱奇艺、斗鱼和映客这些应用都在尝试接入。
<img src="https://static001.geekbang.org/resource/image/ee/d6/ee643cc2a974ad530230274dd8c6e0d6.png" alt="">
讲到这里你可能会问为什么今天我会花这么多时间来讲Link Turbo技术并不是因为我收了广告费而是我发现很多时候在优化到一定程度之后单靠应用本身很难再有大的突破。**这个时候可能要考虑跟手机厂商、芯片厂商或者运营商合作,因此我们要随时关注行业的动态,并且清楚这些新技术背后的本质所在。**
## 网络I/O
在前面的专栏里我讲了文件I/O的处理流程以及不同I/O方式的使用场景今天我们再一起来看看网络I/O跟文件I/O有哪些差异。
**1. I/O模型**
“一切皆文件”Linux内核会把所有外部设备都看作一个文件来操作。在网络I/O中系统对一个 Socket的读写也会有相应的描述符称为socket fdSocket描述符
如下图以Socket读取数据recvfrom调用为例它整个I/O流程分为两个阶段
<li>
等待Socket数据准备好。
</li>
<li>
将数据从内核拷贝到应用进程中 。
</li>
<img src="https://static001.geekbang.org/resource/image/f8/20/f872176205d997f30753ab87a276fa20.png" alt="">
在《UNIX网络编程》中将UNIX网络I/O模型分为以下五种。
<img src="https://static001.geekbang.org/resource/image/e9/39/e9278a9e4e35f28c0272757f280ea339.png" alt="">
在开发过程中比较常用的有阻塞I/O、非阻塞I/O以及多路复用I/O。关于UNIX网络I/O模型的更多资料你可以参考《UNIX网络编程》第六章、[《聊聊Linux五种I/O模型》](https://www.jianshu.com/p/486b0965c296)、[《Unix网络I/O模型及Linux的I/O多路复用模型》](http://matt33.com/2017/08/06/unix-io/)。
在查资料的时候我发现网上有很多文章的描述还是存在问题的,我们需要辩证地看。
<li>
多路复用I/O一定比阻塞I/O要好跟文件I/O一样最简单的I/O并发方式就是多线程+阻塞I/O。如果我们同一时间活动的网络连接非常多使用多路复用I/O性能的确更好。但是对于客户端来说这个假设不一定成立。对于多路复用I/O来说整个流程会增加大量的select/epoll这样的系统调用不一定比阻塞I/O要快。
</li>
<li>
epoll一定比select/poll要好如果同一时间的连接数非常少的情况select的性能不会比epoll很多时候会比epoll更好。
</li>
<li>
epoll使用了mmap减少内核到用户空间的拷贝网上很多的文章都说epoll使用了mmap的技术但是我查看了Linux与Android的[epoll实现](http://androidxref.com/9.0.0_r3/xref/external/libevent/epoll.c),并没有找到相关的实现。而且我个人认为也不太可能会这样实现,因为直接共享内存可能会引发比较大的安全漏洞。
</li>
**2. 数据处理**
在下一期我还会跟你一起分析当前一些热门网络库的I/O模型现在我们再往底层走走看看底层收发包的流程是怎么样的。
<img src="https://static001.geekbang.org/resource/image/08/43/08f8dba5e1a23bbefd6ea91fd254fd43.png" alt="">
跟文件I/O一样网络I/O也使用了中断。不过网络I/O的中断会更加复杂一些它同时使用了[软中断和硬中断](https://www.mayou18.com/detail/pdt0EOHM.html)。通过硬中断通知CPU有数据来了但这个处理会非常轻量。耗时的操作移到软中断处理函数里面来慢慢处理。其中查看系统软中断可以通过/proc/softirqs文件查看硬中断可以通过/proc/interrupts文件。
关于网卡收发包的流程网上的资料不多,感兴趣的同学可以参考下面几篇文章:
<li>
[网卡收包流程](https://mp.weixin.qq.com/s/UhF2KCASoIhTiKXPFOPiww)
</li>
<li>
[Linux网络 - 数据包的接收过程](https://segmentfault.com/a/1190000008836467)
</li>
<li>
[Linux网络 - 数据包的发送过程](https://segmentfault.com/a/1190000008926093)
</li>
<li>
[Illustrated Guide to Monitoring and Tuning the Linux Networking Stack: Receiving Data](https://blog.packagecloud.io/eng/2016/10/11/monitoring-tuning-linux-networking-stack-receiving-data-illustrated/)
</li>
**考虑到这块比较复杂,我在专栏里提供给你参考资料,有兴趣的同学可以进一步深入研究。**
## 网络性能评估
我们常说的网络性能优化,通常都优化哪些方面呢?有的同学可能会关注网络的带宽和服务器成本,特别是直播、视频类的企业,这部分的成本非常高昂。虽然有的时候会做一些取舍,但是用户的访问速度与体验是所有应用的一致追求。
**1. 延迟与带宽**
如果说速度是关键,那对网络传输速度有决定性影响的主要有以下两个方面:
<li>
延迟:数据从信息源发送到目的地所需的时间。
</li>
<li>
带宽:逻辑或物理通信路径最大的吞吐量。
</li>
回想一下文件I/O性能评估似乎已经很复杂了但是它至少整个流程都在手机系统内部。对于网络来说整个流程涉及的链路就更加复杂了。一个数据包从手机出发要经过无线网络、核心网络以及外部网络互联网才能到达我们的服务器。
<img src="https://static001.geekbang.org/resource/image/3b/de/3b27d9692bfc276aa16ac2e4c6effede.png" alt="">
那延迟和带宽又跟什么因素有关呢这里面涉及的因素也非常多例如信号的强度、附近有没有基站、距离有多远等还跟使用的网络制式正在使用3G、4G还是5G网络有关并且网络的拥塞情况也会产生影响比如是不是在几万人聚集的大型活动场所等。
下面是不同网络制式的带宽和延迟的一般参考值,你可以在脑海里建立一个大致的印象。
<img src="https://static001.geekbang.org/resource/image/36/83/368f97424661e9ee3295b8c610fb9b83.png" alt="">
当出现上面说到的那些因素时,网络访问的带宽要大打折扣,延迟会加倍放大。而高延迟、低带宽的网络场景也就是我们常说的“弱网络”,它主要特点有:
<img src="https://static001.geekbang.org/resource/image/77/d8/773fa40290c39c0f7e402414273c8fd8.png" alt="">
关于“弱网络”如何进行优化我在微信时针对弱网络优化投入了大量的精力这也是我在下一期所要讲的重点。不过我想说的是即使未来5G普及了但是各种各样的影响因素依然存在弱网络优化这个课题是有长期存在的价值。
另外一个方面,不同的应用对延迟和带宽的侧重点可能不太一样。对于直播类应用或者“王者荣耀”来说,延迟会更重要一些;对腾讯视频、爱奇艺这种点播的应用来说,带宽会更加重要一些。**网络优化需要结合自己应用的实际情况来综合考虑。**
**2. 性能测量**
正如文件I/O测量一样有哪些方法可以帮助我们评估网络的性能呢
对于网络来说,我们关心的是下面这些指标:
<li>
吞吐量:网络接口接收和传输的每秒字节数。
</li>
<li>
延迟:系统调用发送/接收延时、连接延迟、首包延迟、网络往返时间等。
</li>
<li>
连接数:每秒的连接数。
</li>
<li>
错误:丢包计数、超时等。
</li>
Linux提供了大量的网络性能分析工具下面这些工具是Android支持并且比较实用的这些工具完整的功能请参考文档或者网上其他资料这里就不再赘述了。
<img src="https://static001.geekbang.org/resource/image/25/2a/250ae9961286f6c29629767fdf40aa2a.png" alt="">
如果你对Linux底层更加熟悉可以直接查看/proc/net它里面包含了许多网络统计信息的文件。例如Android的[TrafficState](https://developer.android.com/reference/android/net/TrafficStats)接口就是利用/proc/net/xt_qtaguid/stats和/proc/net/xt_qtaguid/iface_stat_fmt文件来统计应用的流量信息。
## 总结
从网络通信发展的历程来说从2G到4G经历了十几年的时间这背后离不开几百万个基站、几亿个路由器以及各种各样的专利支持。虽然网络标准不停地演进不过受限于基建它的速度看起来很快但是又很慢。
那对我们自己或者应用会有哪些思考呢HTTP 2.0、HTTP 3.0QUIC等网络技术一直在向前演进我们需要坚持不懈地学习思考它们对我们可以产生哪些影响这是对网络“快”的思考。TCP和UDP协议、弱网络等很多东西在这二十多年来依然没有太大的改变网络的基础知识对我们来说还是非常重要的这是对网络“慢”的思考。
## 课后作业
在讲Link Turbo的时候我说过iPhone的无线网络助理、小米和一加的自适应WLAN它们在检测WiFi不稳定时会自动切换到移动网络。那请你思考一下它们是如何实现侦测如何区分是应用后台服务器出问题还是WiFi本身有问题呢今天的作业是在留言区写写你对这个问题的看法欢迎留言跟我和其他同学一起讨论。
今天我推荐一本必读的网络书籍:**《Web性能权威指南》**,它里面第一句话就讲得非常好,我把它分享给你:“合格的开发者知道怎么做,而优秀的开发者知道为什么那么做”。
对于想进一步深入研究的同学,你可以研读这些书籍:
<li>
《UNIX网络编程》
</li>
<li>
《TCP/IP详解 卷1协议》
</li>
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,282 @@
<audio id="audio" title="16 | 网络优化(中):复杂多变的移动网络该如何优化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/00/914765e9ad83323025bbf26103ed5400.mp3"></audio>
在PC互联网时代网络优化已经是一项非常复杂的工作。对于移动网络来说弱网络、网络切换、网络劫持这些问题更加突出网络优化这项工作也变得更加艰巨。
那作为一名移动开发者面对复杂多变的移动网络我们该如何去优化呢可能也有人会说我只要用好AFNetworking/OkHttp这些成熟网络库就可以了并不需要额外去做什么优化。那你确定你真的能用好这些网络库吗它们内部是怎样实现的、有哪些差异点、哪个网络库更好呢
虽然我们可能只是客户端App开发人员但在关于网络优化还是可以做很多事情的很多大型的应用也做了很多的实践。今天我们一起来看一下如何让我们的应用在各种的网络条件下都能“快人一步”。
## 移动端优化
回想上一期我给出的网络架构图,一个数据包从手机出发要经过无线网络、核心网络以及外部网络(互联网),才能到达我们的服务器。那整个网络请求的速度会跟哪些因素有关呢?
<img src="https://static001.geekbang.org/resource/image/d3/4d/d39734690f1241262b0a289acdecbf4d.png" alt="">
从上面这张图上看,客户端网络库实现、服务器性能以及网络链路的质量都是影响网络请求速度的因素。下面我们先从客户端的网络库说过,看看应该如何进行网络优化。
**1. 何为网络优化**
在讲怎么去优化网络之前,我想先明确一下所谓的网络优化,究竟指的是什么?在我看来,核心内容有以下三个:
<li>
**速度**。在网络正常或者良好的时候,怎样更好地利用带宽,进一步提升网络请求速度。
</li>
<li>
**弱网络**。移动端网络复杂多变,在出现网络连接不稳定的时候,怎样最大程度保证网络的连通性。
</li>
<li>
**安全**。网络安全不容忽视,怎样有效防止被第三方劫持、窃听甚至篡改。
</li>
除了这三个问题,我们可能还会关心网络请求造成的耗电、流量问题,这两块内容我们在后面会统一地讲,今天就不再展开。
那对于速度、弱网络以及安全的优化,又该从哪些方面入手呢?首先你需要先搞清楚一个网络请求的整个过程。
<img src="https://static001.geekbang.org/resource/image/8d/8b/8d6e833f2a6c6d1e8beeb92d4579c38b.png" alt="">
从图上看到,整个网络请求主要分为几个步骤,而整个请求的耗时可以细分到每一个步骤里面。
<li>
**DNS解析**。通过DNS服务器拿到对应域名的IP地址。在这个步骤我们比较关注DNS解析耗时情况、运营商LocalDNS的劫持、DNS调度这些问题。
</li>
<li>
**创建连接**。跟服务器建立连接这里包括TCP三次握手、TLS密钥协商等工作。多个IP/端口该如何选择、是否要使用HTTPS、能否可以减少甚至省下创建连接的时间这些问题都是我们优化的关键。
</li>
<li>
**发送/接收数据**。在成功建立连接之后,就可以愉快地跟服务器交互,进行组装数据、发送数据、接收数据、解析数据。我们关注的是,如何根据网络状况将带宽利用好,怎么样快速地侦测到网络延时,在弱网络下如何调整包大小等问题。
</li>
<li>
**关闭连接**。连接的关闭看起来非常简单,其实这里的水也很深。这里主要关注主动关闭和被动关闭两种情况,一般我们都希望客户端可以主动关闭连接。
</li>
所谓的网络优化,就是围绕速度、弱网络、安全这三个核心内容,减少每一个步骤的耗时,打造快速、稳定且安全的高质量网络。
**2. 何为网络库**
在实际的开发工作中我们很少会像《UNIX网络编程》那样直接去操作底层的网络接口一般都会使用网络库。Square出品的[OkHttp](https://github.com/square/okhttp)是目前最流行的Android网络库它还被Google加入到Android系统内部为广大开发者提供网络服务。
那网络库究竟承担着一个什么样的角色呢?在我看来,它屏蔽了下层复杂的网络接口,让我们可以更高效地使用网络请求。
<img src="https://static001.geekbang.org/resource/image/ff/d8/ff9f3155d55ccd0a721ff9ee560300d8.png" alt="">
如上图所示,一个网络库的核心作用主要有以下三点:
<li>
**统一编程接口**。无论是同步还是异步请求接口都非常简单易用。同时我们可以统一做策略管理统一进行流解析JSON、XML、Protocol Buffers等。
</li>
<li>
**全局网络控制**。在网络库内部我们可以做统一的网络调度、流量监控以及容灾管理等工作。
</li>
<li>
**高性能**。既然我们把所有的网络请求都交给了网络库那网络库是否实现高性能就至关重要。既然要实现高性能那我会非常关注速度CPU、内存、I/O的使用以及失败率、崩溃率、协议的兼容性等方面。
</li>
不同的网络库实现差别很大,比较关键有这几个模块:
<img src="https://static001.geekbang.org/resource/image/1e/5a/1e3df0ccca73d9364bb61e5066be1e5a.png" alt="">
那网络库实现到底哪家强接下来我们一起来对比OkHttp、Chromium的[Cronet](https://chromium.googlesource.com/chromium/src/+/master/components/cronet/)以及微信[Mars](https://github.com/Tencent/mars)这三个网络库的内部实现。
**3. 高质量网络库**
据我了解业内的[蘑菇街](https://www.infoq.cn/article/mogujie-app-chromium-network-layer?useSponsorshipSuggestions=true%2F)、头条、UC浏览器都在Chromium网络库上做了二次开发而微信Mars在弱网络方面做了大量优化拼多多、虎牙、链家、美丽说这些应用都在使用Mars。
下面我们一起来对比一下各个网络库的核心实现。对于参与网络库相关工作来说我的经验还算是比较丰富的。在微信的时候曾经参与过Mars的开发目前也在基于Chromium网络库做二次开发。
<img src="https://static001.geekbang.org/resource/image/ec/ef/ec4efbcad6a976425731ebcfdf4917ef.png" alt="">
为什么我从来没使用过OkHttp主要因为它并不支持跨平台对于大型应用来说跨平台是非常重要的。我们不希望所有的优化Android和iOS都要各自去实现一套不仅浪费人力而且还容易出问题。
对于Mars来说它是一个跨平台的Socket层解决方案并不支持完整的HTTP协议所以Mars从严格意义上来讲并不是一个完整的网络库。但是它在弱网络和连接上做了大量的优化并且支持长连接。关于Mars的网络多优化的更多细节你可以参考[Wiki](https://github.com/Tencent/mars/wiki)右侧的文章列表。
<img src="https://static001.geekbang.org/resource/image/de/1a/de1ec3e21c80d084db05c58d41fb3d1a.png" alt="">
Chromium网络库作为标准的网络库基本上可以说是找不到太大的缺点。而且我们可以享受Google后续网络优化的成果类似TLS 1.3、QUIC支持等。
但是它针对弱网络场景没有做太多定制的优化也不支持长连接。事实上目前我在Chromium网络库的二次开发主要工作也是补齐弱网络优化与长连接这两个短板。
## 大网络平台
对于大公司来说,我们不能只局限在客户端网络库的双端统一上。网络优化不仅仅是客户端的事情,所以我们有了统一的网络中台,它负责提供前后台一整套的网络解决方案。
阿里的[ACCS](https://www.infoq.cn/article/taobao-mobile-terminal-access-gateway-infrastructure)、蚂蚁的[mPaaS](https://mp.weixin.qq.com/s/nz8Z3Uj9840KHluWjwyelw)、携程的[网络服务](https://www.infoq.cn/article/how-ctrip-improves-app-networking-performance)都是公司级的网络中台服务,这样所有的网络优化可以让整个集团的所有接入应用受益。
下图是mPaaS的网络架构图所有网络请求都会先经过统一的接入层再转发到业务服务器。这样我们可以在业务服务器无感知的情况下在接入层做各种各样的网络优化。
<img src="https://static001.geekbang.org/resource/image/ae/ba/ae51f02faaa7bf500d0ad732a0e56fba.png" alt="">
**1. HTTPDNS**
DNS的解析是我们网络请求的第一项工作默认我们使用运营商的LocalDNS服务。这块耗时在3G网络下可能是200300ms4G网络也需要100ms。
解析慢并不是默认LocalDNS最大的“原罪”它还存在一些其他问题
<li>
**稳定性**。UDP协议无状态容易域名劫持难复现、难定位、难解决每天至少几百万个域名被劫持一年至少十次大规模事件。
</li>
<li>
**准确性**。LocalDNS调度经常出现不准确比如北京的用户调度到广东IP移动的运营商调度到电信的IP跨运营商调度会导致访问慢甚至访问不了。
</li>
<li>
**及时性**。运营商可能会修改DNS的TTL导致DNS修改生效延迟。不同运营商的服务实现不一致我们也很难保证DNS解析的耗时。
</li>
为了解决这些问题就有了HTTPDNS。简单来说自己做域名解析的工作通过HTTP请求后台去拿到域名对应的IP地址直接解决上述所有问题。
微信有自己部署的NEWDNS阿里云和腾讯云也有提供自己的HTTPDNS服务。对于大网络平台来说我们会有统一的HTTPDNS服务并将它和运维系统打通。在传统的DNS基础上还会增加精准的流量调度、网络拨测/灰度、网络容灾等功能。
<img src="https://static001.geekbang.org/resource/image/05/f7/050064c7efe78e194497c3b9b859d8f7.png" alt="">
关于HTTPDNS的更多知识你可以参考百度的[《DNS优化》](https://mp.weixin.qq.com/s/iaPtSF-twWz-AN66UJUBDg)。对客户端来说我们可以通过预请求的方法提前拿到一批域名的IP不过这里需要注意IPv4与IPv6协议栈的选择问题。
**2. 连接复用**
在DNS解析之后我们来到了创建连接这个环节。创建连接要经过TCP三次握手、TLS密钥协商连接建立的代价是非常大的。这里我们主要的优化思路是复用连接这样不用每次请求都重新建立连接。
在前面我就讲过连接管理,网络库并不会立刻把连接释放,而是放到连接池中。这时如果有另一个请求的域名和端口是一样的,就直接拿出连接池中的连接进行发送和接收数据,少了建立连接的耗时。
这里我们利用HTTP协议里的keep-alive而HTTP/2.0的多路复用则可以进一步的提升连接复用率。它复用的这条连接支持同时处理多条请求,所有请求都可以并发在这条连接上进行。
<img src="https://static001.geekbang.org/resource/image/82/ac/8215799e2bb66c6668e9b73e4130f0ac.png" alt="">
虽然H2十分强大不过这里还有两个问题需要解决。一个是同一条H2连接只支持同一个域名一个是后端支持HTTP/2.0需要额外的改造。这个时候我们只需要在统一接入层做改造接入层将数据转换到HTTP/1.1再转发到对应域名的服务器。
<img src="https://static001.geekbang.org/resource/image/d7/c9/d7dc96541811392f2a669232582c0bc9.png" alt="">
这样所有的服务都不用做任何改造就可以享受HTTP/2.0的所有优化不过这里需要注意的是H2的多路复用在本质上依然是同一条TCP连接如果所有的域名的请求都集中在某一条连接中在网络拥塞的时候容易出现TCP队首阻塞问题。
对于客户端网络库来说无论OkHttp还是Chromium网络库对于HTTP/2.0的连接同一个域名只会保留一条连接。对于一些第三方请求特别是文件下载以及视频播放这些场景可能会遇到对方服务器单连接限速的问题。在这种情况下我们可以通过修改网络库实现也可以简单的通过禁用HTTP/2.0协议解决。
**3. 压缩与加密**
**压缩**
讲完连接我们再来看看发送和接收的优化。我第一时间想到的还是减少传输的数据量也就是我们常说的数据压缩。首先对于HTTP请求来说数据主要包括三个部分
<li>
请求URL
</li>
<li>
请求header
</li>
<li>
请求body
</li>
对于header来说如果使用HTTP/2.0连接本身的[头部压缩](https://imququ.com/post/header-compression-in-http2.html)技术因此需要压缩的主要是请求URL和请求body。
对于请求URL来说一般会带很多的公共参数这些参数大部分都是不变的。这样不变的参数客户端只需要上传一次即可其他请求我们可以在接入层中进行参数扩展。
对于请求body来说一方面是数据通信协议的选择在网络传输中目前最流行的两种数据序列化方式是JSON和Protocol Buffers。正如我之前所说的一样Protocol Buffers使用起来更加复杂一些但在数据压缩率、序列化与反序列化速度上面都有很大的优势。
另外一方面是压缩算法的选择通用的压缩算法主要是如gzipGoogle的[Brotli](https://github.com/google/brotli)或者Facebook的[Z-standard](https://github.com/facebook/zstd)都是压缩率更高的算法。其中如果Z-standard通过业务数据样本训练出适合的字典是目前压缩率表现最好的算法。但是各个业务维护字典的成本比较大这个时候我们的大网络平台的统一接入层又可以大显神威了。
<img src="https://static001.geekbang.org/resource/image/ea/11/ea0bbc3fba7296eac80eb20650ff2511.png" alt="">
例如我们可以抽样1%的请求数据用来训练字典,字典的下发与更新都由统一接入层负责,业务并不需要关心。
当然针对特定数据我们还有其他的压缩方法例如针对图片我们可以使用webp、hevc、[SharpP](https://mp.weixin.qq.com/s/JcBNT2aKTmLXRD9zIOPe6g)等压缩率更高的格式。另外一方面基于AI的[图片超清化](http://imgtec.eetrend.com/d6-imgtec/blog/2017-08/10143.html)也是一大神器QQ空间通过这个技术节约了大量的带宽成本。
**安全**
数据安全也是网络重中之重的一个环节在大网络平台中我们都是基于HTTPS的HTTP/2通道已经有了TLS加密。如果大家不熟悉TLS的基础知识可以参考微信后台一个小伙伴写的[《TLS协议分析》](https://blog.helong.info/blog/2015/09/07/tls-protocol-analysis-and-crypto-protocol-design/)。
但是HTTPS带来的代价也是不小的它需要2-RTT的协商成本在弱网络下时延不可接受。同时后台服务解密的成本也十分高昂在大型企业中需要单独的集群来做这个事情。
HTTPS的优化有下面几个思路
<li>
**连接复用率**。通过多个域名共用同一个HTTP/2连接、长连接等方式提升连接复用率。
</li>
<li>
**减少握手次数**。[TLS 1.3](https://zhuanlan.zhihu.com/p/44980381)可以实现0-RTT协商事实上在TLS 1.3 release之前微信的[mmtls](https://mp.weixin.qq.com/s/tvngTp6NoTZ15Yc206v8fQ)、Facebook的[fizz](https://mp.weixin.qq.com/s?__biz=MzI4MTY5NTk4Ng==&amp;mid=2247489465&amp;idx=1&amp;sn=a54e3fe78fc559458fa47104845e764b&amp;source=41#wechat_redirect)、阿里的SlightSSL都已在企业内部大规模部署。
</li>
<li>
**性能提升**。使用ecc证书代替RSA服务端签名的性能可以提升410倍但是客户端校验性能降低了约20倍从10微秒级降低到100微秒级。另外一方面可以通过Session Ticket会话复用节省一个RTT耗时。
</li>
使用HTTPS之后整个通道是不是就一定高枕无忧呢如果客户端设置了代理TLS加密的数据可以被解开并可能被利用 。这个时候我们可以在客户端将“[证书锁定](https://sec.xiaomi.com/article/48)”Certificate Pinning为了老版本兼容和证书替换的灵活性建议锁定根证书。
我们也可以对传输内容做二次加密,这块在统一接入层实现,业务服务器也同样无需关心这个流程。需要注意的是二次加密会增加客户端与服务器的处理耗时,我们需要在安全性与性能之间做一个取舍。
<img src="https://static001.geekbang.org/resource/image/af/67/afeecc25429ccebb122a2e1c46502167.png" alt="">
**4. 其他优化**
关于网络优化的手段还有很多一些方案可能是需要用钱堆出来的比如部署跨国的专线、加速点多IDC就近接入等。
除此之外,使用[CDN服务](https://toutiao.io/posts/6gb8ih/preview)、[P2P技术](https://mp.weixin.qq.com/s?__biz=MzI4MTY5NTk4Ng==&amp;mid=2247489182&amp;idx=1&amp;sn=e892855fd315ed2f1395f05b765f9c4e&amp;source=41#wechat_redirect)也是比较常用的手段,特别在直播这类场景。总的来说,网络优化我们需要综合用户体验、带宽成本以及硬件成本等多个因素来考虑。
下面为你献上一张高质量网络的全景大图。
<img src="https://static001.geekbang.org/resource/image/7c/31/7cc417ec3a63db950a0a243c1a206b31.png" alt="">
## QUIC与IPv6
今天已经讲得很多了可能还有小伙伴比较关心最近一些比较前沿的技术我简单讲一下QUIC和IPv6。
**1. QUIC**
QUIC协议由Google在2013年实现在2018年基于QUIC协议的HTTP更被确认为[HTTP/3](https://zh.wikipedia.org/wiki/HTTP/3)。在连接复用中我说过HTTP/2 + TCP会存在队首阻塞的问题基于UDP的QUIC才是终极解决方案。
如下图所示你可以把QUIC简单理解为HTTP/2.0 + TLS 1.3 + UDP。
<img src="https://static001.geekbang.org/resource/image/7d/b1/7d97cb60db49e280ac346b85bf943bb1.png" alt="">
事实上,它还有着其他的很多优势:
<li>
**灵活控制拥塞协议**。如果想对TCP内部的拥塞控制算法等模块进行优化和升级整体周期是相对较长的。对于UDP来说我们不需要操作系统支持随时可改例如可以直接使用Google的[BBR算法](https://queue.acm.org/detail.cfm?id=3022184)。
</li>
<li>
**“真”连接复用**。不仅解决了队首阻塞的问题在客户端网络切换的时候也不需要重连用户使用App的体验会更加流畅。
</li>
既然QUIC那么好为什么我们在生产环境没有全部切换成QUIC呢那是因为有很多坑还没有踩完目前发现的主要问题还有
<li>
**创建连接成功率**。主要是UDP的穿透性问题NAT局域网路由、交换机、防火墙等会禁止UDP 443通行目前QUIC在国内建连的成功率大约在95%左右。
</li>
<li>
**运营商支持**。运营商针对UDP通道支持不足表现也不稳定。例如QoS限速丢包有些小的运营商甚至还直接不支持UDP包。
</li>
尽管有这样那样的问题但是QUIC一定是未来。当然通过大网络平台的统一接入层我们业务基本无需做什么修改。目前据我了解[腾讯](https://archstat.com/infoQ/archSummit/2018%E6%9E%B6%E6%9E%84%E5%B8%88%E5%90%88%E9%9B%86/AS%E6%B7%B1%E5%9C%B32018-%E3%80%8AQUIC%E5%8D%8F%E8%AE%AE%E5%9C%A8%E8%85%BE%E8%AE%AF%E7%9A%84%E5%AE%9E%E8%B7%B5%E5%92%8C%E4%BC%98%E5%8C%96%E3%80%8B-%E7%BD%97%E6%88%90.pdf)、[微博](https://github.com/thinkpiggy/qcon2018ppt/blob/master/QUIC%E5%9C%A8%E6%89%8B%E6%9C%BA%E5%BE%AE%E5%8D%9A%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8%E5%AE%9E%E8%B7%B5.pdf)、[阿里](https://mp.weixin.qq.com/s/QhaFKuxTf3mrbF-eWIkZTw)都在内部逐步加大QUIC的流量具体细节可以参考我给出的链接。
**2. IPv6**
运维人员都会深深的感觉到IP资源的珍贵而致力于解决这个问题的IPv6却在中国一直非常沉寂。根据[《2017年IPV6支持度报告》](https://www.ipv6ready.org.cn/public/download/ipv6.pdf)在中国只有0.38%的用户使用v6。
<img src="https://static001.geekbang.org/resource/image/c0/71/c0eb573551efc8fad2b2d7a6daee8571.png" alt="">
IPv6不仅针对IoT技术对万物互联的时代有着非常大的意义。而且它对网络性能也有正向的作用在印度经过我们测试使用IPv6网络相比IPv4连接耗时可以降低10%20%。推行IPv6后无穷无尽的IP地址意味着可以告别各种NATP2P、QUIC的连接也不再是问题。
在过去的一年,无论是[阿里云](https://mp.weixin.qq.com/s/RXICO_3W2cxTYk0UV40GLQ)还是[腾讯云](https://mp.weixin.qq.com/s/ufV7mZWHPfLNE1-QxWmEfQ)都做了大量IPv6的工作。当然主要也是接入层的改造尽量不需要业务服务做太多修改。
## 总结
移动技术发展到今天,跨终端和跨技术栈的联合优化会变得越来越普遍。有的时候我们需要跳出客户端开发的视角,从更高的维度去思考整个大网络平台。当然网络优化的水还是非常深的,有时候我们需要对协议层也有比较深入的研究,也要经常关注国外的一些新的研究成果。
2018年随着工信部发布《推进互联网协议第六版IPv6规模部署行动计划》的通知所有的云提供商需要在2020年完成IPv6的支持。QUIC在2018年被定为HTTP/3草案同时3GPP也将QUIC列入5G核心网协议第二阶段标准3GPP Release 16
随着5G、QUIC与IPv6未来在中国的普及网络优化永不止步它们将推动我们继续努力去做更多尝试让用户可以有更好的网络体验。
## 课后作业
你的应用使用的是哪个网络库?对于网络优化,你还有哪些实践经验?欢迎留言跟我和其他同学一起讨论。
网络优化是一个很大的话题,在课后你还需要进一步扩展学习。除了今天文章里给出的链接,这里还提供一些参考资料给你:
<li>
[微信客户端怎样应对弱网络](https://github.com/WeMobileDev/article/blob/master/%E5%BE%AE%E4%BF%A1%E5%AE%A2%E6%88%B7%E7%AB%AF%E6%80%8E%E6%A0%B7%E5%BA%94%E5%AF%B9%E5%BC%B1%E7%BD%91%E7%BB%9C.pdf)
</li>
<li>
[阿里亿级日活网关通道架构演进](http://img.bigdatabugs.com/ArchSummit%E5%8C%97%E4%BA%AC-%E3%80%8A%E9%98%BF%E9%87%8C%E4%BA%BF%E7%BA%A7%E6%97%A5%E6%B4%BB%E7%BD%91%E5%85%B3%E9%80%9A%E9%81%93%E6%9E%B6%E6%9E%84%E6%BC%94%E8%BF%9B%E3%80%8B-%E6%B4%AA%E6%B5%B7%EF%BC%88%E5%AD%A4%E6%98%9F%EF%BC%89@www.bigDataBugs.com.pdf)
</li>
<li>
[阿里巴巴HTTP 2.0实践及无线通信协议的演进之路](https://github.com/aozhimin/awesome-iOS-resource/blob/master/Conferences/%E9%98%BF%E9%87%8C%E5%B7%B4%E5%B7%B4HTTP%202.0%E5%AE%9E%E8%B7%B5%E5%8F%8A%E6%97%A0%E7%BA%BF%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%AE%AE%E7%9A%84%E6%BC%94%E8%BF%9B%E4%B9%8B%E8%B7%AF.pdf)
</li>
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,260 @@
<audio id="audio" title="17 | 网络优化(下):大数据下网络该如何监控?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/39/6169a3f8ebca2d2ee09dd0628f5eff39.mp3"></audio>
通过上一期的学习,我们对如何打造一个高质量的网络已经有了一个整体的认识。但是这就足够了吗?回想一下,一个网络请求从手机到后台服务器,会涉及基站、光纤、路由器这些硬件设施,也会跟运营商和服务器机房有关。
不论是基站故障、光纤被挖断、运营商挟持还是我们的机房、CDN服务商出现故障都有可能会引起用户网络出现问题。你有没有感觉线上经常突发各种千奇百怪的网络问题很多公司的运维人员每天过得胆战心惊、疲于奔命。
“善良”的故障过了一段时间之后莫名其妙就好了,“顽固”的故障难以定位也难以解决。这些故障究竟是如何产生的?为什么突然就恢复了?它们影响了多少用户、哪些用户?想要解决这些问题离不开高质量的网络,而高质量的网络又离不开强大的监控。今天我们就一起来看看网络该如何监控吧。
## 移动端监控
对于移动端来说我们可能会有各种各样的网络请求。即使使用了OkHttp网络库也可能会有一些开发人员或者第三方组件使用了系统的网络库。那应该如何统一的监控客户端的所有的网络请求呢
**1. 如何监控网络**
**第一种方法:插桩。**
为了兼容性考虑我首先想到的还是插桩。360开源的性能监控工具[ArgusAPM](https://github.com/Qihoo360/ArgusAPM)就是利用Aspect切换插桩实现监控系统和OkHttp网络库的请求。
系统网络库的插桩实现可以参考[TraceNetTrafficMonitor](https://github.com/Qihoo360/ArgusAPM/blob/bc03d63c65019cd3ffe2cbef9533c9228b3f2381/argus-apm/argus-apm-aop/src/main/java/com/argusapm/android/aop/TraceNetTrafficMonitor.java),主要利用[Aspect](http://www.shouce.ren/api/spring2.5/ch06s02.html)的切面功能关于OkHttp的拦截可以参考[OkHttp3Aspect](https://github.com/Qihoo360/ArgusAPM/blob/bc03d63c65019cd3ffe2cbef9533c9228b3f2381/argus-apm/argus-apm-okhttp/src/main/java/com/argusapm/android/okhttp3/OkHttp3Aspect.java)它会更加简单一些因为OkHttp本身就有代理机制。
```
@Pointcut(&quot;call(public okhttp3.OkHttpClient build())&quot;)
public void build() {
}
@Around(&quot;build()&quot;)
public Object aroundBuild(ProceedingJoinPoint joinPoint) throws Throwable {
Object target = joinPoint.getTarget();
if (target instanceof OkHttpClient.Builder &amp;&amp; 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下面几个方法
<li>
连接相关connect。
</li>
<li>
发送数据相关send和sendto。
</li>
<li>
接收数据相关recv和recvfrom。
</li>
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](http://androidxref.com/7.0.0_r1/xref/libcore/ojluni/src/main/native/PlainSocketImpl.c),查看[makefile](http://androidxref.com/7.0.0_r1/xref/libcore/ojluni/src/main/native/openjdksub.mk)可以知道它们会编译在libopenjdk.so中。不过在Android 8.0整个调用流程又完全改变了。为了兼容性考虑我们直接PLT Hook内存的所有so但是需要排除掉Socket函数本身所在的libc.so。
```
hook_plt_method_all_lib(&quot;libc.so&quot;, &quot;connect&quot;, (hook_func) &amp;create_hook);
hook_plt_method_all_lib(&quot;libc.so, &quot;send&quot;, (hook_func) &amp;send_hook);
hook_plt_method_all_lib(&quot;libc.so&quot;, &quot;recvfrom&quot;, (hook_func) &amp;recvfrom_hook);
...
```
这种做法不好的地方在于会把系统的Local Socket也同时接管了需要在代码中增加过滤条件。在今天的Sample中我给你提供了一套简单的实现。其实无论是哪一种Hook如果熟练掌握之后你会发现它并不困难。我们需要耐心地寻找梳理清楚整个调用流程。
**第三种方法:统一网络库。**
尽管拿到了所有的网络调用想想会有哪些使用场景呢模拟网络数据、统计应用流量或者是单独代理WebView的网络请求。
<img src="https://static001.geekbang.org/resource/image/7c/4c/7cecaa1134f078228598d9b3beec6b4c.png" alt="">
一般来说,我们不会非常关心第三方的网络请求情况,而对于我们应用自身的网络请求,最好的监控方法还是统一网络库。**不过我们可以通过插桩和Hook这两个方法监控应用中有哪些地方使用了其他的网络库而不是默认的统一网络库。**
在上一期内容中,我说过“网络质量监控”应该是客户端网络库中一个非常重要的模块,它也会跟大网络平台的接入服务共同协作。通过统一网络库的方式,的确无法监控到第三方的网络请求。不过我们可以通过其他方式拿到应用的整体流量使用情况,下面我们一起来看看。
**2. 如何监控流量**
应用流量监控的方法非常简单一般通过TrafficStats类。TrafficState是Android API 8加入的接口用于获取整个手机或者某个UID从开机算起的网络流量。至于如何使用你可以参考Facebook一个历史比较久远的开源库[network-connection-class](https://github.com/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都有一个网络测试模式感兴趣的同学可以尝试一下。
<li>
iPhone打开拨号界面输入“*3001#12345#*”,然后按拨号键。
</li>
<li>
Android手机打开拨号界面输入“*#*#4636#*#*”,然后按拨号键(可进入工程测试模式,部分版本可能不支持)。
</li>
<img src="https://static001.geekbang.org/resource/image/e6/59/e64bb9e4012132286b787483c01b5959.png" alt="">
为什么系统可以判断此时的WiFi“已连接但无法访问互联网”回想一下专栏第15期我给你留的课后作业
>
iPhone的无线网络助理、小米和一加的自适应WLAN它们在检测WiFi不稳定时会自动切换到移动网络。那请你思考一下它们是如何实现侦测如何区分是应用后台服务器出问题还是WiFi本身有问题呢
我看了一下同学们的回复大部分同学认为需要访问一个公网IP的方式。其实对于手机厂商来说根据不需要它在底层可以拿到的信息有很多。
<li>
网卡驱动层信息。如射频参数可以用来判断WiFi的信号强度网卡数据包队列长度可以用来判断网络是否拥塞。
</li>
<li>
协议栈信息。主要是获取数据包发送、接收、时延和丢包等信息。
</li>
如果一个WiFi发送过数据包但是没有收到任何的ACK回包这个时候就可以初步判断当前的WiFi是有问题的。这样系统可以知道当前WiFi大概率是有问题的它并不关心是不是因为我们后台服务器出问题导致的。
## 大网络平台监控
前面我讲了一些应用网络请求和流量的监控方法,但是还没真正回答应该如何去打造一套强大的网络监控体系。跟网络优化一样,网络监控不是客户端可以单独完成的,它也是整个大网络平台的一个重要组成部分。
不过首先我们需要在客观上承认这件事情做起来并不容易,因为网络问题会存在下面这些特点:
<li>
实时性。部分网络问题过时不候,可能很快就丢失现场。
</li>
<li>
复杂性。可能跟国家、地区、运营商、版本、系统、机型、CDN都有关不仅维度多数据量也巨大。
</li>
<li>
链路长。整个请求链条非常长,客户端故障、网链障络、服务故障都有可能。
</li>
因此所谓的网络监控,并不能保证可以明确找到故障的原因。而我们目标是希望快速发现问题,尽可能拿到更多的辅助信息,协助我们更容易地排查问题。
下面我分别从客户端与接入层的角度出发,一起来看看哪些信息可以帮助我们更好地发现问题和解决问题。
**1. 客户端监控**
客户端的监控使用统网络库的方式,你可以想想我们需要关心哪些内容:
<li>
时延。一般我们比较关心每次请求的DNS时间、建连时间、首包时间、总时间等会有类似1秒快开率、2秒快开率这些指标。
</li>
<li>
维度。网络类型、国家、省份、城市、运营商、系统、客户端版本、机型、请求域名等,这些维度主要用于分析问题。
</li>
<li>
错误。DNS失败、连接失败、超时、返回错误码等会有DNS失败率、连接失败率、网络访问的失败率这些指标。
</li>
通过这些数据我们也可以汇总出应用的网络访问大图。例如在国内无论我们去到哪里都会问有没有WiFiWiFi的占比会超过50%。这其实远远比海外高在印度WiFi的占比仅仅只有15%左右。
<img src="https://static001.geekbang.org/resource/image/62/e1/62926bf186ce0c4898419aa549ce77e1.png" alt="">
同样的我们分版本、分国家、分运营商、分域名等各种各样的维度,来监控我们的时延和错误这些访问指标。
由于维度太多每个维度的取值范围也很广如果是实时计算整个数据量会非常非常大。对于客户端的上报数据微信可以做到分钟级别的监控报警。不过为了运算简单我们会抛弃UV只计算每一分钟部分维度的PV。
**2. 接入层监控**
客户端监控的数据会比接入层更加丰富,因为有可能会出现部分数据还没到达接入层就已经被打回,例如运营商劫持的情况。
<img src="https://static001.geekbang.org/resource/image/13/f3/133c91b1f38a8a8dc23fff33475b13f3.png" alt="">
但是接入层的数据监控还是非常有必要的,主要的原因是:
<li>
实时性。客户端如果使用秒级的实时上报,对用户性能影响会比较大。服务端就不会存在这个问题,它很容易可以做到秒级的监控。
</li>
<li>
可靠性。如果出现某些网络问题,客户端的数据上报通道可能也会受到影响,客户端的数据不完全可靠。
</li>
那接入层应该关心哪些数据呢?一般来说,我们会比较关心服务的入口和出口流量、服务端的处理时延、错误率等。
**3. 监控报警**
无论是客户端还是接入层的监控,它们都是分层的。
<li>
实时监控。秒级或者分钟级别的实时监控的信息会相比少一些例如只有访问量PV、错误率没有去拆分几百个上千个维度也没有独立访问用户数UV实时监控的目的是最快速度发现问题。
</li>
<li>
离线监控。小时或者天级别的监控我们可以拓展出全部的维度来做监控,它的目的是在监控的同时,可以更好地圈出问题的范围。
</li>
下面是一个简单根据客户端、国家以及运营商维度分析的示例。当然更多的时候是某一个服务出现问题,这个时候通过分域名或者错误码就可以很容易的找到原因。
<img src="https://static001.geekbang.org/resource/image/54/86/54901a205444cce26ff1cfb6c802ac86.png" alt="">
那在监控的同时如何实现准确的自动化报警呢?这同样也是业界的一个难题,它的难度在于如果规则过于苛刻,可能会出现漏报;如果过于宽松,可能会出现太多的误报。
业界一般存在两种报警的算法,一套是基于规则,例如失败率与历史数据相比暴涨、流量暴跌等。另一种是基于时间序列算法或者神经网络的智能化报警,使用者不需要录入任何规则,只需有足够长的历史数据,就可以实现自动报警。智能化报警目前准确性也存在一些问题,在智能化基础上面添加少量规则可能会是更好的选择。
如果我们收到一个线上的网络报警,通过接入层和客户端的监控报表,也会有了一个大致的判断。那怎么样才能确定问题的最终原因?我们是否可以拿到用户完整的网络日志?甚至远程地诊断用户的网络情况?关于“网络日志和远程诊断,如何快速定位网络问题”,我会把它单独成篇放在专栏第二模块里,再来讲讲这个话题。
## 总结
监控、监控又是监控,很多性能优化工作其实都是“三分靠优化,七分靠监控”。
为什么监控这么重要呢?对于大公司来说,每一个项目参与人员可能成百上千人。并且大公司要的不是今天或者这个版本可以做好一些事情,而是希望保证每天每个版本都能持续保持应用的高质量。另一方面有了完善的分析和监控的平台,我们可以把复杂的事情简单化,把一些看起来“高不可攀”的优化工作,变成人人都可以做。
最后多谈两句我的感受,我们在工作的时候,希望你可以看得更远,从更高的角度去思考问题。多想想如果我能做好这件事情,怎么保证其他人不会犯错,或者让所有人都可以做得更好。
## 课后作业
对于网络问题,你尝试过哪些监控方法?有没有令你印象深刻的网络故障,最终又是通过什么方式解决的呢?欢迎留言跟我和其他同学一起讨论。
今天我们练习的[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter17)是通过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&amp; lib : allSharedLibs()) {
if (strcmp(lib.first.c_str(), exclueLibname) != 0) {
failures += hook_plt_method(lib.first.c_str(), name, hook);
}
}
return failures;
}
```
希望你通过这几次课后练习可以学会将Hook技术应用到实践当中。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,215 @@
<audio id="audio" title="18 | 耗电优化(上):从电量优化的演进看耗电分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/4b/beaf321fc7e912fdd59cfe644c84bd4b.mp3"></audio>
曾经有一句笑话说的是“用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内置锂离子充电电池电池容量为3174mAh30分钟最多可充至50%电量。
华为Mate 20 Pro升级到4200mAh高度大容量锂离子电池并首次搭载40W华为超级快充技术30分钟充电约70%还有15W高功率无线快充和反向无线充电“黑科技”。而Mate 20 X更是把电池容量升级到5000mAh还创造性地将石墨烯技术应用到智能手机中。
<img src="https://static001.geekbang.org/resource/image/dd/1e/dd8b5d1cb842c822fafa128719ec021e.png" alt="">
从上面两款旗舰机的电池介绍中,我们可以发现手机电池的一些关键指标。
<li>
电池容量。更大的电池容量意味着更长的续航时间我们可以通过增加电池的体积或者密度来达到这个效果。智能手机的大部分空间都贡献给电池了以华为Mate 20为例电池占了所有内部组件中48%的空间,电池容量制约了手机迈向更轻、更薄。
</li>
<li>
充电时间。如果电池容量不是那么容易突破那只能曲线救国考虑如何用更短的时间把电池充满。这里就需要依靠快充技术了OPPO“充电5分钟通话2小时”指的是[VOOC闪充技术](https://baike.baidu.com/item/VOOC%E9%97%AA%E5%85%85/13887450?fromtitle=%E5%85%85%E7%94%B55%E5%88%86%E9%92%9F%E9%80%9A%E8%AF%9D2%E5%B0%8F%E6%97%B6&amp;fromid=18226496)。快充技术无非是增大电流或者电压,目前主要分为两大解决方案,一个是高压低电流快充方案,另一个是低压大电流快充方案。关于快充技术的盘点,你可以参考[这篇文章](https://mobile.pconline.com.cn/1089/10896724.html)。
</li>
<li>
寿命。电池寿命一般使用充电循环次数来衡量一次充电循环表示充满电池全部电量但是并不要求一次性完成。例如在之前电池充到了25%如果再充75%两次组合在一起算是一次充电周期。去年苹果因为“降速门”面临了多起诉讼通过处理器限速来解决续航不足的问题。根据苹果官方数据500次充电循环iPhone电池剩余容量为原来的80%。
</li>
<li>
安全性。手机作为用户随时携带的物品安全性才是首要考虑的因素。特别是从三星Note 7爆炸以来各大手机厂商都在电池容量方面更加保守。所以无论是电池的密度还是快充技术我们首要保证的都是安全性。
</li>
从历史久远的镍铬、镍氢,到现在普遍使用的锂离子电池,还是被称为革命性技术的石墨烯电池,虽然达不到摩尔定律,但电池技术其实也在不停地发展,感兴趣的同学可以参考[《手机电池技术进步》](http://tech.ifeng.com/a/20180319/44911215_0.shtml)。
事实上Mate 20 X只是使用石墨烯技术用于散热系统并不是真正意义上的石墨烯电池。根据最新的研究成果表明使用石墨烯材料可以让电池容量增加45%充电速度可以加快5倍循环寿命更高达3500次左右。可能在未来12分钟就能把我们的手机电池充满如果能够实现普及的话将是电池发展史上的一个重要里程碑。
**2. 电量和硬件**
1000mAh的功能机我们可以使用好几天为什么5000mAh的智能机我们需要每天充电这是因为我们现在的手机需要视频通话需要打“王者”“吃鸡”硬件设备的种类和性能早就不可同日而语。
但是“王者”“吃鸡”等应用程序不会直接去消耗电池,而是通过使用硬件模块消耗相应的电能,下图是手机中一些比较耗电的硬件模块。
<img src="https://static001.geekbang.org/resource/image/03/e0/032e738fd9df278623a79f147e77fce0.png" alt="">
CPU、屏幕、WiFi和数据网络、GPS以及音视频通话都是我们日常的耗电大户。坦白说智能手机硬件的飞速提升许多其实都是厂商叫卖的噱头。绝大部分硬件对于我们来说都已经处于性能过剩的状态但多余的性能同时也在消耗电量。
所以资源调度机制是厂商功耗优化最重要的手段例如在卡顿优化的时候我就讲过CPU芯片会分大小核架构会灵活地为不同任务分配相应的运算资源。手机基带、GPS这些模块在不使用时也会进入低功耗或者休眠模式达到降低功耗的目的。
现在越来越多厂商利用深度学习的本地AI来优化资源的调度对GPU、运行内存等资源进行合理分配确保可以全面降低耗电量。厂商需要在高性能跟电量续航之间寻找一个平衡点有的厂商可能倾向于用户有更好的性能有的厂商会倾向于更长的续航。
功耗的确非常重要,我做手机预装项目时,发现厂商会对耗电有非常严格的规定,这也让我对功耗的认识更深刻了。但是为了为了保证头部应用能有更好的体验,厂商愿意给它们分配更多的资源。所以出现了高通的[CPU Boost](https://developer.qualcomm.com/software/snapdragon-power-optimization-sdk/quick-start-guide)、微信的Hardcode以及各个厂商的合作通道。
但是反过来问一句为什么厂商只把微信和QQ放到后台白名单但没有把淘宝、支付宝、抖音等其他头部应用也一起加入呢根据我的猜测耗电可能是其中一个比较重要的因素。
**3. 电量和应用程序**
各个硬件模块都会耗电,而且不同的硬件耗电量也不太一样,那我们如何评估不同应用程序的耗电情况呢?
<img src="https://static001.geekbang.org/resource/image/61/96/61494e773045613a4339dc56438ef896.png" alt="">
根据物理学的知识,电能的计算公式为
```
电能 = 电压 * 电流 * 时间
```
对于手机来说电压一般不会改变例如华为Mate 20的恒定电压是3.82V。所以在电压恒定的前提下,只需要测量电流和时间就可以确定耗电。
最终不同模块的耗电情况可以通过下面的这个公式计算:
```
模块电量(mAh) = 模块电流(mA) * 模块耗时(h)
```
模块耗时比较容易理解但是模块电流应该怎样去获取呢Android系统要求不同的厂商必须在 `/frameworks/base/core/res/res/xml/power_profile.xml` 中提供组件的电源配置文件。
[power_profiler.xml](https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/xml/power_profile.xml)文件定义了不同模块的电流消耗值以及该模块在一段时间内大概消耗的电量你也可以参考Android Developer文档[《Android 电源配置文件》](https://source.android.com/devices/tech/power)。当然电流的大小和模块的状态也有关系,例如屏幕在不同亮度时的电流肯定会不一样。
<img src="https://static001.geekbang.org/resource/image/fa/c1/fa38166961e917ea6a321f84d3d4d4c1.png" alt="">
Android系统的电量计算[PowerProfile](http://androidxref.com/7.0.0_r1/s?defs=PowerProfile&amp;project=frameworks)也是通过读取`power_profile.xml`的数值而已,不同的厂商具体的数值都不太一样,我们可以通过下面的方法获取:
<li>
从手机中导出`/system/framework/framework-res.apk`文件。
</li>
<li>
使用反编译工具如apktool对导出文件`framework-res.apk`进行反编译。
</li>
<li>
查看`power_profile.xml`文件在`framework-res`反编译目录路径:`/res/xml/power_profile.xml`
</li>
对于系统的电量消耗情况我们可以通过dumpsys batterystats导出。
```
adb shell dumpsys batterystats &gt; 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](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/am/BatteryStatsService.java)是对外的电量统计服务,但具体的统计工作是由[BatteryStatsImpl](http://androidxref.com/7.0.0_r1/xref/frameworks/base/core/java/com/android/internal/os/BatteryStatsImpl.java)来完成的而BatteryStatsImpl内部使用的就是PowerProfile。BatteryStatsImpl会为每一个应用创建一个UID实例来监控应用的系统资源使用情况统计的系统资源包括下面图里的内容。
<img src="https://static001.geekbang.org/resource/image/4d/88/4d145e1d2bfd986c10def9a6afa9cf88.png" alt="">
电量的使用也会跟环境有关,例如在零下十度的冬天电量会消耗得更快一些,系统提供的电量测量方法只是提供一个参考的数值。不过通过上面的这个方法,**我们可以成功把电量的测量转化为功能模块的使用时间或者次数。**
准确的测量电量并不是那么容易,在[《大众点评App的短视频耗电量优化实战》](https://tech.meituan.com/2018/03/11/dianping-shortvideo-battery-testcase.html)一文中,为我们总结了下面几种电量测试的方法。
<img src="https://static001.geekbang.org/resource/image/ae/fe/ae1b52340f802f25a09c31c13a2a22fe.png" alt="">
当测试或者其他人反馈耗电问题时,[bug report](https://developer.android.com/studio/debug/bug-report)结合[Battery Historian](https://github.com/google/battery-historian)是最好的排查方法。
```
//7.0和7.0以后
$ adb bugreport bugreport.zip
//6.0和6.0之前:
$ adb bugreport &gt; bugreport.txt
//通过historian图形化展示结果
python historian.py -a bugreport.txt &gt; 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进程以及广播拉起等各种保活流行了起来。
用户手机用电如流水,会明显感受到下面几个问题:
<li>
**耗电与安装应用程序的数量有关**。用户安装越多的应用程序,无论是否打开它们,手机耗电都会更快。
</li>
<li>
**App耗电量与App使用时间无关**。用户希望App的耗电量应该与它的使用时间相关但是有些应用即使常年不打开依然非常耗电。
</li>
<li>
**电量问题排查复杂**。无论是电量的测量,还是耗电问题的排查都异常艰难。
</li>
当然在Android 5.0之前,系统也有尝试做一些省电相关的优化措施。
<img src="https://static001.geekbang.org/resource/image/e5/1b/e5440a10376fee148c96c53a846a6f1b.png" alt="">
**2. 逐步收紧Android 5.0Android 8.0**
Android 5.0专门开启了一个[Volta项目](https://developer.android.com/about/versions/android-5.0?hl=zh-cn)目标是改善电池的续航。在优化电量的同时还增加了的dumpsys batteryst等工具生成设备电池使用情况统计数据。
<img src="https://static001.geekbang.org/resource/image/8f/99/8f77b820113df5635497a5ad20a03299.png" alt="">
从Android 6.0开始Google开始着手清理后台应用和广播来进一步优化省电。在这个阶段还存在以下几个问题
<li>
**省电模式不够省电**。Doze低功耗模式限制得不够严格例如屏幕关闭还可以获取位置、后台应用的网络权限等。
</li>
<li>
**用户对应用控制力度不够**。用户不能简单的对某些应用做更加细致的电量和后台行为的控制,但是其实国内很多的厂商已经提前实现了这个功能。
</li>
<li>
**Target API开发者响应不积极**。为了不受新版本的某些限制大部分国内的应用坚持不把Target API升级到Oreo以上所以很多省电的功能事实上并没有生效。
</li>
**3. 最严限制Android 9.0**
我在Android 9.0刚出来的时候,正常使用了一天手机,在通知栏竟然弹出了下面这样一个提示:**微信正在后台严重耗电**。
<img src="https://static001.geekbang.org/resource/image/c6/1b/c6d2c20c09e84190c7b4a64578d0cc1b.png" alt="">
尽管经过几个版本的优化Android的续航问题依然没有根本性的改善。但你可以看到的是从Android 9.0开始Google对[电源管理](https://developer.android.com/about/versions/pie/power?hl=zh-cn)引入了几个更加严格的限制。
<img src="https://static001.geekbang.org/resource/image/13/8e/13697353748c1637643a6970db22808e.png" alt="">
通过应用待机分组功能,我们可以确保应用使用的电量和它们的使用时间成正比,而不是和手机上安装的应用数量成正比。对于不常用的应用,它们可以“作恶”的可能性更小了。通过省电模式和应用后台限制,用户可以知道哪些应用是耗电的应用,我们也可以对它们做更加严格的限制。
另一方面无论是Google Play还是国内的Android绿色联盟都要求应用在一年内更新到最新版本的Target API。电池续航始终是Android的生命线我相信今年的Android Q也会推出更多的优化措施。
## 总结
今天我讲了应用程序、Android系统、手机硬件与电池之间的关系也回顾了Android耗电优化的演进历程。那落实到具体工作时我们应该如何去做耗电优化呢下一期我们来解决这个问题。
在讲内存、CPU、存储和网络这些知识的时候我都会讲一些硬件相关的知识。主要是希望帮你建立一套从应用层到操作系统再到硬件的整体认知。当你的脑海里面有一套完整的知识图谱时才能更得心应手地解决一些疑难问题进而可以做好对应的性能优化工作。
## 课后作业
今天的课后作业是,在日常的开发过程中,你遇到过哪些耗电问题?遇到这些问题的时候,你一般通过哪些手段去定位和修复呢?欢迎留言跟我和其他同学一起讨论。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,235 @@
<audio id="audio" title="19 | 耗电优化(下):耗电的优化方法与线上监控" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/93/73a7c411cd7a790e4f8bc142c408fe93.mp3"></audio>
相比启动、卡顿、内存和网络的优化来说,可能大多数应用对耗电优化的关注不是太多。当然并不是我们不想做耗电优化,更多时候是感觉有些无从下手。
不同于启动时间、卡顿率耗电在线上一直缺乏一个可以量化的指标。Android系统通过计算获得的应用耗电数据只是一个估算值从Android 4.4开始,连这个估算值也无法拿到了。当有用户投诉我们应用耗电的时候,我们一般也无所适从,不知道该如何定位、如何分析。
耗电优化究竟需要做哪些工作?我们如何快速定位代码中的不合理调用,并且持续监控应用的耗电情况呢?今天我们就一起来学习耗电的优化方法和线上监控方案。
## 耗电优化
在开始讲如何做耗电优化之前,你需要先明确什么是耗电优化,做这件事情的目的究竟是什么。
**1. 什么是耗电优化**
有些同学可能会疑惑所谓的耗电优化不就是减少应用的耗电增加用户的续航时间吗但是落到实践中如果我们的应用需要播放视频、需要获取GPS信息、需要拍照这些耗电看起来是无法避免的。
如何判断哪些耗电是可以避免或者是需要去优化的呢你可以看下面这张图当用户去看耗电排行榜的时候发现“王者荣耀”使用了7个多小时这时用户对“王者荣耀”的耗电是有预期的。
<img src="https://static001.geekbang.org/resource/image/5f/90/5f98c8a117745ce2fd7ef8f873894090.png" alt="">
假设这个时候发现某个应用他根本没怎么使用(前台时间很少),但是耗电却非常多。这种情况会跟用户的预期差别很大,他可能就会想去投诉。
**所以耗电优化的第一个方向是优化应用的后台耗电**。知道了系统是如何计算耗电的那反过来看我们也就可以知道应用在后台不应该做什么例如长时间获取WakeLock、WiFi和蓝牙的扫描等。为什么说耗电优化第一个方向就是优化应用后台耗电因为大部分厂商预装项目要求最严格的正是应用后台待机耗电。
<img src="https://static001.geekbang.org/resource/image/b0/2b/b01e359b45d22bd80efda51eee2f5f2b.png" alt="">
当然前台耗电我们不会完全不管,但是标准会放松很多。你再来看看下面这张图,如果系统对你的应用弹出这个对话框,可能对于微信来说,用户还可以忍受,但是对其他大多数的应用来说,可能很多用户就直接把你加入到后台限制的名单中了。
<img src="https://static001.geekbang.org/resource/image/c6/1b/c6d2c20c09e84190c7b4a64578d0cc1b.png" alt="">
**耗电优化的第二个方向是符合系统的规则,让系统认为你耗电是正常的**。而Android P是通过Android Vitals监控后台耗电所以我们需要符合Android Vitals的规则目前它的具体规则如下
<img src="https://static001.geekbang.org/resource/image/62/15/620748a58e45e50fdea1098f15c77d15.png" alt="">
虽然上面的标准可能随时会改变但是可以看到Android系统目前比较关心后台Alarm唤醒、后台网络、后台WiFi扫描以及部分长时间WakeLock阻止系统后台休眠。
**2. 耗电优化的难点**
既然已经明确了耗电优化的目的和方向,那我们就开始动手吧。但我想说的是,只有当你跳进去的时候,才能发现耗电优化这个坑有多深。它主要有下面几个问题:
- **缺乏现场,无法复现**。用户上传某个截图你的应用耗电占比30%。通过电量的详细使用情况,我们可能会有一些猜测。但是用户也无法给出更丰富的信息,以及具体是在什么场景发生的,可以说是毫无头绪。
<img src="https://static001.geekbang.org/resource/image/7a/b2/7ae7234370738c60d2685c8b096a19b2.png" alt="">
- **信息不全,难以定位**。如果是开发人员或者厂商可以提供bug report利用Battery Historian可以得到非常全的耗电统计信息。但是Battery Historian缺失了最重要的堆栈信息代码调用那么复杂可能还有很多的第三方SDK我们根本不知道是哪一行代码申请了WakeLock、使用了Sensor、调用了网络等。
<img src="https://static001.geekbang.org/resource/image/8e/75/8e5d2527d61cefbd4e457deafde91c75.png" alt="">
- **无法评估结果**。通过猜测我们可能会尝试一些解决方案。但是从Android 4.4开始,我们无法拿到应用的耗电信息。尽管我们解决了某个耗电问题,也很难去评估它是否已经生效,以及对用户产生的价值有多大。
**3. 耗电优化的方法**
无法复现、难以定位,也无法评估结果,耗电优化之路实在是不容易。在真正去做优化之前,先来看看我们的应用为什么需要在后台耗电?
大部分的开发者不是为了“报复社会”,故意去浪费用户的电量,主要可能有以下一些原因:
<li>
**某个需求场景**。最普遍的场景就是推送,为了实现推送我们只能做各种各样的保活。在需求面前,用户的价值可能被排到第二位。
</li>
<li>
**代码的Bug**。因为某些逻辑考虑不周可能导致GPS没有关闭、WakeLock没有释放。
</li>
所以相反地,耗电优化的思路也非常简单。
- **找到需求场景的替代方案**。以推送为例我们是否可以更多地利用厂商通道或者定时的拉取最新消息这种模式。如果真是迫不得已是不是可以使用foreground service或者引导用户加入白名单。后台任务的总体指导思想是**减少、延迟和合并**,可以参考微信一个小伙写的[《Android后台调度任务与省电》](https://blog.dreamtobe.cn/2016/08/15/android_scheduler_and_battery/)。在后台运行某个任务之前,我们都需要经过下面的思考:
<img src="https://static001.geekbang.org/resource/image/67/ac/67488fb06348423717cb0adba242bdac.png" alt="">
- **符合Android规则**。首先系统的大部分耗电监控,都是在手机在没有充电的时候。我们可以选择在用户充电时才去做一些耗电的工作,具体方法可查看官方文档[《监控电池电量和充电状态》](https://developer.android.com/training/monitoring-device-state/battery-monitoring?hl=zh-cn)。其次是尽早适配最新的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](https://mp.weixin.qq.com/s/APhUH7MBDUZ6tQv0xDgaWQ)系统也会允许应用部分地使用后台网络、Alarm以及JobSheduler事件[不同的分组,限制次数不同](https://developer.android.google.cn/topic/performance/power/power-details)。因此出现异常情况的可能性还是存在的更不用说低版本的系统。对于异常的情况我们需要类似Android Vitals电量监控一样将规则抽象出来并且增加上更多辅助我们定位问题的信息。
## 耗电监控
在I/O监控中我指定了重复I/O、主线程I/O、Buffer过大以及I/O泄漏这四个规则。对于耗电监控也是如此我们首先需要抽象出具体的规则然后收集尽量多的辅助信息帮助问题的排查。
**1. Android Vitals**
前面已经说过Android Vitals的几个关于电量的监控方案与规则我们先复习一下。
<li>
[Alarm Manager wakeup 唤醒过多](https://developer.android.com/topic/performance/vitals/wakeup)
</li>
<li>
[频繁使用局部唤醒锁](https://developer.android.google.cn/topic/performance/vitals/wakelock)
</li>
<li>
[后台网络使用量过高](https://developer.android.com/topic/performance/vitals/bg-network-usage)
</li>
<li>
[后台WiFi scans过多](https://developer.android.com/topic/performance/vitals/bg-wifi)
</li>
在使用了一段时间之后我发现它并不是那么好用。以Alarm wakeup为例Vitals以每小时超过10次作为规则。由于这个规则无法做修改很多时候我们可能希望针对不同的系统版本做更加细致的区分。
其次跟Battery Historian一样我们只能拿到wakeup的标记的组件拿不到申请的堆栈也拿不到当时手机是否在充电、剩余电量等信息。
<img src="https://static001.geekbang.org/resource/image/33/1d/33aa19f951d577b759527c717c7d6e1d.png" alt="">
对于网络、WiFi scans以及WakeLock也是如此。虽然Vitals帮助我们缩小了排查的范围但是依然需要在茫茫的代码中寻找对应的可疑代码。
**2. 耗电监控都监控什么**
Android Vitals并不是那么好用而且对于国内的应用来说其实也根本无法使用。不管怎样我们还是需要搭建自己的耗电监控系统。
那我们的耗电监控系统应该监控哪些内容怎么样才能比Android Vitals做得更好呢
<li>
**监控信息**。简单来说系统关心什么,我们就监控什么,而且应该**以后台耗电监控为主**。类似Alarm wakeup、WakeLock、WiFi scans、Network都是必须的其他的可以根据应用的实际情况。如果是地图应用后台获取GPS是被允许的如果是计步器应用后台获取Sensor也没有太大问题。
</li>
<li>
**现场信息**。监控系统希望可以获得完整的堆栈信息比如哪一行代码发起了WiFi scans、哪一行代码申请了WakeLock等。还有当时手机是否在充电、手机的电量水平、应用前台和后台时间、CPU状态等一些信息也可以帮助我们排查某些问题。
</li>
<li>
**提炼规则**。最后我们需要将监控的内容抽象成规则,当然不同应用监控的事项或者参数都不太一样。
</li>
由于每个应用的具体情况都不太一样,下面是一些可以用来参考的简单规则。
<img src="https://static001.geekbang.org/resource/image/d4/be/d48b7e4d3fdceb101fa7716b5892b0be.png" alt="">
在安卓绿色联盟的会议中,华为公开过他们后台资源使用的“红线”,你也可以参考里面的一些规则:
<img src="https://static001.geekbang.org/resource/image/86/ff/86a65ea0d9216a11a341d7224fce93ff.png" alt="">
**2. 如何监控耗电**
明确了我们需要监控什么以及具体的规则之后终于可以来到实现这个环节了。跟I/O监控、网络监控一样我首先想到的还是Hook方案。
**Java Hook**
Hook方案的好处在于使用者接入非常简单不需要去修改自己的代码。下面我以几个比较常用的规则为例看看如果使用Java Hook达到监控的目的。
- [WakeLock](https://developer.android.com/training/scheduling/wakelock)。WakeLock用来阻止CPU、屏幕甚至是键盘的休眠。类似Alarm、JobService也会申请WakeLock来完成后台CPU操作。WakeLock的核心控制代码都在[PowerManagerService](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/power/PowerManagerService.java)中,实现的方法非常简单。
```
// 代理PowerManagerService
ProxyHook().proxyHook(context.getSystemService(Context.POWER_SERVICE), &quot;mService&quot;, this)
@Override
public void beforeInvoke(Method method, Object[] args) {
// 申请Wakelock
if (method.getName().equals(&quot;acquireWakeLock&quot;)) {
if (isAppBackground()) {
// 应用后台逻辑,获取应用堆栈等等
} else {
// 应用前台逻辑,获取应用堆栈等等
}
// 释放Wakelock
} else if (method.getName().equals(&quot;releaseWakeLock&quot;)) {
// 释放的逻辑
}
}
```
- [Alarm](https://developer.android.com/training/scheduling/alarms)。Alarm用来做一些定时的重复任务它一共有四个类型其中[ELAPSED_REALTIME_WAKEUP](https://developer.android.com/reference/android/app/AlarmManager.html#ELAPSED_REALTIME_WAKEUP)和[RTC_WAKEUP](https://developer.android.com/reference/android/app/AlarmManager.html#RTC_WAKEUP)类型都会唤醒设备。同样Alarm的核心控制逻辑都在[AlarmManagerService](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/AlarmManagerService.java)中,实现如下:
```
// 代理AlarmManagerService
new ProxyHook().proxyHook(context.getSystemService
(Context.ALARM_SERVICE), &quot;mService&quot;, this)
public void beforeInvoke(Method method, Object[] args) {
// 设置Alarm
if (method.getName().equals(&quot;set&quot;)) {
// 不同版本参数类型的适配,获取应用堆栈等等
// 清除Alarm
} else if (method.getName().equals(&quot;remove&quot;)) {
// 清除的逻辑
}
}
```
- 其他。对于后台CPU我们可以使用卡顿监控学到的方法。对于后台网络同样我们可以通过网络监控学到的方法。对于GPS监控我们可以通过Hook代理[LOCATION_SERVICE](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/LocationManagerService.java)。对于Sensor我们通过Hook [SENSOR_SERVICE](http://androidxref.com/7.0.0_r1/xref/frameworks/base/core/java/android/hardware/SystemSensorManager.java)中的“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](https://github.com/facebookincubator/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](https://github.com/AndroidAdvanceWithGeektime/Chapter19)中。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="20 | UI 优化UI 渲染的几个关键概念" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4f/97/4f3e50a9ce2e5b1ef1ce41b43b8a5097.mp3"></audio>
在开始今天的学习前,我祝各位同学新春快乐、工作顺利、身体健康、阖家幸福,绍文给您拜年啦!
>
每个做UI的Android开发上辈子都是折翼的天使。
多年来有那么一群苦逼的Android开发他们饱受碎片化之苦面对着各式各样的手机屏幕尺寸和分辨率还要与“凶残”的产品和UI设计师过招日复一日、年复一年的做着UI适配和优化工作蹉跎着青春的岁月。更加不幸的是最近两年这个趋势似乎还愈演愈烈刘海屏、全面屏还有即将推出的柔性折叠屏UI适配将变得越来越复杂。
UI优化究竟指的是什么呢我认为所谓的UI优化应该包含两个方面一个是效率的提升我们可以非常高效地把UI的设计图转化成应用界面并且保证UI界面在不同尺寸和分辨率的手机上都是一致的另一个是性能的提升在正确实现复杂、炫酷的UI设计的同时需要保证用户有流畅的体验。
那如何将我们从无穷无尽的UI适配中拯救出来呢
## UI渲染的背景知识
究竟什么是UI渲染呢Android的图形渲染框架十分复杂不同版本的差异也比较大。但是无论怎么样它们都是为了将我们代码中的View或者元素显示到屏幕中。
而屏幕作为直接面对用户的手机硬件,类似厚度、色彩、功耗等都是厂家非常关注的。从功能机小小的黑白屏,到现在超大的全面屏,我们先来看手机屏幕的发展历程。
**1. 屏幕与适配**
作为消费者来说通常会比较关注屏幕的尺寸、分辨率以及厚度这些指标。Android的碎片化问题令人痛心疾首屏幕的差异正是碎片化问题的“中心”。屏幕的尺寸从3英寸到10英寸分辨率从320到1920应有尽有对我们UI适配造成很大困难。
除此之外材质也是屏幕至关重要的一个评判因素。目前智能手机主流的屏幕可分为两大类一种是LCDLiquid Crystal Display即液晶显示器另一种是OLEDOrganic Light-Emitting Diode的即有机发光二极管。
最新的旗舰机例如iPhone XS Max和华为Mate 20 Pro使用的都是OLED屏幕。相比LCD屏幕OLED屏幕在色彩、可弯曲程度、厚度以及耗电都有优势。正因为这些优势全面屏、曲面屏以及未来的柔性折叠屏使用的都是OLED材质。关于OLED与LCD的具体差别你可以参考[《OLED和LCD区别》](https://www.zhihu.com/question/22263252)和[《手机屏幕的前世今生,可能比你想的还精彩》](http://mobile.zol.com.cn/680/6805742.html)。今年柔性折叠屏肯定是最大的热点不过OLED的单价成本要比LCD高很多。
对于屏幕碎片化的问题Android推荐使用dp作为尺寸单位来适配UI因此每个Android开发都应该很清楚px、dp、dpi、ppi、density这些概念。
<img src="https://static001.geekbang.org/resource/image/e3/ce/e3094e900dccacb9d9e72063ca3084ce.png" alt="">
通过dp加上自适应布局可以基本解决屏幕碎片化的问题也是Android推荐使用的[屏幕兼容性](https://developer.android.com/guide/practices/screens_support?hl=zh-cn)适配方案。但是它会存在两个比较大的问题:
<li>
不一致性。因为dpi与实际ppi的差异性导致在相同分辨率的手机上控件的实际大小会有所不同。
</li>
<li>
效率。设计师的设计稿都是以px为单位的开发人员为了UI适配需要手动通过百分比估算出dp值。
</li>
除了直接dp适配之外目前业界比较常用的UI适配方法主要有下面几种
<li>
限制符适配方案。主要有宽高限定符与smallestWidth限定符适配方案具体可以参考[《Android 目前稳定高效的UI适配方案》](https://mp.weixin.qq.com/s?__biz=MzAxMTI4MTkwNQ==&amp;mid=2650826034&amp;idx=1&amp;sn=5e86768d7abc1850b057941cdd003927&amp;chksm=80b7b1acb7c038ba8912b9a09f7e0d41eef13ec0cea19462e47c4e4fe6a08ab760fec864c777&amp;scene=21#wechat_redirect)[《smallestWidth 限定符适配方案》](https://mp.weixin.qq.com/s?__biz=MzAxMTI4MTkwNQ==&amp;mid=2650826381&amp;idx=1&amp;sn=5b71b7f1654b04a55fca25b0e90a4433&amp;chksm=80b7b213b7c03b0598f6014bfa2f7de12e1f32ca9f7b7fc49a2cf0f96440e4a7897d45c788fb&amp;scene=21#wechat_redirect)。
</li>
<li>
今日头条适配方案。通过反射修正系统的density值具体可以参考[《一种极低成本的Android屏幕适配方式》](https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&amp;mid=2247484502&amp;idx=2&amp;sn=a60ea223de4171dd2022bc2c71e09351&amp;scene=21#wechat_redirect)[《今日头条适配方案》](https://mp.weixin.qq.com/s/oSBUA7QKMWZURm1AHMyubA)。
</li>
**2. CPU与GPU**
除了屏幕UI渲染还依赖两个核心的硬件CPU与GPU。UI组件在绘制到屏幕之前都需要经过Rasterization栅格化操作而栅格化操作又是一个非常耗时的操作。GPUGraphic Processing Unit )也就是图形处理器,它主要用于处理图形运算,可以帮助我们加快栅格化操作。
<img src="https://static001.geekbang.org/resource/image/1c/8d/1c94e50372ff29ef68690da92c6b468d.png" alt="">
你可以从图上看到软件绘制使用的是Skia库它是一款能在低端设备如手机上呈现高质量的2D跨平台图形框架类似Chrome、Flutter内部使用的都是Skia库。
**3. OpenGL与Vulkan**
对于硬件绘制我们通过调用OpenGL ES接口利用GPU完成绘制。[OpenGL](https://developer.android.com/guide/topics/graphics/opengl)是一个跨平台的图形API它为2D/3D图形处理硬件指定了标准软件接口。而OpenGL ES是OpenGL的子集专为嵌入式设备设计。
在官方[硬件加速的文档](https://developer.android.com/guide/topics/graphics/hardware-accel)中可以看到很多API都有相应的Android API level限制。
<img src="https://static001.geekbang.org/resource/image/d5/5d/d57d364750071b7eb39968fea1a1b15d.png" alt="">
这是为什么呢?其实这主要是受[OpenGL ES](https://www.khronos.org/opengles/)版本与系统支持的限制直到最新的Android P有3个API是仍然没有支持。对于不支持的API我们需要使用软件绘制模式渲染的性能将会大大降低。
<img src="https://static001.geekbang.org/resource/image/cf/31/cf13332abe87502c7d60ff78b6aeb931.png" alt="">
Android 7.0把OpenGL ES升级到最新的3.2版本同时,还添加了对[Vulkan](https://source.android.com/devices/graphics/arch-vulkan)的支持。Vulkan是用于高性能3D图形的低开销、跨平台 API。相比OpenGL ESVulkan在改善功耗、多核优化提升绘图调用上有着非常明显的[优势](https://zhuanlan.zhihu.com/p/20712354)。
在国内“王者荣耀”是比较早适配Vulkan的游戏虽然目前兼容性还有一些问题但是Vulkan版本的王者荣耀在流畅性和帧数稳定性都有大幅度提升即使是战况最激烈的团战阶段也能够稳定保持在5560帧。
## Android渲染的演进
跟耗电一样Android的UI渲染性能也是Google长期以来非常重视的基本每次Google I/O都会花很多篇幅讲这一块。每个开发者都希望自己的应用或者游戏可以做到60 fps如丝般顺滑不过相比iOS系统Android的渲染性能一直被人诟病。
Android系统为了弥补跟iOS的差距在每个版本都做了大量的优化。在了解Android的渲染之前需要先了解一下Android图形系统的[整体架构](https://source.android.com/devices/graphics),以及它包含的主要组件。
<img src="https://static001.geekbang.org/resource/image/7e/66/7efc5431b860634224f1cd7dda8abd66.png" alt="">
我曾经在一篇文章看过一个生动的比喻如果把应用程序图形渲染过程当作一次绘画过程那么绘画过程中Android的各个图形组件的作用是
<li>
画笔Skia或者OpenGL。我们可以用Skia画笔绘制2D图形也可以用OpenGL来绘制2D/3D图形。正如前面所说前者使用CPU绘制后者使用GPU绘制。
</li>
<li>
画纸Surface。所有的元素都在Surface这张画纸上进行绘制和渲染。在Android中Window是View的容器每个窗口都会关联一个Surface。而WindowManager则负责管理这些窗口并且把它们的数据传递给SurfaceFlinger。
</li>
<li>
画板Graphic Buffer。Graphic Buffer缓冲用于应用程序图形的绘制在Android 4.1之前使用的是双缓冲机制在Android 4.1之后,使用的是三缓冲机制。
</li>
<li>
显示SurfaceFlinger。它将WindowManager提供的所有Surface通过硬件合成器Hardware Composer合成并输出到显示屏。
</li>
接下来我将通过Android渲染演进分析的方法帮你进一步加深对Android渲染的理解。
**1. Android 4.0:开启硬件加速**
在Android 3.0之前或者没有启用硬件加速时系统都会使用软件方式来渲染UI。
<img src="https://static001.geekbang.org/resource/image/8f/97/8f85be65392fd7b575393e5665f49a97.png" alt="">
整个流程如上图所示:
<li>
Surface。每个View都由某一个窗口管理而每一个窗口都关联有一个Surface。
</li>
<li>
Canvas。通过Surface的lock函数获得一个CanvasCanvas可以简单理解为Skia底层接口的封装。
</li>
<li>
Graphic Buffer。SurfaceFlinger会帮我们托管一个[BufferQueue](https://source.android.com/devices/graphics/arch-bq-gralloc)我们从BufferQueue中拿到Graphic Buffer然后通过Canvas以及Skia将绘制内容栅格化到上面。
</li>
<li>
SurfaceFlinger。通过Swap Buffer把Front Graphic Buffer的内容交给SurfaceFinger最后硬件合成器Hardware Composer合成并输出到显示屏。
</li>
整个渲染流程是不是非常简单但是正如我前面所说CPU对于图形处理并不是那么高效这个过程完全没有利用到GPU的高性能。
**硬件加速绘制**
所以从Androd 3.0开始Android开始支持硬件加速到Android 4.0时,默认开启硬件加速。
<img src="https://static001.geekbang.org/resource/image/79/e8/79c315275abac0823971e5d6b9657be8.png" alt="">
硬件加速绘制与软件绘制整个流程差异非常大最核心就是我们通过GPU完成Graphic Buffer的内容绘制。此外硬件绘制还引入了一个DisplayList的概念每个View内部都有一个DisplayList当某个View需要重绘时将它标记为Dirty。
当需要重绘时仅仅只需要重绘一个View的DisplayList而不是像软件绘制那样需要向上递归。这样可以大大减少绘图的操作数量因而提高了渲染效率。
<img src="https://static001.geekbang.org/resource/image/f9/51/f9da12b7c4d49f47d650cd8e14303c51.png" alt="">
**2. Android 4.1Project Butter**
优化是无止境的Google在2012年的I/O大会上宣布了Project Butter黄油计划并且在Android 4.1中正式开启了这个机制。
Project Butter主要包含两个组成部分一个是VSYNC一个是Triple Buffering。
**VSYNC信号**
在讲文件I/O跟网络I/O的时候我讲到过中断的概念。对于Android 4.0CPU可能会因为在忙别的事情导致没来得及处理UI绘制。
为解决这个问题Project Buffer引入了[VSYNC](https://source.android.com/devices/graphics/implement-vsync)它类似于时钟中断。每收到VSYNC中断CPU会立即准备Buffer数据由于大部分显示设备刷新频率都是60Hz一秒刷新60次也就是说一帧数据的准备工作都要在16ms内完成。
<img src="https://static001.geekbang.org/resource/image/06/bd/06753998a26642edd3481f85fc93c8bd.png" alt="">
这样应用总是在VSYNC边界上开始绘制而SurfaceFlinger总是VSYNC边界上进行合成。这样可以消除卡顿并提升图形的视觉表现。
**三缓冲机制Triple Buffering**
在Android 4.1之前Android使用双缓冲机制。怎么理解呢一般来说不同的View或者Activity它们都会共用一个Window也就是共用同一个Surface。
而每个Surface都会有一个BufferQueue缓存队列但是这个队列会由SurfaceFlinger管理通过匿名共享内存机制与App应用层交互。
<img src="https://static001.geekbang.org/resource/image/88/96/887c5ff4ae381733a95634c115c7a296.png" alt="">
整个流程如下:
<li>
每个Surface对应的BufferQueue内部都有两个Graphic Buffer 一个用于绘制一个用于显示。我们会把内容先绘制到离屏缓冲区OffScreen Buffer在需要显示时才把离屏缓冲区的内容通过Swap Buffer复制到Front Graphic Buffer中。
</li>
<li>
这样SurfaceFlinge就拿到了某个Surface最终要显示的内容但是同一时间我们可能会有多个Surface。这里面可能是不同应用的Surface也可能是同一个应用里面类似SurefaceView和TextureView它们都会有自己单独的Surface。
</li>
<li>
这个时候SurfaceFlinger把所有Surface要显示的内容统一交给Hareware Composer它会根据位置、Z-Order顺序等信息合成为最终屏幕需要显示的内容而这个内容会交给系统的帧缓冲区Frame Buffer来显示Frame Buffer是非常底层的可以理解为屏幕显示的抽象
</li>
如果你理解了双缓冲机制的原理那就非常容易理解什么是三缓冲区了。如果只有两个Graphic Buffer缓存区A和B如果CPU/GPU绘制过程较长超过了一个VSYNC信号周期因为缓冲区B中的数据还没有准备完成所以只能继续展示A缓冲区的内容这样缓冲区A和B都分别被显示设备和GPU占用CPU无法准备下一帧的数据。
<img src="https://static001.geekbang.org/resource/image/55/53/551fb7b5a8a0bed7d81edde6aff99653.png" alt="">
如果再提供一个缓冲区CPU、GPU和显示设备都能使用各自的缓冲区工作互不影响。简单来说三缓冲机制就是在双缓冲机制基础上增加了一个Graphic Buffer缓冲区这样可以最大限度的利用空闲时间带来的坏处是多使用的了一个Graphic Buffer所占用的内存。
<img src="https://static001.geekbang.org/resource/image/4d/ed/4d84d2d6a8f8e25e1622665141d993ed.png" alt="">
对于VSYNC信号和Triple Buffering更详细的介绍可以参考[《Android Project Butter分析》](https://blog.csdn.net/innost/article/details/8272867)。
**数据测量**
“工欲善其事必先利其器”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渲染速度和绘制过度》](https://developer.android.com/studio/profile/inspect-gpu-rendering)。
<img src="https://static001.geekbang.org/resource/image/1b/d3/1b2bebe9a74374d6089ef13f23088cd3.png" alt="">
**3. Android 5.0RenderThread**
经过Project Butter黄油计划之后Android的渲染性能有了很大的改善。但是不知道你有没有注意到一个问题虽然我们利用了GPU的图形高性能运算但是从计算DisplayList到通过GPU绘制到Frame Buffer整个计算和绘制都在UI主线程中完成。
<img src="https://static001.geekbang.org/resource/image/77/b1/778a18e6f9f9c1d08a5f5e12645c21b1.png" alt="">
UI主线程“既当爹又当妈”任务过于繁重。如果整个渲染过程比较耗时可能造成无法响应用户的操作进而出现卡顿。GPU对图形的绘制渲染能力更胜一筹如果使用GPU并在不同线程绘制渲染图形那么整个流程会更加顺畅。
正因如此在Android 5.0引入了两个比较大的改变。一个是引入了RenderNode的概念它对DisplayList及一些View显示属性做了进一步封装。另一个是引入了RenderThread所有的GL命令执行都放到这个线程上渲染线程在RenderNode中存有渲染帧的所有信息可以做一些属性动画这样即便主线程有耗时操作的时候也可以保证动画流畅。
在官方文档 [《检查 GPU 渲染速度和绘制过度》](https://developer.android.com/studio/profile/inspect-gpu-rendering)中我们还可以开启Profile GPU Rendering检查。在Android 6.0之后,会输出下面的计算和绘制每个阶段的耗时:
<img src="https://static001.geekbang.org/resource/image/5e/f2/5e61bfdec7dabd49b082bbbebb497cf2.png" alt="">
如果我们把上面的步骤转化线程模型可以得到下面的流水线模型。CPU将数据同步sync给GPU之后一般不会阻塞等待GPU渲染完毕而是通知结束后就返回。而RenderThread承担了比较多的绘制工作分担了主线程很多压力提高了UI线程的响应速度。
<img src="https://static001.geekbang.org/resource/image/7f/7d/7f349aefe7a081259218af30b9a9fc7d.png" alt="">
**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渲染架构非常庞大而且演进得也非常快。如果你还有哪些不理解的地方可以进一步阅读下面的参考资料
<li>
2018 Google I/O[Drawn out: how Android renders](https://www.youtube.com/watch?v=zdQRIYOST64)
</li>
<li>
官方文档:[Android 图形架构](https://source.android.com/devices/graphics)
</li>
<li>
浏览器渲染:[一颗像素的诞生](https://mp.weixin.qq.com/s/QoFrdmxdRJG5ETQp5Ua3-A)
</li>
<li>
[Android 屏幕绘制机制及硬件加速](https://blog.csdn.net/qian520ao/article/details/81144167)
</li>
<li>
[Android性能优化之渲染篇](http://hukai.me/android-performance-render/)
</li>
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,296 @@
<audio id="audio" title="21 | UI 优化(下):如何优化 UI 渲染?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/55/bb1e21495da391431c3faa4f33474455.mp3"></audio>
孔子曰“温故而知新”在学习如何优化UI渲染之前我们先来回顾一下在“卡顿优化”中学到的知识。关于卡顿优化我们学习了4种本地排查卡顿的工具以及多种线上监控卡顿、帧率的方法。为什么要回顾卡顿优化呢那是因为UI渲染也会造成卡顿并且肯定会有同学疑惑卡顿优化和UI优化的区别是什么。
在Android系统的VSYNC信号到达时如果UI线程被某个耗时任务堵塞长时间无法对UI进行渲染这时就会出现卡顿。但是这种情形并不是我们今天讨论的重点UI优化要解决的核心是由于渲染性能本身造成用户感知的卡顿它可以认为是卡顿优化的一个子集。
从设计师和产品的角度他们希望应用可以用丰富的图形元素、更炫酷的动画来实现流畅的用户体验。但是Android系统很有可能无法及时完成这些复杂的界面渲染操作这个时候就会出现掉帧。也正因如此我才希望做UI优化因为我们有更高的要求希望它能达到流畅画面所需要的60 fps。这里需要说的是即使40 fps用户可能不会感到明显的卡顿但我们也仍需要去做进一步的优化。
那么接下来我们就来看看如何让我们的UI渲染达到60 fps有哪些方法可以帮助我们优化UI渲染性能
## UI渲染测量
通过上一期的学习你应该已经掌握了一些UI测试和问题定位的工具。
<li>
测试工具Profile GPU Rendering和Show GPU Overdraw具体的使用方法你可以参考[《检查GPU渲染速度和绘制过度》](https://developer.android.com/studio/profile/inspect-gpu-rendering)。
</li>
<li>
问题定位工具Systrace和Tracer for OpenGL ES具体使用方法可以参考[《Slow rendering》](https://developer.android.com/topic/performance/vitals/render)。
</li>
在Android Studio 3.1之后Android推荐使用[Graphics API Debugger](https://github.com/google/gapid)GAPID来替代Tracer for OpenGL ES工具。GAPID可以说是升级版它不仅可以跨平台而且功能更加强大支持Vulkan与回放。
<img src="https://static001.geekbang.org/resource/image/5c/38/5c390e9148664f338fb61781e650a138.png" alt="">
通过上面的几个工具我们可以初步判断应用UI渲染的性能是否达标例如是否经常出现掉帧、掉帧主要发生在渲染的哪一个阶段、是否存在Overdraw等。
虽然这些图形化界面工具非常好用但是它们难以用在自动化测试场景中那有哪些测量方法可以用于自动化测量UI渲染性能呢
**1. gfxinfo**
[gfx](https://developer.android.com/training/testing/performance)[info](https://developer.android.com/training/testing/performance)可以输出包含各阶段发生的动画以及帧相关的性能信息,具体命令如下:
```
adb shell dumpsys gfxinfo 包名
```
除了渲染的性能之外gfxinfo还可以拿到渲染相关的内存和View hierarchy信息。在Android 6.0之后gxfinfo命令新增了framestats参数可以拿到最近120帧每个绘制阶段的耗时信息。
```
adb shell dumpsys gfxinfo 包名 framestats
```
通过这个命令我们可以实现自动化统计应用的帧率更进一步还可以实现自定义的“Profile GPU Rendering”工具在出现掉帧的时候自动统计分析是哪个阶段的耗时增长最快同时给出相应的[建议](https://developer.android.com/topic/performance/rendering/profile-gpu)。
<img src="https://static001.geekbang.org/resource/image/4f/b2/4f74599b0b3eca4fc3cde7901fcbe2b2.png" alt="">
**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)
//序号 //状态 //对象 //大小
&gt;[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](https://developer.android.com/reference/android/view/SurfaceView)或者[TextureView](https://developer.android.com/reference/android/view/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 ms60 fps内完成。
<img src="https://static001.geekbang.org/resource/image/bc/0d/bcbf90aa1c684c261d009c04f489810d.png" alt="">
所谓的UI优化就是拆解渲染的各个阶段的耗时找到瓶颈的地方再加以优化。接下来我们一起来看看UI优化的一些常用的手段。
**1. 尽量使用硬件加速**
通过上一期学习相信你也发自内心地认同硬件加速绘制的性能是远远高于软件绘制的。所以说UI优化的第一个手段就是保证渲染尽量使用硬件加速。
有哪些情况我们不能使用硬件加速呢之所以不能使用硬件加速是因为硬件加速不能支持所有的Canvas API具体API兼容列表可以见[drawing-support](https://developer.android.com/guide/topics/graphics/hardware-accel#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](https://github.com/iReaderAndroid/X2C)。但坦白说,还是有不少情况是不支持直接转换的。
所以我们需要兼容性能与开发效率,我建议只在对性能要求非常高,但修改又不非常频繁的场景才使用这个方式。
**异步创建**
那我们能不能在线程提前创建View实现UI的预加载吗尝试过的同学都会发现系统会抛出下面这个异常
```
java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()
at android.os.Handler.&lt;init&gt;(Handler.java:121)
```
事实上我们可以通过又一个非常取巧的方式来实现。在使用线程创建UI的时候先把线程的Looper的MessageQueue替换成UI线程Looper的Queue。
<img src="https://static001.geekbang.org/resource/image/54/55/54ab7385263b71ded795a5001df24a55.png" alt="">
不过需要注意的是在创建完View后我们需要把线程的Looper恢复成原来的。
**View重用**
正常来说View会随着Activity的销毁而同时销毁。ListView、RecycleView通过View的缓存与重用大大地提升渲染性能。因此我们可以参考它们的思想实现一套可以在不同Activity或者Fragment使用的View缓存机制。
但是这里需要保证所有进入缓存池的View都已经“净身出户”不会保留之前的状态。微信曾经就因为这个缓存导致出现不同的用户聊天记录错乱。
<img src="https://static001.geekbang.org/resource/image/d2/fa/d21f2febd742c91cbeca9a14755b71fa.png" alt="">
**3. measure/layout优化**
渲染流程中measure和layout也是需要CPU在主线程执行的对于这块内容网上有很多优化的文章一般的常规方法有
<li>
**减少UI布局层次**。例如尽量扁平化,使用`&lt;ViewStub&gt;` `&lt;Merge&gt;`等优化。
</li>
<li>
**优化layout的开销**。尽量不使用RelativeLayout或者基于weighted LinearLayout它们layout的开销非常巨大。这里我推荐使用ConstraintLayout替代RelativeLayout或者weighted LinearLayout。
</li>
<li>
**背景优化**。尽量不要重复去设置背景这里需要注意的是主题背景theme) theme默认会是一个纯色背景如果我们自定义了界面的背景那么主题的背景我们来说是无用的。但是由于主题背景是设置在DecorView中所以这里会带来重复绘制也会带来绘制性能损耗。
</li>
对于measure和layout我们能不能像Create View一样实现线程的预布局呢这样可以大大地提升首次显示的性能。
Textview是系统控件中非常强大也非常重要的一个控件强大的背后就代表着需要做很多计算。在2018年的Google I/O大会发布了[PrecomputedText](https://developer.android.com/reference/android/text/PrecomputedText)并已经集成在Jetpack中它给我们提供了接口可以异步进行measure和layout不必在主线程中执行。
## UI优化的进阶手段
那对于其他的控件我们是不是也可以采用相同的方式接下来我们一起来看看近两年新框架的做法我来介绍一下Facebook的一个开源库Litho以及Google开源的Flutter。
**1. Litho异步布局**
[Litho](https://github.com/facebook/litho)是Facebook开源的声明式Android UI渲染框架它是基于另外一个Facebook开源的布局引擎[Yoga](https://github.com/facebook/yoga)开发的。
Litho本身非常强大内部做了很多非常不错的优化。下面我来简单介绍一下它是如何优化UI的。
**异步布局**<br>
一般来说的Android所有的控件绘制都要遵守measure -&gt; layout -&gt; draw的流水线并且这些都发生在主线程中。
<img src="https://static001.geekbang.org/resource/image/b8/5c/b8bd2cb5ad88a64f301381b0cf45b15c.png" alt="">
Litho如我前面提到的PrecomputedText一样把measure和layout都放到了后台线程只留下了必须要在主线程完成的draw这大大降低了UI线程的负载。它的渲染流水线如下
<img src="https://static001.geekbang.org/resource/image/40/63/40ed08e561093024b58b0840af80a663.png" alt="">
**界面扁平化**
前面也提到过降低UI的层级是一个非常通用的优化方法。你肯定会想有没有一种方法可以直接降低UI的层级而不通过代码的改变呢Litho就给了我们一种方案由于Litho使用了自有的布局引擎Yoga)在布局阶段就可以检测不必要的层级、减少ViewGroups来实现UI扁平化。比如下面这样图上半部分是我们一般编写这个界面的方法下半部分是Litho编写的界面可以看到只有一层层级。
<img src="https://static001.geekbang.org/resource/image/17/90/1758d00240d0eda842570038caf92090.png" alt="">
**优化RecyclerView**<br>
Litho还优化了RecyclerView中UI组件的缓存和回收方法。原生的RecyclerView或者ListView是按照viewType来进行缓存和回收但如果一个RecyclerView/ListView中出现viewType过多会使缓存形同虚设。但Litho是按照text、image和video独立回收的这可以提高缓存命中率、降低内存使用率、提高滚动帧率。
<img src="https://static001.geekbang.org/resource/image/9d/8d/9d8a2830ef39dd84ca8165a08a38098d.png" alt="">
Litho虽然强大但也有自己的缺点。它为了实现measure/layout异步化使用了类似react单向数据流设计这一定程度上加大了UI开发的复杂性。并且Litho的UI代码是使用Java/Kotlin来进行编写无法做到在AS中预览。
如果你没有计划完全迁移到Litho我建议可以优先使用Litho中的RecyclerCollectionComponent和Sections来优化自己的RecyelerView的性能。
**2. Flutter自己的布局 + 渲染引擎**
如下图所示Litho虽然通过使用自己的布局引擎Yoga一定程度上突破了系统的一些限制但是在draw之后依然走的系统的渲染机制。
<img src="https://static001.geekbang.org/resource/image/28/e8/28ea86e8b516825d8cd97071ce25abe8.png" alt="">
那我们能不能再往底层深入把系统的渲染也同时接管过来Flutter正是这样的框架它也是最近十分火爆的一个新框架这里我也简单介绍一下。
[Flutter](https://github.com/flutter/flutter)是Google推出并开源的移动应用开发框架开发者可以通过Dart语言开发App一套代码同时运行在iOS和Android平台。
我们先整体看一下Flutter的架构在Android上Flutter完全没有基于系统的渲染引擎而是把Skia引擎直接集成进了App中这使得Flutter App就像一个游戏App。并且直接使用了Dart虚拟机可以说是一套跳脱出Android的方案所以Flutter也可以很容易实现跨平台。
<img src="https://static001.geekbang.org/resource/image/8a/a4/8a8773ea7258eb5518b22f1fb6f964a4.png" alt="">
开发Flutter应用总的来说简化了线程模型框架给我们抽象出各司其职的Runner包括UI、GPU、I/O、Platform Runner。Android平台上面每一个引擎实例启动的时候会为UI Runner、GPU Runner、I/O Runner各自创建一个新的线程所有Engine实例共享同一个Platform Runner和线程。
由于本期我们主要讨论UI渲染相关的内容我来着重分析一下Flutter的渲染步骤相关的具体知识你可以阅读[《Flutter原理与实践》](https://tech.meituan.com/2018/08/09/waimai-flutter-practice.html)。
<li>
首先UI Runner会执行root isolate可以简单理解为main函数。需要简单解释一下isolate的概念isolate是Dart虚拟机中一种执行并发代码实现Dart虚拟机实现了Actor的并发模型与大名鼎鼎的Erlang使用了类似的并发模型。如果不太了解Actor的同学可以简单认为isolate就是Dart虚拟机的“线程”Root isolate会通知引擎有帧要渲染
</li>
<li>
Flutter引擎得到通知后会告知系统我们要同步VSYNC。
</li>
<li>
得到GPU的VSYNC信号后对UI Widgets进行Layout并生成一个Layer Tree。
</li>
<li>
然后Layer Tree会交给GPU Runner进行合成和栅格化。
</li>
<li>
GPU Runner使用Skia库绘制相关图形。
</li>
<img src="https://static001.geekbang.org/resource/image/d0/a9/d0ac4c878a5c61a7226ea09aac8f97a9.png" alt="">
Flutter也采用了类似Litho、React属性不可变单向数据流的方案。这已经成为现代UI渲染引擎的标配。这样做的好处是可以将视图与数据分离。
总体来说Flutter吸取和各个优秀前端框架的精华还“加持”了强大的Dart虚拟机和Skia渲染引擎可以说是一个非常优秀的框架闲鱼、今日头条等很多应用部分功能已经使用Flutter开发。结合Google最新的Fuchsia操作系统它会不会是一个颠覆Android的开发框架我们在专栏后面会单独详细讨论Flutter。
**3. RenderThread与RenderScript**
在Android 5.0系统增加了RenderThread对于ViewPropertyAnimator和CircularReveal动画我们可以使用[RenderThead实现动画的异步渲染](https://mp.weixin.qq.com/s/o-e0MvrJbVS_0HHHRf43zQ)。当主线程阻塞的时候普通动画会出现明显的丢帧卡顿而使用RenderThread渲染的动画即使阻塞了主线程仍不受影响。
现在越来越多的应用会使用一些高级图片或者视频编辑功能,例如图片的高斯模糊、放大、锐化等。拿日常我们使用最多的“扫一扫”这个场景来看,这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。
图片的变换涉及大量的计算任务而根据我们上一期的学习这个时候使用GPU是更好的选择。那如何进一步压榨系统GPU的性能呢
我们可以通过[RenderScript](https://developer.android.com/guide/topics/renderscript/compute)它是Android操作系统上的一套API。它基于异构计算思想专门用于密集型计算。RenderScript提供了三个基本工具一个硬件无关的通用计算API一个类似于CUDA、OpenCL和GLSL的计算API一个类[C99](https://zh.wikipedia.org/wiki/C99)的脚本语言。允许开发者以较少的代码实现功能复杂且性能优越的应用程序。
如何将它们应用到我们的项目中?你可以参考下面的一些实践方案:
<li>
[RenderScript渲染利器](https://www.jianshu.com/p/b72da42e1463)
</li>
<li>
[RenderScript :简单而快速的图像处理](http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2016/0504/4205.html?utm_source=itdadao&amp;utm_medium=referral)
</li>
<li>
[Android RenderScript 简单高效实现图片的高斯模糊效果](http://yifeng.studio/2016/10/20/android-renderscript-blur/)
</li>
## 总结
回顾一下UI优化的所有手段我们会发现它存在这样一个脉络
**1. 在系统的框架下优化**。布局优化、使用代码创建、View缓存等都是这个思路我们希望减少甚至省下渲染流水线里某个阶段的耗时。
**2. 利用系统新的特性**。使用硬件加速、RenderThread、RenderScript都是这个思路通过系统一些新的特性最大限度压榨出性能。
**3. 突破系统的限制**。由于Android系统碎片化非常严重很多好的特性可能低版本系统并不支持。而且系统需要支持所有的场景在一些特定场景下它无法实现最优解。这个时候我们希望可以突破系统的条条框框例如Litho突破了布局Flutter则更进一步把渲染也接管过来了。
回顾一下过去所有的UI优化第一阶段的优化我们在系统的束缚下也可以达到非常不错的效果。不过越到后面越容易出现瓶颈这个时候我们就需要进一步往底层走可以对整个架构有更大的掌控力需要造自己的“轮子”。
对于UI优化的另一个思考是效率目前Android Studio对设计并不友好例如不支持Sketch插件和AE插件。[Lottie](https://github.com/airbnb/lottie-android)是一个非常好的案例,它很大提升了开发人员写动画的效率。
“设计师和产品你们长大了要学会自己写UI了”。在未来我们希望UI界面与适配可以实现自动化或者干脆把它交还给设计师和产品。
## 课后作业
在你平时的工作中做过哪些UI优化的工作有没有什么“大招”跟其他同学分享对于Litho, Flutter你又有什么看法欢迎留言跟我和其他同学一起讨论。
今天还有两个课后小作业尝试使用Litho和Flutter这两个框架。
1.使用Litho实现一个信息流界面。
2.使用Flutter写一个Hello World分析安装包的体积。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,334 @@
<audio id="audio" title="22 | 包体积优化(上):如何减少安装包大小?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/b0/b707ad189cc813c7c22cf2d1c8d4fdb0.mp3"></audio>
曾经在15年的时候我在WeMobileDev公众号就写过一篇文章[《Android安装包相关知识汇总》](https://mp.weixin.qq.com/s/QRIy_apwqAaL2pM8a_lRUQ),也开源了一个不少同学都使用过的资源混淆工具[AndResGuard](https://mp.weixin.qq.com/s/6YUJlGmhf1-Q-5KMvZ_8_Q)。
现在再看看这篇4年前的文章就像看到了4年前的自己感触颇多啊。几年过去了网上随意一搜都有大量安装包优化的文章那还有哪些“高深”的珍藏秘笈值得分享呢
时至今日微信包体积也从当年的30MB增长到现在的100MB了。我们经常会想现在WiFi这么普遍了而且5G都要来了包体积优化究竟还有没有意义它对用户和应用的价值在哪里
## 安装包的背景知识
还记得在2G时代我们每个月只有30MB流量那个时候安装包体积确实至关重要。当时我在做“搜狗输入法”的时候我们就严格要求包体积在5MB以内。
几年过去了,我们对包体积的看法有什么改变吗?
**1. 为什么要优化包体积**
在2018年的Google I/OGoogle透露了Google Play上安装包体积与下载转化率的关系图。
<img src="https://static001.geekbang.org/resource/image/f8/68/f8a5e264dee4ee6879cd6c30d4bbf368.png" alt="">
从这张图上看,大体来说,安装包越小,转化率越高这个结论依然成立。而包体积对应用的影响,主要有下面几点:
<li>
**下载转化率**。一个100MB的应用用户即使点了下载也可能因为网络速度慢、突然反悔下载失败。对于一个10MB的应用用户点了下载之后在犹豫要不要下的时候已经下载完了。但是正如上图的数据安装包大小与转化率的关系是非常微妙的。**10MB跟15MB可能差距不大但是10MB跟40MB的差距还是非常明显的。**
</li>
<li>
**推广成本**。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。
</li>
<li>
**应用市场**。苹果的App Store强制超过150MB的应用只能使用WiFi网络下载Google Play要求超过100MB的应用只能使用[APK扩展文件方式](https://developer.android.com/google/play/expansion-files)上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。
</li>
目前成熟的超级App越来越多很多产品也希望自己成为下一个超级App希望功能可以包罗万象满足用户的一切需求。但这同样也导致安装包不断变大其实很多用户只使用到很少一部分功能。
下面我们就来看看微信、QQ、支付宝以及淘宝这几款超级App这几年安装包增长的情况。
<img src="https://static001.geekbang.org/resource/image/e0/76/e0c8bc58d363e81ff3ac7a141f784776.png" alt="">
我还记得在15年的时候为了让微信6.2版本小于30MB我使用了各种各样的手段把体积从34MB降到29.85MB资源混淆工具AndResGuard也就是在那个优化专项中写的。几年过去了微信包体积已经涨到100MB了淘宝似乎也不容乐观。相比之下QQ和支付宝相对还比较节制。
**2. 包体积与应用性能**
React Native 5MB、Flutter 4MB、浏览器内核20MB、Chromium网络库2MB…现在第三方开发框架和扩展库越来越多很多的应用包体积都已经几十是MB起步了。
那包体积除了转化率的影响,它对我们应用性能还有哪些影响呢?
<li>
**安装时间**。文件拷贝、Library解压、编译ODEX、签名校验特别对于Android 5.0和6.0系统来说Android 7.0之后有了混合编译微信13个Dex光是编译ODEX的时间可能就要5分钟。
</li>
<li>
**运行内存**。在内存优化的时候我们就说过Resource资源、Library以及Dex类加载这些都会占用不少的内存。
</li>
<li>
**ROM空间**。100MB的安装包启动解压之后很有可能就超过200MB了。对低端机用户来说也会有很大的压力。在“I/O优化”中我们讨论过如果闪存空间不足非常容易出现写入放大的情况。
</li>
对于大部分一两年前的“千元机”,淘宝和微信都已经玩不转了。“技术短期内被高估,长期会被低估”,特别在业务高速发展的时候,性能往往就被排到后面。
包体积对技术人员来说应该是非常重要的技术指标,我们不能放任它的增长,它对我们还有不少意义。
<li>
**业务梳理**。删除无用或者低价值的业务,永远都是最有效的性能优化方式。我们需要经常回顾过去的业务,不能只顾着往前冲,适时地还一些“技术债务”。
</li>
<li>
**开发模式升级**。如果所有的功能都不能移除那可能需要倒逼开发模式的转变更多地采用小程序、H5这样开发模式。
</li>
## 包体积优化
国内地开发者都非常羡慕海外的应用因为海外有统一的Google Play市场。它可以根据用户的ABI、density和language发布还有在2018年最新推出的[App Bundle](https://developer.android.com/platform/technology/app-bundle/)。
<img src="https://static001.geekbang.org/resource/image/3d/a2/3d27aa4b299f9768ef0e6a7771d436a2.png" alt="">
事实上安装包中无非就是Dex、Resource、Assets、Library以及签名信息这五部分接下来我们就来看看对于国内应用来说还有什么高级“秘籍”。
**1. 代码**
对于大部分应用来说Dex都是包体积中的大头。看一下上面表格中微信、QQ、支付宝和淘宝的数据它们的Dex数量从1个增长到10多个我们的代码量真的增长了那么多倍吗
而且Dex的数量对用户安装时间也是一个非常大的挑战在不砍功能的前提下我们看看有哪些方法可以减少这部分空间。
**ProGuard**<br>
“十个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混淆但是需要完成下面几个工作
<li>
**XML替换**。在代码混淆之后需要同时修改AndroidManifest以及资源XML中引用的名称。
</li>
<li>
**代码替换**。需要遍历其他已经混淆好的代码,将变量或者方法体中定义的字符串也同时修改。需要注意的是,代码中不能出现经过运算得到的类名,这种情况会导致替换失败。
</li>
```
// 情况一:变量
public String activityName = &quot;com.sample.TestActivity&quot;;
// 情况二:方法体
startActivity(new Intent(this, &quot;com.sample.TestActivity&quot;));
// 情况三:通过运算得到,不支持
startActivity(new Intent(this, &quot;com.sample&quot; + &quot;.TestActivity&quot;));
```
代码替换的方法我推荐使用ASM。不熟悉ASM的同学也不用着急后面我会专门讲它的原理和用法。饿了么曾经开源过一个可以实现四大组件和View混淆的组件[Mess](https://github.com/eleme/Mess),不过似乎已经没在维护了,可供你参考。
Android Studio 3.0推出了[新Dex编译器D8与新混淆工具R8](https://blog.dreamtobe.cn/android_d8_r8/)目前D8已经正式Release大约可以减少3%的Dex体积。但是计划用于取代ProGuard的[R8](https://www.guardsquare.com/en/blog/proguard-and-r8)依然处于实验室阶段,期待它在未来能有更好的表现。
**去掉Debug信息或者去掉行号**<br>
某个应用通过相同的ProGuard规则生成一个Debug包和Release包其中Debug包的大小是4MBRelease包只有3.5MB。
既然它们ProGuard的混淆与优化的规则是一样的那它们之间的差异在哪里呢那就是DebugItem。
<img src="https://static001.geekbang.org/resource/image/69/10/69ec4986053903876d55fbd37d47a710.png" alt="">
DebugItem里面主要包含两种信息
<li>
**调试的信息**。函数的参数变量和所有的局部变量。
</li>
<li>
**排查问题的信息**。所有的指令集行号和源文件行号的对应关系。
</li>
事实上在ProGuard配置中一般我们也会通过下面的方式保留行号信息。
```
-keepattributes SourceFile, LineNumberTable
```
对于去除debuginfo以及行号信息更详细的分析推荐你认真看一下支付宝的一篇文章[《Android包大小极致压缩》](https://mp.weixin.qq.com/s/_gnT2kjqpfMFs0kqAg4Qig)。通过这个方法我们可以实现既保留行号但是又可以减少大约5%的Dex体积。
事实上支付宝参考的是Facebook的一个开源编译工具[ReDex](https://github.com/facebook/redex)。ReDex除了没有文档之外绝对是客户端领域非常硬核的一个开源库非常值得你去认真研究。
ReDex这个库里面的好东西实在是太多了后面我们还会反复讲到其中去除Debug信息是通过StripDebugInfoPass完成。
```
{
&quot;redex&quot; : {
&quot;passes&quot; : [
&quot;StripDebugInfoPass&quot;
]
},
&quot;StripDebugInfoPass&quot; : {
&quot;drop_all_dbg_info&quot; : &quot;0&quot;, // 去除所有的debug信息0表示不去除
&quot;drop_local_variables&quot; : &quot;1&quot;, // 去除所有局部变量1表示去除
&quot;drop_line_numbers&quot; : &quot;0&quot;, // 去除行号0表示不去除
&quot;drop_src_files&quot; : &quot;0&quot;,
&quot;use_whitelist&quot; : &quot;0&quot;,
&quot;drop_prologue_end&quot; : &quot;1&quot;,
&quot;drop_epilogue_begin&quot; : &quot;1&quot;,
&quot;drop_all_dbg_info_if_empty&quot; : &quot;1&quot;
}
}
```
**Dex分包**<br>
当我们在Android Studio查看一个APK的时候不知道你是否知道下图中“defines 19272 methods”和“references 40229 methods”的区别。
<img src="https://static001.geekbang.org/resource/image/fb/c4/fbd2ebe2b0ffc43447e414994c56d6c4.png" alt="">
关于Dex的格式以及各个字段的定义你可以参考[《Dex文件格式详解》](https://www.jianshu.com/p/f7f0a712ddfe)。为了加深对Dex格式的理解推荐你使用010Editor。
<img src="https://static001.geekbang.org/resource/image/87/33/87815d218abfaff9dc02a46c079cfb33.png" alt="">
“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。
<img src="https://static001.geekbang.org/resource/image/96/d5/96d08f01c5fe27c74bfcd5ac529232d5.png" alt="">
因为跨Dex调用造成的这些冗余信息它对我们Dex的大小会造成哪些影响呢
<li>
**method id爆表**。我们都知道每个Dex的method id需要小于65536因为method id的大量冗余导致每个Dex真正可以放的Class变少这是造成最终编译的Dex数量增多。
</li>
<li>
**信息冗余**。因为我们需要记录跨Dex调用的方法的详细信息所以在classes2.dex我们还需要记录Class B以及method b的定义造成string_ids、type_ids、proto_ids这几部分信息的冗余。
</li>
事实上我自己定义了一个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在分析类调用关系后使用的是[贪心算法](https://github.com/facebook/redex/blob/master/opt/interdex/InterDex.cpp#L619)计算局部最优值,具体算法可查看[CrossDexDefMinimizer](https://github.com/facebook/redex/blob/master/opt/interdex/CrossDexRefMinimizer.cpp)。
为什么我们不能计算到最优解因为我们需要在编译速度和效果之间找一个平衡点在ReDex中使用这个优化的配置如下
```
{
&quot;redex&quot; : {
&quot;passes&quot; : [
&quot;InterDexPass&quot;
]
},
&quot;InterDexPass&quot; : {
&quot;minimize_cross_dex_refs&quot;: true,
&quot;minimize_cross_dex_refs_method_ref_weight&quot;: 100,
&quot;minimize_cross_dex_refs_field_ref_weight&quot;: 90,
&quot;minimize_cross_dex_refs_type_ref_weight&quot;: 100,
&quot;minimize_cross_dex_refs_string_ref_weight&quot;: 90
}
}
```
那么通过Dex分包可以对包体积优化多少呢因为Android默认的分包方式做得实在不好如果你的应用有4个以上的Dex我相信这个优化至少有10%的效果。
**Dex压缩**<br>
我曾经在逆向Facebook的App时惊奇地发现它怎么可能只有一个700多KB的Dex。Google Play是不允许动态下发代码的那它的代码都放到哪里了呢
<img src="https://static001.geekbang.org/resource/image/00/f7/008dc38d277aab4eabfb580ccac7aef7.png" alt="">
事实上Facebook App的classes.dex只是一个壳真正的代码都放到assets下面。它们把所有的Dex都合并成同一个secondary.dex.jar.xzs文件并通过XZ压缩。
<img src="https://static001.geekbang.org/resource/image/66/22/66abe10ca8e67e86ced07087555b8f22.png" alt="">
[XZ压缩算法](https://tukaani.org/xz/)和7-Zip一样内部使用的都是LZMA算法。对于Dex格式来说XZ的压缩率可以比Zip高30%左右。但是不知道你有没有注意到,这套方案似乎存在一些问题:
<li>
**首次启动解压**。应用首次启动的时候需要将secondary.dex.jar.xzs解压缩根据上图的配置信息应该一共有11个Dex。Facebook使用多线程解压的方式这个耗时在高端机是几百毫秒左右在低端机可能需要35秒。**这里为什么不采用Zstandard或者Brotli呢主要是压缩率与解压速度的权衡。**
</li>
<li>
**ODEX文件生成**。前面我就讲过当Dex非常多的时候会增加应用的安装时间。对于Facebook的这个做法首次生成ODEX的时间可能就会达到分钟级别。Facebook为了解决这个问题使用了ReDex另外一个超级硬核的方法那就是[oatmeal](https://github.com/facebook/redex/tree/master/tools/oatmeal)。
</li>
oatmeal的原理非常简单就是根据ODEX文件的格式自己生成一个ODEX文件。它生成的结果跟解释执行的ODEX一样内部是没有机器码的。
<img src="https://static001.geekbang.org/resource/image/6c/f4/6c7c1cceca23db77f7f0f51509ef62f4.png" alt="">
如上图所示对于正常的流程我们需要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压缩**<br>
跟Dex压缩一样Library优化最有效果的方法也是使用XZ或者7-Zip压缩。
<img src="https://static001.geekbang.org/resource/image/8f/c6/8f8a924549a14fd298f6efb4564f8ac6.png" alt="">
在默认的lib目录我们只需要加载少数启动过程相关的Library其他的Library我们都在首次启动时解压。对于Library格式来说压缩率同样可以比Zip高30%左右,效果十分惊人。
Facebook有一个So加载的开源库[SoLoader](https://github.com/facebook/SoLoader),它可以跟这套方案配合使用。**和Dex压缩一样压缩方案的主要缺点在于首次启动的时间毕竟对于低端机来说多线程的意义并不大因此我们要在包体积和用户体验之间做好平衡。**
**Library合并与裁剪**<br>
对于Native LibraryFacebook中的编译构建工具[Buck](https://buckbuild.com/)也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在[源码](https://github.com/facebook/buck)中。
<li>
**Library合并**。在Android 4.3之前进程加载的Library数量是[有限制的](https://android.googlesource.com/platform/bionic/+/ba98d9237b0eabc1d8caf2600fd787b988645249%5E%21/)。在编译过程我们可以自动将部分Library合并成一个。具体思路你可以参考文章[《Android native library merging》](https://code.fb.com/android/android-native-library-merging/)以及[Demo](https://github.com/fbsamples/android-native-library-merging-demo)。
</li>
<li>
**Library裁剪**。Buck里面有一个[relinker](https://github.com/facebook/buck/blob/master/src/com/facebook/buck/android/relinker/NativeRelinker.java)的功能原理就是分析代码中JNI方法以及不同Library的方法调用找到没有无用的导出symbol将它们删掉。**这样linker在编译的时候也会把对应的无用代码同时删掉**这个方法相当于实现了Library的ProGuard Shrinking功能。
</li>
<img src="https://static001.geekbang.org/resource/image/b8/a0/b86745a05656f05116443549cec6f3a0.png" alt="">
## 包体积监控
关于包体积如果一直放任不管几个版本之后就会给你很大的“惊喜”。我了解到一些应用对包体积卡得很紧任何超过100KB的功能都需要审批。
对于包体积的监控,通常有下面几种:
<li>
**大小监控**。这个非常好理解,每个版本跟上一个版本包体积的对比情况。如果某个版本体积增长过大,需要分析具体原因,是否有优化空间。
</li>
<li>
**依赖监控**。每一版本我们都需要监控依赖这里包括新增JAR以及AAR依赖。这是因为很多开发者非常不细心经常会不小心把一些超大的开源库引进来。
</li>
<li>
**规则监控**。如果发现某个版本包体积增长很大我们需要分析原因。规则监控也就是将包体积的监控抽象为规则例如无用资源、大文件、重复文件、R文件等。比如我在微信的时候使用[ApkChecker](https://mp.weixin.qq.com/s/tP3dtK330oHW8QBUwGUDtA)实现包体积的规则监控。
</li>
<img src="https://static001.geekbang.org/resource/image/bd/c9/bd20c2420a06e332a78737deaa0aedc9.png" alt="">
包体积的监控最好可以实现自动化与平台化,作为发布流程的其中一个环节。不然通过人工的方式,很难持续坚持下去。
## 总结
今天我们一起分析了实现难度比较大的包体积优化方法,可能有人会想这些方法实现难度那么大,真的有价值吗?根据我的理解,现在我们已经到了移动优化的“深水区”,网上那些千篇一律的文章已经无法满足需求。也就是说,简单的方法我们都掌握了,而且也都已经在做了,需要考虑接下来应该如何进一步优化。
这时候就需要静下心来学会思考与钻研再往底层走走。我们要去研究APK的文件格式进一步还要研究内部Dex、Library以及Resource的文件格式。同时思考整个编译流程才能找到那些可以突破的地方。
在实现AndResGuard的时候我就对resources.arsc格式以及Android加载资源的流程有非常深入的研究。几年过去了对于资源的优化又有哪些新的秘籍呢我们下一期就会讨论“资源优化”这个主题。
从Buck和ReDex看出来Facebook比国内的研究真的要高深很多希望他们可以补充一些文档让我们学习起来更轻松一些。
## 课后作业
你的应用会关注包体积吗?你做过哪些包体积优化的工作,有哪些好的方法可以跟同学们分享呢?欢迎留言跟我和其他同学一起讨论。
今天的练习[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter22)尝试使用ReDex这个项目来优化我们应用的包体积主要有下面几个小任务
<li>
strip debuginfo
</li>
<li>
分包优化
</li>
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="23 | 包体积优化(下):资源优化的进阶实践" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/24/b05a154f374ee0d3b041f6be79e85824.mp3"></audio>
上一期我们聊了Dex与Native Library的优化是不是还有点意犹未尽的感觉呢那安装包还有哪些可以优化的地方呢
<img src="https://static001.geekbang.org/resource/image/30/46/30d73f5021ac8b4333db3e49a31c8a46.png" alt="">
请看上面这张图Assets、Resource以及签名metadata都是安装包中的“资源”部分今天我们就一起来看看如何进一步优化资源的体积。
## AndResGuard工具
在美团的一篇文章[《Android App包瘦身优化实践》](https://tech.meituan.com/2017/04/07/android-shrink-overall-solution.html)中也讲到了很多资源优化相关的方法例如WebP和SVG、R文件、无用资源、资源混淆以及语言压缩等。
在我们的安装包中,资源相关的文件具体有下面这几个,它们都是我们需要优化的目标文件。
<img src="https://static001.geekbang.org/resource/image/dd/b7/dd5c7efb6074ff0f2bd18296f9ecf1b7.png" alt="">
想使用好[AndResGuard](https://github.com/shwenzhang/AndResGuard)工具需要对安装包格式以及Android资源编译的原理有很深地理解它主要有两个功能一个是资源混淆一个是资源的极限压缩。
接下来我们先来复习一下这个工具的核心实现,然后再进一步思考还有哪些地方需要继续优化。
**1. 资源混淆**
ProGuard的核心优化主要有三个Shrink、Optimize和Obfuscate也就是裁剪、优化和混淆。当初我在写AndResGuard的时候希望实现的就是ProGuard中的混淆功能。
资源混淆的思路其实非常简单,就是把资源和文件的名字混淆成短路径:
```
Proguard -&gt; Resource Proguard
R.string.name -&gt; R.string.a
res/drawable/icon -&gt; res/s/a
```
那么这样的实现究竟对哪些资源文件有优化作用呢?
<li>
**resources.arsc**。因为资源索引文件resources.arsc需要记录资源文件的名称与路径使用混淆后的短路径res/s/a可以减少整个文件的大小。
</li>
<li>
**metadata签名文件**。[签名文件MF与SF](https://cloud.tencent.com/developer/article/1354380)都需要记录所有文件的路径以及它们的哈希值,使用短路径可以减少这两个文件的大小。
</li>
<img src="https://static001.geekbang.org/resource/image/25/5c/25792171ce386fff9d5c0b73d382ce5c.png" alt="">
- **ZIP文件索引**。ZIP文件格式里面也需要记录每个文件Entry的路径、压缩算法、CRC、文件大小等信息。使用短路径本身就可以减少记录文件路径的字符串大小。
<img src="https://static001.geekbang.org/resource/image/54/a8/54760d2eab5e7199572e02ed70377fa8.png" alt="">
资源文件有一个非常大的特点那就是文件数量特别多。以微信7.0为例安装包中就有7000多个资源文件。所以说资源混淆工具仅仅通过短路径的优化就可以达到减少resources.arsc、签名文件以及ZIP文件大小的目的。
既然移动优化已经到了“深水区”正如Dex和Library优化一样我们需要对它们的格式以及特性有非常深入的研究才能找到优化的思路。而我们要做的资源优化也是如此要对resources.arsc、签名文件以及ZIP格式需要有非常深入的研究与思考。
**2. 极限压缩**
AndResGuard的另外一个优化就是极限压缩它的极限压缩功能体现在两个方面
<li>
**更高的压缩率**。虽然我们使用的还是Zip算法但是利用了7-Zip的大字典优化APK的整体压缩率可以提升3%左右。
</li>
<li>
**压缩更多的文件**。Android编译过程中下面这些格式的文件会指定不压缩在AndResGuard中我们支持针对resources.arsc、PNG、JPG以及GIF等文件的强制压缩。
</li>
```
/* these formats are already compressed, or don't compress well */
static const char* kNoCompressExt[] = {
&quot;.jpg&quot;, &quot;.jpeg&quot;, &quot;.png&quot;, &quot;.gif&quot;,
&quot;.wav&quot;, &quot;.mp2&quot;, &quot;.mp3&quot;, &quot;.ogg&quot;, &quot;.aac&quot;,
&quot;.mpg&quot;, &quot;.mpeg&quot;, &quot;.mid&quot;, &quot;.midi&quot;, &quot;.smf&quot;, &quot;.jet&quot;,
&quot;.rtttl&quot;, &quot;.imy&quot;, &quot;.xmf&quot;, &quot;.mp4&quot;, &quot;.m4a&quot;,
&quot;.m4v&quot;, &quot;.3gp&quot;, &quot;.3gpp&quot;, &quot;.3g2&quot;, &quot;.3gpp2&quot;,
&quot;.amr&quot;, &quot;.awb&quot;, &quot;.wma&quot;, &quot;.wmv&quot;, &quot;.webm&quot;, &quot;.mkv&quot;
};
```
这里可能会有一个疑问为什么Android系统会专门选择不去压缩这些文件呢
<li>
**压缩效果并不明显**。这些格式的文件大部分本身已经压缩过重新做Zip压缩效果并不明显。例如PNG和JPG格式重新压缩只有3%5%的收益,并不是十分明显。
</li>
<li>
**读取时间与内存的考虑**。如果文件是没有压缩的系统可以利用mmap的方式直接读取而不需要一次性解压并放在内存中。
</li>
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文件信息都会有影响。而且因为资源文件数量非常非常多导致这部分的体积非常可观。
那我们能不能把所有的资源文件都合并成同一个大文件,这样做肯定会比资源混淆方案效果更好。
<img src="https://static001.geekbang.org/resource/image/e6/0d/e6a587ffa43b7dfb7887ace3d973a30d.png" alt="">
事实上,大部分的换肤方案也是采用这个思路,这个大资源文件就相当于一套皮肤。因此我们完全可以把这套方案推广开来,但是实现起来还是需要解决不少问题的。
- **资源的解析**。我们需要模拟系统实现资源文件的解析例如把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](https://cloud.tencent.com/developer/article/1014614)这个静态代码扫描工具它里面就支持Unused Resources扫描。
<img src="https://static001.geekbang.org/resource/image/f0/80/f09d7215a06d330bb19d72869df80580.png" alt="">
然后我们直接选择“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等资源并没有被删除。
<img src="https://static001.geekbang.org/resource/image/15/97/1587d1a3bd95ad0f318bfd5731c0bc97.png" alt="">
- **没有真正删除资源文件**。对于Drawable、Layout这些无用资源shrinkResources也没有真正把它们删掉而是仅仅替换为一个空文件。为什么不能删除呢主要还是因为resources.arsc里面还有这些文件的路径具体你可以查看这个[issues](https://issuetracker.google.com/issues/37010152)。
所以尽管我们的应用有大量的无用资源但是系统目前的做法并没有真正减少文件数量。这样resources.arsc、签名信息以及ZIP文件信息这几个“大头”依然没有任何改善。
那为什么Studio不把这些资源真正删掉呢事实上Android也知道有这个问题在它的核心实现[ResourceUsageAnalyzer](https://android.googlesource.com/platform/tools/base/+/studio-master-dev/build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/ResourceUsageAnalyzer.java)中的注释也写得非常清楚,并尝试解决这个问题提供了两种思路。
<img src="https://static001.geekbang.org/resource/image/85/a6/854c91e20d7724bd61b0f5376cebf5a6.png" alt="">
如果想解答系统为什么不能直接把这些资源删除我们需要先回过头来重温一下Android的编译流程。
<img src="https://static001.geekbang.org/resource/image/89/cf/8929932b9db83b06444c54435948d2cf.png" alt="">
<li>
由于Java代码需要用到资源的R.java文件所以我们就需要把R.java提前准备好。
</li>
<li>
在编译Java代码过程已经根据R.java文件直接将代码中资源的引用替换为常量例如将R.String.sample替换为0x7f0c0003。
</li>
<li>
.ap_资源文件的同步编译例如resources.arsc、XML文件的处理等。
</li>
如果我们在这个过程强行把无用资源文件删除resources.arsc和R.java文件的资源ID都会改变因为默认都是连续的这个时候代码中已经替换过的0x7f0c0003就会出现资源错乱或者找不到的情况。
因此系统为了避免发生这种情况采用了折中的方法并没有二次处理resources.arsc文件只是仅仅把无用的Drawable和Layout文件替换为空文件。
**第三阶段realShrinkResources**
那怎么样才能真正实现无用资源的删除功能呢ResourceUsageAnalyzer的注释中就提供了一个思路我们可以利用resources.arsc中Public ID的机制实现非连续的资源ID。
简单来说就是keep住保留资源的ID保证已经编译完的代码可以正常找到对应的资源。
<img src="https://static001.geekbang.org/resource/image/c6/14/c670fcb26d8acffec355ec7ba539fb14.png" alt="">
但是重写resources.arsc的方法会比资源混淆更加复杂我们既要从这个文件中抹去所有的无用资源相关信息还要keep住所有保留资源的ID相当于把整个文件都重写了。
正因为异常复杂所以目前Android还没有提供这套方案的完整实现。我最近也正在按照这个思路来实现这套方案希望完成后可以尽快开源出来。
## 总结
今天我们回顾了AndResGuard工具的实现原理也学习了两种资源优化的进阶方式。特别是无用资源的优化你可以看到尽管是无所不能的Google也并没有把方案做到最好依然存在一些妥协的地方。
其实这种不完美的地方还有很多很多,也正是有了这些不完美的地方,才会出现各种各样优秀的开源方案。也因此我们才会不断思考如何突破系统的限制,去实现更多、更底层的优化。
## 课后作业
对于Android的编译流程你还有不理解的地方吗对于安装包中的资源你还有哪些好的优化方案欢迎留言跟我和其他同学一起讨论。
不知道你有没有想过其实“第三阶段”的无用资源删除方案也并不是终极解决方案因为它并没有考虑到无用的Assets资源。
但是对于Assets资源代码中会有各种各样的引用方式如果想准确地识别出无用的Assets并不是那么容易。当初在Matrix中我们尝试提供了一套简单的实现你可以参考[UnusedAssetsTask](https://github.com/Tencent/matrix/blob/master/matrix/matrix-android/matrix-apk-canary/src/main/java/com/tencent/matrix/apk/model/task/UnusedAssetsTask.java)。
希望你在课后也可以进一步思考我们可以如何识别出无用的Assets资源在这个过程中会遇到哪些问题
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="24 | 想成为Android高手你需要先搞定这三个问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/8e/776141df1fbe1038eeb9fbee89fae58e.mp3"></audio>
专栏上线已经两个多月,模块一“高质量开发”也已经更新完毕,你掌握地如何呢?我知道有不少同学一直随着专栏更新积极学习、认真完成课后的[练习作业](https://github.com/AndroidAdvanceWithGeektime),并且及时给我反馈,作为专栏作者我很欣慰。
但也有不少同学表示很难跟上专栏的进度似乎对“如何成为Android开发高手”感到更加迷茫也更困惑了。“这个专栏实在是太难了”“我日常工作根本用不上这些知识”“我应该怎样做才能更好地学习这个专栏”。**太难了、工作用不到、想学但是又不知道从何入手**,这是我听到同学们反馈最多的三个问题。
今天既然是专栏“模块一”的答疑时间那我就来解答这三个问题力求帮助迷茫的你拨开云雾找到通向Android开发高手之路。
前几年在业务红利期Atlas、Tinker、React Native、Weex、小程序大行其道我们的应用程序堆积了各种各样的框架以及无数的业务代码。当现在需要出海东南亚需要下沉到三四线城市时在面对各种低端机和恶劣网络条件的时候再猛然回头一看会发现原来我们已经欠下了如此恐怖的“技术债”。
如果想成为一名开发高手,只做好需求是远远不够的,还需要有系统性解决应用性能和架构问题的能力。而这些问题本来就是很复杂的,可能对于一些同学来说解决复杂问题会感到很难,但你要成为高手,就一定要具备解决复杂问题的能力。下面我就先来谈谈这个专栏真的这么“难”吗?
## 问题一:这个专栏太难了?
>
性能优化就像一个坑,你永远不知道自己跳进去的坑有多深 。
在过去几个月,我一直在填“启动优化”这个坑,也对这句话有了更深的感触。
<li>
**业务优化:应用层**。开始的时候通过业务代码的优化我很轻松地就把启动速度优化了50%。但正如《大停滞》所说的这只是摘完了“所有低垂的果实”。之后很快就陷入了停滞就像冲进了一条漆黑的隧道不知道还可以做哪些事情很多现象从表面也不知道如何解释I/O有时候为什么那么慢线程的优化应该怎么样去衡量
</li>
<li>
**Android Framework系统框架层**。为了冲出这条漆黑的隧道我去研究了Android的内存管理、文件系统、渲染框架等各个模块学习如何去优化和监控I/O、线程、卡顿以及帧率建立了各种各样的性能监控框架。为什么我对监控如此重视这是因为对于大厂来说“挖坑容易填坑难”几十上百人协同开发一个项目我们不希望只是解决具体的某一个问题而是要彻底解决某一类问题。但想要实现一个监控框架前提是需要对Framework有非常充分地理解和研究。
</li>
<li>
**Linux Kernel内核层**。再深入下去我还需要利用Linux的一些机制例如ftrace、Perf、JVMTI等。在做I/O的类重排、文件重排评估的时候还需要自己去修改内核的参数去刷ROM。
</li>
<li>
**Hardware硬件层**。高端机和低端机的硬件差异究竟在哪里eMMC闪存和UFS闪存的区别是什么除了更加了解硬件的性能和特性到了这个阶段我还希望可以向手机厂商和硬件厂商要性能。例如高通的[CPU Boost](https://developer.qualcomm.com/software/snapdragon-power-optimization-sdk/quick-start-guide)、微信的Hardcoder、OPPO的开放平台等。
</li>
<img src="https://static001.geekbang.org/resource/image/88/76/8830c500c7d86d82837f0b6a1ee35876.png" alt="">
启动优化的过程,就像是一个知识爬坡的过程。我们不停地尝试往底层深入,希望去摘更高的果实。那再回到你疑惑的问题:**这个专栏难不难?难,因为它试图为你从上往下拆解整个知识架构。**
坦白说现在很多移动开发工程师更像是API工程师背后的数据结构、算法和架构相关的知识是不达标的。这个时候如果想往底层走就会感觉步步艰辛。但是上层的API很容易被Deprecated即使你对Android的所有API倒背如流也无法成为真正的开发高手。这样的你即便以后把Android替换成Fuchsia你也还只是一个Dart API工程师。
相反越底层的东西越不容易过时假如我们以后面对的不是Linux内核的系统比如Fuchsia OS也可以根据已经掌握的系统知识套用到现有的操作系统上因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的在迁移到新的系统上时可以有一个全局的视角帮助你快速上手。
**因此我希望5年后再回头看这个专栏它依然不会过时**。所以从知识的深度来看,这个专栏的确难。那从知识的广度来看呢?
<img src="https://static001.geekbang.org/resource/image/fb/fb/fb492a5ede709bbacb59953c04d986fb.png" alt="">
崩溃、内存、卡顿、I/O、渲染、网络…这个专栏涉及的知识的确非常多而且这个专栏也只能提纲挈领还需要你花时间去补充文章里给你链接的更多知识。
所以说无论从知识的深度还是广度来看专栏的确算是比较难。那这个专栏究竟有多难呢可以说超越了大多数腾讯T3或者阿里P7的水平。如果你还没到达这个级别看不懂是正常的因为大部分内容BAT的工程师第一遍可能也看不懂。
把这个专栏写“难”并不是因为我想炫技而是成为一名真正的Android开发高手本来就没有想象得那么容易。只有看到差距才有前进的动力2019年你需要真正迈出走向高手的第一步。
## 问题二:专栏所讲的工作上用不到?
>
我一直只是做业务,没有机会接触性能。对于这个专栏,我更期待可以对日常工作有帮助的内容。
正如我上面所说的之所以选择写这些内容是因为它们是移动开发高手所必须掌握的。如果我告诉你MAT怎么用、Profiler怎么用或者告诉你如何去写界面或许这些内容对你日常工作能有帮助但仅仅这些你依然不可能成为一名Android的高手。
这个专栏目希望可以提高你的个人能力,帮助你成长,或许不一定与你当前的工作完全契合,那这个问题怎么解决呢?我认为完全不需要等着别人给你安排,我们在完成工作之余,可以尝试去解决一些应用性能和架构的问题,又或者是团队效率的问题。
这样你工作上的表现也会超出上级的预期,并且可能以后这些高级问题大家都会来咨询你。同事对你建立了信任,这些事情以后可能就都由你负责了,你也成为了大家心目中的“开发高手”。
当然如果你认为目前的平台对未来的发展制约太多,**那这个专栏同样也是你去面试大厂的一块非常好的敲门砖。**
“打铁还需自身硬”专栏里很多内容大厂面试官可能也不熟悉我专栏里讲的很多问题其实大厂目前做得也都不很完善。在专栏中我会力求去分析腾讯、阿里、头条、Facebook、Google等国内外大厂目前遇到的问题、尝试解决的方案以及未来优化的方向。希望可以扩宽你的视野帮你知道大厂在玩什么、他们都在意什么因为这些对于面试来说也非常重要。
虽然这个专栏涉及那么多的内容但毕竟我们不可能每一项都精通。之前我在一篇文章曾经讲过微信的T型人才理论说的是微信在面试时不会问你Android和iOS的API怎么使用而是希望候选人在某一个领域研究得特别牛、特别深入并且是可以打动面试官的。这意味着如果你在某一个领域证明过自己那微信也会愿意在其他领域给你机会。这里我推荐你看看[《谈谈腾讯的技术价值观与技术人才修炼》](https://mp.weixin.qq.com/s/Vn0eKvY5AU1DEOrxbOxABQ)这篇文章。
不夸张地说LeetCode适量刷题加上这个专栏知识的广度如果再找其中一两个知识点更加深入地研究这样的话进入大公司是不会有太大问题的。
## 问题三:这个专栏应该怎么学习
>
我工作用不上,平时还那么忙,应该怎么去学习这个专栏呢?
如果专栏的学习可以跟我们的工作紧密结合在一起,的确是一个非常理想的情况。但是即使是理想情况,关键也还是要靠个人的自驱力。
在极客时间的年终总结里看到一句话特别有感触“2018年买了32个专栏完成了开篇词的学习”。**这个专栏应该怎么学?你首先应该抛弃焦虑,无所畏惧地往前冲。**
既然腾讯T3或者阿里P7都会觉得难如果看不懂真的不要气馁也不要焦虑可以结合参考资料慢慢看。因为专栏一直都在可以按照自己的节奏来学习甚至可以用2019年一整年的时间来“死磕”它但千万不要放弃。
还记得当初你在专栏“[导读](https://time.geekbang.org/column/article/70250)”里立下的flag吗你可以利用这个专栏好好地将知识架构补充完整。我们的基础能力提升了未来无论是大前端还是Flutter都会有用武之地也就更加无需担心Android系统是否会被颠覆。
**这个专栏应该怎么学?我给你的第二个建议是多看、多想、多实践**。看再多的文章,不去思考文章所讲的内容和意图也是没用的;思考再多,不去动手真正实践也是没用的。
正因为实践这么重要所以我在写专栏时才会把大量的时间花在Sample上面。想想现在有那么多的开源项目可能我们只是调用API或者提一两个issue并不算是真正使用。想要真正用好开源项目需要你去研究内部的机制思考作者的意图。只有在认真研究之后我们才能发现优化的空间。
在学习专栏时,我建议可以先挑一两个知识点开始深入学习。如果你觉得崩溃相关的内容比较困难,可以先略过,等学习完其他知识后再回头来看,肯定会有不一样的体会。
<img src="https://static001.geekbang.org/resource/image/e5/c6/e567f175cc91fa2055a9398eefd73fc6.png" alt="">
我们的学习过程也是一个树立信心的过程,可能在某几个阶段会感到煎熬,但是只要你攀登上去了,一切都会柳暗花明。
另外专栏的很多文章我喜欢用演进的思路去讲比如耗电的演进、渲染的演进、Android Runtime的演进等。同样的你成为高手的道路应该也是不停向前演进的可能刚开始时不一定是最好的但是只要方向是正确的终究可以到达“高手”这个终点。
最后,也是我反复强调过的,专栏的学习还需要结合大量的背景知识和外部资料,推荐的书籍你可以参考[《专栏学得苦?可能你还需要一份配套学习书单》](https://time.geekbang.org/column/article/78354)。
## 总结
今天我们一起来定一个“小目标”也不迟按照专栏给出的方向尝试一下、努力一下走向通往Android开发的高手之路。
未来移动开发无论是变成大前端还是Flutter的世界性能、效率和架构都是永恒不变的主线。今天我们在Android开发打下的坚实基础未来也会帮助我们更好地理解和深入新的开发模式或者新的系统。崩溃、内存、存储、渲染、I/O、网络…这些知识以及它们背后的底层原理依然还是非常重要的。对于其他领域也是如此一个前端开发工程师不能只知道HTML、CSS和JS语法还需要知道它们编译的原理、浏览器实现的原理以及底层的渲染机制等。
最后我想说一个人学习可能会比较孤独,如果可以找到更多志同道合的朋友一起学习,效果可能会更好。正如很多开发所提倡的结对编程,推荐你看看[《两位拯救谷歌的超级工程师的故事》](https://mp.weixin.qq.com/s/NTT1leTSxuDKXVeZTOfKpQ)。除了结对编程的范例之外,文中有一段话对我的触动也非常大。
>
Jeff与Sanjay对于计算机的工作原理非常熟悉能够立足bit层级进行思考。Jeff曾经整理出一份[《每位程序员都应该了解的那些延迟数字》](http://yifei.me/note/566)清单。虽然名为“每位程序员都应该了解”但大多数从业者对这些数字其实非常陌生——例如一级缓存引用通常需要半纳秒或者从内存中顺序读取1MB 大概需要250微秒等等。但这些数字已经直接烙进了Jeff与Sanjay的大脑当中。凭借着他们对谷歌核心软件的多次重写该系统的容量已经提升至新的数量级。
2019年已经过去将近1/6了今年你定的目标完成得怎么样了还有哪些学习计划有什么感受想跟其他同学分享吗欢迎留言跟我和其他同学一起见证你的成长。
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。