mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
85
极客时间专栏/Android开发高手课/导读/导读 | 如何打造高质量的应用?.md
Normal file
85
极客时间专栏/Android开发高手课/导读/导读 | 如何打造高质量的应用?.md
Normal file
@@ -0,0 +1,85 @@
|
||||
<audio id="audio" title="导读 | 如何打造高质量的应用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/d8/84e6a65e9d137c406e891461a46505d8.mp3"></audio>
|
||||
|
||||
今年年初,我去上海参加一个移动技术会议,问了很多开发者最近在忙啥。令我非常惊讶的是,大家讲的最多的还是用户体验和应用质量。特别是出海东南亚的同学,面对一堆512MB内存的设备、无处不在的弱网络流下了无助的眼泪。
|
||||
|
||||
除了内存优化、弱网络优化,想做一款高质量的应用还远远不止这些。一方面,我们面对的环境越来越复杂。过去的iOS开发者可能做梦也想不到,现在也要开始适配屏幕和双卡双待了,更不用说Android那多如繁星的机型、厂家和系统。如果你的应用也要出海,那么还要面对几十个国家不同的语言、环境。
|
||||
|
||||
另一方面,我们的代码跟业务也越来越复杂了。先不说大量“年久失修”的历史代码,业务越来越复杂,如何管理好几十上百个模块?还要面对React Native、Flutter、TensorFlow等各种语言跟框架堆积在一起的情况,再加上复杂的环境和庞大的系统,想想做一款高质量的应用真的不容易。
|
||||
|
||||
## 从应用交付的流程说起
|
||||
|
||||
既然打造一款高质量的应用那么困难,我们可以先从哪里入手做些什么呢?我的方法是把应用当成一件商品,想象一下商品在流水线生产的过程,那么怎样在每个步骤做好“质检”呢?这就要从应用交付的流程说起。
|
||||
|
||||
在我看来,一个应用至少会经过开发、编译CI、测试、灰度和发布这几个阶段。每个阶段需要关注什么问题呢?
|
||||
|
||||
1.开发阶段。在面试的时候,常常有人说自己熟练掌握各种开发工具。但是,我们真的懂吗?就拿我们比较熟悉的耗时分析工具Traceview来说,它背后的实现原理是什么?能不能做一个完全没有性能损耗的Traceview?或者怎么样将它移植到线上使用?
|
||||
|
||||
2.编译CI阶段。如何防止代码不断地恶化?怎样进一步优化性能?d8与ReDex有什么神奇的黑科技?如何利用好Coverity、Infer这些静态分析工具?这部分可能需要一些编译原理的知识,你会发现移动开发也有很多值得深入研究的东西。
|
||||
|
||||
3.测试阶段。我们常说敏捷开发,用户是最好的测试。遇到问题在线上反复试错,对自己、对用户都十分痛苦。我们希望可以做到测试“左移”,尽可能早地发现问题。但是很多时候我们不是不想测试,而是发现测不出什么问题。那么怎样提升实验室发现问题的能力呢?如何尽可能地模拟用户的操作路径?做好测试并不容易,自动化测试结合AI或许可以帮助我们解决一些痛点。
|
||||
|
||||
4.灰度和发布阶段。动态部署流行起来之后,很多开发变得松懈起来。有问题发补丁,一个不行就两个,两个不行就十个。怎样去保证产品质量?很多线上问题概率很低,基本很难复现,比如对于一个印度的用户,我们希望有一个远程的听诊器,而不需要把用户拉到我们的手术台上。
|
||||
|
||||
对照应用的交付流程,我来介绍一下专栏的学习方法。专栏“高质量开发”模块主要对应的是开发阶段,你可以带着实践过程的困惑去深入学习开发需要的各种武器。专栏“高效开发”模块主要对应编译CI、测试、灰度和发布阶段,你可以结合实际工作全面提升整个应用交付的效率。另外,我认为一个好的架构可以减少甚至避免团队出错,也是打造一款高质量应用非常重要的一环,因此我会在最后的“架构演进”模块和你聊聊如何设计一个好的架构,以及架构该如何选型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/52/db04d36b1bf1f1278f4539348f4e8252.jpeg" alt="">
|
||||
|
||||
## 移动APM质量平台
|
||||
|
||||
请你思考一下,在应用交付的这几个阶段中,我们对高质量的目标和实现方式是否一样呢?开发阶段有开发人员,可能希望采集尽可能多的数据;测试阶段有测试人员,可能更针对实验室环境或与竞品对比进行测试;灰度和发布阶段可能也有专门的运维人员,策略会相对保守一些。很明显,不同阶段我们对高质量的目标跟手段可能不太一样。
|
||||
|
||||
一个公司有多套质量系统,这在大公司是非常普遍的现象。我们希望有一个统一的平台,整合应用的人员和开发流程,这就是我们常说APM质量平台。
|
||||
|
||||
APM的全称是“Application Performance Management”,即应用性能管理。据我了解,国内像阿里、腾讯、美团点评、饿了么、爱奇艺这些公司都在大力投入。Google今年也发力Android Vitals监控,新增了耗电、权限管理模块。那么APM质量平台究竟有着什么样的魅力呢?
|
||||
|
||||
**1. 统一管理**。A同学写了一个耗时监控工具,B同学写了一个内存监控工具,它们在不同的仓库,上报格式可能不太一样,各自都搭了一个简单的页面。如果想评估一个应用的质量,总是要去几个系统汇总数据,想想都费劲。
|
||||
|
||||
**2. 统一三端**。一个公司可能有多个应用,一个应用也可能有H5、iOS、Android多个端。我们希望它们只是采集数据方式有所不同,上报、后台分析、展示、报警都是共用的。随着技术的发展,我们可能会增加React Native、Flutter这些新模块的监控,这个平台应该是统一演进的。当然我们非常希望业界有一套开源的方案,大家可以一起优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/20/2b4e75cef0fc7982ecd6fd3e12dc0c20.png" alt="">
|
||||
|
||||
那这个质量平台需要关注哪些问题呢?这需要看我们用户关心什么问题。有的问题可能是致命的,像崩溃、卡死、白屏。另一大类问题就是性能问题,安装包大小、启动、耗时、内存、耗电、流量都是这一个范畴。在这个专栏里,我并不会教你如何从头搭建一个APM平台,我会更期待你掌握背后所需要的知识,它们主要包括:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/fb/fb492a5ede709bbacb59953c04d986fb.png" alt="">
|
||||
|
||||
由于Android版本的碎片化和国内Android生态的乱象,或者换句话说,“Android开发者很苦,国内的Android开发者更苦”。在11月举办的Android绿色联盟开发者大会上推出的应用体验标准,有对应用的兼容性、稳定性、性能、功能和安全做了详细的定义。我贴张图,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/fd/211405b9dcf1291e1e1bb1fbfb2a70fd.jpeg" alt="">
|
||||
|
||||
在极致性能的同时,我们希望能更进一步地打造“绿色应用”。在这个过程中,一个全面而强大的APM质量平台会是我们坚实的后盾。当然对于大多数中小开发者来说,我们更建议选择成熟的第三方服务。但深入了解它们背后的原理,无论是对我们如何选择合适的服务,还是日常开发工作都会有很大的帮助。在学习完上面的这些内容之后,你也会觉得其实“性能优化”并不是那么“高不可攀”,我们也可以慢慢地迈向“性能优化专家”之路。
|
||||
|
||||
不过我们需要明确一点,虽然移动APM质量平台可以帮助我们快速发现和定位问题,但是监控并不能保证实现高质量,这里最关键的永远是人,而不是系统。为什么呢?我举两个小例子。
|
||||
|
||||
你在工作中可能总能遇到这样的场景,我管它叫反馈问题三连击:“是我的问题吗?”“能复现吗?”“你的测试靠谱吗?”。虽然通过APM质量平台可以减少推卸责任,但有些人的做法通常还是发现空指针加一个判空,发现并发问题加一个锁。这里的空指针真正原因是什么?这里判空了后面的逻辑是否还会运行正常?有没有更加好的方法或架构可以避免这个问题?我们真正应该反问的是这三个问题,把“质量观”深入骨髓,真正去想要得到个人成长,深挖背后的原因。
|
||||
|
||||
第二个例子是,我发现许多人都在问题无法忍受,或者说是老板无法忍受的时候才去开启各种优化专项,但事后又不了了之。我们很多时候都在用战术的勤奋掩盖战略的懒惰,性能优化的关键在于**如何解决存量问题,同时快速发现增量问题**。APM质量平台只可以协助我们,并不能解决组织内部的心态问题。
|
||||
|
||||
## 总结
|
||||
|
||||
看到这里可能你会有这样的疑问,我们在小公司根本没有机会学习到这些东西呀?确实如此,个人与公司一起成长是最快速,也是非常难得的事情,但并不一定人人都会有这样的机会。从我自己的经验来看,在搜狗、微信会遇到各种各样的疑难问题,也可以有很多时间去研究,让我在解决问题的过程中获得成长。
|
||||
|
||||
幸运的是现在大家都更加乐于去分享,在专栏和技术会议中,我们可以看到很多成熟的解决问题的经验和思路,在GitHub我们可以找到很多优秀的源代码。在这个环境下,我们需要耐得住寂寞,多抠一些细节,多深入研究,多停下来总结。
|
||||
|
||||
我来分享一个我的故事,2013年初我去面试微信,前面都不太理想,最后还是通过了。后来有一次跟面试官闲聊说起这个事情,他们认为有一件事情打动了他们。2012年的时候,LeakCanary还没开源,我在使用MAT做内存泄漏分析的时候总觉得很不爽。为什么不能做自动化?为什么看不到Bitmap的图片?我当时深入研究了内存文件Hprof的格式,做了几个小创新:
|
||||
|
||||
1.实现内存的自动化测试。在自动化测试后回到首页,这个时候获取应用的内存快照。自动分析内存中Activity实例,正常情况应该只存在一个,其他都认为是泄漏。为了支持正式包,还做了通过mapping文件反混淆Hprof文件的功能。
|
||||
|
||||
2.查看图片。自动将内存中重复的图片、比较大的图片转换成PNG格式输出到文件。
|
||||
|
||||
现在看起来这些功能并不是太难,但如果放到六年前,想到而且能做到的人相信并不多。讲这个故事还是希望你能在工作和实践中多停下来思考,多深入研究一些细节,很多看似不经意的思考和创新,可能在日后发挥更大的价值。
|
||||
|
||||
## 课后作业
|
||||
|
||||
“纸上得来终觉浅,绝知此事要躬行”,只有通过实践,运用到自己的项目里面,才会对知识有更深入地理解。
|
||||
|
||||
我为专栏的“课后作业”环节专门设计了可供你操作实践的Sample,并在GitHub新建了专栏的[Group](https://github.com/AndroidAdvanceWithGeektime),后面会陆续将这些Sample放到上面。课后作业主要是根据Sample进行操作,需要你理解实现原理,并在极客时间App专栏文章下面的留言区提交你的学习总结和思考。我和极客时间为认真提交作业的同学准备了丰厚的学习加油礼包,里面包含我推荐的经典图书、极客时间周历、专栏阅码等礼品。我还会选出坚持参与学习并分享心得的同学,在专栏模块一、模块三结束时,分别送出2张2019年GMTC大会的门票。
|
||||
|
||||
**为了更好地给同学们答疑,我邀请了我的朋友孙鹏飞作为课程的“学习委员”,他将不定期讲解我们Sample的实现原理**。鹏飞是Android资深开发工程师,现在负责某知名网约车平台司机端的业务开发、性能优化等工作。我们的学习委员对系统框架、虚拟机、性能优化等都有很深入的理解,实战经验也很丰富,相信鹏飞的加入可以更好地帮助你掌握课程的要点,并能解决实践中的问题。
|
||||
|
||||
**今天是课程导读,也正式开启了我们接下来3个多月的学习之旅,所以最后特别邀请你在留言区也做个自我介绍,介绍下自己,聊聊目前的工作、学习情况,或者说说遇到的具体问题和困惑,也可以写写自己对这个课程的期待,让我们彼此有更多了解。**
|
||||
|
||||
希望你能够积极参与进来,咱们教学相长,共同进步。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
<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="">
|
||||
37
极客时间专栏/Android开发高手课/开篇词/开篇词 | 焦虑的移动开发者该如何破局?.md
Normal file
37
极客时间专栏/Android开发高手课/开篇词/开篇词 | 焦虑的移动开发者该如何破局?.md
Normal file
@@ -0,0 +1,37 @@
|
||||
<audio id="audio" title="开篇词 | 焦虑的移动开发者该如何破局?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/9b/ad60c738a9bb3e94313e20a817d6999b.mp3"></audio>
|
||||
|
||||
最近半年,常常有人问我两个问题:“现在学习移动开发还有前景吗?”“移动开发还有什么可以研究的?”。这两个问题其实对应着同一个现象,无论是准备入行或刚刚入行,还是比较资深的开发者,对于移动开发职业的未来,都有一些迷茫、一些焦虑。为什么会这样?
|
||||
|
||||
现在想想,移动互联网的发展不知不觉已经十多年了,Mobile First也已经变成了AI First。换句话说,我们已经不再是“风口上的猪”。移动开发的光环和溢价开始慢慢消失,并且正在向AI、区块链等新的领域转移。移动开发的新鲜血液也已经变少,最明显的是国内应届生都纷纷涌向了AI方向。
|
||||
|
||||
可以说,国内移动互联网的红利期已经过去了,现在是增量下降、存量厮杀,从争夺用户到争夺时长。比较明显的是手机厂商纷纷互联网化,与传统互联网企业直接竞争。另外一方面,过去渠道的打法失灵,小程序、快应用等新兴渠道崛起,无论是手机厂商,还是各大App都把出海摆到了战略的位置。
|
||||
|
||||
从技术的角度来看,今年移动端的技术变革也有点缓慢。大前端的概念虽然说了很久也很多,但React Native、PWA(Progressive Web App)的效果依然不尽人意。在插件化热潮之后,移动端的精品文章开始变少。去年“安利”完Kotlin之后,今年讲得比较多的还是Flutter。
|
||||
|
||||
这么看来,移动开发的前景不明朗,再加上竞争激烈以及技术变革放缓,我们感到迷茫、焦虑似乎就不难理解了。
|
||||
|
||||
带着同样的焦虑,2017年年底我选择离开了微信,目前在某知名互联网企业负责海外产品的技术工作。
|
||||
|
||||
回想一下自己的过去,经历了搜狗手机输入法用户从一千万到一亿,也见证了微信用户从一亿到十亿的增长。在快速成长的同时,让我感到更加快乐的是对社区还是做出了那么一点贡献。写了一些文章,建立并运营了WeMobileDev公众号,输出了一些技术干货和思考;拥抱了开源,Tinker、Mars、WCDB相继开源,特别是Tinker作为腾讯在GitHub的第一个开源产品,让我体会到了开源的魅力。
|
||||
|
||||
在进入新工作环境的这大半年里,我遇到了一些困难,也接受了很多新的挑战。慢慢明白,或许是移动互联网这个大环境变了,推动我们不得不跟着转变。**移动端的招聘量变少,但中高端的职位却多了起来,这说明行业只是变得成熟规范起来了。竞争激烈,但产品质量与留存变得更加重要,我们进入了技术赋能业务的时代。大前端正在跨平台,移动开发者的未来更可能是跨终端,产品、运营、数据分析、后端,技多不压身。**
|
||||
|
||||
那企业需要什么样的移动开发人才?移动开发还可以做些什么?我希望通过这个专栏来回答你这两个问题。专栏里,我会结合业界的现状,讲讲国内外各大公司的尝试,以及他们的发展方向。我会尽量少放源码,侧重结合工作上的实践经验,分享一些疑难问题的解决思路。整体来说,主要包括以下三个部分 **(注:60%是Android相关的,40%是可以跨平台的)**。
|
||||
|
||||
**1. 高质量开发**。在如今的竞争态势下,保证产品的基础用户体验尤为重要。最近国内外各大公司,对APM性能监控系统也越来越重视。我挑选了崩溃、内存、卡顿、启动、I/O、存储、网络、耗电、渲染、安装包体积等比较常见的关键点,为你全面梳理性能优化的经验技巧与方向,帮你学会如何解决已知的存量问题,同时也能及时发现增量问题。
|
||||
|
||||
**2. 高效开发**。持续交付、DevOps近年在国内非常火热,我们都在寻找内部突破,提升效率。一个应用从想法到成品,需要经历开发、编译CI、测试、灰度、发布等多个阶段,那怎样提升各个阶段的效率,也是你我比较关心的话题。**跨平台开发可能是解决开发阶段的一个答案,动态部署可能是发布阶段的一个答案**。头条和快手如何做到数据驱动式的开发,怎样才是高效的运营,在这个模块,我将围绕这些内容一一展开。
|
||||
|
||||
**3. 架构演进**。“君有疾在腠理,不治将恐深”,对于一个应用来说,架构一定是核心中的核心。在这个模块,我会讲到Google的一些架构演进,例如Android P、App Bundle、虚拟机、耗电等,也会讲到移动网络架构的一些选择,跨平台开发、动态化实践等热点知识。
|
||||
|
||||
在我看来,如今的移动开发开始冷下来了,或者有人说开始进入移动互联网的下半场了。其实,对于我们开发人员来说,不管是下半场还是上半场,我们重要的是要把技术做好做精做深。是的,现在移动开发已经不再是风口,但是,这并不是说移动开发已经被淘汰,而是说移动开发的发展进入了成熟期,就像Web技术一样。
|
||||
|
||||
如果说现在,你还只是能做好产品给过来的“需求”,我认为这是远远不够的。作为一个移动开发工程师,你我都需要深耕细作,都需要有工匠精神,把已有的事情从好做到更好。因为在迈向更好的过程中,你必然需要学习底层原理,你必然需要拓展知识面,你必然需要结合其他的技术,有了这么多必然,你也必然会变得更强。
|
||||
|
||||
所以,我在这里回答我一开始提出的那个让人焦虑的问题。首先,我认为,移动开发不等于App开发,所有新的技术浪潮其实都可以融入到移动开发的体系里,比如IoT、音视频、边缘计算、VR/AR,我们要做的,只是打好基础,随时准备战斗。其次,从心态上,我觉得我们千万**不要把时间浪费在纠结问题上,而是应该放在解决问题上**。“王者荣耀”“吃鸡”并不能解决我们的焦虑,**拥抱变化,才能拥有未来**,让我们共勉。
|
||||
|
||||
最后,我和极客时间一起,为你准备了丰厚的学习大礼包,听说礼包里面有GMTC大会的门票哦。今天先卖个关子,我会在下一节“导读”里给你揭晓。
|
||||
|
||||
希望通过这个专栏,可以在这个节骨眼上,给你一些启发和帮助。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/c0/24c190870d71c3daa203a939d67358c0.jpg" alt="">
|
||||
165
极客时间专栏/Android开发高手课/模块一 高质量开发/01 | 崩溃优化(上):关于“崩溃”那些事儿.md
Normal file
165
极客时间专栏/Android开发高手课/模块一 高质量开发/01 | 崩溃优化(上):关于“崩溃”那些事儿.md
Normal 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==&mid=2247488429&idx=1&sn=448b414a0424d06855359b3eb2ba8569&source=41#wechat_redirect)的技术来保障客户端的启动流程,在监控到客户端启动失败后,给用户自救的机会。
|
||||
|
||||
现在回到文章开头程序员“华山论剑”的小故事,我来揭秘他们解决崩溃率的“独家秘笈”。
|
||||
|
||||
程序员B对所有线程、任务都封装了一层try catch,“消化”掉了所有Java崩溃。至于程序是否会出现其他异常表现,这是上帝要管的事情,反正我是实现了“千分之一”的目标。
|
||||
|
||||
程序员C认为Native崩溃太难解决,所以他想了一个“好方法”,就是不采集所有的Native崩溃,美滋滋地跟老板汇报“万分之一”的工作成果。
|
||||
|
||||
了解了美好数字产生的“秘笈”后,不知道你有何感想?其实程序员B和C都是真实的案例,而且他们的用户体量都还不算小。技术指标过于KPI化,是国内比较明显的一个现象。崩溃率只是一个数字,我们的出发点应该是让用户有更好的体验。
|
||||
|
||||
## 如何客观地衡量稳定性
|
||||
|
||||
到此,我们讨论了崩溃是怎么回事儿,以及怎么客观地衡量崩溃。那崩溃率是不是就能完全等价于应用的稳定性呢?答案是肯定不行。处理了崩溃,我们还会经常遇到ANR(Application Not Responding,程序没有响应)这个问题。
|
||||
|
||||
出现ANR的时候,系统还会弹出对话框打断用户的操作,这是用户非常不能忍受的。这又带来另外一个问题,我们怎么去发现应用中的ANR异常呢?总结一下,通常有两种做法。
|
||||
|
||||
**1. 使用FileObserver监听/data/anr/traces.txt 的变化**。非常不幸的是,很多高版本的ROM,已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径,海外可以使用Google Play服务,而国内微信利用[Hardcoder](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="">
|
||||
249
极客时间专栏/Android开发高手课/模块一 高质量开发/02 | 崩溃优化(下):应用崩溃了,你应该如何去分析?.md
Normal file
249
极客时间专栏/Android开发高手课/模块一 高质量开发/02 | 崩溃优化(下):应用崩溃了,你应该如何去分析?.md
Normal 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内存、RSS(Resident Set Size)、PSS(Proportional 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,虚拟内存一般在3~4GB之间。当然如果我们支持64位进程,虚拟内存就不会成为问题。Google Play要求 2019年8月一定要支持64位,在国内虽然支持64位的设备已经在90%以上了,但是商店都不支持区分CPU架构类型发布,普及起来需要更长的时间。
|
||||
|
||||
4.资源信息
|
||||
|
||||
有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。
|
||||
|
||||
- 文件句柄fd。文件句柄的限制可以通过/proc/self/limits获得,一般单个进程允许打开的最大文件句柄个数为1024。但是如果文件句柄超过800个就比较危险,需要将所有的fd以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。
|
||||
|
||||
```
|
||||
opened files count 812:
|
||||
0 -> /dev/null
|
||||
1 -> /dev/log/main4
|
||||
2 -> /dev/binder
|
||||
3 -> /data/data/com.crash.sample/files/test.config
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
- 线程数。当前线程数大小可以通过上面的status文件得到,一个线程可能就占2MB的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过400个就比较危险。需要将所有的线程id以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
|
||||
|
||||
```
|
||||
threads count 412:
|
||||
1820 com.sample.crashsdk
|
||||
1844 ReferenceQueueD
|
||||
1869 FinalizerDaemon
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
- JNI。使用JNI时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过DumpReferenceTables统计JNI的引用表,进一步分析是否出现了JNI泄漏等问题。
|
||||
|
||||
5.应用信息
|
||||
|
||||
除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。
|
||||
|
||||
<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="">
|
||||
206
极客时间专栏/Android开发高手课/模块一 高质量开发/03 | 内存优化(上):4GB内存时代,再谈内存优化.md
Normal file
206
极客时间专栏/Android开发高手课/模块一 高质量开发/03 | 内存优化(上):4GB内存时代,再谈内存优化.md
Normal 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效率相比提升了5~10倍。如果想具体测试GC的性能,例如暂停挂起时间、总耗时、GC吞吐量,我们可以通过发送**SIGQUIT信号获得ANR日志**。
|
||||
|
||||
```
|
||||
adb shell kill -S QUIT PID
|
||||
adb pull /data/anr/traces.txt
|
||||
|
||||
```
|
||||
|
||||
它包含一些ANR转储信息以及GC的详细性能信息。
|
||||
|
||||
```
|
||||
sticky concurrent mark sweep paused: Sum: 5.491ms 99% C.I. 1.464ms-2.133ms Avg: 1.830ms Max: 2.133ms // GC 暂停时间
|
||||
|
||||
Total time spent in GC: 502.251ms // GC 总耗时
|
||||
Mean GC size throughput: 92MB/s // GC 吞吐量
|
||||
Mean GC object throughput: 1.54702e+06 objects/s
|
||||
|
||||
```
|
||||
|
||||
另外我们还可以使用systrace来观察GC的性能耗时,这部分内容在专栏后面会详细讲到。
|
||||
|
||||
除了频繁GC造成卡顿之外,物理内存不足时系统会触发low memory killer机制,系统负载过高是造成卡顿的另外一个原因。
|
||||
|
||||
2.两个误区
|
||||
|
||||
除了内存引起的异常和卡顿,在日常做内存优化和架构设计时,很多同学还非常容易陷入两个误区之中。
|
||||
|
||||
**误区一:内存占用越少越好**
|
||||
|
||||
VSS、PSS、Java堆内存不足都可能会引起异常和卡顿。有些同学认为内存是洪水猛兽,占用越少应用的性能越好,这种认识在具体的优化过程中很容易“用力过猛”。
|
||||
|
||||
应用是否占用了过多的内存,跟设备、系统和当时情况有关,而不是300MB、400MB这样一个绝对的数值。当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到**“用时分配,及时释放”**,就像下面这张图一样,当系统内存出现压力时,能够迅速释放各种缓存来减少系统压力。
|
||||
|
||||
<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内存中。如果不手动调用recycle,Bitmap Native内存的回收完全依赖finalize函数回调,熟悉Java的同学应该知道,这个时机不太可控。
|
||||
</li>
|
||||
<li>
|
||||
Android 3.0~Android 7.0将Bitmap对象和像素数据统一放到Java堆中,这样就算我们不调用recycle,Bitmap内存也会随着对象一起被回收。不过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.0~Android 7.0,也能做到相同的效果,只是流程相对复杂一些。
|
||||
|
||||
步骤一:通过直接调用libandroid_runtime.so中Bitmap的构造函数,可以得到一张空的Bitmap对象,而它的内存是放到Native堆中。但是不同Android版本的实现有那么一点差异,这里都需要适配。
|
||||
|
||||
步骤二:通过系统的方法创建一张普通的Java Bitmap。
|
||||
|
||||
步骤三:将Java Bitmap的内容绘制到之前申请的空的Native Bitmap中。
|
||||
|
||||
步骤四:将申请的Java Bitmap释放,实现图片内存的“偷龙转凤”。
|
||||
|
||||
```
|
||||
// 步骤一:申请一张空的 Native Bitmap
|
||||
Bitmap nativeBitmap = nativeCreateBitmap(dstWidth, dstHeight, nativeConfig, 22);
|
||||
|
||||
// 步骤二:申请一张普通的 Java Bitmap
|
||||
Bitmap srcBitmap = BitmapFactory.decodeResource(res, id);
|
||||
|
||||
// 步骤三:使用 Java Bitmap 将内容绘制到 Native Bitmap 中
|
||||
mNativeCanvas.setBitmap(nativeBitmap);
|
||||
mNativeCanvas.drawBitmap(srcBitmap, mSrcRect, mDstRect, mPaint);
|
||||
|
||||
// 步骤四:释放 Java Bitmap 内存
|
||||
srcBitmap.recycle();
|
||||
srcBitmap = null;
|
||||
|
||||
```
|
||||
|
||||
虽然最终图片的内存的确是放到Native中了,不过这个“黑科技”有两个主要问题,一个是兼容性问题,另外一个是频繁申请释放Java Bitmap容易导致内存抖动。
|
||||
|
||||
## 测量方法
|
||||
|
||||
在日常开发中,有时候我们需要去排查应用程序中的内存问题。对于系统内存和应用内存的使用情况,你可以参考Android Developer中 [《调查RAM使用情况》。](http://developer.android.com/studio/profile/investigate-ram?hl=zh-cn)
|
||||
|
||||
```
|
||||
adb shell dumpsys meminfo <package_name|pid> [-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.<APP> '"LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper"'
|
||||
|
||||
```
|
||||
|
||||
[Malloc钩子](http://android.googlesource.com/platform/bionic/+/master/libc/malloc_hooks/README.md)是在Android P之后,Android的libc支持拦截在程序执行期间发生的所有分配/释放调用,这样我们就可以构建出自定义的内存检测工具。
|
||||
|
||||
```
|
||||
adb shell setprop wrap.<APP> '"LIBC_HOOKS_ENABLE=1"'
|
||||
|
||||
```
|
||||
|
||||
但是在使用“Malloc调试”时,感觉整个App都会变卡,有时候还会产生ANR。如何在Android上对应用Native内存分配和泄漏做自动化分析,也是我最近想做的事情。据我了解,微信最近几个月在Native内存泄漏监控上也做了一些尝试,我会在专栏下一期具体讲讲。
|
||||
|
||||
## 总结
|
||||
|
||||
LPDDR5将在明年进入量产阶段,移动内存一直向着更大容量、更低功耗、更高带宽的方向发展。伴随内存的发展,内存优化的挑战和解决方案也不断变化。而内存优化又是性能优化重要的一部分,今天我讲到了很多的异常和卡顿都是因为内存不足引起的,并在最后讲述了如何在日常开发中分析和测量内存的使用情况。
|
||||
|
||||
一个好的开发者并不满足于做完需求,我们在设计方案的时候,还需要考虑要使用多少的内存,应该怎么去管理这些内存。在需求完成之后,我们也应该去回归需求的内存情况,是否存在使用不当的地方,是否出现内存泄漏。
|
||||
|
||||
## 课后作业
|
||||
|
||||
内存优化是一个非常“古老”的话题,大家在工作中也会遇到各种各样内存相关的问题。今天的课后作业是分享一下你在工作中遇到的内存问题,总结一下通过Sample的练习有什么收获。
|
||||
|
||||
在今天文章里我提到,希望可以脱离Android Studio实现一个自定义的Allocation Tracker,这样就可以将它用到自动化分析中。本期的[Sample](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="">
|
||||
198
极客时间专栏/Android开发高手课/模块一 高质量开发/04 | 内存优化(下):内存优化这件事,应该从哪里着手?.md
Normal file
198
极客时间专栏/Android开发高手课/模块一 高质量开发/04 | 内存优化(下):内存优化这件事,应该从哪里着手?.md
Normal 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 >= 2013) {
|
||||
// Do advanced animation
|
||||
} else if (year >= 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("art.gc.gc-count");
|
||||
// GC使用的总耗时,单位是毫秒
|
||||
Debug.getRuntimeStat("art.gc.gc-time");
|
||||
// 阻塞式GC的次数
|
||||
Debug.getRuntimeStat("art.gc.blocking-gc-count");
|
||||
// 阻塞式GC的总耗时
|
||||
Debug.getRuntimeStat("art.gc.blocking-gc-time");
|
||||
|
||||
```
|
||||
|
||||
需要特别注意阻塞式GC的次数和耗时,因为它会暂停应用线程,可能导致应用发生卡顿。我们也可以更加细粒度地分应用场景统计,例如启动、进入朋友圈、进入聊天页面等关键场景。
|
||||
|
||||
## 总结
|
||||
|
||||
在具体进行内容优化前,我们首先要问清楚自己几个问题,比如我们要优化到什么目标、内存对我们造成了多少异常和卡顿。只有在明确了应用的现状和优化目标后,我们才能去进行下一步的操作。
|
||||
|
||||
在探讨了内存优化的思路时,针对不同的设备、设备不同的情况,我们希望可以给用户不同的体验。这里我主要讲到了关于Bitmap内存优化和内存泄漏排查、监控的一些方法。最后我提到了怎样在线上监控内存的异常情况,通常内存异常率、触顶率这些指标对我们很有帮助。
|
||||
|
||||
**目前我们在Native泄漏分析上做的还不是那么完善,不过做优化工作的时候,我特别喜欢用演进的思路来看问题。用演进的思路来看,即使是Google, 在时机不成熟时也会做一些权衡和妥协。换到我们个人身上,等到时机成熟或者我们的能力达到了,就需要及时去还这些“技术债务”。**
|
||||
|
||||
## 课后作业
|
||||
|
||||
看完我分享的内存优化的方法后,相信你也肯定还有很多好的思路和方法,今天的课后作业是分享一下你的内存优化“必杀技”,在留言区分享一下今天学习、练习的收获与心得。
|
||||
|
||||
在文中我提到Hprof文件裁剪和重复图片监控,这是很多应用目前都没有做的,而这两个功能也是微信的APM框架Matrix中内存监控的一部分。Matrix是我一年多前在微信负责的最后一个项目,也付出了不少心血,最近听说终于准备开源了。
|
||||
|
||||
那今天我们就先来练练手,尝试使用HAHA库快速判断内存中是否存在重复的图片,并且将这些重复图片的PNG、堆栈等信息输出。最终的实现可以通过向[Sample](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="">
|
||||
267
极客时间专栏/Android开发高手课/模块一 高质量开发/05 | 卡顿优化(上):你要掌握的卡顿分析方法.md
Normal file
267
极客时间专栏/Android开发高手课/模块一 高质量开发/05 | 卡顿优化(上):你要掌握的卡顿分析方法.md
Normal 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,还配备了专门为神经网络计算打造的NPU(Neural network Processing Unit)。“A12”就使用了八核心的NPU,每秒可执行五万亿次运算。**从CPU到GPU再到AI芯片,随着手机CPU 整体性能的飞跃,医疗诊断、图像超清化等一些AI应用场景也可以在移动端更好地落地。最近边缘计算也越来越多的被提及,我们希望可以更大程度地利用移动端的计算能力来降低高昂的服务器成本。**
|
||||
|
||||
也因此在开发过程中,我们需要根据设备CPU性能来“看菜下饭”,例如线程池使用线程数根据CPU的核心数,一些高级的AI功能只在主频比较高或者带有NPU的设备开启。
|
||||
|
||||
拓展了那么多再回到前面我讲的CPU时间,也就是用户时间和系统时间。当出现卡顿问题的时候,应该怎么去区分究竟是我们代码的问题,还是系统的问题?用户时间和系统时间可以给我们哪些线索?这里还要集合两个非常重要的指标,可以帮助我们做判断。
|
||||
|
||||
**2. 卡顿问题分析指标**
|
||||
|
||||
出现卡顿问题后,首先我们应该查看**CPU的使用率**。怎么查呢?我们可以通过`/proc/stat`得到整个系统的CPU使用情况,通过`/proc/[pid]/stat`可以得到某个进程的CPU使用情况。
|
||||
|
||||
关于stat文件各个属性的含义和CPU使用率的计算,你可以阅读[《Linux环境下进程的CPU占用率》](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_count:IO 等待的次数
|
||||
se.statistics.iowait_sum: IO 等待的时间
|
||||
|
||||
```
|
||||
|
||||
此外也可以通过uptime命令可以检查CPU在1分钟、5分钟和15分钟内的平均负载。比如一个4核的CPU,如果当前平均负载是8,这意味着每个CPU上有一个线程在运行,还有一个线程在等待。一般平均负载建议控制在“0.7 × 核数”以内。
|
||||
|
||||
```
|
||||
00:02:39 up 7 days, 46 min, 0 users,
|
||||
load average: 13.91, 14.70, 14.32
|
||||
|
||||
```
|
||||
|
||||
另外一个会影响CPU饱和度的是线程优先级,线程优先级会影响Android系统的调度策略,它主要由nice和cgroup类型共同决定。nice值越低,抢占CPU时间片的能力越强。当CPU空闲时,线程的优先级对执行效率的影响并不会特别明显,但在CPU繁忙的时候,线程调度会对执行效率有非常大的影响。
|
||||
|
||||
<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 faults:3094
|
||||
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
|
||||
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
|
||||
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
今天的课后作业是,请你在留言区解读一下上面的信息,分享一下你认为这个示例的瓶颈在什么地方。之后能不能更进一步,自己动手写一个工具,得到一段时间内上面的这些统计信息。同样最终的实现可以通过向[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter05)发送Pull Request。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
180
极客时间专栏/Android开发高手课/模块一 高质量开发/06 | 卡顿优化(下):如何监控应用卡顿?.md
Normal file
180
极客时间专栏/Android开发高手课/模块一 高质量开发/06 | 卡顿优化(下):如何监控应用卡顿?.md
Normal 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秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在0~1秒之间。换句话说,如果我们需要监控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使用的就是这个方案,因为做了大量的优化,所以最终安装包体积只增大1~2%,平均帧率下降也在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对象可以获取当前线程的ManagedStack,ManagedStack是一个单链表,它保存了当前的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不就是完美的化身吗?当然由于它利用了大量的黑科技,兼容性是需要注意的问题。它内部实现有大量函数的Hook,unwind也需要强依赖Android Runtime实现。Facebook已经将Profilo投入到线上使用,但由于目前Profilo快速获取堆栈功能依然不支持Android 8.0和Android 9.0,鉴于稳定性问题,建议采取抽样部分用户的方式来开启该功能。
|
||||
|
||||
**先小结一下,不管我们使用哪种卡顿监控方法,最后我们都可以得到卡顿时的堆栈和当时CPU运行的一些信息。大部分的卡顿问题都比较好定位,例如主线程执行一个耗时任务、读一个非常大的文件或者是执行网络请求等。**
|
||||
|
||||
## 其他监控
|
||||
|
||||
除了主线程的耗时过长之外,我们还有哪些卡顿问题需要关注呢?
|
||||
|
||||
Android Vitals是Google Play官方的性能监控服务,涉及卡顿相关的监控有ANR、启动、帧率三个。尤其是ANR监控,我们应该经常的来看看,主要是Google自己是有权限可以准确监控和上报ANR。
|
||||
|
||||
对于启动和帧率,Android Vitals只是上报了应用的区间分布,但是不能归纳出问题。这也是我们做性能优化时比较迷惑的一点,即使发现整体的帧率比过去降低了5帧,也并不知道是哪里造成的,还是要花很大的力气去做二次排查。
|
||||
|
||||
<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的ftrace,Simpleperf参考了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)。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
258
极客时间专栏/Android开发高手课/模块一 高质量开发/06补充篇 | 卡顿优化:卡顿现场与卡顿分析.md
Normal file
258
极客时间专栏/Android开发高手课/模块一 高质量开发/06补充篇 | 卡顿优化:卡顿现场与卡顿分析.md
Normal 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) { // 情况一:在这里卡住 --> BLOCKED
|
||||
object.wait(); // 情况二:在这里卡住 --> 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.0,getAllStackTraces是不会返回主线程的堆栈的。通过分析收集上来的卡顿日志,我们发现跟AssetManager相关的线程有下面这个。
|
||||
|
||||
```
|
||||
"BackgroundHandler" 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代表utime,HZ代表CPU的时钟频率,将utime转换为毫秒的公式是“time * 1000/HZ”。例子中utm=218,也就是218*1000/100=2180毫秒。
|
||||
|
||||
```
|
||||
// 线程名称; 优先级; 线程id; 线程状态
|
||||
"main" prio=5 tid=1 Suspended
|
||||
// 线程组; 线程suspend计数; 线程debug suspend计数;
|
||||
| group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400
|
||||
// 线程native id; 进程优先级; 调度者优先级;
|
||||
| sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec
|
||||
// native线程状态; 调度者状态; 用户时间utime; 系统时间stime; 调度的CPU
|
||||
| state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100
|
||||
// stack相关信息
|
||||
| stack=0xff717000-0xff719000 stackSize=8MB
|
||||
|
||||
```
|
||||
|
||||
**疑问一:Native线程状态**
|
||||
|
||||
细心的你可能会发现,为什么上面的ANR日志中“main”线程的状态是Suspended?想了一下,Java线程中的6种状态中并不存在Suspended状态啊。
|
||||
|
||||
事实上,Suspended代表的是Native线程状态。怎么理解呢?在Android里面Java线程的运行都委托于一个Linux标准线程pthread来运行,而Android里运行的线程可以分成两种,一种是Attach到虚拟机的,一种是没有Attach到虚拟机的,在虚拟机管理的线程都是托管的线程,所以本质上Java线程的状态其实是Native线程的一种映射。
|
||||
|
||||
不同的Android版本Native线程的状态不太一样,例如Android 9.0就定义了27种线程状态,它能更加明确地区分线程当前所处的情况。关于Java线程状态、Native线程状态转换,你可以参考[thread_state.h](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 <0x41ddc798> (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]的方式重新获取。
|
||||
|
||||
```
|
||||
"main" prio=7 tid=1 Native
|
||||
| group="" sCount=0 dsCount=0 obj=0x74e99000 self=0xb8811080
|
||||
| sysTid=23023 nice=-4 cgrp=default sched=0/0 handle=0xb6fccbec
|
||||
| state=? schedstat=( 0 0 0 ) utm=0 stm=0 core=0 HZ=100
|
||||
| stack=0xbe4dd000-0xbe4df000 stackSize=8MB
|
||||
| held mutexes=
|
||||
|
||||
```
|
||||
|
||||
**总的来说,通过Hook方式我们实现了一套“无损”获取所有Java线程堆栈与详细信息的方法。为了降低上报数据量,只有主线程的Java线程状态是WAITING、TIME_WAITING或者BLOCKED的时候,才会进一步使用这个“大杀器”。**
|
||||
|
||||
**4. 现场信息**
|
||||
|
||||
现在再来看,这样一份我们自己构造的“ANR日志”是不是已经是收集崩溃现场信息的完全体了?它似乎缺少了我们常见的头部信息,例如进程CPU使用率、GC相关的信息。
|
||||
|
||||
正如第6期文章开头所说的一样,卡顿跟崩溃一样是需要“现场信息”的。能不能进一步让卡顿的“现场信息”的比系统ANR日志更加丰富?我们可以进一步增加这些信息:
|
||||
|
||||
<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库。我相信当你熟悉这些方法之后,一定会惊喜地发现实现起来其实真的不难。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
212
极客时间专栏/Android开发高手课/模块一 高质量开发/07 | 启动优化(上):从启动过程看启动速度优化.md
Normal file
212
极客时间专栏/Android开发高手课/模块一 高质量开发/07 | 启动优化(上):从启动过程看启动速度优化.md
Normal 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("Test.test()");
|
||||
//原来的工作
|
||||
Trace.o();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**只有准确的数据评估才能指引优化的方向,这一步是非常非常重要的。我见过太多同学在没有充分评估或者评估使用了错误的方法,最终得到了错误的方向。辛辛苦苦一两个月,最后发现根本达不到预期的效果。**
|
||||
|
||||
**2. 优化方式**
|
||||
|
||||
在拿到整个启动流程的全景图之后,我们可以清楚地看到这段时间内系统、应用各个进程和线程的运行情况,现在我们要开始真正开始“干活”了。
|
||||
|
||||
具体的优化方式,我把它们分为闪屏优化、业务梳理、业务优化、线程优化、GC优化和系统调用优化。
|
||||
|
||||
- 闪屏优化
|
||||
|
||||
今日头条把预览窗口实现成闪屏的效果,这样用户只需要很短的时间就可以看到“预览闪屏”。这种完全“跟手”的感觉在高端机上体验非常好,但对于中低端机,会把总的的闪屏时间变得更长。
|
||||
|
||||
如果点击图标没有响应,用户主观上会认为是手机系统响应比较慢。所以**我比较推荐的做法是,只在Android 6.0或者Android 7.0以上才启用“预览闪屏”方案,让手机性能好的用户可以有更好的体验**。
|
||||
|
||||
微信做的另外一个优化是合并闪屏和主页面的Activity,减少一个Activity会给线上带来100毫秒左右的优化。但是如果这样做的话,管理时会非常复杂,特别是有很多例如PWA、扫一扫这样的第三方启动流程的时候。
|
||||
|
||||
- 业务梳理
|
||||
|
||||
我们首先需要梳理清楚当前启动过程正在运行的每一个模块,哪些是一定需要的、哪些可以砍掉、哪些可以懒加载。我们也可以根据业务场景来决定不同的启动模式,例如通过扫一扫启动只需要加载需要的几个模块即可。对于中低端机器,我们要学会降级,学会推动产品经理做一些功能取舍。但是需要注意的是,**懒加载要防止集中化,否则容易出现首页显示后用户无法操作的情形**。
|
||||
|
||||
- 业务优化
|
||||
|
||||
通过梳理之后,剩下的都是启动过程一定要用的模块。这个时候,我们只能硬着头皮去做进一步的优化。优化前期需要“抓大放小”,先看看主线程究竟慢在哪里。最理想是通过算法进行优化,例如一个数据解密操作需要1秒,通过算法优化之后变成10毫秒。退而求其次,我们要考虑这些任务是不是可以通过异步线程预加载实现,**但需要注意的是过多的线程预加载会让我们的逻辑变得更加复杂。**
|
||||
|
||||
业务优化做到后面,会发现一些架构和历史包袱会拖累我们前进的步伐。比较常见的是一些事件会被各个业务模块监听,大量的回调导致很多工作集中执行,部分框架初始化“太厚”,例如一些插件化框架,启动过程各种反射、各种Hook,整个耗时至少几百毫秒。还有一些历史包袱又非常沉重,而且“牵一发动全身”,改动风险比较大。但是我想说,如果有合适的时机,我们依然需要勇敢去偿还这些“历史债务”。
|
||||
|
||||
- 线程优化
|
||||
|
||||
线程优化就像做填空题和解锁题,我们希望能把所有的时间片都利用上,因此主线程和各个线程都是一直满载的。当然我们也希望每个线程都开足马力向前跑,而不是作为接力棒。**所以线程的优化主要在于减少CPU调度带来的波动,让应用的启动时间更加稳定。**
|
||||
|
||||
从具体的做法来看,线程的优化一方面是控制线程数量,线程数量太多会相互竞争CPU资源,因此要有统一的线程池,并且根据机器性能来控制数量。
|
||||
|
||||
线程切换的数据我们可以通过卡顿优化中学到的sched文件查看,这里特别需要注意nr_involuntary_switches被动切换的次数。
|
||||
|
||||
```
|
||||
proc/[pid]/sched:
|
||||
nr_voluntary_switches:
|
||||
主动上下文切换次数,因为线程无法获取所需资源导致上下文切换,最普遍的是IO。
|
||||
nr_involuntary_switches:
|
||||
被动上下文切换次数,线程被系统强制调度导致上下文切换,例如大量线程在抢占CPU。
|
||||
|
||||
```
|
||||
|
||||
另一方面是检查线程间的锁。为了提高启动过程任务执行的速度,有一次我们把主线程内的一个耗时任务放到线程中并发执行,但是发现这样做根本没起作用。仔细检查后发现线程内部会持有一个锁,主线程很快就有其他任务因为这个锁而等待。通过systrace可以看到锁等待的事件,我们需要排查这些等待是否可以优化,特别是防止主线程出现长时间的空转。
|
||||
|
||||
<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("art.gc.gc-time");
|
||||
// 阻塞式GC的总耗时
|
||||
Debug.getRuntimeStat("art.gc.blocking-gc-time");
|
||||
|
||||
```
|
||||
|
||||
如果我们发现主线程出现比较多的GC同步等待,那就需要通过Allocation工具做进一步的分析。启动过程避免进行大量的字符串操作,特别是序列化跟反序列化过程。一些频繁创建的对象,例如网络库和图片库中的Byte数组、Buffer可以复用。如果一些模块实在需要频繁创建对象,可以考虑移到Native实现。
|
||||
|
||||
Java对象的逃逸也很容易引起GC问题,我们在写代码的时候比较容易忽略这个点。我们应该保证对象生命周期尽量的短,在栈上就进行销毁。
|
||||
|
||||
- 系统调用优化
|
||||
|
||||
通过systrace的System Service类型,我们可以看到启动过程System Server的CPU工作情况。在启动过程,我们尽量不要做系统调用,例如PackageManagerService操作、Binder调用等待。
|
||||
|
||||
在启动过程也不要过早地拉起应用的其他进程,System Server和新的进程都会竞争CPU资源。特别是系统内存不足的时候,当我们拉起一个新的进程,可能会成为“压死骆驼的最后一根稻草”。它可能会触发系统的low memory killer机制,导致系统杀死和拉起(保活)大量的进程,从而影响前台进程的CPU。
|
||||
|
||||
讲个实践的案例,之前我们的一个程序在启动过程会拉起下载和视频播放进程,改为按需拉起后,线上启动时间提高了3%,对于1GB以下的低端机优化,整个启动时间可以优化5%~8%,效果还是非常明显的。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们首先学习了启动的整个流程,其中比较关键的是4个阶段。在这4个阶段中,用户可能会出现“点击图标很久都不响应“”首页显示太慢“和”首页显示后无法操作“这3个问题。
|
||||
|
||||
接着我们学习了启动优化和监控的一些常规方法。针对不同的业务场景、不同性能的机器,需要采用不同的策略。**有些知识点似乎比较“浅尝辄止”,我更希望你能够通过学习和实践将它们丰富起来。**
|
||||
|
||||
我讲到的大部分内容都是跟业务相关,业务的梳理和优化也是最快出成果的。不过这个过程我们要学会取舍,你可能遇到过,很多产品经理为了提升自己负责的模块的数据,总会逼迫开发做各种各样的预加载。但是大家都想快,最后的结果就是代码一团糟,肯定都快不起来。
|
||||
|
||||
比如只有1%用户使用的功能,却让所有用户都做预加载。面对这种情况,我们要狠下心来,只留下那些真正不能删除的业务,或者通过场景化直接找到那1%的用户。跟产品经理PK可能不是那么容易,关键在于数据。**我们需要证明启动优化带来整体留存、转化的正向价值,是大于某个业务取消预加载带来的负面影响**。
|
||||
|
||||
启动优化是性能优化工作非常重要的一环,今天的课后作业是,在你过去的工作中,曾经针对启动做过哪些优化,最终效果又是怎样的呢?请你在留言区分享一下今天学习、练习的收获与心得。
|
||||
|
||||
## 课后练习
|
||||
|
||||
“工欲善其事必先利其器”,我多次提到“systrace + 函数插桩”是一个非常不错的卡顿排查工具,那么通过今天的[Sample](http://github.com/AndroidAdvanceWithGeektime/Chapter07),我们一起来看一下它是如何实现的。需要注意的是Sample选择了ASM插桩的方式,感兴趣的同学可以课后学习一下它的使用方法,在后续我们也会有关于插桩的专门课程。
|
||||
|
||||
我们可以将Sample运用到自己的应用中,虽然它过滤了大部分的函数,但是我们还是需要注意白名单的配置。例如log、加解密等在底层非常频繁调用的函数,都要在白名单中配置过滤掉,不然可能会出现类似下面这样大量的毛刺。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/92/6e217938a569f32adffa84ff4eedc492.png" alt="">
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
230
极客时间专栏/Android开发高手课/模块一 高质量开发/08 | 启动优化(下):优化启动速度的进阶方法.md
Normal file
230
极客时间专栏/Android开发高手课/模块一 高质量开发/08 | 启动优化(下):优化启动速度的进阶方法.md
Normal 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<?> findClass(String name) {
|
||||
// 将 name 记录到文件
|
||||
writeToFile(name,"coldstart_classes.txt");
|
||||
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("file:"+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="">
|
||||
|
||||
这两个优化可能会带来100~200毫秒的提高,**我们还可以大大减少启动过程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和类的流程。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
258
极客时间专栏/Android开发高手课/模块一 高质量开发/09 | I|O优化(上):开发工程师必备的I|O优化知识.md
Normal file
258
极客时间专栏/Android开发高手课/模块一 高质量开发/09 | I|O优化(上):开发工程师必备的I|O优化知识.md
Normal 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系统以后,苹果使用APFS(Apple File System)替代之前旧的文件系统HFS+。对于Android来说,现在普遍使用的是Linux常用的ext4文件系统。
|
||||
|
||||
关于文件系统还需要多说两句,华为在EMUI 5.0以后就使用F2FS取代ext4,Google也在最新的旗舰手机Pixel 3使用了F2FS文件系统。Flash-Friendly File System是三星是专门为NAND闪存芯片开发的文件系统,也做了大量针对闪存的优化。根据华为的测试数据,F2FS文件系统在小文件的随机读写方面比ext4更快,例如随机写可以优化60%,不足之处在于可靠性方面出现过一些问题。我想说的是,随着Google、华为的投入和规模化使用,F2FS系统应该是未来Android的主流文件系统。
|
||||
|
||||
还是回到文件系统的I/O。应用程序调用read()方法,系统会通过中断从用户空间进入内核处理流程,然后经过VFS(Virtual File System,虚拟文件系统)、具体文件系统、页缓存Page Cache。下面是Linux一个通用的I/O架构模型。
|
||||
|
||||
<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>MLC>TLC)。
|
||||
|
||||
下图是闪存(Flash Memory)的结构图,其中比较重要的是FTL(Flash 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_count:IO 等待的次数
|
||||
se.statistics.iowait_sum: IO 等待的时间
|
||||
|
||||
```
|
||||
|
||||
如果是root的机器,我们可以开启内核的I/O监控,将所有block读写dump到日志文件中,这样可以通过dmesg命令来查看。
|
||||
|
||||
```
|
||||
echo 1 > /proc/sys/vm/block_dump
|
||||
dmesg -c grep pid
|
||||
|
||||
.sample.io.test(7540): READ block 29262592 on dm-1 (256 sectors)
|
||||
.sample.io.test(7540): READ block 29262848 on dm-1 (256 sectors)
|
||||
|
||||
```
|
||||
|
||||
**第二种方法:使用strace。**
|
||||
|
||||
Linux提供了iostat、iotop等一些相关的命令,不过大部分Anroid设备都不支持。我们可以通过 strace来跟踪I/O相关的系统调用次数和耗时。
|
||||
|
||||
```
|
||||
strace -ttT -f -p [pid]
|
||||
|
||||
read(53, "*****************"\.\.\., 1024) = 1024 <0.000447>
|
||||
read(53, "*****************"\.\.\., 1024) = 1024 <0.000084>
|
||||
read(53, "*****************"\.\.\., 1024) = 1024 <0.000059>
|
||||
|
||||
```
|
||||
|
||||
通过上面的日志,你可以看到应用程序在读取文件操作符为53的文件,每次读取1024个字节。第一次读取花了447us,后面两次都使用了100us不到。这跟启动优化提到的“数据重排”是一个原因,文件系统每次读取以block为单位,而block的大小一般是4KB,后面两次的读取是从页缓存得到。
|
||||
|
||||
我们也可以通过strace统计一段时间内所有系统调用的耗时概况。不过strace本身也会消耗不少资源,对执行时间也会产生影响。
|
||||
|
||||
```
|
||||
strace -c -f -p [pid]
|
||||
|
||||
% time seconds usecs/call calls errors syscall
|
||||
------ ----------- ----------- --------- --------- ----------------
|
||||
97.56 0.041002 21 1987 read
|
||||
1.44 0.000605 55 11 write
|
||||
|
||||
```
|
||||
|
||||
从上面的信息你可以看到,读占了97.56%的时间,一共调用了1987次,耗时0.04s,平均每次系统调用21us。同样的道理,**我们也可以计算应用程序某个任务I/O耗时的百分比**。假设一个任务执行了10s,I/O花了9s,那么I/O耗时百分比就是90%。这种情况下,I/O就是我们任务很大的瓶颈,需要去做进一步的优化。
|
||||
|
||||
**第三种方法:使用vmstat。**
|
||||
|
||||
vmstat的各个字段说明可以参考[《vmstat监视内存使用情况》](https://linuxtools-rst.readthedocs.io/zh_CN/latest/tool/vmstat.html),其中Memory中的buff和cache,I/O中的bi和bo,System中的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 > /proc/sys/vm/drop_caches
|
||||
//每隔1秒输出1组vmstat数据
|
||||
vmstat 1
|
||||
|
||||
|
||||
//测试写入速度,写入文件/data/data/test,buffer大小为4K,次数为1000次
|
||||
dd if=/dev/zero of=/data/data/test bs=4k count=1000
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
在性能优化的过程中,我们关注最多的是CPU和内存,I/O也是性能优化中比较重要的一部分。
|
||||
|
||||
今天我们学习I/O处理的整个流程,它包括应用程序、文件系统和磁盘三个部分。不过I/O这个话题真的很大,在课后需要花更多时间学习课后练习中的一些参考资料。
|
||||
|
||||
LPDDR5、UFS 3.0很快就要在2019年面世,有些同学会想,随着硬件越来越牛,我们根本就不需要去做优化了。但是一方面考虑到成本的问题,在嵌入式、IoT等一些场景的设备硬件不会太好;另一方面,我们对应用体验的要求也越来越高,沉浸体验(VR)、人工智能(AI)等新功能对硬件的要求也越来越高。所以,应用优化是永恒的,只是在不同的场景下有不同的要求。
|
||||
|
||||
## 课后练习
|
||||
|
||||
学习完今天的内容,可能大部分同学会感觉有点陌生、有点茫然。但是没有关系,我们可以在课后补充更多的基础知识,下面的链接是我推荐给你的参考资料。今天的课后作业是,通过今天的学习,在留言区写写你对I/O的理解,以及你都遇到过哪些I/O方面的问题。
|
||||
|
||||
1.[磁盘I/O那些事](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的机器。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
206
极客时间专栏/Android开发高手课/模块一 高质量开发/10 | I|O优化(中):不同I|O方式的使用场景是什么?.md
Normal file
206
极客时间专栏/Android开发高手课/模块一 高质量开发/10 | I|O优化(中):不同I|O方式的使用场景是什么?.md
Normal 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/O(Buffered 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="">
|
||||
|
||||
利用mmap,Binder在跨进程通信只需要一次数据拷贝,比传统的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方式的使用场景。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
303
极客时间专栏/Android开发高手课/模块一 高质量开发/11 | I|O优化(下):如何监控线上I|O操作?.md
Normal file
303
极客时间专栏/Android开发高手课/模块一 高质量开发/11 | I|O优化(下):如何监控线上I|O操作?.md
Normal 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 -> IoBridge.open -> Libcore.os.open
|
||||
-> BlockGuardOs.open -> 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<?> clibcore = Class.forName("libcore.io.Libcore");
|
||||
Field fos = clibcore.getDeclaredField("os");
|
||||
|
||||
```
|
||||
|
||||
我们可以通过动态代理的方式,在所有I/O相关方法前后加入插桩代码,统计I/O操作相关的信息。事实上,BlockGuardOs里面还有一些Socket相关的方法,我们也可以用来统计网络相关的请求。
|
||||
|
||||
```
|
||||
// 动态代理对象
|
||||
Proxy.newProxyInstance(cPosix.getClassLoader(), getAllInterfaces(cPosix), this);
|
||||
|
||||
beforeInvoke(method, args, throwable);
|
||||
result = method.invoke(mPosixOs, args);
|
||||
afterInvoke(method, args, result);
|
||||
|
||||
```
|
||||
|
||||
看起来这个方案好像挺不错的,但在实际使用中很快就发现这个方法有几个缺点。
|
||||
|
||||
<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& functionHooks = getFunctionHooks();
|
||||
auto& seenLibs = getSeenLibs();
|
||||
facebook::profilo::hooks::hookLoadedLibs(functionHooks, seenLibs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不同版本的Android系统实现有所不同,在Android 7.0之后,我们还需要替换下面这三个方法。
|
||||
|
||||
```
|
||||
open64
|
||||
__read_chk
|
||||
__write_chk
|
||||
|
||||
```
|
||||
|
||||
**3. 监控内容**
|
||||
|
||||
在实现I/O跟踪后,我们需要进一步思考需要监控哪些I/O信息。假设读取一个文件,我们希望知道这个文件的名字、原始大小、打开文件的堆栈、使用了什么线程这些基本信息。
|
||||
|
||||
接着我们还希望得到这一次操作一共使用了多长时间,使用的Buffer是多大的。是一次连续读完的,还是随机的读取。通过上面Hook的四个接口,我们可以很容易的采集到这些信息。
|
||||
|
||||
<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, "*****************"\.\.\., 1024) = 1024 <0.000447>
|
||||
read(53, "*****************"\.\.\., 1024) = 1024 <0.000084>
|
||||
read(53, "*****************"\.\.\., 1024) = 1024 <0.000059>
|
||||
|
||||
```
|
||||
|
||||
虽然后面两次系统调用的时间的确会少一些,但是也会有一定的耗时。如果我们的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("/data").getBlockSize()
|
||||
|
||||
```
|
||||
|
||||
所以我们最终选择的判断条件为:
|
||||
|
||||
<li>
|
||||
buffer size小于block size,这里一般为4KB。
|
||||
</li>
|
||||
<li>
|
||||
read/write的次数超过一定的阈值,例如5次,这主要是为了减少上报量。
|
||||
</li>
|
||||
|
||||
buffer size不应该小于4KB,那它是不是越大越好呢?你可以通过下面的命令做一个简单的测试,读取测试应用的iotest文件,它的大小是40M。其中bs就是buffer size,bs分别使用不同的值,然后观察耗时。
|
||||
|
||||
```
|
||||
// 每次测试之前需要手动释放缓存
|
||||
echo 3 > /proc/sys/vm/drop_caches
|
||||
time dd if=/data/data/com.sample.io/files/iotest of=/dev/null bs=4096
|
||||
|
||||
```
|
||||
|
||||
<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 > /proc/sys/vm/drop_caches
|
||||
|
||||
```
|
||||
|
||||
如果频繁地读取某个文件,并且这个文件一直没有被写入更新,我们可以通过缓存来提升性能。不过为了减少上报量,我会增加以下几个条件:
|
||||
|
||||
<li>
|
||||
重复读取次数超过3次,并且读取的内容相同。
|
||||
</li>
|
||||
<li>
|
||||
读取期间文件内容没有被更新,也就是没有发生过write。
|
||||
</li>
|
||||
|
||||
加一层内存cache是最直接有效的办法,比较典型的场景是配置文件等一些数据模块的加载,如果没有内存cache,那么性能影响就比较大了。
|
||||
|
||||
```
|
||||
public String readConfig() {
|
||||
if (Cache != null) {
|
||||
return cache;
|
||||
}
|
||||
cache = read("configFile");
|
||||
return cache;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**4. 资源泄漏**
|
||||
|
||||
在崩溃分析中,我说过有部分的OOM是由于文件句柄泄漏导致。资源泄漏是指打开资源包括文件、Cursor等没有及时close,从而引起泄露。这属于非常低级的编码错误,但却非常普遍存在。
|
||||
|
||||
如何有效的监控资源泄漏?这里我利用了Android框架中的StrictMode,StrictMode利用[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,参与到开源的事业中吧。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
198
极客时间专栏/Android开发高手课/模块一 高质量开发/12 | 存储优化(上):常见的数据存储方法有哪些?.md
Normal file
198
极客时间专栏/Android开发高手课/模块一 高质量开发/12 | 存储优化(上):常见的数据存储方法有哪些?.md
Normal 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方法替换系统默认实现,这种方式虽然不是最好的方法,不过它主要的优点在于代码的侵入性比较低,无需修改太多的代码。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
269
极客时间专栏/Android开发高手课/模块一 高质量开发/13 | 存储优化(中):如何优化数据存储?.md
Normal file
269
极客时间专栏/Android开发高手课/模块一 高质量开发/13 | 存储优化(中):如何优化数据存储?.md
Normal 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条,初始化的时间可能就需要50~100ms。我们内部另外一个支持随机读写的存储模块,初始化时间并不会因为存储条数的数量而变化,即使有几万条数据,初始化时间也在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空间不足等场景,会弹框提示用户操作。
|
||||
|
||||
## 课后作业
|
||||
|
||||
今天的课后作业是,你的应用选择了哪种对象序列化和数据序列化方案?对数据的存储你还有哪些体会?请你在留言区写写你的方案和想法,与其他同学一起交流。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
344
极客时间专栏/Android开发高手课/模块一 高质量开发/14 | 存储优化(下):数据库SQLite的使用和优化.md
Normal file
344
极客时间专栏/Android开发高手课/模块一 高质量开发/14 | 存储优化(下):数据库SQLite的使用和优化.md
Normal 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==&mid=2649286603&idx=1&sn=d243dd27f2c6614631241cd00570e853&chksm=8334c349b4434a5fd81809d656bfad6072f075d098cb5663a85823e94fc2363edd28758ab882&mpshare=1&scene=1&srcid=0609GLAeaGGmI4zCHTc2U9ZX#rd),到SQLite源码的优化,最后打造出从实验室到线上的整个监控体系。
|
||||
|
||||
在2017年,我们开源了内部使用的SQLite数据库[WCDB](https://github.com/Tencent/wcdb/wiki)。这里多说两句,看一个开源项目是否靠谱,就看这个项目对产品本身有多重要。微信开源坚持内部与外部使用同一个版本,虽然我现在已经离开了微信团队,但还是欢迎有需要的同学使用WCDB。
|
||||
|
||||
在开始学习前我要提醒你,SQLite的优化同样也很难通过一两篇文章就把每个细节都讲清楚。今天的内容我选择了一些比较重要的知识点,并且为你准备了大量的参考资料,遇到陌生或者不懂的地方需要结合参考资料反复学习。
|
||||
|
||||
**1. ORM**
|
||||
|
||||
坦白说可能很多BAT的高级开发工程师都不完全了解SQLite的内部机制,也不能正确地写出高效的SQL语句。大部分应用为了提高开发效率,会引入ORM框架。ORM(Object 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 < (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),下面是一个通过优化转换达到使用索引目的的例子。
|
||||
|
||||
```
|
||||
BETWEEN:myfiedl索引无法生效
|
||||
SELECT * FROM mytable WHERE myfield BETWEEN 10 and 20;
|
||||
转换成:myfiedl索引可以生效
|
||||
SELECT * FROM mytable WHERE myfield >= 10 AND myfield <= 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/20000~1/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> EXPLAIN QUERY PLAN SELECT * FROM t1 WHERE a=1 AND b>2;
|
||||
QUERY PLAN
|
||||
|--SEARCH TABLE t1 USING INDEX i2 (a=? AND b>?)
|
||||
|
||||
```
|
||||
|
||||
关于SQLite命令行与EXPLAIN QUERY PLAN的使用,可以参考[Command Line Shell For SQLite](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>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
200
极客时间专栏/Android开发高手课/模块一 高质量开发/15 | 网络优化(上):移动开发工程师必备的网络优化知识.md
Normal file
200
极客时间专栏/Android开发高手课/模块一 高质量开发/15 | 网络优化(上):移动开发工程师必备的网络优化知识.md
Normal 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”指的是蜂窝网络在理论上速度可以达到光纤级别的1Gbps(125MB/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&timestamp=1580647446&ver=2134&signature=8h5fb0NUiU4OKOcr-GPNgb4yexcVWJ4OGy6ve8Mqb*ZkNEDFhWwotq*SSrIaktLcvwnxsgaItbDwHK1khe*c2FpwTvi3y8ySBcGNczBd8*REqgAeQyqrufMYvVAjgYD6&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 fd(Socket描述符)。
|
||||
|
||||
如下图以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.0(QUIC)等网络技术一直在向前演进,我们需要坚持不懈地学习,思考它们对我们可以产生哪些影响,这是对网络“快”的思考。TCP和UDP协议、弱网络等很多东西在这二十多年来依然没有太大的改变,网络的基础知识对我们来说还是非常重要的,这是对网络“慢”的思考。
|
||||
|
||||
## 课后作业
|
||||
|
||||
在讲Link Turbo的时候我说过,iPhone的无线网络助理、小米和一加的自适应WLAN它们在检测WiFi不稳定时会自动切换到移动网络。那请你思考一下,它们是如何实现侦测,如何区分是应用后台服务器出问题还是WiFi本身有问题呢?今天的作业是在留言区写写你对这个问题的看法,欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天我推荐一本必读的网络书籍:**《Web性能权威指南》**,它里面第一句话就讲得非常好,我把它分享给你:“合格的开发者知道怎么做,而优秀的开发者知道为什么那么做”。
|
||||
|
||||
对于想进一步深入研究的同学,你可以研读这些书籍:
|
||||
|
||||
<li>
|
||||
《UNIX网络编程》
|
||||
</li>
|
||||
<li>
|
||||
《TCP/IP详解 卷1:协议》
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
282
极客时间专栏/Android开发高手课/模块一 高质量开发/16 | 网络优化(中):复杂多变的移动网络该如何优化?.md
Normal file
282
极客时间专栏/Android开发高手课/模块一 高质量开发/16 | 网络优化(中):复杂多变的移动网络该如何优化?.md
Normal 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网络下可能是200~300ms,4G网络也需要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使用起来更加复杂一些,但在数据压缩率、序列化与反序列化速度上面都有很大的优势。
|
||||
|
||||
另外一方面是压缩算法的选择,通用的压缩算法主要是如gzip,Google的[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==&mid=2247489465&idx=1&sn=a54e3fe78fc559458fa47104845e764b&source=41#wechat_redirect)、阿里的SlightSSL都已在企业内部大规模部署。
|
||||
</li>
|
||||
<li>
|
||||
**性能提升**。使用ecc证书代替RSA,服务端签名的性能可以提升4~10倍,但是客户端校验性能降低了约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==&mid=2247489182&idx=1&sn=e892855fd315ed2f1395f05b765f9c4e&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地址意味着可以告别各种NAT,P2P、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>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
260
极客时间专栏/Android开发高手课/模块一 高质量开发/17 | 网络优化(下):大数据下网络该如何监控?.md
Normal file
260
极客时间专栏/Android开发高手课/模块一 高质量开发/17 | 网络优化(下):大数据下网络该如何监控?.md
Normal 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("call(public okhttp3.OkHttpClient build())")
|
||||
public void build() {
|
||||
}
|
||||
|
||||
@Around("build()")
|
||||
public Object aroundBuild(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
Object target = joinPoint.getTarget();
|
||||
if (target instanceof OkHttpClient.Builder && Client.isTaskRunning(ApmTask.TASK_NET)) {
|
||||
OkHttpClient.Builder builder = (OkHttpClient.Builder) target;
|
||||
builder.addInterceptor(new NetWorkInterceptor());
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
插桩的方法看起来很好,但是并不全面。如果使用的不是系统和OkHttp网络库,又或者使用了Native代码的网络请求,都无法监控到。
|
||||
|
||||
**第二种方法:Native Hook。**
|
||||
|
||||
跟I/O监控一样,这个时候我们想到了强大的Native Hook。网络相关的我们一般会Hook下面几个方法 :
|
||||
|
||||
<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("libc.so", "connect", (hook_func) &create_hook);
|
||||
hook_plt_method_all_lib("libc.so, "send", (hook_func) &send_hook);
|
||||
hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &recvfrom_hook);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
这种做法不好的地方在于会把系统的Local Socket也同时接管了,需要在代码中增加过滤条件。在今天的Sample中,我给你提供了一套简单的实现。其实无论是哪一种Hook,如果熟练掌握之后你会发现它并不困难。我们需要耐心地寻找,梳理清楚整个调用流程。
|
||||
|
||||
**第三种方法:统一网络库。**
|
||||
|
||||
尽管拿到了所有的网络调用,想想会有哪些使用场景呢?模拟网络数据、统计应用流量,或者是单独代理WebView的网络请求。
|
||||
|
||||
<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>
|
||||
|
||||
通过这些数据,我们也可以汇总出应用的网络访问大图。例如在国内无论我们去到哪里都会问有没有WiFi,WiFi的占比会超过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& lib : allSharedLibs()) {
|
||||
if (strcmp(lib.first.c_str(), exclueLibname) != 0) {
|
||||
failures += hook_plt_method(lib.first.c_str(), name, hook);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
希望你通过这几次课后练习,可以学会将Hook技术应用到实践当中。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
215
极客时间专栏/Android开发高手课/模块一 高质量开发/18 | 耗电优化(上):从电量优化的演进看耗电分析.md
Normal file
215
极客时间专栏/Android开发高手课/模块一 高质量开发/18 | 耗电优化(上):从电量优化的演进看耗电分析.md
Normal 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内置锂离子充电电池,电池容量为3174mAh,30分钟最多可充至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&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&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 > 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 > bugreport.txt
|
||||
//通过historian图形化展示结果
|
||||
python historian.py -a bugreport.txt > battery.html
|
||||
|
||||
```
|
||||
|
||||
## Android耗电的演进历程
|
||||
|
||||
虽然iPhone XS Max电池容量只有3174mAh,远远低于大部分Android的旗舰机,但是很多时候我们发现它的续航能力会优于大部分的Android手机。
|
||||
|
||||
仔细想想这个问题就会发现,Android是基于Linux内核,而Linux大部分使用在服务器中,它对功耗并没有做非常严格苛刻的优化。特别是国内会有各种各样的“保活黑科技”,大量的应用在后台活动简直就是“电量黑洞”。
|
||||
|
||||
那Android为了电量优化都做了哪些努力呢?Google I/O每年都会单独讲解耗电优化,下面我们一起来看看Android在耗电方面都做了哪些改变。
|
||||
|
||||
**1. 野蛮生长:Pre Android 5.0**
|
||||
|
||||
在Android 5.0之前,系统并不是那么完善,对于电量优化相对还是比较少的。特别没有对应用的后台做严格的限制,多进程、fork native进程以及广播拉起等各种保活流行了起来。
|
||||
|
||||
用户手机用电如流水,会明显感受到下面几个问题:
|
||||
|
||||
<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.0~Android 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、存储和网络这些知识的时候,我都会讲一些硬件相关的知识。主要是希望帮你建立一套从应用层到操作系统,再到硬件的整体认知。当你的脑海里面有一套完整的知识图谱时,才能更得心应手地解决一些疑难问题,进而可以做好对应的性能优化工作。
|
||||
|
||||
## 课后作业
|
||||
|
||||
今天的课后作业是,在日常的开发过程中,你遇到过哪些耗电问题?遇到这些问题的时候,你一般通过哪些手段去定位和修复呢?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
235
极客时间专栏/Android开发高手课/模块一 高质量开发/19 | 耗电优化(下):耗电的优化方法与线上监控.md
Normal file
235
极客时间专栏/Android开发高手课/模块一 高质量开发/19 | 耗电优化(下):耗电的优化方法与线上监控.md
Normal 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), "mService", this);
|
||||
|
||||
@Override
|
||||
public void beforeInvoke(Method method, Object[] args) {
|
||||
// 申请Wakelock
|
||||
if (method.getName().equals("acquireWakeLock")) {
|
||||
if (isAppBackground()) {
|
||||
// 应用后台逻辑,获取应用堆栈等等
|
||||
} else {
|
||||
// 应用前台逻辑,获取应用堆栈等等
|
||||
}
|
||||
// 释放Wakelock
|
||||
} else if (method.getName().equals("releaseWakeLock")) {
|
||||
// 释放的逻辑
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- [Alarm](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), "mService", this);
|
||||
|
||||
public void beforeInvoke(Method method, Object[] args) {
|
||||
// 设置Alarm
|
||||
if (method.getName().equals("set")) {
|
||||
// 不同版本参数类型的适配,获取应用堆栈等等
|
||||
// 清除Alarm
|
||||
} else if (method.getName().equals("remove")) {
|
||||
// 清除的逻辑
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 其他。对于后台CPU,我们可以使用卡顿监控学到的方法。对于后台网络,同样我们可以通过网络监控学到的方法。对于GPS监控,我们可以通过Hook代理[LOCATION_SERVICE](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)中。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
251
极客时间专栏/Android开发高手课/模块一 高质量开发/20 | UI 优化(上):UI 渲染的几个关键概念.md
Normal file
251
极客时间专栏/Android开发高手课/模块一 高质量开发/20 | UI 优化(上):UI 渲染的几个关键概念.md
Normal 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适配造成很大困难。
|
||||
|
||||
除此之外,材质也是屏幕至关重要的一个评判因素。目前智能手机主流的屏幕可分为两大类:一种是LCD(Liquid Crystal Display),即液晶显示器;另一种是OLED(Organic 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==&mid=2650826034&idx=1&sn=5e86768d7abc1850b057941cdd003927&chksm=80b7b1acb7c038ba8912b9a09f7e0d41eef13ec0cea19462e47c4e4fe6a08ab760fec864c777&scene=21#wechat_redirect)[《smallestWidth 限定符适配方案》](https://mp.weixin.qq.com/s?__biz=MzAxMTI4MTkwNQ==&mid=2650826381&idx=1&sn=5b71b7f1654b04a55fca25b0e90a4433&chksm=80b7b213b7c03b0598f6014bfa2f7de12e1f32ca9f7b7fc49a2cf0f96440e4a7897d45c788fb&scene=21#wechat_redirect)。
|
||||
</li>
|
||||
<li>
|
||||
今日头条适配方案。通过反射修正系统的density值,具体可以参考[《一种极低成本的Android屏幕适配方式》](https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247484502&idx=2&sn=a60ea223de4171dd2022bc2c71e09351&scene=21#wechat_redirect)[《今日头条适配方案》](https://mp.weixin.qq.com/s/oSBUA7QKMWZURm1AHMyubA)。
|
||||
</li>
|
||||
|
||||
**2. CPU与GPU**
|
||||
|
||||
除了屏幕,UI渲染还依赖两个核心的硬件:CPU与GPU。UI组件在绘制到屏幕之前,都需要经过Rasterization(栅格化)操作,而栅格化操作又是一个非常耗时的操作。GPU(Graphic 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 ES,Vulkan在改善功耗、多核优化提升绘图调用上有着非常明显的[优势](https://zhuanlan.zhihu.com/p/20712354)。
|
||||
|
||||
在国内,“王者荣耀”是比较早适配Vulkan的游戏,虽然目前兼容性还有一些问题,但是Vulkan版本的王者荣耀在流畅性和帧数稳定性都有大幅度提升,即使是战况最激烈的团战阶段,也能够稳定保持在55~60帧。
|
||||
|
||||
## 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函数获得一个Canvas,Canvas可以简单理解为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.1:Project Butter**
|
||||
|
||||
优化是无止境的,Google在2012年的I/O大会上宣布了Project Butter黄油计划,并且在Android 4.1中正式开启了这个机制。
|
||||
|
||||
Project Butter主要包含两个组成部分,一个是VSYNC,一个是Triple Buffering。
|
||||
|
||||
**VSYNC信号**
|
||||
|
||||
在讲文件I/O跟网络I/O的时候,我讲到过中断的概念。对于Android 4.0,CPU可能会因为在忙别的事情,导致没来得及处理UI绘制。
|
||||
|
||||
为解决这个问题,Project Buffer引入了[VSYNC](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.0:RenderThread**
|
||||
|
||||
经过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>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
296
极客时间专栏/Android开发高手课/模块一 高质量开发/21 | UI 优化(下):如何优化 UI 渲染?.md
Normal file
296
极客时间专栏/Android开发高手课/模块一 高质量开发/21 | UI 优化(下):如何优化 UI 渲染?.md
Normal 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)
|
||||
//序号 //状态 //对象 //大小
|
||||
>[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 ms/60 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.<init>(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布局层次**。例如尽量扁平化,使用`<ViewStub>` `<Merge>`等优化。
|
||||
</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 -> layout -> 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&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,分析安装包的体积。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
334
极客时间专栏/Android开发高手课/模块一 高质量开发/22 | 包体积优化(上):如何减少安装包大小?.md
Normal file
334
极客时间专栏/Android开发高手课/模块一 高质量开发/22 | 包体积优化(上):如何减少安装包大小?.md
Normal 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/O,Google透露了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 = "com.sample.TestActivity";
|
||||
// 情况二:方法体
|
||||
startActivity(new Intent(this, "com.sample.TestActivity"));
|
||||
// 情况三:通过运算得到,不支持
|
||||
startActivity(new Intent(this, "com.sample" + ".TestActivity"));
|
||||
|
||||
```
|
||||
|
||||
代码替换的方法,我推荐使用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包的大小是4MB,Release包只有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完成。
|
||||
|
||||
```
|
||||
{
|
||||
"redex" : {
|
||||
"passes" : [
|
||||
"StripDebugInfoPass"
|
||||
]
|
||||
},
|
||||
"StripDebugInfoPass" : {
|
||||
"drop_all_dbg_info" : "0", // 去除所有的debug信息,0表示不去除
|
||||
"drop_local_variables" : "1", // 去除所有局部变量,1表示去除
|
||||
"drop_line_numbers" : "0", // 去除行号,0表示不去除
|
||||
"drop_src_files" : "0",
|
||||
"use_whitelist" : "0",
|
||||
"drop_prologue_end" : "1",
|
||||
"drop_epilogue_begin" : "1",
|
||||
"drop_all_dbg_info_if_empty" : "1"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Dex分包**<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中使用这个优化的配置如下:
|
||||
|
||||
```
|
||||
{
|
||||
"redex" : {
|
||||
"passes" : [
|
||||
"InterDexPass"
|
||||
]
|
||||
},
|
||||
"InterDexPass" : {
|
||||
"minimize_cross_dex_refs": true,
|
||||
"minimize_cross_dex_refs_method_ref_weight": 100,
|
||||
"minimize_cross_dex_refs_field_ref_weight": 90,
|
||||
"minimize_cross_dex_refs_type_ref_weight": 100,
|
||||
"minimize_cross_dex_refs_string_ref_weight": 90
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那么通过Dex分包可以对包体积优化多少呢?因为Android默认的分包方式做得实在不好,如果你的应用有4个以上的Dex,我相信这个优化至少有10%的效果。
|
||||
|
||||
**Dex压缩**<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使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要3~5秒。**这里为什么不采用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 Library,Facebook中的编译构建工具[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>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
226
极客时间专栏/Android开发高手课/模块一 高质量开发/23 | 包体积优化(下):资源优化的进阶实践.md
Normal file
226
极客时间专栏/Android开发高手课/模块一 高质量开发/23 | 包体积优化(下):资源优化的进阶实践.md
Normal 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 -> Resource Proguard
|
||||
R.string.name -> R.string.a
|
||||
res/drawable/icon -> 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[] = {
|
||||
".jpg", ".jpeg", ".png", ".gif",
|
||||
".wav", ".mp2", ".mp3", ".ogg", ".aac",
|
||||
".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet",
|
||||
".rtttl", ".imy", ".xmf", ".mp4", ".m4a",
|
||||
".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2",
|
||||
".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这里可能会有一个疑问,为什么Android系统会专门选择不去压缩这些文件呢?
|
||||
|
||||
<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资源,在这个过程中会遇到哪些问题?
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
116
极客时间专栏/Android开发高手课/模块一 高质量开发/24 | 想成为Android高手,你需要先搞定这三个问题.md
Normal file
116
极客时间专栏/Android开发高手课/模块一 高质量开发/24 | 想成为Android高手,你需要先搞定这三个问题.md
Normal 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了,今年你定的目标完成得怎么样了?还有哪些学习计划?有什么感受想跟其他同学分享吗?欢迎留言跟我和其他同学一起见证你的成长。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
180
极客时间专栏/Android开发高手课/模块三 架构演进/34 | 聊聊重构:优秀的架构都是演进而来的.md
Normal file
180
极客时间专栏/Android开发高手课/模块三 架构演进/34 | 聊聊重构:优秀的架构都是演进而来的.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="34 | 聊聊重构:优秀的架构都是演进而来的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/88/8ac0a596d1de125566a9ca4094959188.mp3"></audio>
|
||||
|
||||
每个程序员心中都有一个成为架构师的梦想,那成为架构师这个目标是否“遥不可及”呢?从我的工作经历来看,我一共负责过搜狗输入法、微信等4款亿级产品的架构工作,可能有同学会好奇这些大型的App是如何做架构设计的。从我接手的这些应用的现实情况来看,看似光鲜的外表下都有一颗千疮百孔的心:各种日志随便输出、单例满天飞、生命周期混乱、线程乱创建、线程不安全这些问题随处可见。
|
||||
|
||||
所以你可以看到每个大型应用都背负着沉重的历史技术债务,架构师很重要的一项工作就是重构“老态龙钟”的陈旧架构。在接下来的“架构演进”模块中,我们一起学习架构该如何的重构和演进,帮助我们及时偿还这些“历史债务”。
|
||||
|
||||
虽然我们天天都在谈“架构”,那你有没有想过究竟什么是架构呢?
|
||||
|
||||
## 什么是架构
|
||||
|
||||
什么是架构,每个人都有自己的看法。在我看来,所谓的架构就是面对业务需求场景给出合适的解决方案,使业务能够快速迭代,从而达到“提质增效”的目标。
|
||||
|
||||
我先举个例子,告诉你什么是架构,以及架构的作用。我曾经为了解决UI渲染卡顿这个需求场景,我们设计了异步创建View、异步布局与主线程渲染这个架构。不过架构只是设计的抽象,对于具体的实现,我们可以称之为框架。好的框架可以隐藏大家不需要关心的部分,提升我们的效率。例如Facebook的Litho、微信的[Vending](https://gryamy.gitbooks.io/vending-doc/content/),它们通过框架约束和异步来解决Android应用UI线程卡顿问题。
|
||||
|
||||
如果说监控是为了发现问题,核心在于“防”,那好的架构可以直接避免出现问题,所以架构设计的目标在于“治”。为了帮助你更好地理解架构,我们先从Android的架构设计说起。
|
||||
|
||||
**1. Android的架构**
|
||||
|
||||
在官方文档[《平台架构》](https://developer.android.com/guide/platform?hl=zh-cn)中,对Android的描述如下:
|
||||
|
||||
>
|
||||
Android是一种基于Linux的开放源代码软件栈,为广泛的设备和机型而创建。
|
||||
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/df/90763fd9662c8a75553dc92a78112ddf.png" alt="">
|
||||
|
||||
在深入Android架构之前,我们先来思考一下Android需要满足的需求场景,也就是各方对它的诉求。
|
||||
|
||||
<li>
|
||||
**用户**。在低内存的设备上也可以流畅地运行各种应用,并且有持久的续航能力。
|
||||
</li>
|
||||
<li>
|
||||
**开发者**。应用开发简便,并且可以在平台上获得收益。
|
||||
</li>
|
||||
<li>
|
||||
**硬件厂商**。无论是芯片厂商还是手机制造商,都可以低成本地适配和升级自己的系统。
|
||||
</li>
|
||||
|
||||
回想Android诞生之初,它为了团结一切可以团结的力量,广泛取得硬件厂商、开发者以及用户的支持。所以在做架构设计的时候,就充分考虑到了这些因素。
|
||||
|
||||
<li>
|
||||
**Java API接口层**。长久以来Java一直霸占第一大编程语言宝座,具有广泛的开发者基础。从当时来看,Android选择Java作为接口语言是十分明智的。从现在看,只是没有在Oracle之前抢先收购Sun是比较失策的,导致出现当前的专利诉讼困局。总的来说,开发者通过自身熟悉的Java语言,友好的接口层就可以使用系统和硬件的各种能力,快速搭建出自己的应用。
|
||||
</li>
|
||||
<li>
|
||||
**硬件抽象层**。设备制造商需要为内核开发硬件驱动程序,为了降低设备制造商的开发成本,Android选择成熟、开源的Linux内核,并且将Android打造成一个免费、开源的操作系统,吸引了更多的手机厂商入局。但是芯片厂商和手机厂商还是需要一起做大量的工作才可以更新到最新的系统,在Android 8.0之后,Android新增了HAL硬件抽象层。它向上屏蔽了硬件的具体实现,使小米这些手机厂商可以跳过芯片厂商,单独更新Android的Framework框架。
|
||||
</li>
|
||||
<li>
|
||||
**应用层**。在Android设计之初,它就考虑到移动设备的各种限制。为了用户在低内存设备有更好的体验,同样做了大量的工作。例如设计了基于寄存器架构、可执行文件更小的Dalvik虚拟机以及内核的Low Memory Killer等。为了满足用户的基本需求,Android系统在推出之初就内置许多的基础应用。并且通过吸引更多开发者进入,也在不断地满足用户的各种需求场景。当然,Android也在持续优化Framework和Runtime的性能,例如我前面提到过的黄油计划、耗电优化等。
|
||||
</li>
|
||||
|
||||
架构是为了需求场景服务,而Android的架构正是为了更好地满足硬件设备厂商、开发者以及用户而设计的。我非常推荐你看看[《关于Android设计及其意义》](https://mp.weixin.qq.com/s/twfpUMf9CfXcgwtFFkJ4Ig)和[《Android技术架构演进与未来》](http://mp.weixin.qq.com/s/W38aauoCEEUbL8KvUkb_Rw),可以让你从Android系统设计到技术支撑系统发展有更加深刻的理解。
|
||||
|
||||
**2. 如何做架构选型**
|
||||
|
||||
对于Android开发者来说,很多架构和框架已经非常成熟,通常我们更多面临的问题是如何为自己的应用选择合适的框架。回顾一下专栏前面学习过的内容,其实我们已经做过一次次的选择,例如OkHttp、Cronet、Mars应该选择哪个作为我们的高质量网络库,JSON、Protocol Buffers数据序列化方案该如何选择等。
|
||||
|
||||
网络库、图片库、UI框架、消息通信框架、存储框架,无论是GitHub还是Google官方都有非常多的方案,在选择过程我们主要要考虑下面三个因素:
|
||||
|
||||
<li>
|
||||
**框架的成熟度**。框架是否已经被大量应用所实践,特别是亿级以上的应用。还有就是框架目前是否还在维护、框架的性能如何等。
|
||||
</li>
|
||||
<li>
|
||||
**工具链的成熟度**。配套的工具链是否成熟、完善。例如Flutter作为一门新的技术,从开发、编译、测试到发布,是否有完善的工具链支持。
|
||||
</li>
|
||||
<li>
|
||||
**人员的学习成本、文档是否完备**。结合团队的现状,需要考虑框架的学习成本是否可以接受、学习路径是否平滑、有没有足够的文档和社区支持。
|
||||
</li>
|
||||
|
||||
对于架构选型,康威定律是比较重要的准则,这里推荐你看看[《从康威定律和技术债看研发之痛》](https://blog.csdn.net/junecauzhang/article/details/61427915)这篇文章,我们的组织架构、代码架构以及流程都应该跟我们团队的规模相匹配。这句话怎么理解呢?就是架构设计或者架构选型不能好高骛远,我们有多大的规模,就做多少的支撑。警惕长期的事情短期做,或者短期的事情长期做。
|
||||
|
||||
微信在2013年就开始了模块化改造,与此同时淘宝则进行了组件化改造。为什么会有这样的差别呢?因为当时微信只有一个团队在开发,Android端也就30人不到。为了代码的隔离,微信将基础组件下沉,放到单独的仓库,由专门的人员负责。对于业务来说,依然只需要保留同一个仓库,只是拆成不同的业务Module。感兴趣的同学可以参考[《微信Android模块化架构重构实践》](https://mp.weixin.qq.com/s/6Q818XA5FaHd7jJMFBG60w)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/9c/4ff7f07b2bdec613bc460b9e9dec719c.png" alt="">
|
||||
|
||||
但对于淘宝来说,当时就有几百人同时在一个应用上面开发,而且这些人分别属于不同的团队,分散在全国各地。所以无论是基于代码的权限保护,还是从开发效率的考量,都要求将所有业务模块隔离开来,也就是每个业务模块都应该是单独的仓库。
|
||||
|
||||
## 什么是架构演进
|
||||
|
||||
“没有过不去的坎,只有过不完的坎”。在业务发展的过程,总会遇到一些新的问题,而且可能在发展到某一时刻时,一些旧的问题就不复存在了。例如为了兼容Android 4.X,当年我们在架构上做了大量的兼容设计,但是当不再需要兼容4.X设备的时候,这些包袱我们就可以适时抛弃掉。
|
||||
|
||||
**1. 为什么要做架构演进**
|
||||
|
||||
架构是为了业务需求场景服务,那它也要顺应业务的变化而适时调整。也就是说,架构需要跟随业务的发展而演进。
|
||||
|
||||
“君有疾在腠理,不治将恐深”,微信每年都会经历一次大的重构,因为我们坚持代码架构最终都会腐烂,该推倒了就该重构,不要一直修修补补。架构演进可以给团队带来下面几个变化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/d0/d08aff81147f83407fe9deb3d6bcddd0.png" alt="">
|
||||
|
||||
<li>
|
||||
**打破不满**。需要打破保守的做法,要积极面对不合理的地方。团队定期需要着手开启重构,将大家平日对代码的不满释放出来。将架构的腐化(效率降低、抱怨上升)转化为架构优化的动力。
|
||||
</li>
|
||||
<li>
|
||||
**重构信任**。重构开发者之间的心态,不定期的推动模块重构。一些问题的解决,往往可以推动更多人去尝试。
|
||||
</li>
|
||||
<li>
|
||||
**团队培养**。重构也是团队进步的机会,让更多的成员掌握架构能力,培养全员架构意识,实现“人人都是架构师”。
|
||||
</li>
|
||||
|
||||
但是对于架构演进的过程,我们需要有辨别能力,也就是常说的“技术视野”。这里包括对各种技术栈的选择和比较、架构设计的考虑,要结合业务和团队当前的情况,做出合理的判断,要清楚的知道做什么事情收益最大等。
|
||||
|
||||
这里的反面例子可能就是辛辛苦苦造了一套轮子,结果发现别人早就有了,甚至比我们做得更好。这个问题的原因就在于你的技术视野。在“高质量开发”和“高效开发”模块,我反复地跟你分享目前国内外大厂的最新实践方案,正是希望提升我们的技术视野。特别是“高效开发”模块,可能有同学会认为这些话题太大了,跟自己好像关系不大。其实应用开发流程的每一个步骤都关系到你我,同时又涉及大量的内容,每一块铺开来可能都可以是一个新的专栏。而作为“Android开发高手课”,我更想从顶层给你呈现完整的架构设计,而不是去详细分析某个细节优化点。这些都是希望可以帮你站在高处看问题,全面提升你的技术视野。
|
||||
|
||||
对于技术视野的培养,可能没有太多的捷径,需要我们经过长时间的实践,经历反反复复的挫折,才能从“巨婴”成长为“大师”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/9d/cb31d2a5f5c10ea23a3e99a3aa35c39d.png" alt="">
|
||||
|
||||
在“架构演进”模块,为了进一步帮助我们提升技术视野和架构的能力,我准备了下面这些内容:
|
||||
|
||||
<li>
|
||||
GOT Hook、PLT Hook、Inline Hook,我将对比三种Native Hook框架的原理和差异,告诉你应该如何去选择。以及对于Native开发,我还有哪些经验。
|
||||
</li>
|
||||
<li>
|
||||
[大前端纷纷扰扰,如何演进和选择](https://mp.weixin.qq.com/s/WWqsd-SnILUWbiKEnSArDQ),如何选择适合我们应用的跨平台和动态化方案。
|
||||
</li>
|
||||
<li>
|
||||
技术日新月异,对于新技术我们应该如何考虑是否跟进,Flutter是不是真的可以一统天下。
|
||||
</li>
|
||||
<li>
|
||||
在应用开发之外,我们还邀请了三位专家,分别介绍他们在移动游戏、音视频和AI领域开发的经验。对于这些领域,它们的架构是什么样的,我们又该如何学习和转型。
|
||||
</li>
|
||||
|
||||
**2. 如何做架构演进**
|
||||
|
||||
架构演进是必要的,但是我们需要充分认识到困难,真正去做远比想要难多了,特别是其中各种各样的历史包袱问题。
|
||||
|
||||
架构的演进,通常来说具体实践方式就是重构。如果我们下定决心要重构,我有两个小建议送给你:
|
||||
|
||||
- **演进式的,符合团队现状的**。我们很难找到一个性能最好、成本最省、时间最快的方案,需要权衡性能、成本、时间三者的关系。如果我们时间充裕,那可以朝着更好的性能目标去努力。但如果时间紧急,我们可以分阶段去重构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/df/d1a3a57aa97e4a510e39a00c88acd8df.png" alt="">
|
||||
|
||||
- **可度量的,每个阶段都要有成果**。架构演进不能一味“憋大招”,最好能分阶段实施,并且每个阶段都要有成果。这样可以让团队成员更快地感受到优化成果,也可以激励更多的人参与到重构的事业中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/20/771f780657230e86ef33d99a11bef520.png" alt="">
|
||||
|
||||
在《Android技术架构演进与未来》一文中,回顾了Android版本的发布时间线。Android系统每年都会发布一个新的版本,每个版本也会有大大小小的重构。重构的目的依然是希望更好地满足用户、开发者以及硬件厂商的诉求。例如为了提升手机的续航能力,我们可以回顾一下Android在耗电优化的演进历程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/8c/0035fdf871970fe18ebc3a89bfc5c68c.png" alt="">
|
||||
|
||||
为了应用程序执行速度更快,Android Runtime也是每个版本都会优化的模块,下面是Android Runtime各个版本的演进历程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/c0/ee4c0ff385955162fc9d77ffdf7d9ac0.png" alt="">
|
||||
|
||||
对于虚拟机的运行机制与各个版本的差异,也是很多公司在面试时喜欢问的。下面是一些关于虚拟机架构演进比较不错的资料,我把它们分享给你。
|
||||
|
||||
<li>
|
||||
[What’s new in Android Runtime (Google I/O '18)](https://www.youtube.com/watch?v=Yi9-BqUxsno&list=PLOU2XLYxmsIInFRc3M44HUTQc3b_YJ4-Y&index=19)
|
||||
</li>
|
||||
<li>
|
||||
[Android 8.0中的ART功能改进](https://source.android.com/devices/tech/dalvik/improvements)
|
||||
</li>
|
||||
<li>
|
||||
[oat格式演进](http://romainthomas.fr/oat/)
|
||||
</li>
|
||||
|
||||
而Android 8.0的[Treble](https://source.android.com/devices/architecture#resources)计划,引入了HAL硬件抽象层,解决了硬件厂商升级难的问题。但是即使厂商升级到最新的系统也并不能直接交付给用户,这里还存在应用兼容性的问题。为什么Android P要极力推出Hidden API的限制?这里最初考虑的并不是安全性的问题,而是为了减少每次Android版本升级的兼容性适配时间,让Android版本的发布节奏快起来。
|
||||
|
||||
Hidden API的设计也有出于架构演进的考量,Android不希望出现修改Framework内部任意一个私有方法的时候,都可能会引起外部应用兼容适配,这会对重构带来非常大的包袱。
|
||||
|
||||
Android系统如此,应用的架构演进也是如此。由于组件化带来的各种性能问题,支付宝和淘宝在架构上也顺应了这种变化。在工程结构上,它们依然保留组件在仓库上的代码隔离。但是在最终产物上,组件化已经回归模块化,非核心业务会逐渐迁移到H5或者小程序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/ba/bd0a6b6b8f236a770bce2acbc947e9ba.png" alt="">
|
||||
|
||||
无论微信、支付宝、淘宝,大家都想当超级App,努力成为满足用户尽可能多需求的微型操作系统。应用的架构也需要顺应业务形态的转变,在[《敏捷开发与动态更新在支付宝 App 内的实践》](https://mp.weixin.qq.com/s/eXzojM0lCaaCW4JxBaU6BQ)一文中,也描述了支付宝这几年在架构升级驱动研发方式转变,推荐你仔细读读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/ef/e1c5114eb3ce98a98408a1932339bdef.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
从初步接触架构设计,到基本掌握架构的精髓,可以说同样也没有捷径可言。架构设计能力的成长是建立在一个又一个坑、一次又一次的重构之上。不过成为架构师这个目标并不“遥不可及”,在日常工作中我们可以反复进行锻炼。
|
||||
|
||||
架构设计不一定是整个应用或者系统的设计,也可以是一个模块或者一个需求的设计。每接手一个需求,我们可以对自己提更高的要求,更加细致地考虑问题。例如如何对现有代码的影响最小,如何快捷清晰的实现功能,在开发过程中如何对组件、控件做更好的封装,如何去优化性能,有没有哪些新的技术可以帮助开发这个需求等。
|
||||
|
||||
## 课后作业
|
||||
|
||||
一个技术人的一生应该有个代表作,给自己的技术生涯一个交代。在你的工作中,有没有令你感到满意的架构设计(某个应用、某个模块或者某个框架都可以)?你对架构演进有什么看法,又遇到过哪些问题?欢迎留言分享给我和其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
318
极客时间专栏/Android开发高手课/模块三 架构演进/35 | Native Hook 技术,天使还是魔鬼?.md
Normal file
318
极客时间专栏/Android开发高手课/模块三 架构演进/35 | Native Hook 技术,天使还是魔鬼?.md
Normal file
@@ -0,0 +1,318 @@
|
||||
<audio id="audio" title="35 | Native Hook 技术,天使还是魔鬼?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/09/6db27ce706e7ae726f4f1e27d6358b09.mp3"></audio>
|
||||
|
||||
相信一直坚持学习专栏的同学对Hook一定不会陌生,在前面很多期里我无数次提到Hook。可能有不少同学对于Hook还是“懵懵懂懂”,那今天我们从来头了解一下什么是Hook。
|
||||
|
||||
Hook直译过来就是“钩子”的意思,是指截获进程对某个API函数的调用,使得API的执行流程转向我们实现的代码片段,从而实现我们所需要得功能,这里的功能可以是监控、修复系统漏洞,也可以是劫持或者其他恶意行为。
|
||||
|
||||
相信许多新手第一次接触Hook时会觉得这项技术十分神秘,只能被少数高手、黑客所掌握,那Hook是不是真的难以掌握?希望今天的文章可以打消你的顾虑。
|
||||
|
||||
## Native Hook的不同流派
|
||||
|
||||
对于Native Hook技术,我们比较熟悉的有GOT/PLT Hook、Trap Hook以及Inline Hook,下面我来逐个讲解这些Hook技术的实现原理和优劣比较。
|
||||
|
||||
**1. GOT/PLT Hook**
|
||||
|
||||
在[Chapter06-plus](https://github.com/AndroidAdvanceWithGeektime/Chapter06-plus)中,我们使用了PLT Hook技术来获取线程创建的堆栈。先来回顾一下它的整个流程,我们将libart.so中的外部函数pthread_create替换成自己的方法pthread_create_hook。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/f5/715e03d40d7c5f185959e284c23e9df5.png" alt="">
|
||||
|
||||
你可以发现,GOT/PLT Hook主要是用于替换某个SO的外部调用,通过将外部函数调用跳转成我们的目标函数。GOT/PLT Hook可以说是一个非常经典的Hook方法,它非常稳定,可以达到部署到生产环境的标准。
|
||||
|
||||
那GOT/PLT Hook的实现原理究竟是什么呢?你需要先对SO库文件的ELF文件格式和动态链接过程有所了解。
|
||||
|
||||
**ELF格式**
|
||||
|
||||
ELF(Executableand Linking Format)是可执行和链接格式,它是一个开放标准,各种UNIX系统的可执行文件大多采用ELF格式。虽然ELF文件本身就支持三种不同的类型(重定位、执行、共享),不同的视图下格式稍微不同,不过它有一个统一的结构,这个结构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/96/181d3fed100b9ca11360a03625db5296.png" alt="">
|
||||
|
||||
网上介绍ELF格式的文章非常多,你可以参考[《ELF文件格式解析》](https://felixzhang00.github.io/2016/12/24/2016-12-24-ELF%E6%96%87%E4%BB%B6%E8%A3%85%E8%BD%BD%E9%93%BE%E6%8E%A5%E8%BF%87%E7%A8%8B%E5%8F%8Ahook%E5%8E%9F%E7%90%86/)。顾名思义,对于GOT/PLT Hook来说,我们主要关心“.plt”和“.got”这两个节区:
|
||||
|
||||
<li>
|
||||
**.plt**。该节保存过程链接表(Procedure Linkage Table)。
|
||||
</li>
|
||||
<li>
|
||||
**.got**。该节保存着全局的偏移量表。
|
||||
</li>
|
||||
|
||||
我们也可以使用`readelf -S`来查看ELF文件的具体信息。
|
||||
|
||||
**链接过程**
|
||||
|
||||
接下来我们再来看看动态链接的过程,当需要使用一个Native库(.so文件)的时候,我们需要调用`dlopen("libname.so")`来加载这个库。
|
||||
|
||||
在我们调用了`dlopen("libname.so")`之后,系统首先会检查缓存中已加载的ELF文件列表。如果未加载则执行加载过程,如果已加载则计数加一,忽略该调用。然后系统会用从libname.so的`dynamic`节区中读取其所依赖的库,按照相同的加载逻辑,把未在缓存中的库加入加载列表。
|
||||
|
||||
你可以使用下面这个命令来查看一个库的依赖:
|
||||
|
||||
```
|
||||
readelf -d <library> | grep NEEDED
|
||||
|
||||
```
|
||||
|
||||
下面我们大概了解一下系统是如何加载的ELF文件的。
|
||||
|
||||
<li>
|
||||
读ELF的程序头部表,把所有PT_LOAD的节区mmap到内存中。
|
||||
</li>
|
||||
<li>
|
||||
从“.dynamic”中读取各信息项,计算并保存所有节区的虚拟地址,然后执行重定位操作。
|
||||
</li>
|
||||
<li>
|
||||
最后ELF加载成功,引用计数加一。
|
||||
</li>
|
||||
|
||||
但是这里有一个关键点,在ELF文件格式中我们只有函数的绝对地址。如果想在系统中运行,这里需要经过**重定位**。这其实是一个比较复杂的问题,因为不同机器的CPU架构、加载顺序不同,导致我们只能在运行时计算出这个值。不过还好动态加载器(/system/bin/linker)会帮助我们解决这个问题。
|
||||
|
||||
如果你理解了动态链接的过程,我们再回头来思考一下“.got”和“.plt”它们的具体含义。
|
||||
|
||||
<li>
|
||||
**The Global Offset Table (GOT)**。简单来说就是在数据段的地址表,假定我们有一些代码段的指令引用一些地址变量,编译器会引用GOT表来替代直接引用绝对地址,因为绝对地址在编译期是无法知道的,只有重定位后才会得到 ,GOT自己本身将会包含函数引用的绝对地址。
|
||||
</li>
|
||||
<li>
|
||||
**The Procedure Linkage Table (PLT)**。PLT不同于GOT,它位于代码段,动态库的每一个外部函数都会在PLT中有一条记录,每一条PLT记录都是一小段可执行代码。 一般来说,外部代码都是在调用PLT表里的记录,然后PLT的相应记录会负责调用实际的函数。我们一般把这种设定叫作“[蹦床](http://en.wikipedia.org/wiki/Trampoline_%28computing%29)”(Trampoline)。
|
||||
</li>
|
||||
|
||||
PLT和GOT记录是一一对应的,并且GOT表第一次解析后会包含调用函数的实际地址。既然这样,那PLT的意义究竟是什么呢?PLT从某种意义上赋予我们一种懒加载的能力。当动态库首次被加载时,所有的函数地址并没有被解析。下面让我们结合图来具体分析一下首次函数调用,请注意图中黑色箭头为跳转,紫色为指针。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/33/b86ff32360acd5151050fd30e1762233.png" alt="">
|
||||
|
||||
<li>
|
||||
我们在代码中调用func,编译器会把这个转化为func@plt,并在PLT表插入一条记录。
|
||||
</li>
|
||||
<li>
|
||||
PLT表中第一条(或者说第0条)PLT[0]是一条特殊记录,它是用来帮助我们解析地址的。通常在类Linux系统,这个的实现会位于动态加载器,就是专栏前面文章提到的/system/bin/linker。
|
||||
</li>
|
||||
<li>
|
||||
其余的PLT记录都均包含以下信息:
|
||||
<ul>
|
||||
<li>
|
||||
跳转GOT表的指令(jmp *GOT[n])。
|
||||
</li>
|
||||
<li>
|
||||
为上面提到的第0条解析地址函数准备参数。
|
||||
</li>
|
||||
<li>
|
||||
调用PLT[0],这里resovler的实际地址是存储在GOT[2] 。
|
||||
</li>
|
||||
|
||||
在解析前GOT[n]会直接指向jmp *GOT[n]的下一条指令。在解析完成后,我们就得到了func的实际地址,动态加载器会将这个地址填入GOT[n],然后调用func。
|
||||
|
||||
如果对上面的这个调用流程还有疑问,你可以参考[《GOT表和PLT表》](https://www.jianshu.com/p/0ac63c3744dd)这篇文章,它里面有一张图非常清晰。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/c6/8bacb98e41eaa8ed048294dbf42896c6.png" alt="">
|
||||
|
||||
当第一次调用发生后,之后再调用函数func就高效简单很多。首先调用PLT[n],然后执行jmp *GOT[n]。GOT[n]直接指向func,这样就高效的完成了函数调用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/1e/08d7d70593533b9908426945d16ad91e.png" alt="">
|
||||
|
||||
总结一下,因为很多函数可能在程序执行完时都不会被用到,比如错误处理函数或一些用户很少用到的功能模块等,那么一开始把所有函数都链接好实际就是一种浪费。为了提升动态链接的性能,我们可以使用PLT来实现延迟绑定的功能。
|
||||
|
||||
对于函数运行的实际地址,我们依然需要通过GOT表得到,整个简化过程如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/27/698af1afdf2327233ef5bf2d09df4c27.png" alt="">
|
||||
|
||||
看到这里,相信你已经有了如何Hack这一过程的初步想法。这里业界通常会根据修改PLT记录或者GOT记录区分为GOT Hook和PLT Hook,但其本质原理十分接近。
|
||||
|
||||
**GOT/PLT Hook实践**
|
||||
|
||||
GOT/PLT Hook看似简单,但是实现起来也是有一些坑的,需要考虑兼容性的情况。一般来说,推荐使用业界的成熟方案。
|
||||
|
||||
<li>
|
||||
微信Matrix开源库的[ELF Hook](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-android-commons/src/main/cpp/elf_hook),它使用的是GOT Hook,主要使用它来做性能监控。
|
||||
</li>
|
||||
<li>
|
||||
爱奇艺开源的的[xHook](https://github.com/iqiyi/xHook),它使用的也是GOT Hook。
|
||||
</li>
|
||||
<li>
|
||||
Facebook的[PLT Hook](https://github.com/facebookincubator/profilo/tree/master/deps/plthooks)。
|
||||
</li>
|
||||
|
||||
如果不想深入它内部的原理,我们只需要直接使用这些开源的优秀方案就可以了。因为这种Hook方式非常成熟稳定,除了Hook线程的创建,我们还有很多其他的使用范例。
|
||||
|
||||
<li>
|
||||
“I/O优化”中使用[matrix-io-canary](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android/matrix-io-canary) Hook文件的操作。
|
||||
</li>
|
||||
<li>
|
||||
“网络优化”中使用Hook了Socket的相关操作,具体你可以参考[Chapter17](https://github.com/AndroidAdvanceWithGeektime/Chapter17)。
|
||||
</li>
|
||||
|
||||
这种Hook方法也不是万能的,因为它只能替换导入函数的方式。有时候我们不一定可以找到这样的外部调用函数。如果想Hook函数的内部调用,这个时候就需要用到我们的Trap Hook或者Inline Hook了。
|
||||
|
||||
**2. Trap Hook**
|
||||
|
||||
对于函数内部的Hook,你可以先从头想一下,会发现调试器就具备一切Hook框架具有的能力,可以在目标函数前断住程序,修改内存、程序段,继续执行。相信很多同学都会使用调试器,但是对调试器如何工作却知之甚少。下面让我们先了解一下软件调试器是如何工作的。
|
||||
|
||||
**ptrace**
|
||||
|
||||
一般软件调试器都是通过ptrace系统调用和SIGTRAP配合来进行断点调试,首先我们来了解一下什么是ptrace,它又是如何断住程序运行,然后修改相关执行步骤的。
|
||||
|
||||
所谓合格的底层程序员,对于未知知识的了解,第一步就是使用`man`命令来查看系统文档。
|
||||
|
||||
>
|
||||
The ptrace() system call provides a means by which one process (the “tracer”) may observe and control the execution of another process (the “tracee”), and examine and change the tracee’s memory and registers. It is primarily used to implement breakpoint debugging and system call tracing.
|
||||
|
||||
|
||||
这段话直译过来就是,ptrace提供了一种让一个程序(tracer)观察或者控制另一个程序(tracee)执行流程,以及修改被控制程序内存和寄存器的方法,主要用于实现调试断点和系统调用跟踪。
|
||||
|
||||
我们再来简单了解一下调试器(GDB/LLDB)是如何使用ptrace的。首先调试器会基于要调试进程是否已启动,来决定是使用fork或者attach到目标进程。当调试器与目标程序绑定后,目标程序的任何signal(除SIGKILL)都会被调试器做先拦截,调试器会有机会对相关信号进行处理,然后再把执行权限交由目标程序继续执行。可以你已经想到了,这其实已经达到了Hook的目的。
|
||||
|
||||
**如何Hook**
|
||||
|
||||
但更进一步思考,如果我们不需要修改内存或者做类似调试器一样复杂的交互,我们完全可以不依赖ptrace,只需要接收相关signal即可。这时我们就想到了句柄(signal handler)。对!我们完全可以主动raise signal,然后使用signal handler来实现类似的Hook效果。
|
||||
|
||||
业界也有不少人将Trap Hook叫作断点Hook,它的原理就是在需要Hook的地方想办法触发断点,并捕获异常。一般我们会利用SIGTRAP或者SIGKILL(非法指令异常)这两种信号。下面以SIGTRAP信号为例,具体的实现步骤如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/cc/0746c1505e06c80345cbeb30b7c6c6cc.png" alt="">
|
||||
|
||||
<li>
|
||||
注册信号接收句柄(signal handler),不同的体系结构可能会选取不同的信号,我们这里用SIGTRAP。
|
||||
</li>
|
||||
<li>
|
||||
在我们需要Hook得部分插入Trap指令。
|
||||
</li>
|
||||
<li>
|
||||
系统调用Trap指令,进入内核模式,调用我们已经在开始注册好的信号接收句柄(signal handler)。
|
||||
</li>
|
||||
<li>
|
||||
执行我们信号接收句柄(signal handler),这里需要注意,所有在信号接收句柄(signal handler)执行的代码需要保证[async-signal-safe](http://man7.org/linux/man-pages/man7/signal-safety.7.html)。这里我们可以简单的只把信号接收句柄当作蹦床,使用logjmp跳出这个需要async-signal-safe(正如我在“崩溃分析”所说的,部分函数在signal回调中使用并不安全)的环境,然后再执行我们Hook的代码。
|
||||
</li>
|
||||
<li>
|
||||
在执行完Hook的函数后,我们需要恢复现场。这里如果我们想继续调用原来的函数A,那直接回写函数A的原始指令并恢复寄存器状态。
|
||||
</li>
|
||||
|
||||
**Trap Hook实践**
|
||||
|
||||
Trap Hook兼容性非常好,它也可以在生产环境中大规模使用。但是它最大的问题是效率比较低,不适合Hook非常频繁调用的函数。
|
||||
|
||||
对于Trap Hook的实践方案,在“[卡顿优化(下)](https://time.geekbang.org/column/article/72642)”中,我提到过Facebook的[Profilo](https://github.com/facebookincubator/profilo),它就是通过定期发送SIGPROF信号来实现卡顿监控的。
|
||||
|
||||
**3. Inline Hook**
|
||||
|
||||
跟Trap Hook一样,Inline Hook也是函数内部调用的Hook。它直接将函数开始(Prologue)处的指令更替为跳转指令,使得原函数直接跳转到Hook的目标函数函数,并保留原函数的调用接口以完成后续再调用回来的目的。
|
||||
|
||||
与GOT/PLT Hook相比,Inline Hook可以不受GOT/PLT表的限制,几乎可以Hook任何函数。不过其实现十分复杂,我至今没有见过可以用在生产环境的实现。并且在ARM体系结构下,无法对叶子函数和很短的函数进行Hook。
|
||||
|
||||
在深入“邪恶的”细节前,我们需要先对Inline Hook的大体流程有一个简单的了解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/56/b78e7fd925a5299b13058ef54d094d56.jpg" alt="">
|
||||
|
||||
如图所示,Inline Hook的基本思路就是在已有的代码段中插入跳转指令,把代码的执行流程转向我们实现的Hook函数中,然后再进行指令修复,并跳转回原函数继续执行。这段描述看起来是不是十分简单而且清晰?
|
||||
|
||||
对于Trap Hook,我们只需要在目标地址前插入特殊指令,并且在执行结束后把原始指令写回去就可以了。但是对Inline Hook来说,它是直接进行指令级的复写与修复。怎么理解呢?就相当于我们在运行过程中要去做ASM的字节码修改。
|
||||
|
||||
当然Inline Hook远远比ASM操作更加复杂,因为它还涉及不同CPU架构带来的指令集适配问题,我们需要根据不同指令集来分别进行指令复写与跳转。
|
||||
|
||||
下面我先来简单说明一下Android常见的CPU架构和指令集:
|
||||
|
||||
<li>
|
||||
**x86和MIPS架构**。这两个架构已经基本没有多少用户了,我们可以直接忽视。一般来说我们只关心主流的ARM体系架构就可以了。
|
||||
</li>
|
||||
<li>
|
||||
**ARMv5和ARMv7架构**。它的指令集分为4字节对齐的定长的ARM指令集和2字节对齐的变长Thumb/Thumb-2指令集。Thumb-2指令集虽为2字节对齐,但指令集本身有16位也有32位。其中ARMv5使用的是16位的Thumb16,在ARMv7使用的是32位的Thumb32。**不过目前ARMv5也基本没有多少用户了,我们也可以放弃Thumb16指令集的适配**。
|
||||
</li>
|
||||
<li>
|
||||
**ARMv8架构**。64位的ARMv8架构可以兼容运行32位,所以它在ARM32和Thumb32指令集的基础上,增加了ARM64指令集。关于它们具体差异,你可以查看[ARM的官方文档](http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0801b/IBAIEGDJ.html)。
|
||||
</li>
|
||||
|
||||
ARM64目前我还没有适配,不过Google Play要求所有应用在2019年8月1日之前需要支持64位,所以今年上半年也要折腾一下。但它们的原理基本类似,下面我以最主流的ARMv7架构为例,为你庖丁解牛Inline Hook。
|
||||
|
||||
**ARM32指令集**
|
||||
|
||||
ARMv7中有一种广为流传的`$PC=$PC+8`的说法。这是指ARMv7中的三级流水线(取指、解码、执行),换句话说`$PC`寄存器总是指向正在取指的指令,而不是指向正在执行的指令。取指总会比执行快2个指令,在ARM32指令集下2个指令的长度为8个字节,所以`$PC`寄存器的值总是比当前指令地址要大8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/f9/bb8ea924c24a27638d575e25ca101cf9.png" alt="">
|
||||
|
||||
是不是感觉有些复杂,其实这是为了引出ARM指令集的常用跳转方法:
|
||||
|
||||
```
|
||||
LDR PC, [PC, #-4] ;0xE51FF004
|
||||
$TRAMPOLIN_ADDR
|
||||
|
||||
```
|
||||
|
||||
在了解了三级流水线以后,就不会对这个PC-4有什么疑惑了。
|
||||
|
||||
按照我们前面描述的Inline Hook的基本步骤,首先插入跳转指令,跳入我们的蹦床(Trampoline),执行我们实现的Hook后函数。这里还有一个“邪恶的”细节,由于指令执行是依赖当前运行环境的,即所有寄存器的值,而我们插入新的指令是有可能更改寄存器的状态的,所以我们要保存当前全部的寄存器状态到栈中,使用BLX指令跳转执行Hook后函数,执行完成后,再从栈中恢复所有的寄存器,最后才能像未Hook一样继续执行原先函数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/2d/0589d761c3079aad02287b13a7c0612d.jpg" alt="">
|
||||
|
||||
在执行完Hook后的函数后,我们需要跳转回原先的函数继续执行。这里不要忘记我们在一开始覆盖的LDR指令,我们需要先执行被我们复写的指令,然后再使用如下指令,继续执行原先函数。
|
||||
|
||||
```
|
||||
LDR PC, [PC, #-4]
|
||||
HOOKED_ADDR+8
|
||||
|
||||
```
|
||||
|
||||
是不是有一种大功告成的感觉?其实这里还有一个巨大的坑在等着我们,那就是**指令修复**。前面我提到保存并恢复了寄存器原有的状态,已达到可以继续像原有程序一样的继续执行。但仅仅是恢复寄存器就足够么?显然答案是否定的,虽然寄存器被我们完美恢复了,但是2条备份的指令被移动到了新的地址。当执行它们的时候,`$PC`寄存器的值是与原先不同的。这条指令的操作如果涉及`$PC`的值,那么它们将会执行出完全不同的结果。
|
||||
|
||||
到这里我就不对指令修复再深入解析了,感兴趣的同学可以在留言区进行讨论。
|
||||
|
||||
**Inline Hook实践**
|
||||
|
||||
对于Inline Hook,虽然它功能非常强大,而且执行效率也很高,但是业界目前还没有一套完全稳定可靠的开源方案。Inline Hook一般会使用在自动化测试或者线上疑难问题的定位,例如“UI优化”中说到libhwui.so崩溃问题的定位,我们就是利用Inline Hook去收集系统信息。
|
||||
|
||||
业界也有一些不错的参考方案:
|
||||
|
||||
<li>
|
||||
[Cydia Substrate](http://www.cydiasubstrate.com/)。在[Chapter3](https://github.com/AndroidAdvanceWithGeektime/Chapter03)中,我们就使用它来Hook系统的内存分配函数。
|
||||
</li>
|
||||
<li>
|
||||
[adbi](https://github.com/crmulliner/adbi)。支付宝在[GC抑制](https://juejin.im/post/5be1077d518825171140dbfa)中使用的Hook框架,不过已经好几年没有更新了。
|
||||
</li>
|
||||
|
||||
## 各个流派的优缺点比较
|
||||
|
||||
最后我们再来总结一下不同的Hook方式的优缺点:
|
||||
|
||||
1.GOT/PLT Hook是一个比较中庸的方案,有较好的性能,中等的实现难度,但其只能Hook动态库之间的调用的函数,并且无法Hook未导出的私有函数,而且只存在安装与卸载2种状态,一旦安装就会Hook所有函数调用。
|
||||
|
||||
2.Trap Hook最为稳定,但由于需要切换运行模式(R0/R3),且依赖内核的信号机制,导致性能很差。
|
||||
|
||||
3.Inline Hook是一个非常激进的方案,有很好的性能,并且也没有PLT作用域的限制,可以说是一个非常灵活、完美的方案。但其实现难度极高,我至今也没有看到可以部署在生产环境的Inline Hook方案,因为涉及指令修复,需要编译器的各种优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/b9/dbaf5281ac153571b5f2b93f75ae2db9.jpg" alt="">
|
||||
|
||||
但是需要注意,无论是哪一种Hook都只能Hook到应用自身的进程,我们无法替换系统或其他应用进程的函数执行。
|
||||
|
||||
## 总结
|
||||
|
||||
总的来说Native Hook是一门非常底层的技术,它会涉及库文件编译、加载、链接等方方面面的知识,而且很多底层知识是与Android甚至移动平台无关的。
|
||||
|
||||
在这一领域,做安全的同学可能会更有发言权,我来讲可能班门弄斧了。不过希望通过这篇文章,让你对看似黑科技的Hook有一个大体的了解,希望可以在自己的平时的工作中使用Hook来完成一些看似不可能的任务,比如修复系统Bug、线上监控Native内存分配等。
|
||||
|
||||
## 课后作业
|
||||
|
||||
今天的信息量是不是有点大?关于Native Hook,你对它有什么看法,还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
Native Hook技术的确非常复杂,即使我们不懂得它的内部原理,我们也应该学会使用成熟的开源框架去实现一些功能。当然对于想进一步深入研究的同学,推荐你学习下面这些资料。
|
||||
|
||||
<li>
|
||||
[链接程序和库指南](https://docs.oracle.com/cd/E37934_01/pdf/E36754.pdf)
|
||||
</li>
|
||||
<li>
|
||||
[程序员的自我修养:链接、装载与库](https://item.jd.com/10067200.html)
|
||||
</li>
|
||||
<li>
|
||||
[链接器和加载器 Linkers and Loaders](https://item.jd.com/42971729145.html)
|
||||
</li>
|
||||
<li>
|
||||
[Linux二进制分析 Learning Linux Binary Analysis](https://item.jd.com/12240585.html)
|
||||
</li>
|
||||
|
||||
如果你对调试器的研究也非常有兴趣,强烈推荐[Eli Bendersky](https://eli.thegreenplace.net/)写的博客,里面有一系列非常优秀的底层知识文章。其中一些关于debugger的,感兴趣的同学可以去阅读,并亲手实现一个简单的调试器。
|
||||
|
||||
<li>
|
||||
[how-debuggers-work-part-1](https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1)
|
||||
</li>
|
||||
<li>
|
||||
[how-debuggers-work-part-2-breakpoints](https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints)
|
||||
</li>
|
||||
<li>
|
||||
[how-debuggers-work-part-3-debugging-information](https://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information)
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
237
极客时间专栏/Android开发高手课/模块三 架构演进/36 | 跨平台开发的现状与应用.md
Normal file
237
极客时间专栏/Android开发高手课/模块三 架构演进/36 | 跨平台开发的现状与应用.md
Normal file
@@ -0,0 +1,237 @@
|
||||
<audio id="audio" title="36 | 跨平台开发的现状与应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/ec/75bca44db605bdf6c7125ecab56f67ec.mp3"></audio>
|
||||
|
||||
在2016年,我参加了两场移动技术大会,在当时的GMTC大会上,原生开发看起来如日中天。
|
||||
|
||||
转眼到了2017年,HTML5性能越来越好,Facebook的React Native、阿里的Weex等跨平台方案在越来越多的公司中实践,微信小程序更是给了原生开发最沉重的一击,许多小公司可能不再需要开发自己的应用。
|
||||
|
||||
“Write once, run anywhere”,人们对跨平台开发的尝试从来没有停止过。特别在这个终端碎片化的时代,一份代码可以在同一个平台不同的系统版本,甚至在不同的平台上运行,对开发者的吸引力越来越大。
|
||||
|
||||
回想一下,HTML5与Native开发的斗争已经持续快十年了,那它们的现状是怎样的呢?React Native和Weex方案有哪些优势,又存在什么问题?小程序是不是真的可以一统江湖?焦虑的Native开发又应该如何在这个潮流之下谋发展呢?
|
||||
|
||||
## 跨平台开发的现状
|
||||
|
||||
从2017年开始,GMTC“移动技术大会”就更名为“大前端技术大会”。从现在看来,前端开发和Native开发并没有谁取代谁,而是正在融合,融合之后的产物就是所谓的“大前端”。为了顺应这种趋势,很多大公司的组织架构也做了相应的调整,把前端团队和iOS、Android一起合并为大前端团队。
|
||||
|
||||
移动Web技术、React Native和Weex、小程序,它们是目前最常用到的跨平台开发方案,下面我们一起来看看它们的应用现状。**当然对于今年最为火热的Flutter技术,专栏后面我会花专门的篇幅去介绍。**
|
||||
|
||||
**1. Web**
|
||||
|
||||
从桌面时代开始,以浏览器为载体的Web技术就具备跨平台、动态更新、扩展性强等优点。随着移动设备性能的增强,Web页面的性能也逐渐变得可以接受。客户端中出现越来越多的内嵌Web页面,很多应用也会把一些功能模块改为Web实现。
|
||||
|
||||
**浏览器内核**
|
||||
|
||||
一个Web页面是由HTML + CSS + JavaScript组成,通过浏览器内核执行并渲染成开发者预期的界面。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/6a/ae3ca2e7151208a5ce7bb45d7e3d976a.png" alt="">
|
||||
|
||||
浏览器内核主要包括两大块功能,它们分别是:
|
||||
|
||||
<li>
|
||||
**浏览器引擎**。浏览器引擎负责处理HTML和CSS,遵照的是W3C标准。
|
||||
</li>
|
||||
<li>
|
||||
**JavaScript引擎**。JS引擎负责处理JS,遵照的是ECMAScript标准。
|
||||
</li>
|
||||
|
||||
它们两者互相独立但又有非常紧密的结合,而且在不同浏览器内核中的实现也是不太一样的。但随着微软的Edge宣布将内核切换成Chromium,目前这个战场主要就剩下苹果和Google两个玩家,它们的浏览器引擎分别是Webkit和Blink(其实Blink也是fork自Webkit),JS引擎分别是[JavaScriptCore](https://trac.webkit.org/wiki/JavaScriptCore)和[V8](https://v8.dev/)。
|
||||
|
||||
对于浏览器的渲染流程,可能很多Android开发并没有前端同学熟悉。一般来说,HTML、CSS、JS以及页面用到的一些其他资源(图片、视频、字体等)都需要从网络下载。而HTML会被解析成DOM,CSS会被解析成CSSOM,JS会由JS引擎执行,最后整合DOM和CSSOM之后合成为一棵Render Tree。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/98/4224c50dce3deebc1f4560f3cf65ae98.png" alt="">
|
||||
|
||||
当然整个浏览器的渲染流程不是三言两语就可以说清楚的,下面是我找的一些不错的参考资料,感兴趣的同学可以深入学习:
|
||||
|
||||
<li>
|
||||
浏览器渲染:[一颗像素的诞生](https://mp.weixin.qq.com/s/QoFrdmxdRJG5ETQp5Ua3-A);强烈推荐看PPT: [Life of a Pixel](https://docs.google.com/presentation/d/1boPxbgNrTU0ddsc144rcXayGA_WF53k96imRH8Mp34Y/edit#slide=id.g25ae9c179b_0_75)
|
||||
</li>
|
||||
<li>
|
||||
浏览器引擎:[What is a browser engine?](https://hacks.mozilla.org/2017/05/quantum-up-close-what-is-a-browser-engine/)
|
||||
</li>
|
||||
<li>
|
||||
浏览器系列教程:[How Browsers Work: Behind the scenes of modern web browsers](https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/)
|
||||
</li>
|
||||
<li>
|
||||
Google Web开发者官网:[Rendering on the Web](https://developers.google.cn/web/updates/2019/02/rendering-on-the-web)
|
||||
</li>
|
||||
|
||||
**虽然[Chromium](https://www.chromium.org/Home)是开源的,但是因为它的复杂性,国内对它有深入研究的人非常少,而拥有定制修改能力的人更是少之又少。因此这块需要投入大量的人力物力,国内比较有名的是UC浏览器的U4内核以及腾讯浏览器的X5内核。**
|
||||
|
||||
**性能现状**
|
||||
|
||||
基于WebView的H5跨平台方案,优点确实非常明显。但是性能是它目前最大的问题,主要表现在以下两个方面:
|
||||
|
||||
<li>
|
||||
**启动白屏时间**。WebView是一个非常重量级的控件,无论是WebView的初始化,还是整个渲染流程都非常耗时。这导致界面启动的时候会出现一段白屏时间,体验非常糟糕。
|
||||
</li>
|
||||
<li>
|
||||
**响应流畅度**。由于单线程、历史包袱等原因,页面的渲染和JavaScript的执行效率都不如原生。在一些重交互或者动画复杂的场景,H5的性能还无法满足诉求。
|
||||
</li>
|
||||
|
||||
所以在移动端H5主要应用在一些交互不太复杂的场景,一般来说即使帧率不如原生,但也基本符合要求。从我个人的感受来看,H5当前最大的问题在于启动的白屏时间。
|
||||
|
||||
对于Android界面启动的过程,我们在窗口动画还没结束的时候,大部分时候就已经完成了页面的渲染。启动一个Activity界面,我们一般要求在300毫秒以内。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/14/9fd70d4a528d903355beba20c1c65e14.png" alt="">
|
||||
|
||||
回顾一下浏览器内核渲染的流程,我们其实可以把整个过程拆成三个部分:
|
||||
|
||||
<li>
|
||||
**Native时间**。主要是Activity、WebView创建以及WebView初始化的时间。虽然首次创建WebView的时间会长一些,但总体Native时间是可控的。
|
||||
</li>
|
||||
<li>
|
||||
**网络时间**。这里包括DNS、TCP、SSL的建连时间和下载主文档的时间。当解析主文档的时候,也需要同步去下载主文档依赖的CSS和JS资源,以及必要的数据。
|
||||
</li>
|
||||
<li>
|
||||
**渲染时间**。浏览器内核构建Render Tree、Layout并渲染到屏幕的时间。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/a1/b9685887787e43ed6de77dd6bb302ca1.png" alt="">
|
||||
|
||||
如上图所示,我们会更加关心用户看到完整的页面的时间T2,这里可以用**T2秒开率**作为启动速度的衡量标准。
|
||||
|
||||
**优化方法**
|
||||
|
||||
Native界面的T2秒开率做到90%以上并不困难,相比之下大部分没有经过优化的Web页面的T2秒开率可能都在40%以下,差距还是非常明显的。
|
||||
|
||||
那又应该如何去优化呢?从前端的角度来看,常用的优化方法有:
|
||||
|
||||
<li>
|
||||
**加快请求速度**。整个启动过程中,网络时间是最不可控的。这里的优化方法有很多,例如预解析DNS、减少域名数、减少HTTP请求数、CDN分发、请求复用、懒加载、Gzip压缩、图片格式压缩。
|
||||
</li>
|
||||
<li>
|
||||
**代码优化**。主文档的大小越小越好([要求小于15KB](https://aosabook.org/en/posa/high-performance-networking-in-chrome.html)),这里要求我们对HTML、CSS以及JS进行代码优化。以JS为例,前端的库和框架真的太多了,可能一不小心就引入了各种的依赖框架。对于核心页面,我们要求只能使用原生JS或者非常轻量级的JS框架,例如使用只有几KB的Preact代替庞大的React框架。
|
||||
</li>
|
||||
<li>
|
||||
**SSR**。对于浏览器的渲染流程,我上面描述的是CSR渲染模式,在这种模式下,服务器只返回页面的基本框架。事实上还有一种非常流行的[SSR(Server Side Rendering)](https://developers.google.cn/web/updates/2019/02/rendering-on-the-web)渲染模式,服务器可以一次性生成直接进行渲染的HTML。这样在T2之前,我们可以做到只有一个网络请求,但是带来的代价就是服务器计算资源的增加。一般来说,我们会在服务器前置CDN来解决访问量的问题。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/c0/7de6d6cf32dc307e3b9152bec1b6f7c0.png" alt="">
|
||||
|
||||
通过上面的这些优化,特别是SSR这个“终极大招”,页面的T2秒开率达到70%并不是非常困难的事情。
|
||||
|
||||
前端同学能做的都已经做了,接下来我们还可以做些什么呢?这个时候就需要客户端开发登场了。
|
||||
|
||||
<li>
|
||||
**WebView预创建**。提前创建和初始化WebView,以及实现WebView的复用,这块大约可以节省100~200毫秒。
|
||||
</li>
|
||||
<li>
|
||||
**缓存**。H5是有多级的缓存机制,例如Memory Cache存放在内存中,一般资源响应回来就会放进去,页面关闭就会释放。Client Cache也就是客户端缓存,例如我们最常用的离线包方案,提前将需要网络请求的数据下发到客户端,通过拦截浏览器的资源请求实现加载。[Http Cache](https://developers.google.cn/web/fundamentals/performance/optimizing-content-efficiency/http-caching)是我们比较熟悉的缓存机制,而Net Cache就是指DNS解析结果的缓存,或预连接的缓存等。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/82/761d08be2655efb28f602083a35c7e82.png" alt="">
|
||||
|
||||
从性能上看,Memory Cache > Client Cache >= Http Cache > Net Cache。所谓的缓存,就是在用户真正点击打开页面之前,提前把数据、资源下载到本地内存或者磁盘中,并放到内核相应的缓存中。例如即使我们使用了SSR,也可以在用户点击之前,提前把服务器渲染好的HTML下载好,这样用户真正打开页面的时候,可以做到完全没有网络请求。
|
||||
|
||||
通过预请求的优化,即使比较复杂的页面,T2秒开率也可以达到80%以上。但是既然是预请求就会有命中率的问题,服务器也增加了没有真正命中的请求数。所以在客户端性能和服务器压力之间,我们需要找到一个平衡点。
|
||||
|
||||
那还有没有进一步优化的空间?这个时候需要我们进一步往底层走,需要我们有定制修改甚至优化内核的能力。例如很多接口官方的浏览器内核可能并没有暴露,而腾讯和UC的内核里面都会有很多的特殊接口。
|
||||
|
||||
<li>
|
||||
**托管所有网络请求**。我们不仅可以托管浏览器的Get请求,其他的所有Post请求也能接管,这样我们可以做非常多的定制化优化。
|
||||
</li>
|
||||
<li>
|
||||
**私有接口**。我们可以暴露很多浏览器的一些非公开接口。以预渲染为例,我可以指定在内存直接渲染某个页面,当用户真正打开的时候,只需要直接做刷新就可以了,实现真正的“秒开”。
|
||||
</li>
|
||||
<li>
|
||||
**兼容性和安全**。Android的碎片化导致浏览器内核的兼容性实在令人头疼,而且旧版本内核还存在不少的安全漏洞。在应用自带浏览器内核可以解决这些问题,而且高版本的内核特性也会更加完善,例如支持TLS 1.3、QUIC等。但是带来的代价是安装包增大20MB左右,当然我们也可以采用动态下载的方式。
|
||||
</li>
|
||||
|
||||
定制的自有页面 + 定制的浏览器内核 + 极致的优化,即使是比较复杂的页面T2秒开率也可以达到90%以上,平均T2时间可以做到400毫秒以下。
|
||||
|
||||
**2. React Native和Weex**
|
||||
|
||||
基于WebView的H5跨平台方案,经过近乎疯狂的性能优化,看起来性能真的不错了。但是对于一些交互和动画复杂的场景(例如左右滑屏、手势),性能还是无法满足要求。
|
||||
|
||||
Facebook在2015年开源了[React Native](https://github.com/facebook/react-native),它抛弃了WebView,利用JavaScriptCore来做桥接,将JavaScript调用转为Native调用。也就是说,React Native最终会生成对应的自定义原生控件,走的是系统原生的渲染流程。
|
||||
|
||||
而阿里在2016年也开源了[Weex](https://github.com/apache/incubator-weex),它的思路跟React Native很像,但是上层DSL使用的是Vue。对于Weex和React Native的架构介绍,网上的文章非常多,例如[《大前端的下一站何去何从?》](https://www.infoq.cn/article/9*CZfjFghPVqZJlc7ScM?utm_medium=hao.caibaojian.com&utm_source=hao.caibaojian.com)和[《Weex技术演进》](https://yq.aliyun.com/articles/444881?spm=a2c4e.11163080.searchblog.9.2eba2ec1hzUpLo)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/54/498f043e2c7459e6e41f34ddfddf7b54.png" alt="">
|
||||
|
||||
但是世上哪有十全十美的方案?React Native/Weex方案为了能达到接近原生开发的性能和交互体验,必然要在跨平台和动态性上面做出了牺牲。
|
||||
|
||||
React Native和Weex向上对接了前端生态,向下对接了原生渲染,看起来是非常完美的方案。但是前端和客户端,客户端中的Android和iOS,它们的差异并不那么容易抹平,强行融合就会遇到各种各样的坑。
|
||||
|
||||
“React Native从入门到放弃”是很多开发者的心声,去年Airbnb、Udacity都相继[宣布放弃使用React Native](https://infoq.cn/article/2018/07/Udacity-Abandon-React-Native)。React Native/Weex并没有彻底解决跨平台的问题,而且考虑到对外分享和降级容灾的需要,我们依然需要开发一个H5版本的页面。
|
||||
|
||||
为了解决这个问题,React Native的使用者需要引入一层非常重的中间层,期望在这个中间层中帮助我们去抹平这些差异。例如京东的[JDReact](https://infoq.cn/article/jd-618-ReactNative-jingdong-practise)、携程的[Ctrip React Native](https://s.qunarzz.com/ymfe_conf/ppt/2017autumn_ctrip_liaoliqiang.pdf)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/11/b2f6f19bb6ae9a71546645dc52984411.png" alt="">
|
||||
|
||||
既然React Native和Weex在跨平台上面做了牺牲,那它的性能和交互是不是能直接对齐Native开发呢?非常遗憾, 目前它们的性能我觉得主要还有两个瓶颈。
|
||||
|
||||
<li>
|
||||
**JS的执行时间**。React Native和Weex使用的[JavaScriptCore](https://trac.webkit.org/wiki/JavaScriptCore)引擎,虽然它每年都在进步,但是JS是解释性的动态语言,它的执行效率相比AOT编译后的Java,性能依然会在几倍以上的差距。
|
||||
</li>
|
||||
<li>
|
||||
**跨语言的通信成本**。既然要对接前端和原生两个生态,就无法避免JS -> C++ -> Java/Objective-C 之间频繁的通信和转换,所以这里面会涉及各种序列化,对性能的影响比较大。
|
||||
</li>
|
||||
|
||||
虽然相比H5方案在性能方面有了很大的提升,但是React Native和Weex也要面对启动时间慢、帧率不如原生的性能问题。它属于一种比较中庸的方案,当然也会有自己的应用场景。例如一些二级页面(例如淘宝的分会场),它们的业务也比较重要,但是交互不会特别复杂,同时希望保持一定的动态化能力。
|
||||
|
||||
当然,Facebook已经意识到React Native的种种性能问题,目前正在疯狂重构中,希望让React Native更加轻量化、更适应混合开发,接近甚至达到原生的体验。Facebook现在透漏的信息并不多,感兴趣的同学可以参考[《庖丁解牛!深入剖析React Native下一代架构重构》](https://mp.weixin.qq.com/s/dXZTqXOSi3fiOesDJ7gsFQ)。
|
||||
|
||||
**3. 小程序**
|
||||
|
||||
2017年初,张小龙宣布微信小程序诞生。如今小程序已经走过了两年,在这两年间,小程序的生态也在健康的发展。
|
||||
|
||||
每一个应用都有成为超级App的梦想,各个大厂纷纷推出自己的小程序框架:微信、厂商、支付宝、今日头条、百度、淘宝、[Google Play](https://www.infoq.cn/article/XTE9WzSL11iHmW*WBozi),小程序这个战场已然是“七国大乱战”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/76/1cd013191bd6937fec9b63a47bfb5676.png" alt="">
|
||||
|
||||
但是小程序并不属于一种跨平台开发方案,大家更看重的是它的渠道优势,考虑如何通过微信、支付宝这些全民App获得更多的流量和用户。从技术上看,小程序的框架技术也是开放的,我们可以采用H5方案,也可以采用React Native和Weex,甚至是Flutter。
|
||||
|
||||
从实践上看,我们一起来看看已经正式上线的微信小程序、快应用、支付宝小程序以及百度小程序的差异(技术方面大家公开得并不多,可以参考[《支付宝小程序框架》](https://mp.weixin.qq.com/s/VD0K47KdD-E5EYlnNz2Cbw))。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/61/df12348b0ebadaf13219a66cccb97261.jpg" alt="">
|
||||
|
||||
我们可以看到除了独树一帜的快应用,其他小程序的技术方案基本都跟随了微信。但是考虑到H5在一些场景的性能问题,利用浏览器内核提供的同层渲染能力,在WebView之上支持一些原生的控件。如果哪一天微信小程序支持了所有的原生控件,那也就成为了另外一套React Native/Weex方案。
|
||||
|
||||
“神仙打架,百姓遭殃”,如果我们想从所有的小程序厂商上面获得流量,那就要开发七个不同的小程序。不过幸运的是,支付宝小程序和快应用也希望已有的微信小程序能快速迁移到自己平台上,所以它们的DSL设计都参考了微信的语法,可以说微信推出的DSL已然成为了事实标准。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/21/e8e3f8ab6c7867a85ded1741c1b53a21.png" alt="">
|
||||
|
||||
如上图所示,我们希望有一套可以整合所有小程序框架的解决方案,一次开发就可以生成不同的小程序。滴滴的[Chameleon](https://mp.weixin.qq.com/s/YtBXUATwI1pnuF6W69KJYA)和京东的[Taro](https://mp.weixin.qq.com/s/H4W1SD-E5_KhuR1CNfrD2g)都致力于解决这个问题,目前它们都已经在GitHub上开源。
|
||||
|
||||
## 跨平台开发的应用
|
||||
|
||||
从移动开发诞生之初,跨平台就已经是大家前赴后继不断追求的目标。我们可以看看nwind在2015年写的一篇文章[《聊聊移动端跨平台开发的各种技术》](http://fex.baidu.com/blog/2015/05/cross-mobile/)。如今四年过去了,大部分观点依然成立,并且从最后Dart的介绍中,我们甚至可以看到现在Flutter的雏形。
|
||||
|
||||
**1. 跨平台开发的场景**
|
||||
|
||||
Android、iOS、PC,不同的平台人们的操作习惯、喜好都不尽相同。对于大公司来说,完全的跨平台开发可能是一个伪命题,不同的平台应用的UI和交互都不太一样。
|
||||
|
||||
那我们对跨平台苦苦追寻了那么多年,希望得什么呢?以我的经验来看,跨平台主要的应用场景有:
|
||||
|
||||
<li>
|
||||
**部分业务**。某个业务或者页面的跨平台共享,有的时候我们还希望可以做到跨应用。例如“全民答题”的时候,可以看到这个功能可以运行在头条系的各个应用中。一个公司共用同一套跨平台方案有非常重大的意义,业务可以在不同的应用中尝试。
|
||||
</li>
|
||||
<li>
|
||||
**核心功能**。C++才是生命力最顽强的跨平台方案,大公司也将越来越多的核心模块往底层迁移,例如网络库、数据上报、加解密、音视频等。
|
||||
</li>
|
||||
|
||||
**2. 跨平台开发对比**
|
||||
|
||||
H5的跨平台方案只要投入不太高的开发成本,就能开发出性能、功能还不错的应用。但是如果想做到极致优化,很容易发现开发者可控的东西实在比较少,性能和功能都依赖浏览器的支持。
|
||||
|
||||
这个时候如果想走得更远,我们不仅需要了解浏览器的内部机制,可能还需要具备定制、修改浏览器内核的能力,这也是阿里、腾讯、头条和百度都要组建内核团队的原因。
|
||||
|
||||
原生开发则相反,刚开始要投入很高的开发成本,但是一旦开始有产出之后,开发者能够有更的发挥空间,而React Native和Weex方案更是希望打造兼顾跨平台、开发成本以及性能的全方位解决方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/f7/2e5f5b95bccd1cd0a029b3062339a6f7.png" alt="">
|
||||
|
||||
从目前来看,每一种方案都有着自己的使用场景,无论是React Natve还是H5,都无法完全取代Native开发。当然这里也有一个例外,那就是如果我们不再开发应用,全面投向小程序。小程序跟原生开发的竞争,更多的是在渠道层面的竞争。
|
||||
|
||||
## 总结
|
||||
|
||||
现在好像有个观点说“Android开发没人要”,大家都想转去做大前端开发,是不是真的是这样呢?事实上,无论我们使用哪一种跨平台方案,它们最终都要运行在Android平台上。崩溃、内存、卡顿、耗电这些问题依然存在,而且可能会更加复杂。而且从H5极致体验优化的例子来看,很多优化是需要深入研究平台特性和系统底层机制,我们在“高质量开发”中学到的底层和系统相关的知识依然很重要。
|
||||
|
||||
对开发者来说,唯一不变的就是学习能力。掌握了学习能力和钻研的精神,就能够应对这些趋势变化。无论移动开发未来如何变化,哪怕有一天AI真的能够自动写代码,具备应变能力的人也丝毫不会惧怕的。
|
||||
|
||||
## 课后作业
|
||||
|
||||
跨平台开发也是一个很大很大的话题,今天我只能算是抛砖引玉。对于跨平台开发,你有什么看法?在你的应用中,使用了哪种跨平台开发方式?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
181
极客时间专栏/Android开发高手课/模块三 架构演进/37 | 移动开发新大陆:工作三年半,移动开发转型手游开发.md
Normal file
181
极客时间专栏/Android开发高手课/模块三 架构演进/37 | 移动开发新大陆:工作三年半,移动开发转型手游开发.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="37 | 移动开发新大陆:工作三年半,移动开发转型手游开发" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/e1/b70550f0b3c6035cf1169cd5abc1d5e1.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文。15年认识庆文的时候,他还在微信读书负责Android端的性能优化工作。某一天,他跟我说想转岗去尝试一下游戏开发,当时我脑海里浮现了两个想法,一个是游戏部门传说中60个月的年终奖,看起来游戏是一个非常有“钱途”的方向;另外一个还是担忧,抛弃掉Android开发多年的积累,转去完全不熟悉的游戏领域,他是否可以胜任。
|
||||
|
||||
|
||||
>
|
||||
<p>两年过去了,庆文以他的个人经历亲身证明了两件事情:<br>
|
||||
第一,游戏开发并没有想象中那么困难。当年他是Android版微信读书的技术核心,如今在新的岗位上依然也是技术核心。“一通则百通”,技术是相通的,最珍贵的是我们的学习能力和钻研精神。<br>
|
||||
第二,客户端平台知识依然非常重要。手游虽然有非常独立的开发体系,但是它还是运行在Android或者iOS系统之上。App开发需要用到的很多技术,游戏开发也需要用到。作为Android开发,我们一层一层往底层走的优化能力,反而是其他大部分游戏开发所不具备的,也是我们的优势所在。</p>
|
||||
|
||||
|
||||
>
|
||||
在我看来,无论移动的未来如何发展,不管是大前端的天下,还是转向手游、IoT、AI、音视频等其他方向,今天我们所熟悉的Android平台知识以及学习这些知识所沉淀下来的能力和方法,都是最宝贵的财富。下面我们一起来看看庆文同学的移动开发转型手游开发的那些事。
|
||||
|
||||
|
||||
你好,我是李庆文,来自腾讯旗下一间手游工作室,应绍文的邀请,在《Android开发高手课》里和你聊聊手游开发那些事。
|
||||
|
||||
我在2017年4月,从App客户端开发转岗成为一名“用命创造快乐”的手游客户端开发。在转为手游开发之前,虽然玩游戏会被游戏精美的画面吸引,但是并不清楚游戏是如何将这些精细的画面呈现出来,觉得游戏开发好像一个神秘的组织一样,不知道游戏开发团队的同学每天在做什么。如果你也对游戏开发有些好奇,不知道是不是也有我之前同样的困惑?现在我以一个亲历者的身份,跟你讲讲转向手游开发的那事儿。
|
||||
|
||||
## 揭开手游开发的面纱
|
||||
|
||||
一般来说,一个手游项目组包括制作人、策划组、美术组、运营组、程序组、音频组。其中游戏核心部分完全由项目组自己完成,部分美术、音频可以交给外包来完成。
|
||||
|
||||
策划组分为玩法策划、数值策划,是整个游戏的灵魂,一个游戏的核心玩法是否好玩、周边系统能否更好地支撑核心玩法,是整个游戏成败的关键。有些团队也会把运营组放到策划组当中,运营同学主要的工作职责是外部合作、策划游戏内的活动、根据活动数据对游戏玩法或者活动进行相应调整,希望可以尽量延长游戏生命周期并提高游戏收入。
|
||||
|
||||
美术组包括原画师、视觉设计、2D/3D视觉特效设计,其中负责视觉特效的同学与负责程序的同学沟通较多,主要是因为程序需要与动画配合,比如某个动画播放到某一个关键帧之后,程序要处理某个特定逻辑。
|
||||
|
||||
App开发的程序组同学关注的更多是手机系统的架构,希望尽量多了解系统能为App提供的接口或者能力。而手游的程序组同学更多是关注游戏引擎的架构和如何在引擎的基础上优化游戏性能。
|
||||
|
||||
下面我以Cocos引擎为例,带你看看手游的开发流程以及手游是如何运行的。
|
||||
|
||||
**1. 游戏架构**
|
||||
|
||||
以我的游戏项目为例,游戏整体架构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/91/01389bcd58dcb27a5b99983d69b88991.png" alt="">
|
||||
|
||||
其中:
|
||||
|
||||
<li>
|
||||
资源更新模块让游戏能够不断发布Bugfix和Features。
|
||||
</li>
|
||||
<li>
|
||||
配置系统提供给策划和运营同学,主要是编辑XLS文件用于配置游戏内的关卡、活动等。策划同学的配置会在游戏编译过程中自动转表,根据配置文件生成Lua文件。
|
||||
</li>
|
||||
<li>
|
||||
Services管理着游戏内的全部数据。
|
||||
</li>
|
||||
<li>
|
||||
网络模块负责与后台建立Socket并通信。
|
||||
</li>
|
||||
<li>
|
||||
游戏内使用MVC模型控制各个游戏内模块的跳转。
|
||||
</li>
|
||||
<li>
|
||||
在单个功能模块中,逻辑与动画分离,逻辑层产生动画事件,动画层消费动画事件并让游戏内的精灵根据事件执行特定动作。
|
||||
</li>
|
||||
|
||||
**2. 游戏场景设计**
|
||||
|
||||
Cocos采用节点树形结构来管理游戏对象,一个游戏可以划分为不同的场景(Scene),一个场景又可以分为不同的层(Layer),一个层又可以拥有任意个精灵(Sprite)。游戏里关卡、周边系统的切换也就是一个一个场景的切换,就像在电影中变换舞台和场地一样,由导演(Director)负责不同游戏场景之间的切换。一个Cocos游戏的基本结构如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/0d/9ca7c16d109401bf378443f1a484620d.jpg" alt="">
|
||||
|
||||
在同一个场景中的多个精灵可以通过执行动作(Action)来实现缩放、移动、旋转,从而达到让游戏“动起来”的目的。
|
||||
|
||||
Cocos引擎中,坐标系X轴向右、Y轴向上,Z轴(Z-Order)垂直于手机屏幕指向屏幕外,由于是2D游戏,所以Z轴只用作控制游戏元素的前后顺序。同一个场景中两个重叠或者部分重叠的 Sprite,Z-Order大的精灵会遮挡住Z-Order小的精灵,相同Z-Order的精灵,后添加到场景中的会遮挡住先添加的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/ba/a901e8f4c070edae3da1097d55bb7bba.png" alt="">
|
||||
|
||||
有了树形结构和Z-Order,就能利用Cocos引擎构建出任意2D游戏场景了。
|
||||
|
||||
**3. 手游的运行流程**
|
||||
|
||||
游戏的主Activity在`onCreate`时候创建一个GLSurfaceView,它继承自View,是引擎用于绘制游戏内容的。同时GLSurfaceView也接受玩家的点击事件,用于引擎与玩家的交互。
|
||||
|
||||
GLSurfaceView创建之后,引擎开始执行`main.lua`脚本,导演调用`runWithScene`接口,游戏就进入到第一个“场景”中了。GLSurfaceView中维护了一个`while`循环,循环中发生的事件(比如SurfaceView创建、宽高发生变化等)通过`Renderer`接口传递给外部。`Renderer`其中一个接口就是`onDrawFrame`,Cocos会在`onDrawFrame`接口实现中根据游戏设置的帧率,每间隔一段事件通知一次导演执行一帧游戏循环。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/db/039b709bbd29787d94ba661f38c0cadb.png" alt="">
|
||||
|
||||
借用《我所理解的Cocos2d-x》书中的单帧处理流程图片,你可以看到在每帧中:
|
||||
|
||||
<li>
|
||||
先响应用户输入,因为用户输入可能影响到当前帧接下来的游戏逻辑。
|
||||
</li>
|
||||
<li>
|
||||
根据每个精灵设置的动画,计算精灵的位置、缩放等属性。
|
||||
</li>
|
||||
<li>
|
||||
执行游戏逻辑,比如更新玩家得分、修改当前游戏状态等。在这里开发者依然可以更改精灵的各种属性。
|
||||
</li>
|
||||
<li>
|
||||
遍历UI树,根据之前对精灵属性的设置,生成绘制命令,交给OpenGL绘制,最后把渲染的内容显示到屏幕上。
|
||||
</li>
|
||||
|
||||
## 游戏与App开发的异同
|
||||
|
||||
**1.关于热更新**
|
||||
|
||||
热更新是App开发和游戏开发都避不开的话题,但是App的热更新与游戏的热更新有着本质的区别。App开发的热更新涉及整个系统层面的实现细节,比如我在老东家工作期间实现的热更新框架,需要了解的内容非常多,包括Dex文件的加载过程、SO文件查找过程和APK编译过程的详细细节,每个不小心都可能导致热更新不生效甚至导致严重的外网Bug。
|
||||
|
||||
然而使用脚本的游戏引擎是天生支持热更新的。手游热更新,也叫作资源更新,更新内容包括代码、纹理图片、界面等。
|
||||
|
||||
以Cocos引擎为例,Cocos引擎的核心是C++实现的,对外提供了JS、Lua接口。业务开发过程中绝大部分代码是Lua代码,只有涉及系统相关接口,比如支付、音频播放、WebView等,才需要写一部分系统相关的代码。Lua代码经过编译之后生成的二进制代码是“.luac”文件,并通过luaL_loadbuffer接口来加载一段“.luac”的文件内容。Cocos引擎在初始化过程中会设置“代码查找路径”,加载Lua代码的时候会挨个遍历每一个被设置进来的路径,直到找到或者找不到对应的“.luac”文件。而热更新只需要下载“.luac”文件,放到优先的查找路径中,游戏重启之后引擎就会优先加载新的代码啦。
|
||||
|
||||
当然还有另外一种更为激进的方式,不需要重启游戏就能达到热更新的目的。
|
||||
|
||||
Cocos以LuaEngine为入口,执行Lua代码驱动C++部分的图形库、音频库、物理引擎等。LuaEngine缓存了通过`luaL_loadbuffer`接口加载起来的代码,只要重启整个LuaEngine清理掉缓存的代码,就能在后续需要执行代码的时候去重新加载代码文件。这样就能做到不重启游戏也能达到真正“热”更新的目的。
|
||||
|
||||
其实Unity引擎也是类似,可以使用xLua等类似的技术,让Unity引擎也能开心地写Lua脚本。
|
||||
|
||||
对于热更新图片,只需要清理掉引擎缓存的图片缓存即可。这样引擎在下次使用该图片渲染界面是,发现在缓存中查找不到该图片,自然就会去加载新的图片了。
|
||||
|
||||
**2. 游戏也需要优化安装包大小**
|
||||
|
||||
在App开发过程中,优化安装包的大小是一个不可避免的话题。压缩图片、代码,插件动态下载等众多实现方案也都为大家所熟知。手游安装包大小可能并不像App安装包要求那么严格,热门游戏比如《绝地求生》《王者荣耀》,安装包大小几乎都接近2GB。虽然玩家对游戏安装包大小不是特别在意,但有意识的减小安装包大小也是很有必要的,毕竟安装包大小会影响游戏转化率,进而影响游戏收入。
|
||||
|
||||
手游安装包中,图片资源占了安装包体积的绝大部分,以PNG图片为主。通常直接压缩PNG图片是有损压缩,经过游戏引擎渲染,游戏界面会出现很多噪点。这里有一种分离Alpha通道的压缩方案,可以供你参考。32位透明PNG图片包含了四个通道:RGBA,其中每个通道占8 Bit。把RGBA四个通道中的Alpha通道数据拆分出来存储为一张PNG8图片,剩下的RGB数据存储为一张JPG格式图片。JPG格式的图片在保证高压缩率的同时也能保证图片质量,以此达到压缩图片大小的目的。这种方案的压缩率大概在70%左右。
|
||||
|
||||
在游戏运行过程中,使用纹理之前先把JPG和PNG8文件都读取到内存,将他们包含的RGB和Alpha数据重新合并后,交给OpenGL用于渲染。这个压缩方案简单易操作,并且运行时不需要额外的第三方库支持。
|
||||
|
||||
**3. 游戏的性能优化**
|
||||
|
||||
性能是开发者永远离不开的话题。手游和App开发一样,也需要关注游戏的各方面性能指标,比如内存、帧率等。
|
||||
|
||||
**内存**
|
||||
|
||||
<li>
|
||||
纹理压缩,减少纹理占用的内存。我前面提到的安装优化包大小中压缩图片,也有助于减少内存占用。在美术同学能接受的前提下,降低图片深度也是快速优化内存的一个方法。
|
||||
</li>
|
||||
<li>
|
||||
使用精灵池,尽量复用已经创建的精灵,减少屏幕中精灵创建的个数,及时回收不用的内存。
|
||||
</li>
|
||||
<li>
|
||||
切换场景时尽快释放上一个场景的无用纹理。
|
||||
</li>
|
||||
|
||||
**帧率**
|
||||
|
||||
<li>
|
||||
减少Draw Call。游戏引擎每次准备数据并发送给GPU去渲染的过程,称为一次Draw Call。Cocos把使用相同贴图、blendFunc、Shader的精灵合并到同一个渲染命令中提交给GPU。我们常说的合图就是把很多小图片合并到一张大图片中,这样当屏幕中有多个连续渲染的精灵用到同一个大图,CPU就会(有可能)把它们合并之后统一提交给GPU。另外Cocos合并渲染命令是根据精灵在场景中的顺序来做的,最终的合并效果不一定是最优的。我们可以实现自己更优化的合并逻辑。
|
||||
</li>
|
||||
<li>
|
||||
减少屏幕内需要渲染的精灵个数。如果屏幕中有“成组”的元素需要绘制,比如排行榜中每个玩家的Item,在Cocos中可以使用CCRenderTexture,把每个“成组”的元素绘制到CCRenderTexture绑定的纹理,然后用CCRenderTexture替代原有的Item。这与Android开发中自己实现View的onDraw函数类似,并且这也是减少Draw Call的一种方式。
|
||||
</li>
|
||||
<li>
|
||||
分帧。真的遇到CPU密集型的任务时,例如同屏幕确实需要大量精灵,可以把它们拆分在不同帧里,尽量保证游戏内每一帧在规定时间内完成,否则会出现卡帧现象。
|
||||
</li>
|
||||
|
||||
## App开发转手游开发的思考
|
||||
|
||||
刚刚过去的2018年是游戏比较惨淡的一年,游戏版号从暂停审批到现在终于慢慢开始放出,简直是千军万马过独木桥。但从目前来看,2018年确实也像某些大咖说的那样,可能是游戏行业未来几年最好的一年。国内拿不到版号的游戏只能考虑出海来寻求出路,但是出海需要在海外当地运营或者找海外代理。自己运营可能会遇到水土不服,画风或者游戏内容也可能不满足当地玩家的需求;找海外代理,却可能面临连游戏代码都被代理商卖掉的风险。
|
||||
|
||||
手游早已经结束了野蛮生长的年代,6年前可能随便一款游戏都可能成为爆款,类似捕鱼达人、保卫萝卜,它们诞生在Android、iOS手机爆发的那几年。而现在的游戏玩家需要的是更新颖的玩法和更加精细的游戏体验。例如MOBA类的《王者荣耀》、SLG类的《乱世王者》,甚至细分领域的《恋与制作人》,也都在各自的玩法上不断打磨,让玩家心甘情愿“氪金”。如果想要转向这个赢者通吃的行业,确实需要谨慎,提前考察好目标团队是否符合你的职业发展、是否有盈利能力,尽量避免踩坑。
|
||||
|
||||
**1. App开发转手游开发,可行吗?**
|
||||
|
||||
2018年和2019年,市场对App开发的岗位需求量已经不像几年前那么大,而且也有一些同学也咨询过我App开发转手游开发是否合适。关于这个问题,我的回答是:可以转到手游开发岗位,而且难度并没有想象中那么大。
|
||||
|
||||
手游开发跟App开发相比,只不过是换了工作内容,关注的领域不同而已,基本原理是相通的。App开发需要用到的很多技术,游戏开发依然也需要用到,比如卡顿分析、网络、I/O优化。而且游戏的大部分核心逻辑库是与平台无关的,所以不必担心适应不了新的工作内容。下面我来总结一下转到手游开发的优势和劣势。
|
||||
|
||||
- **优势**
|
||||
|
||||
对手机系统有深入了解,能迅速切入一部分游戏团队内的工作内容。比如我刚刚转到手游团队时解决的第一个问题:游戏在Android系统上JNI找不到函数的Crash稳稳占据了每个版本的前三名,版本Crash率由于这个问题而一直保持在1%左右。当时由于团队对系统没有深入了解,所以并没有很好的解决办法。这个问题其实读一遍Android系统查找Native函数的代码,基本就能找到解决方案了。
|
||||
|
||||
- **劣势**
|
||||
|
||||
毕竟是两个不同的开发方向,转方向的同学也需要迎接压力。与毕业后直接进入游戏行业的同学相比,“半路出家”的同学相对缺乏原始技术积累,需要从基础的概念开始学起,比如上面提到的Draw Call、合图、OpenGL、Shader,需要消耗大量精力去学习。所以需要转方向的同学足够自律,主动补齐这些技术短板。
|
||||
|
||||
**2. 转方向后的技术路线**
|
||||
|
||||
有些同学说App开发者的技术路线宽,担心手游开发者将来技术路线会越来越窄,将来不好找其他工作。有这个担心是正常的,手游开发岗位的市场需求量比移动开发需求量小太多,但这不应该成为害怕转手游开发的理由。个人觉得需要从一个更宏观的角度看待这两个不同的开发岗位,不要钻在技术的牛角尖里无法自拔。技术通道无风险,而更应该担心的是游戏的政策风险。
|
||||
|
||||
小游戏在2017年和2018年真是火了一把,鉴于小游戏入门简单、上手快,想要转方向的同学可以先用小游戏来练手。“麻雀虽小,五脏俱全”,看明白一个小游戏的执行流程之后,相信你对游戏开发也就会有一个感性的认识了。
|
||||
|
||||
最后祝各位开发者游戏愉快!
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
209
极客时间专栏/Android开发高手课/模块三 架构演进/38 | 移动开发新大陆:Android音视频开发.md
Normal file
209
极客时间专栏/Android开发高手课/模块三 架构演进/38 | 移动开发新大陆:Android音视频开发.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="38 | 移动开发新大陆:Android音视频开发" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/92/eed11e99ea5938b47f2a3c5943a9af92.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文。俊杰目前负责微信的音视频开发工作,无论是我们平常经常使用到的小视频,还是最新推出的即刻视频,都是出自他手。俊杰在音视频方面有非常丰富的经验,今天有幸请到他来给我们分享他个人对Android音视频开发的心得和体会。
|
||||
|
||||
|
||||
>
|
||||
在日常生活中,视频类应用占据了我们越来越多的时间,各大公司也纷纷杀入这个战场,不管是抖音、快手等短视频类型,虎牙、斗鱼等直播类型,腾讯视频、爱奇艺、优酷等长视频类型,还是Vue、美拍等视频编辑美颜类型,总有一款适合你。
|
||||
|
||||
|
||||
>
|
||||
未来随着5G普及以及网络资费的下降,音视频的前景是非常广阔的。但是另一方面,无论是音视频的编解码和播放器、视频编辑和美颜的各种算法,还是视频与人工智能的结合([AI剪片](https://mp.weixin.qq.com/s/ZtsqF7bBODFymEaYFG6KAg)、视频修复、超清化等),它们都涉及了方方面面的底层知识,学习曲线比较陡峭,门槛相对比较高,所以也造成了目前各大公司音视频相关人才的紧缺。如果你对音视频开发感兴趣,我也非常建议你去往这个方向尝试,我个人是非常看好音视频开发这个领域的。
|
||||
|
||||
|
||||
>
|
||||
当然音视频开发的经验是靠着一次又一次的“填坑”成长起来的,下面我们一起来看看俊杰同学关于音视频的认识和思考。
|
||||
|
||||
|
||||
大家好,我是周俊杰,现在在微信从事Android多媒体开发。应绍文的邀请,分享一些我关于Android音视频开发的心得和体会。
|
||||
|
||||
不管作为开发者还是用户,现在我们每天都会接触到各种各样的短视频、直播类的App,与之相关的音视频方向的开发也变得越来越重要。但是对于大多数Android开发者来说,从事Android音视频相关的开发可能目前还算是个小众领域,虽然可能目前深入这个领域的开发者还不是太多,但这个方向涉及的知识点可一点都不少。
|
||||
|
||||
## 音视频的基础知识
|
||||
|
||||
**1. 音视频相关的概念**
|
||||
|
||||
说到音视频,先从我们熟悉也陌生的视频格式说起。
|
||||
|
||||
对于我们来说,最常见的视频格式就是[MP4](https://zh.wikipedia.org/zh-cn/MP4)格式,这是一个通用的容器格式。所谓容器格式,就意味内部要有对应的数据流用来承载内容。而且既然是一个视频,那必然有音轨和视轨,而音轨、视轨本身也有对应的格式。常见的音轨、视轨格式包括:
|
||||
|
||||
<li>
|
||||
视轨:[H.265(HEVC)](https://zh.wikipedia.org/wiki/%E9%AB%98%E6%95%88%E7%8E%87%E8%A7%86%E9%A2%91%E7%BC%96%E7%A0%81)、[H.264](https://zh.wikipedia.org/wiki/H.264/MPEG-4_AVC)。其中,目前大部分Android手机都支持H.264格式的直接硬件编码和解码;对于H.265来说,Android 5.0以上的机器就支持直接硬件解码了,但是对于硬件编码,目前只有一部分高端芯片可以支持,例如高通的8xx系列、华为的98x系列。对于视轨编码来说,分辨率越大性能消耗也就越大,编码所需的时间就越长。
|
||||
</li>
|
||||
<li>
|
||||
音轨:[AAC](https://zh.wikipedia.org/wiki/%E9%80%B2%E9%9A%8E%E9%9F%B3%E8%A8%8A%E7%B7%A8%E7%A2%BC)。这是一种历史悠久音频编码格式,Android手机基本可以直接硬件编解码,几乎很少遇到兼容性问题。可以说作为视频的音轨格式,AAC已经非常成熟了。
|
||||
</li>
|
||||
|
||||
对于编码本身,上面提到的这些格式都是[有损编码](https://zh.wikipedia.org/zh-hans/%E6%9C%89%E6%8D%9F%E6%95%B0%E6%8D%AE%E5%8E%8B%E7%BC%A9),因此压缩编码本身还需要一个衡量压缩之后,数据量多少的指标,这个标准就是[码率](https://zh.wikipedia.org/wiki/%E6%AF%94%E7%89%B9%E7%8E%87#%E5%A4%9A%E5%AA%92%E4%BD%93%E7%9A%84%E6%AF%94%E7%89%B9%E7%8E%87)。同一个压缩格式下,码率越高质量也就越好。更多Android本身支持的编解码格式,你可以参考[官方文档](https://developer.android.com/guide/topics/media/media-formats)。
|
||||
|
||||
小结一下,要拍摄一个MP4视频,我们需要将视轨 + 音轨分别编码,然后作为MP4的数据流,再合成出一个MP4文件。
|
||||
|
||||
**2. 音视频编码的流程**
|
||||
|
||||
接下来,我们再来看看一个视频是怎么拍摄出来的。首先,既然是拍摄,少不了跟摄像头、麦克风打交道。从流程来说,以H.264/AAC编码为例,录制视频的总体流程是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/73/406204beab2de273ecdb436d4bf66773.png" alt="">
|
||||
|
||||
我们分别从摄像头/录音设备采集数据,将数据送入编码器,分别编码出视轨/音轨之后,再送入合成器(MediaRemuxer或者类似mp4v2、FFmpeg之类的处理库),最终输出MP4文件。接下来,我主要以视轨为例,来介绍下编码的流程。
|
||||
|
||||
首先,直接使用系统的[MediaRecorder](https://developer.android.com/reference/android/media/MediaRecorder)录制整个视频,这是最简单的方法,直接就能输出MP4文件。但是这个接口可定制化很差,比如我们想录制一个正方形的视频,除非摄像头本身支持宽高一致的分辨率,否则只能后期处理或者各种Hack。另外,在实际App中,除非对视频要求不是特别高,一般也不会直接使用MediaRecorder。
|
||||
|
||||
视轨的处理是录制视频中相对比较复杂的部分,输入源头是Camera的数据,最终输出是编码的H.264/H.265数据。下面我来介绍两种处理模型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/82/fb9fe38693f3a86af745b97fc8d22882.png" alt="">
|
||||
|
||||
第一种方法是利用Camera获取摄像头输出的原始数据接口(例如onPreviewFrame),经过预处理,例如缩放、裁剪之后,送入编码器,输出H.264/H.265。
|
||||
|
||||
摄像头输出的原始数据格式为NV21,这是[YUV](https://zh.wikipedia.org/wiki/YUV)颜色格式的一种。区别于RGB颜色,YUV数据格式占用空间更少,在视频编码领域使用十分广泛。
|
||||
|
||||
一般来说,因为摄像头直接输出的NV21格式大小跟最终视频不一定匹配,而且编码器往往也要求输入另外一种YUV格式(一般来说是YUV420P),因此在获取到NV21颜色格式之后,还需要进行各种缩放、裁剪之类的操作,一般会使用[FFmpeg](https://www.ffmpeg.org/)、[libyuv](https://chromium.googlesource.com/libyuv/libyuv/)这样的库处理YUV数据。
|
||||
|
||||
最后会将数据送入到编码器。在视频编码器的选择上,我们可以直接选择系统的MediaCodec,利用手机本身的硬件编码能力。但如果对最终输出的视频大小要求比较严格的话,使用的码率会偏低,这种情况下大部分手机的硬件编码器输出的画质可能会比较差。另外一种常见的选择是利用[x264](https://www.videolan.org/developers/x264.html)来进行编码,画质表现相对较好,但是比起硬件编码器,速度会慢很多,因此在实际使用时最好根据场景进行选择。
|
||||
|
||||
除了直接处理摄像头原始数据以外,还有一种常见的处理模型,利用Surface作为编码器的输入源。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/06/5003363cbda442463e50b7c6a245b306.png" alt="">
|
||||
|
||||
对于Android摄像头的预览,需要设置一张Surface/SurfaceTexture来作为摄像头预览数据的输出,而MediaCodec在API 18+之后,可以通过[createInputSurface](https://developer.android.com/reference/android/media/MediaCodec#createInputSurface)来创建一张Surface作为编码器的输入。这里所说的另外一种方式就是,将摄像头预览Surface的内容,输出到MediaCodec的InputSurface上。
|
||||
|
||||
而在编码器的选择上,虽然InputSurface是通过MediaCodec来创建的,乍看之下似乎只能通过MediaCodec来进行编码,无法使用x264来编码,但利用PreviewSurface,我们可以创建一个OpenGL的上下文,这样所有绘制的内容,都可以通过glReadPixel来获取,然后再讲读取数据转换成YUV再输入到x264即可(另外,如果是在GLES 3.0的环境,我们还可以利用[PBO](http://www.songho.ca/opengl/gl_pbo.html)来加速glReadPixles的速度)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/cb/98ae4dbac4cac80639d7dfe4682414cb.png" alt="">
|
||||
|
||||
由于这里我们创建了一个OpenGL的上下文,对于目前的视频类App来说,还有各种各样的滤镜和美颜效果,实际上都可以基于OpenGL来实现。
|
||||
|
||||
而至于这种方式录制视频具体实现代码,你可以参考下grafika中[示例](https://github.com/google/grafika/blob/master/app/src/main/java/com/android/grafika/CameraCaptureActivity.java)。
|
||||
|
||||
## 视频处理
|
||||
|
||||
**1. 视频编辑**
|
||||
|
||||
在当下视频类App中,你可以见到各种视频裁剪、视频编辑的功能,例如:
|
||||
|
||||
<li>
|
||||
裁剪视频的一部分。
|
||||
</li>
|
||||
<li>
|
||||
多个视频进行拼接。
|
||||
</li>
|
||||
|
||||
对于视频裁剪、拼接来说,Android直接提供了[MediaExtractor](https://developer.android.com/reference/android/media/MediaExtractor)的接口,结合seek以及对应读取帧数据readSampleData的接口,我们可以直接获取对应时间戳的帧的内容,这样读取出来的是已经编码好的数据,因此无需重新编码,直接可以输入合成器再次合成为MP4。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/ad/61b18d372ba93905b547d60da313a1ad.png" alt="">
|
||||
|
||||
我们只需要seek到需要裁剪原视频的时间戳,然后一直读取sampleData,送入MediaMuxer即可,这是视频裁剪最简单的实现方式。
|
||||
|
||||
但在实践时会发现,[seekTo](https://developer.android.com/reference/android/media/MediaExtractor#seekTo)并不会对所有时间戳都生效。比如说,一个**4min**左右的视频,我们想要seek到大概**2min**左右的位置,然后从这个位置读取数据,但实际调用seekTo到2min这个位置之后,再从MediaExtractor读取数据,你会发现实际获取的数据上可能是从2min这里前面一点或者后面一点位置的内容。这是因为MediaExtractor这个接口只能seek到视频[关键帧](https://zh.wikipedia.org/wiki/%E9%97%9C%E9%8D%B5%E6%A0%BC#%E8%A6%96%E8%A8%8A%E7%B7%A8%E8%BC%AF%E7%9A%84%E9%97%9C%E9%8D%B5%E6%A0%BC)的位置,而我们想要的位置并不一定有关键帧。这个问题还是要回到视频编码,在视频编码时两个关键帧之间是有一定间隔距离的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/7b/9a59d62bc13f09d9ccf971ce6d8d5b7b.jpg" alt="">
|
||||
|
||||
如上图所示,关键帧被成为**I帧**,可以被认为是一帧没有被压缩的画面,解码的时候无需要依赖其他视频帧。但是在两个关键帧之间,还存在这**B帧**、**P帧**这样的压缩帧,需要依赖其他帧才能完整解码出一个画面。至于两个关键帧之间的间隔,被称为一个[GOP](https://zh.wikipedia.org/wiki/%E5%9C%96%E5%83%8F%E7%BE%A4%E7%B5%84) ,在GOP内的帧,MediaExtractor是无法直接seek到的,因为这个类不负责解码,只能seek到前后的关键帧。但如果GOP过大,就会导致视频编辑非常不精准了(实际上部分手机的ROM有改动,实现的MediaExtractor也能精确seek)。
|
||||
|
||||
既然如此,那要实现精确裁剪也就只能去依赖解码器了。解码器本身能够解出所有帧的内容,在引入解帧之后,整个裁剪的流程就变成了下面的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/53/89f6cf7d68677a1a6225905117877853.png" alt="">
|
||||
|
||||
我们需要先seek到需要位置的前一I帧上,然后送入解码器,解码器解除一帧之后,判断当前帧的[PTS](https://en.wikipedia.org/wiki/Presentation_timestamp)是否在需要的时间戳范围内,如果是的话,再将数据送入编码器,重新编码再次得到H.264视轨数据,然后合成MP4文件。
|
||||
|
||||
上面是基础的视频裁剪流程,对于视频拼接,也是类似得到多段H.264数据之后,才一同送入合成器。
|
||||
|
||||
另外,在实际视频编辑中,我们还会添加不少视频特效和滤镜。前面在视频拍摄的场景下,我们利用Surface作为MediaCodec的输入源,并且利用Surface创建了OpenGL的上下文。而MediaCodec作为解码器的时候,也可以在[configure](https://developer.android.com/reference/android/media/MediaCodec#configure)的时候,指定一张Surface作为其解码的输出。大部分视频特效都是可以通过OpenGL来实现的,因此要实现视频特效,一般的流程是下面这样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/5d/0f4a26fd743155b102887758056ba85d.png" alt="">
|
||||
|
||||
我们将解码之后的渲染交给OpenGL,然后输出到编码器的InputSurface上,来实现整套编码流程。
|
||||
|
||||
**2. 视频播放**
|
||||
|
||||
任何视频类App都会涉及视频播放,从录制、剪辑再到播放,构成完整的视频体验。对于要播放一个MP4文件,最简单的方式莫过于直接使用系统的[MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer),只需要简单几行代码,就能直接播放视频。对于本地视频播放来说,这是最简单的实现方式,但实际上我们可能会有更复杂的需求:
|
||||
|
||||
<li>
|
||||
需要播放的视频可能本身并不在本地,很多可能都是网络视频,有边下边播的需求。
|
||||
</li>
|
||||
<li>
|
||||
播放的视频可能是作为视频编辑的一部分,在剪辑时需要实时预览视频特效。
|
||||
</li>
|
||||
|
||||
对于第二种场景,我们可以简单配置播放视频的View为一个GLSurfaceView,有了OpenGL的环境,我们就可以在这上实现各种特效、滤镜的效果了。而对于视频编辑常见的快进、倒放之类的播放配置,MediaPlayer也有直接的接口可以设置。
|
||||
|
||||
更为常见的是第一种场景,例如一个视频流界面,大部分视频都是在线视频,虽然MediaPlayer也能实现在线视频播放,但实际使用下来,会有两个问题:
|
||||
|
||||
<li>
|
||||
通过设置MediaPlayer视频URL方式下载下来的视频,被放到了一个私有的位置,App不容易直接访问,这样会导致我们没法做视频预加载,而且之前已经播放完、缓冲完的视频,也不能重复利用原有缓冲内容。
|
||||
</li>
|
||||
<li>
|
||||
同视频剪辑直接使用MediaExtractor返回的数据问题一样,MediaPlayer同样无法精确seek,只能seek到有关键帧的地方。
|
||||
</li>
|
||||
|
||||
对于第一个问题,我们可以通过视频URL代理下载的方式来解决,通过本地使用Local HTTP Server的方式代理下载到一个指定的地方。现在开源社区已经有很成熟的项目实现,例如[AndroidVideoCache](http://AndroidVideoCache)。
|
||||
|
||||
而对于第二个问题来说,没法精确seek的问题在有些App上是致命的,产品可能无法接受这样的体验。那同视频编辑一样,我们只能直接基于MediaCodec来自行实现播放器,这部分内容就比较复杂了。当然你也可以直接使用Google开源的[ExoPlayer](https://github.com/google/ExoPlayer),简单又快捷,而且也能支持设置在线视频URL。
|
||||
|
||||
看似所有问题都有了解决方案,是不是就万事大吉了呢?
|
||||
|
||||
常见的网络边下边播视频的格式都是MP4,但有些视频直接上传到服务器上的时候,我们会发现无论是使用MediaPlayer还是ExoPlayer,似乎都只能等待到整个视频都下载完才能开始播放,没有达到边下边播的体验。这个问题的原因实际上是因为MP4的格式导致的,具体来看,是跟MP4[格式](http://l.web.umkc.edu/lizhu/teaching/2016sp.video-communication/ref/mp4.pdf)中的moov有关。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/42/6439de8111b1ad7173291c0723071142.png" alt="">
|
||||
|
||||
MP4格式中有一个叫作moov的地方存储这当前MP4文件的元信息,包括当前MP4文件的音轨视轨格式、视频长度、播放速率、视轨关键帧位置偏移量等重要信息,MP4文件在线播放的时候,需要moov中的信息才能解码音轨视轨。
|
||||
|
||||
而上述问题发生的原因在于,当moov在MP4文件尾部的时候,播放器没有足够的信息来进行解码,因此视频变得需要直接下载完之后才能解码播放。因此,要实现MP4文件的边下边播,则需要将moov放到文件头部。目前来说,业界已经有非常成熟的工具,[FFmpeg](https://ffmpeg.org/ffmpeg-formats.html#Options-8)跟[mp4v2](https://code.google.com/archive/p/mp4v2/)都可以将一个MP4文件的moov提前放到文件头部。例如使用FFmpeg,则是如下命令:
|
||||
|
||||
```
|
||||
ffmpeg -i input.mp4 -movflags faststart -acodec copy -vcodec copy output.mp4
|
||||
|
||||
```
|
||||
|
||||
使用`-movflags faststart`,我们就可以把视频文件中的moov提前了。
|
||||
|
||||
另外,如果想要检测一个MP4的moov是否在前面,可以使用类似[AtomicParsley](http://atomicparsley.sourceforge.net/)的工具来检测。
|
||||
|
||||
在视频播放的实践中,除了MP4格式来作为边下边播的格式以外,还有更多的场景需要使用其他格式,例如m3u8、FLV之类,业界在客户端中常见的实现包括[ijkplayer](https://github.com/bilibili/ijkplayer)、[ExoPlayer](https://github.com/google/ExoPlayer),有兴趣的同学可以参考下它们的实现。
|
||||
|
||||
## 音视频开发的学习之路
|
||||
|
||||
音视频相关开发涉及面很广,今天我也只是简单介绍一下其中基本的架构,如果想继续深入这个领域发展,从我个人学习的经历来看,想要成为一名合格的开发者,除了基础的Android开发知识以外,还要深入学习,我认为还需要掌握下面的技术栈。
|
||||
|
||||
**语言**
|
||||
|
||||
<li>
|
||||
C/C++:音视频开发经常需要跟底层代码打交道,掌握C/C++是必须的技能。这方面资料很多,相信我们都能找到。
|
||||
</li>
|
||||
<li>
|
||||
ARM NEON汇编:这是一项进阶技能,在视频编解码、各种帧处理低下时很多都是利用NEON汇编加速,例如FFmpeg/libyuv底层都大量利用了NEON汇编来加速处理过程。虽说它不是必备技能,但有兴趣也可以多多了解,具体资料可以参考ARM社区的[教程](https://community.arm.com/processors/b/blog/posts/coding-for-neon---part-1-load-and-stores)。
|
||||
</li>
|
||||
|
||||
**框架**
|
||||
|
||||
<li>
|
||||
[FFmpeg](https://ffmpeg.org/):可以说是业界最出名的音视频处理框架了,几乎囊括音视频开发的所有流程,可以说是必备技能。
|
||||
</li>
|
||||
<li>
|
||||
[libyuv](https://chromium.googlesource.com/libyuv/libyuv/):Google开源的YUV帧处理库,因为摄像头输出、编解码输入输出也是基于YUV格式,所以也经常需要这个库来操作数据(FFmpeg也有提供了这个库里面所有的功能,在[libswscale](https://www.ffmpeg.org/doxygen/2.7/swscale_8h.html)都可以找到类似的实现。不过这个库性能更好,也是基于NEON汇编加速)。
|
||||
</li>
|
||||
<li>
|
||||
[libx264](https://www.videolan.org/developers/x264.html)/[libx265](http://x265.org/):目前业界最为广泛使用的H.264/H.265软编解码库。移动平台上虽然可以使用硬编码,但很多时候出于兼容性或画质的考虑,因为不少低端的Android机器,在低码率的场景下还是软编码的画质会更好,最终可能还是得考虑使用软编解码。
|
||||
</li>
|
||||
<li>
|
||||
[OpenGL ES](https://www.khronos.org/opengles/):当今,大部分视频特效、美颜算法的处理,最终渲染都是基于GLES来实现的,因此想要深入音视频的开发,GLES是必备的知识。另外,除了GLES以外,[Vulkan](https://www.khronos.org/vulkan/)也是近几年开始发展起来的一个更高性能的图形API,但目前来看,使用还不是特别广泛。
|
||||
</li>
|
||||
<li>
|
||||
[ExoPlayer](https://github.com/google/ExoPlayer)/[ijkplayer](https://github.com/bilibili/ijkplayer):一个完整的视频类App肯定会涉及视频播放的体验,这两个库可以说是当下业界最为常用的视频播放器了,支持众多格式、协议,如果你想要深入学习视频播放处理,它们几乎也算是必备技能。
|
||||
</li>
|
||||
|
||||
从实际需求出发,基于上述技术栈,我们可以从下面两条路径来深入学习。
|
||||
|
||||
**1. 视频相关特效开发**
|
||||
|
||||
直播、小视频相关App目前越来越多,几乎每个App相关的特效,往往都是利用OpenGL本身来实现。对于一些简单的特效,可以使用类似[Color Look Up Table](https://en.wikipedia.org/wiki/3D_lookup_table)的技术,通过修改素材配合Shader来查找颜色替换就能实现。如果要继续学习更加复杂的滤镜,推荐你可以去[shadertoy](https://www.shadertoy.com/)学习参考,上面有非常多Shader的例子。
|
||||
|
||||
而美颜、美型相关的效果,特别是美型,需要利用人脸识别获取到关键点,对人脸纹理进行三角划分,然后再通过Shader中放大、偏移对应关键点纹理坐标来实现。如果想要深入视频特效类的开发,我推荐可以多学习OpenGL相关的知识,这里会涉及很多优化点。
|
||||
|
||||
**2. 视频编码压缩算法**
|
||||
|
||||
H.264/H.265都是非常成熟的视频编码标准,如何利用这些视频编码标准,在保证视频质量的前提下,将视频大小最小化,从而节省带宽,这就需要对视频编码标准本身要有非常深刻的理解。这可能是一个门槛相对较高的方向,我也尚处学习阶段,有兴趣的同学可以阅读相关编码标准的文档。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
136
极客时间专栏/Android开发高手课/模块三 架构演进/39 | 移动开发新大陆: 边缘智能计算的趋势.md
Normal file
136
极客时间专栏/Android开发高手课/模块三 架构演进/39 | 移动开发新大陆: 边缘智能计算的趋势.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<audio id="audio" title="39 | 移动开发新大陆: 边缘智能计算的趋势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/42/3464f2a7af5033f49be15faf6b237842.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文。今天文章的作者黄振是算法领域的专家,过去曾经和他合作过移动端的AI项目,无论是他的算法水平,还是工程上的能力,都让我深感佩服。今天我非常幸运地请到他来给我们分享关于移动边缘智能计算的认识。
|
||||
|
||||
|
||||
>
|
||||
如果说过去几年移动开发是那头在风口上的猪,现在这个风口明显已经转移到AI,最明显的是国内应届生都纷纷涌向了AI方向。那作为移动开发,我们如何在AI浪潮之下占据自己的一席之地?
|
||||
|
||||
|
||||
>
|
||||
以我个人看法来看,即使目前AI在移动端的落地场景有限,但是AI这个“坑”我们是一定要跳的。我们可以尝试使用TensorFlow这些框架去做一些简单的Demo,逐步加深对深度学习的理解。
|
||||
|
||||
|
||||
>
|
||||
Google的[TensorFlow Lite](https://www.tensorflow.org/lite)、Facebook的[Cafe2](https://caffe2.ai/)、腾讯的[NCNN](https://github.com/Tencent/ncnn)和[FatherCNN](https://github.com/Tencent/FeatherCNN)、小米的[MACE](https://github.com/XiaoMi/mace)、百度的[Paddle-Mobile](https://github.com/PaddlePaddle/paddle-mobile),各个公司都开源了自己的移动深度学习框架。移动端未来必然是深度学习一个非常重要的战场,移动开发们需要发挥自己的平台特长。我们对于这些移动深度学习框架的优化应该更有发言权,要做到既有算法思维,也有更加强大的工程能力。就像之前黄振合作的项目中,整个框架的性能优化也是由我们来主导,使用了大量ARM NEON指令和汇编进行优化。
|
||||
|
||||
|
||||
大家好,我是黄振,目前在一家大型互联网公司从事算法工作。我一直在关注边缘智能计算,正好过去也开展过移动端机器学习的项目,期间也接触了不少Android开发的同学。在一起合作期间,我也在思考AI和Android开发可以相结合的点,也看到团队中不少Android开发同学展现出的机器学习技术的开发能力。正好应绍文邀请,在Android开发专栏和你分享一下我对移动端机器学习的认识,希望对你有所帮助。
|
||||
|
||||
目前,技术的发展有两个趋势。一个趋势是,随着5G网络的发展,物联网使“万物互联”成为可能。物联网时代,也是云计算和边缘计算并存的世界,且边缘计算将扮演重要的角色。边缘计算是与云计算相反的计算范式,指的是在网络边缘节点(终端设备)进行数据处理和分析。边缘计算之所以重要,原因在于:首先,在物联网时代,将有数十亿的终端设备持续采集数据,带来的计算量将是云计算所不能承受的;其次,终端设备的计算能力不断提高,并不是所有的计算都需要在云端完成;再有,将数据传回云端计算再将结果返回终端的模式,不可避免存在时延,不仅影响用户体验,有些场景也是不可接受的;最后,随着用户对数据安全和隐私的重视,也会要求在终端进行数据加工和计算。
|
||||
|
||||
另一个趋势是,人工智能技术的迅速发展。最近几年,以深度学习为代表的人工智能技术取得了突破性的进展,不仅在社会上引起了人们的兴趣和关注,同时也正在重塑商业的格局。人工智能技术的重大价值在于,借助智能算法和技术,将之前必须人工才能完成的工作进行机器化,用机器的规模化解放人工,突破人力生产要素的瓶颈,从而大幅提高生产效率。在商业社会,“规模化”技术将释放巨大的能量、创造巨大的价值,甚至重塑商业的竞争,拓展整个商业的外部边界。
|
||||
|
||||
边缘计算既然有计算能力,在很多应用场景中就会产生智能计算的需求,两者结合在一起,就形成了边缘智能计算。
|
||||
|
||||
## 移动端机器学习的开发技术
|
||||
|
||||
目前,移动端机器学习的应用技术主要集中在图像处理、自然语言处理和语音处理。在具体的应用上,包括但不限于视频图像的物体检测、语言翻译、语音助手、美颜等,在自动驾驶、教育、医疗、智能家居和物联网等方面有巨大的应用需求。
|
||||
|
||||
**1. 计算框架**
|
||||
|
||||
因为深度学习算法具有很多特定的算法算子,为了提高移动端开发和模型部署的效率,各大厂都开发了移动端深度学习的计算框架,例如谷歌的[TensorFlow Lite](https://www.tensorflow.org/lite)、Facebook的[Caffe2](https://caffe2.ai/)等,Android开发的同学还是挺有必要去了解这些计算框架的。为了培养兴趣这方面的兴趣并获得感性的认识,你可以挑选一个比较成熟的大厂框架,例如TensorFlow Lite,在手机开发一个物体检测的Demo练练手。
|
||||
|
||||
在实际项目中,TensorFlow Lite和Caffe2等通常运行比较慢。在网上可以很容易找到各个计算框架的对比图。如果要真正入门的话,在这里我推荐大家使用[NCNN](https://github.com/Tencent/ncnn)框架。NCNN是腾讯开源的计算框架,优点比较明显,代码结构清晰、文件不大,而且运行效率高。强烈推荐有兴趣的Android开发同学把源码阅读几遍,不仅有助于理解深度学习常用的算法,也有助于理解移动端机器学习的计算框架。
|
||||
|
||||
阅读NCNN源码抓住三个最基础的数据结构,Mat、Layer和Net。其中,Mat用于存储矩阵的值,神经网络中的每个输入、输出以及权重,都是用Mat来存储的。Layer实际上表示操作,所以每个Layer都必须有前向操作函数(forward函数),所有算子,例如,卷积操作(convolution)、LSTM操作等都是从Layer派生出来的。Net用来表示整个网络,将所有数据节点和操作结合起来。
|
||||
|
||||
在阅读NCNN源码的同时,建议大家也看些关于卷积神经网络算法的入门资料,会有助于理解。
|
||||
|
||||
**2. 计算性能优化**
|
||||
|
||||
在实际项目中,如果某个路径成为时间开销的瓶颈,通常可以将该节点用NDK去实现。但在通常情况下,移动端机器学习的计算框架已经是NDK实现的,这时改进的方向是采用ARM NEON指令+汇编进行优化。
|
||||
|
||||
NEON是适用于ARM Cortex-A系列处理器的一种128Bit SIMD(Single Instruction, Multiple Data,单指令、多数据)扩展结构。ARM NEON指令之所有能达到性能优化的目的,关键就在于单指令多数据。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/ae/503ce6dbbd5ace49e5bd94ab5459b5ae.png" alt="">
|
||||
|
||||
两个操作数各有128Bit,各包含4个32Bit的同类型的寄存器,通过如下所示的一条指令,就可以实现4个32Bit数据的并行计算,从而达到性能优化的效果。
|
||||
|
||||
```
|
||||
VADDQ.S32 Q0,Q1,Q2
|
||||
|
||||
```
|
||||
|
||||
我们曾经用NDK的方式实现了深度学习算法中的PixelShuffle操作,后来采用ARM NEON+汇编优化的方式,计算的效率提升40倍,效果还是很显著的。
|
||||
|
||||
如果还想进一步提高计算的性能,可以采用Int 8量化的方法。在深度学习里面,大多数运算都是基于Float 32类型进行的,Float 32是32Bit,128Bit的寄存器一次可以存储4个Float 32类型数据;相比之下,Int 8类型的数据可以存储16个。结合前面提到的单指令多数据,如果采用Int 8类型的数据,单条指令可以同时执行16个Int 8数据的运算,从而大大提高了并行性。
|
||||
|
||||
但是,将Float 32类型的数值量化到Int 8表达,不可避免会影响到数据的精度,而且量化的过程也是需要时间开销的,这些都是需要注意的地方。关于量化的方法,感兴趣的同学可以阅读这篇[论文](https://arxiv.org/abs/1806.08342)。
|
||||
|
||||
如果设备具有GPU,还可以应用OpenCL进行GPU加速,例如小米开源的移动端机器学习框架[MACE](https://github.com/XiaoMi/mace)。
|
||||
|
||||
## 移动端机器学习的算法技术
|
||||
|
||||
对于刚开始学习算法的同学,我一直主张不要把算法想象得太复杂,也不要想得太数学化,否则容易让人望而生畏。数学是思维逻辑的表达,我们希望用数学帮助我们理解算法。我们要能够达到对算法的直观理解,从直观上知道和理解为什么这个算法会有这样的效果,只有这样才算是真正掌握了算法。相反,对于一个算法,如果你只记住了数学推导,没形成直观的理解,是不能够灵活应用的。
|
||||
|
||||
**1. 算法设计**
|
||||
|
||||
深度学习的图像处理具有很广的应用,而且比较直观和有趣,建议你可以从深度学习的图像处理入手。深度学习的图像处理,最基本的知识是卷积神经网络,所以你可以先学习卷积神经网络。网上有很多关于卷积神经网络的介绍,这里就不赘述了。
|
||||
|
||||
理解卷积神经网络关键是理解卷积神经网络的学习机制,理解为什么能够学习。想要理解这一点,首先需要明确两个关键点“前向传播”和“反向传播”。整个神经网络在结构和激活函数确定之后,所谓“训练”或者“学习”的过程,其实就是在不断地调整神经网络的每个权重,让整个网络的计算结果趋近于期望值(目标值)。前向传播是从输入端开始计算,目标是得到网络的输出结果,再将输出结果和目标值相比较,得到结果误差。之后将结果误差沿着网络结构反向传播拆解到每个节点,得到每个节点的误差,然后根据每个节点的误差调整该节点的权重。“前向传播”的目的是得到输出结果,“反向传播”的目的是通过反向传播误差来调整权重。通过两者互相交替迭代,希望达到输出结果和目标值一致。
|
||||
|
||||
理解卷积神经网络之后,我们可以动手实现手写字体识别的示例。掌握之后,可以接着学习深度学习里的物体检测算法,例如YOLO、Faster R-CNN等,最后可以动手用TensorFlow都写一遍,跑跑训练数据、调调参数,边动手边理解。在学习过程中,要特别留意算法模型的设计思想和解决思路。
|
||||
|
||||
**2. 效果优化**
|
||||
|
||||
效果优化指提升算法模型的准确率等指标,通常的方式有以下几种:
|
||||
|
||||
<li>
|
||||
优化训练数据
|
||||
</li>
|
||||
<li>
|
||||
优化算法设计
|
||||
</li>
|
||||
<li>
|
||||
优化模型训练方式
|
||||
</li>
|
||||
|
||||
**优化训练数据**
|
||||
|
||||
因为算法模型是从训练数据中学习的,模型无法学习到训练数据之外的模式。所以,在选择训练数据时要特别小心,必须使得训练数据包含实际场景中会出现的模式。精心挑选或标注训练数据,会有效提升模型的效果。训练数据的标注对效果非常重要,以至于有创业公司专门从事数据标注,还获得了不少融资。
|
||||
|
||||
**优化算法设计**
|
||||
|
||||
根据问题采用更好的算法模型,采用深度学习模型而不是传统的机器学习模型,采用具有更高特征表达能力的模型等,例如使用残差网络或DenseNet提高网络的特征表达能力。
|
||||
|
||||
**优化模型训练方式**
|
||||
|
||||
优化模型训练方式包括采用哪种损失函数、是否使用正则项、是否使用Dropout结构、使用哪种梯度下降算法等。
|
||||
|
||||
**3. 计算量优化**
|
||||
|
||||
虽然我们在框架侧做了大量的工作来提高计算性能,但是如果在算法侧能够减少计算量,那么整体的计算实时性也会提高。从模型角度,减少计算量的思路有两种,一种是设计轻量型网络模型,一种是对模型进行压缩。
|
||||
|
||||
**轻量型网络设计**
|
||||
|
||||
学术界和工业界都设计了轻量型卷积神经网络,在保证模型精度的前提下,大幅减少模型的计算量,从而减少模型的计算开销。这种思路的典型代表是谷歌提出的[MobileNet](https://blog.csdn.net/u011974639/article/details/79199306),从名字上也可以看出设计的目标是移动端使用的网络结构。MobileNet是将标准的卷积神经网络运算拆分成Depthwise卷积运算和Pointwise卷积运算,先用Depthwise卷积对输入各个通道分别进行卷积运算,然后用Pointwise卷积实现各个通道间信息的融合。
|
||||
|
||||
**模型压缩**
|
||||
|
||||
模型压缩包括结构稀疏化和蒸馏两种方式。
|
||||
|
||||
在逻辑回归算法中,我们通过引入正则化,使得某些特征的系数近似为0。在卷积神经网络中,我们也希望通过引入正则化,使得卷积核的系数近似为0。与普通的正则化不同的是,在结构稀疏化中,我们希望正则化实现结构性的稀疏,比如某几个通道的卷积核的系数全部近似为0,从而可以将这几个通道的卷积核剪枝掉,减少不必要的计算开销。
|
||||
|
||||
蒸馏方法有迁移学习的意思,就是设计一个简单的网络,通过训练的方式,使得该简单的网络具有目标网络近似的表示能力,从而达到“蒸馏”的效果。
|
||||
|
||||
## Android开发同学的机会
|
||||
|
||||
移动端机器学习的计算框架和算法,前者负责模型计算的性能,减少时间开销;后者主要负责模型的精度,还可以通过一些算法设计减少算法的计算量,从而达到减少时间开销的目的。
|
||||
|
||||
需要注意的是,在移动端机器学习中,**算法模型的训练通常是在服务器端进行的**。目前,终端设备通常不负责模型的训练。在使用时,由终端设备加载训练结果模型,执行前向计算得到模型的计算结果。
|
||||
|
||||
但是前面讲了那么多行业趋势和机器学习的基本技术,那对于移动开发的同学来说,如何进入这个“热门”的领域呢?移动端机器学习是边缘智能计算范畴的一个领域,而且移动端开发是Android开发同学特别熟悉的领域,所以这也是Android开发同学的一个发展机会,转型进入边缘智能计算领域。Android开发同学可以发挥自己的技术专业优势,先在边缘计算的终端设备程序开发中站稳脚跟,在未来的技术分工体系中有个坚固的立足点;同时,逐步学习深度学习算法,以备将来往前迈一步,进入边缘智能计算领域,创造更高的技术价值。
|
||||
|
||||
可能在大部分情况下,Android开发同学在深度学习算法领域,跟专业的算法同学相比,不具有竞争优势,所以我们千万不要放弃自己所专长的终端设备的开发经验。对大多数Android开发同学而言,“专精Android开发 + 懂深度学习算法”才是在未来技术分工中,创造最大价值的姿势。
|
||||
|
||||
对于**学习路径**,我建议Android开发同学可以先学习卷积神经网络的基础知识(结构、训练和前向计算),然后阅读学习NCNN开源框架,掌握计算性能的优化方法,把开发技术掌握好。同时,可以逐步学习算法技术,主要学习各种常见的深度学习算法模型,并重点学习近几年出现轻量型神经网络算法。总之,Android开发同学要重点掌握提高计算实时性的开发技术和算法技术,兼顾学习深度学习算法模型。
|
||||
|
||||
基于前面的描述,我梳理了移动端机器学习的技术大图供你参考。图中红圈的部分,是我建议Android开发同学重点掌握的内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/96/141be237a4475c362daa1aa3be794996.png" alt="">
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
177
极客时间专栏/Android开发高手课/模块三 架构演进/40 | 动态化实践,如何选择适合自己的方案?.md
Normal file
177
极客时间专栏/Android开发高手课/模块三 架构演进/40 | 动态化实践,如何选择适合自己的方案?.md
Normal file
@@ -0,0 +1,177 @@
|
||||
<audio id="audio" title="40 | 动态化实践,如何选择适合自己的方案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/96/2513a3decd8de8dca0ef60cea74da296.mp3"></audio>
|
||||
|
||||
在专栏第36期[《跨平台开发的现状与应用》](https://time.geekbang.org/column/article/88161)中,我分享了H5、React Native/Weex、小程序这几种常见的跨平台开发方式。站在开发的角度,虽然跨平台的开发效率要比Native开发更高,但是这并不是大前端在国内盛行的最主要原因。
|
||||
|
||||
相比跨平台能力,国内对大前端的动态化能力更加偏执。这是为什么呢?移动互联网已经发展十年了,随着业务成熟和功能的相对稳定,整体重心开始偏向运营,强烈的运营需求对客户端架构和发布模式都提出了更高的要求。如果每个修改都需要经历开发、上线、版本覆盖等漫长的过程,根本无法达到快速响应的要求。
|
||||
|
||||
所以H5、React Native/Weex、小程序在国内的流行,可以说是动态化能力远比跨平台能力重要。那我们应该选择哪一种动态化方式呢?正如我在跨平台开发所说的,目前这几种方案或多或少都还存在一些性能问题,如果一定要使用Native开发方式,又有哪些动态化方案?今天我们一起来学习应该如何选择适合自己的动态化方案。
|
||||
|
||||
## 动态化实践的背景
|
||||
|
||||
前几天在朋友圈看到说淘宝iOS客户端上一个版本的更新,已经是两个多月前的事情了。淘宝作为一个业务异常庞大且复杂的电商平台,这样的发布节奏在过去是很难想象的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/7d/bb60313024c59b2ed4b4430396e25f7d.png" alt="">
|
||||
|
||||
而现在即使不通过发布新版本,我们也能实现各式各样的运营活动和个性化推荐。依赖客户端的动态化能力,我们不需要等待应用商店审核,也无须依赖用户的主动更新,产品在快速迭代的同时,也有着非常强大的试错能力。
|
||||
|
||||
**1. 常见的动态化方案**
|
||||
|
||||
移动端动态化方案在最近几年一直是大家关注的重点,虽然它已经发展了很多年,但是每年都会有新的变化,这里我们先来看看各大公司有哪些已知的动态化方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/73/27f332588dae2d5f4d1a210e3e77dc73.jpg" alt="">
|
||||
|
||||
在2018年北京QCon大会上,美团工程师分享了他们在动态化的实践[《美团客户端动态化实践》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/Qcon%E5%8C%97%E4%BA%AC2018--%E3%80%8A%E7%BE%8E%E5%9B%A2%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%8A%A8%E6%80%81%E5%8C%96%E5%AE%9E%E8%B7%B5%E3%80%8B--%E6%96%B9%E9%94%A6%E6%B6%9B.pdf)。美团作为一个强运营的应用,对动态化有非常强烈的诉求,也有着非常丰富的实践经验,他们将动态化方案分为下面四种类型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/e0/5e851695fbc022bc80a9ce634b5b34e0.png" alt="">
|
||||
|
||||
<li>
|
||||
**Web容器增强**。基于H5实现,但是还有离线包等各种优化手段加持,代表方案有PWA、腾讯的VasSonic、淘宝的zCache以及大部分的小程序方案。
|
||||
</li>
|
||||
<li>
|
||||
**虚拟运行环境**。使用独立的虚拟机运行,但最终使用原生控件渲染,代表方案有React Native、Weex、快应用等。
|
||||
</li>
|
||||
<li>
|
||||
**业务插件化**。基于Native的组件化开发,这种方式在淘宝、支付宝、美团、滴滴、360等航母应用上十分常见。代表方案有阿里的Atlas、360的RePlugin、滴滴的VirtualAPK等。除此之外,我认为各个热修复框架应该也属于业务插件化的一种类型,例如微信的Tinker、美团的Robust、阿里的AndFix。
|
||||
</li>
|
||||
<li>
|
||||
**布局动态化**。插件化或者热修复虽然可以做到页面布局和数据的动态修改,但是代价巨大,而且也不容易实现个性化运营。为了实现“千人千面”,淘宝和美团的首页结构都可以通过动态配置更新。代表的方案有阿里的Tangram、Facebook的Yoga。
|
||||
</li>
|
||||
|
||||
**2. 动态化方案的选择**
|
||||
|
||||
四大动态化方案哪家强,我们又应该如何选择?在回答这个问题之前,我们先来看看它们的差别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/67/2b7dcceb9c990918e09e2f0ddd05a967.jpg" alt="">
|
||||
|
||||
目前我们还无法找到一种“十全十美”的动态化方案,每种方案都有自己的优缺点和对应的使用场景。比如Web容器增强方案在动态化能力、开发效率上有着非常大的优势,但稳定性和流畅度差强人意。恰恰相反,布局动态化方案在性能上面有非常不错的表现,但是在动态化能力和开发效率上面却受到不少限制。
|
||||
|
||||
所以说动态方案的选择,我们需要考虑下面这些因素。
|
||||
|
||||
<li>
|
||||
**业务类型**。主要考虑业务的重要性、交互是否复杂、对性能的要求、是否长期迭代等因素。
|
||||
</li>
|
||||
<li>
|
||||
**团队技术栈和代码的历史包袱**。在选择方案的时候,也需要结合团队的技术栈现状以及代码的历史包袱综合考虑。以微信为例,作为一个强交互的IM应用,团队基本以Native开发为主,而且微信基本没有太多运营上的需求,所以当时在动态化方案上只使用了Tinker。当然团队的技术栈并不是永恒不变,有了微信小程序之后,内部的一些业务也尝试使用小程序来改造。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/70/d985b8e7426ae89d9ccabced38422c70.jpg" alt="">
|
||||
|
||||
最终无论我们选择哪种动态化类型,我都建议公司内部同一种动态化类型都使用同一个方案,这样在统一技术栈的同时,也可以实现代码在不同业务之间的迁移。比如阿里内部的虚拟运行环境统一使用Weex,一个业务在手淘的效果不错,也可以快速迁移到飞猪、天猫等其他应用中,实现应用的流量矩阵。
|
||||
|
||||
同样对于运营活动也是如此,阿里内部有一个叫[PopLayer](https://yq.aliyun.com/articles/59050)的组件,它可以在任意Native页面(这个页面甚至可以是Browser)弹出H5的部署容器,可以在无需发版的情况下对已有的Native界面上浮出透明浮层,并且可以不影响Native页面本身的交互。这样做活动我们不需要在客户端提前一两个月开发代码,而且同一个活动也可以快速在公司内部的各个应用中上线。
|
||||
|
||||
## Native动态化方案
|
||||
|
||||
Web容器增强和虚拟运行环境方案通过独立的Runtime和JS-SDK来桥接Native模块,而业务插件化则通过插件化框架和接口能力直接调用。相比之下前者更加抽象而不易造成代码混乱,这也是目前各大公司逐渐开始“去插件化”的原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/24/3bfe93916ac78f7190474cdd6c36ad24.png" alt="">
|
||||
|
||||
最近两年,大前端开发越演越烈,传统的Native动态化方案是否还存在价值,它又该何去何从?热修复、插件化这些方案的未来又将如何演进呢?
|
||||
|
||||
**1. 热修复和插件化**
|
||||
|
||||
2016年在开源Tinker的时候有两件事情是超出我预料的,一个是热修复在国内竟然有那么大的反响,另外一个就是它竟然如此的“坑坑不息”。
|
||||
|
||||
从[《Tinker:技术的初心与坚持》](https://mp.weixin.qq.com/s/tlDy6kx8qVHQOZjNpG514w)一文中,你可以看到过去我们踩过的一小部分坑,但非常不幸的是,填坑之路至今依然没有结束。每次Android新版本发布,我们就像迎来期末考试一样步步惊心。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/fa/85a4abdd15b978a0f2ec2dec8d9298fa.png" alt="">
|
||||
|
||||
曾经微信希望使用Tinker来代替版本发布,在热修复的基础上实现四大组件的代理。但是Android P私有API限制的出现,基本打消了这个念头。热修复不能代替版本发布,但是我们可以通过它来实现一些应用商店不支持的功能,例如精准的灰度人数控制、渠道和用户属性选择、整包的A/B测试等。
|
||||
|
||||
另一方面,热修复给国内的Android生态也带来一些不太好的影响,比如增加用户ROM体积占用、App启动变慢15%、OTA首次卡顿等。特别是Android Q之后,动态加载的Dex都只使用解释模式执行,会加剧对启动性能的影响。因为性能的问题,目前大公司基本暂停了全量用户的热修复,只使用热修复用于灰度和测试。
|
||||
|
||||
热修复如此,插件化也是如此。笨重的插件化框架不仅影响应用的启动速度,而且多团队协作的时候并没有想象得那么和谐,接口混乱、仓库不好管理、编译速度慢这些问题都会存在。插件化回归模块化和组件化,这也是目前各大公司都在逐步推进的事情。
|
||||
|
||||
前一阵子,徐川在[《移动开发的罗曼蒂克消亡史 》](https://mp.weixin.qq.com/s/2xBnlmESZjq7UTtcfzqhcA)一文中回顾了热修复和插件化的前世今生。时间一转三年过去了,对于曾经参与这个浪潮的一份子来说,我可以做的只是顺应潮流的变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/0b/388999a6bcca0adeb91635d3f0eca80b.png" alt="">
|
||||
|
||||
**热修复的未来**
|
||||
|
||||
Tinker设计之初参考了Instant Run的编译方案,但是正如专栏第26期[《关于编译,你需要了解什么?》](https://time.geekbang.org/column/article/82468)中所说的,Google在Android Studio 3.5之后,对于Android 8.0以上的设备将会使用[Apply Changes](https://medium.com/androiddevelopers/android-studio-project-marble-apply-changes-e3048662e8cd)替代之前的Instant Run方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e6/69/e6396e5202b17edc691e7b934f6e6b69.png" alt="">
|
||||
|
||||
Apply Changes不再使用插入PathClassloader的方式,而是使用我们已经多次讨论过的JVM TI。在Android 8.0之后,JVM TI开始逐渐支持ClassTransform和ClassRedefine这两个接口,它们可以允许虚拟机在运行时动态修改类,实现运行时的动态字节码编织。事实上这个技术在JVM就已经非常成熟,Java服务端利用这两个接口实现了类似热部署、远程调试、动态追踪等能力,具体你可以参考[《Java动态追踪技术探究》](https://tech.meituan.com/2019/02/28/java-dynamic-trace.html)。
|
||||
|
||||
那热修复的未来将要走向何方?本来我对热修复的未来是非常悲观的,但是Android Q给了我一个很大的惊喜。我们知道,Android P在中国有非常多的应用出现了兼容性问题,其中大部分是热修复、插件化以及加固等原因造成的(Google提供的数据是43%的兼容性问题由这三个问题造成)。
|
||||
|
||||
为了解决这个问题,并且减少我们对私有API的调用,Google在Android P新增了[AppComponentFactory](https://developer.android.com/reference/android/app/AppComponentFactory) API,并且在Android Q增加了替换Classloader的接口instantiateClassloader。在Android Q以后,我们可以实现在运行时替换已经存在ClassLoader和四大组件。中国热修复的先驱们用自己的“牺牲”,总算换来了Google官方的支持。我们使用Google官方API就可以实现热修复,这样以后Android版本再升级也不用担惊受怕了。移动开发的罗曼蒂克并没有消亡,Native的热修复再次迎来了春天。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/d3/2c57e70df59f3db4ee33e96819c3ced3.png" alt="">
|
||||
|
||||
**插件化的未来**
|
||||
|
||||
对于插件化的未来,我们需要思考如何“回归官道”。Google在2018年推出了[Android App Bundles](https://developer.android.com/guide/app-bundle),它可以实现模块的动态下载,但是与插件化不同的是,它并不支持四大组件代理的能力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/10/d7162ca4f725a4b3671702a87ad65c10.png" alt="">
|
||||
|
||||
但是Android App Bundles方案依赖Play Service,在国内我们根本无法使用。爱奇艺的[Qigsaw](https://zhuanlan.zhihu.com/p/40035587?utm_source=wechat_timeline&utm_medium=social&utm_oi=748276889193299968&from=timeline&isappinstalled=0)可能对我们有所启发,它基于Android App Bundles实现(支持动态更新,但是不支持四大组件代理),同时完全仿照AAB提供的Play Core Library接口加载插件,如果有国际化需求的公司可以在国内版和国际版上无缝切换。这种方案不仅可以使用Google提供的编译工具链,也支持国际国内双轨,相当于Google为我们维护整个组件化框架,在国内只需要实现自己的“Play Service”即可。
|
||||
|
||||
当然和热修复一样,如果使用[AppComponentFactory](https://developer.android.com/reference/android/app/AppComponentFactory) API,我们也可以实现插件化的四大组件代理。但是具体实现上依然需要在AndroidManifest中预先注册四大组件,然后具体的替换规则可以在我们自定义的AppComponentFactory实现类中埋好。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/c2/b095bdb7c73b481904c384a296eb4ec2.png" alt="">
|
||||
|
||||
以Activity替换为例,我们可以将某些类名的Activity替换成其他的Activity,新的Activity可以在补丁中,也可以在其他插件中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/43/9d7c201af7f57909e7d8af4da1010c43.png" alt="">
|
||||
|
||||
**热修复和插件化作为Native动态化方案,它们有一定的局限性。随着移动技术的发展,部分功能可能会被替换成小程序等其他动态化方案。但是从目前来看,它们依然有非常大的存在价值和使用场景。**
|
||||
|
||||
**2. 布局动态化**
|
||||
|
||||
正如上文所说,像淘宝、美团首页这些场景,我们对性能要求非常高,这里只能使用Native实现。但是首页也是流量的聚集地,“提增长、提留存、提转化”都要求我们有强大的运营能力。最近两年,淘宝、天猫一直推行“千人千面”,每个用户看到的主页布局、内容可能都不太一样。
|
||||
|
||||
布局动态化正是在这个背景之下应运而生,在我看来,布局动态化需要具备下面三个能力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/a8/1791818514edda690fe9b455f32083a8.jpg" alt="">
|
||||
|
||||
<li>
|
||||
**UI容器化**。能够动态地新增、调整UI界面而无需发版。
|
||||
</li>
|
||||
<li>
|
||||
**能力接口化**。点击、跳转等通用能力可以通过路由协议对外提供,满足UI容器化后的调用需求。
|
||||
</li>
|
||||
<li>
|
||||
**数据通道化**。数据上报也可以通过字段配置,实现客户端根据配置自动上报。
|
||||
</li>
|
||||
|
||||
在具体的实践上,天猫开源的[Tangram](https://github.com/alibaba/Tangram-Android/blob/master/README-ch.md)是一个不错的选择。但是Tangram的整体方案会相对复杂一些,我们也可以基于底层的[VirtualView](https://github.com/alibaba/Virtualview-Android/blob/master/README-ch.md)做二次开发。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/d8/59e307f779fdd94f43403d8b9baaabd8.jpg" alt="">
|
||||
|
||||
总的来说,布局动态化相比虚拟运行环境来说,它不仅实现了UI的动态新增和修改,也有着良好的体验和性能,同时接入和学习成本也比较低。
|
||||
|
||||
## 总结
|
||||
|
||||
“路漫漫其修远兮,吾将上下而求索”,我们对动态化实践的探索一直没有停止。今年,Flutter也强势地杀入了这个“战场”,那Flutter在跨平台和动态化方面表现如何,我们将在专栏下一期中揭晓。
|
||||
|
||||
动态化如今在国内是炙手可热的研究方向,虽然每个公司都强行造了自己的轮子,但是动态化方案目前还有很多没有解决的问题。所以在我们解决这些问题的过程中,也还会不断演变出其他的各种新方案。
|
||||
|
||||
现在各种类型的动态化方案,目前都能找到自己的应用场景。移动技术在快速地发展,我们无法准确预料到未来,比如说在Android P我们正准备放弃热修复的时候,Android Q又使它重新焕发了青春。但是我们坚信,无论未来采用何种方案,都是为了给用户更好的体验,同时让业务可以更快地迭代,并在不断地尝试中,给用户提供更好的产品。
|
||||
|
||||
## 课后作业
|
||||
|
||||
对于动态化实践,你有什么看法?在你的应用中,使用了哪种动态化方式?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
跨平台和动态化可以说是大前端时代最大的两个特点,也是每年技术大会的重点。今天的课后作业是仔细阅读下面大会的分享内容,学习各大公司在大前端的实践经验,并留言写写你的心得体会。
|
||||
|
||||
<li>
|
||||
2018年北京QCon:美团[《美团客户端动态化实践》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/Qcon%E5%8C%97%E4%BA%AC2018--%E3%80%8A%E7%BE%8E%E5%9B%A2%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%8A%A8%E6%80%81%E5%8C%96%E5%AE%9E%E8%B7%B5%E3%80%8B--%E6%96%B9%E9%94%A6%E6%B6%9B.pdf)。
|
||||
</li>
|
||||
<li>
|
||||
2018年GMTC:闲鱼[《基于Google+Flutter的移动端跨平台应用实践》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/GMTC2018%EF%BC%8D%E3%80%8A%E5%9F%BA%E4%BA%8EGoogle%2BFlutter%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%B7%A8%E5%B9%B3%E5%8F%B0%E5%BA%94%E7%94%A8%E5%AE%9E%E8%B7%B5%E3%80%8B-%E7%8E%8B%E6%A0%91%E5%BD%AC.pdf)。
|
||||
</li>
|
||||
<li>
|
||||
2018年GMTC:京东[《当插件化遇上Android+P》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/GMTC2018-%E3%80%8A%E5%BD%93%E6%8F%92%E4%BB%B6%E5%8C%96%E9%81%87%E4%B8%8AAndroid%2BP%E3%80%8B-%E5%BC%A0%E5%BF%97%E5%BC%BA.pdf)。
|
||||
</li>
|
||||
<li>
|
||||
2018年GMTC:小米[《快应用开发与实现指南》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/GMTC2018-%E3%80%8A%E5%BF%AB%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E4%B8%8E%E5%AE%9E%E7%8E%B0%E6%8C%87%E5%8D%97%E3%80%8B-%E8%91%A3%E6%B0%B8%E6%B8%85.pdf)。
|
||||
</li>
|
||||
<li>
|
||||
2018年GMTC:绿色守护[《Android+研发的昨天、今天与明天》](https://github.com/AndroidAdvanceWithGeektime/Chapter37/blob/master/GMTC2018-%E3%80%8AAndroid%2B%E7%A0%94%E5%8F%91%E7%9A%84%E6%98%A8%E5%A4%A9%E3%80%81%E4%BB%8A%E5%A4%A9%E4%B8%8E%E6%98%8E%E5%A4%A9%E3%80%8B-%E5%86%AF%E6%A3%AE%E6%9E%97.pdf)。
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
155
极客时间专栏/Android开发高手课/模块三 架构演进/41 | 聊聊Flutter,面对层出不穷的新技术该如何跟进?.md
Normal file
155
极客时间专栏/Android开发高手课/模块三 架构演进/41 | 聊聊Flutter,面对层出不穷的新技术该如何跟进?.md
Normal file
@@ -0,0 +1,155 @@
|
||||
<audio id="audio" title="41 | 聊聊Flutter,面对层出不穷的新技术该如何跟进?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/06/b887fa6cd26b7e10205ddb962be06906.mp3"></audio>
|
||||
|
||||
“天下苦秦久矣”,不管是H5、React Native,还是过去两年火热的小程序,这些跨平台方案在性能和稳定性上总让我们诟病不已。最明显的例子是React Native已经发布几年了,却一直还处在Beta阶段。
|
||||
|
||||
Flutter作为今年最火热的移动开发新技术,从我们首次看到Beta测试版,到2018年12月的1.0正式版,总共才经过了9个多月。Flutter在保持原生性能的前提下实现了跨平台开发,而且更是成为Google下一代操作系统Fuchsia的UI框架,为移动技术的未来发展提供了非常大的想象空间。
|
||||
|
||||
高性能、跨平台,而且更是作为Google下一个操作系统的重要部分,Flutter已经有这么多光环加身,那我们是否应该立刻投身这个浪潮之中呢?新的技术、新的框架每一年都在不断涌现,我们又应该如何跟进呢?
|
||||
|
||||
## Flutter的前世今生
|
||||
|
||||
大部分所谓的“新技术”最终都会被遗忘在历史的长河中,面对新技术,我们首先需要持怀疑态度,在决定是否跟进之前,你需要了解它的方方面面。下面我们就一起来看看Flutter的前世今生。
|
||||
|
||||
Flutter的早期开发者Eric Seidel曾经参加过一个访谈[What is Flutter](https://www.youtube.com/watch?v=h7HOt3Jb1Ts),在这个访谈中他谈到了当初为什么开发Flutter,以及Flutter的一些设计原则和方向。<br>
|
||||
<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/5c/aca58328ba1b4f1463d1b2b806c5ad5c.png" alt="">
|
||||
|
||||
Eric Seidel和Flutter早期的几位开发人员都是来自Chrome团队,他们在排版和渲染方面具有非常丰富的经验。那为什么要去开发Flutter?一直以来他们都为浏览器的性能而感到沮丧,有一天他们决定跳出Web的范畴,在Chromium基础上通过删除大量的代码,抛弃Web的兼容性,竟然发现性能是之前的20倍。对此我也深有感触,最近半年也一直在做主App的Lite版本,安装包也从42MB降到8MB,通过删除大量的历史业务,性能比主包要好太多太多。
|
||||
|
||||
正如访谈中所说,Flutter是一个Google内部孵化多年的产品,从它开发之初到现在,一直秉承着两个最重要的设计原则:
|
||||
|
||||
<li>
|
||||
**性能至上**。内置布局和渲染引擎,使用Skia通过GPU做光栅化。选择Dart语言作为开发语言,在发布正式版本时使用AOT编译,不再需要通过解析器解释执行或者JIT。并且支持Tree Shaking无用代码删除,减少发布包的体积。
|
||||
</li>
|
||||
<li>
|
||||
**效率至上**。在开发阶段支持代码的[Hot Reload](https://juejin.im/post/5bc80ef7f265da0a857aa924),实现秒级编译更新。重视开发工具链,从开发、调试、测试、性能分析都有完善的工具。内置Runtime实现真正的跨平台,一套代码可以同时生成Android/iOS应用,降低开发成本。
|
||||
</li>
|
||||
|
||||
那为什么要选择Dart语言?“Dart团队办公室离Flutter团队最近”肯定是其中的一个原因,但是Dart也为Flutter追求性能和效率的道路提供了大量的帮助,比如AOT编译、Tree Shaking、Hot Reload、多生代无锁垃圾回收等。正因为这些特性,Flutter在筛选了多种语言后,最终选择了Dart。这里也推荐你阅读[Why Flutter Uses Dart](https://hackernoon.com/why-flutter-uses-dart-dd635a054ebf)这篇文章。
|
||||
|
||||
总的来说,Flutter内置了布局和渲染引擎,使用Dart作为开发语言,采用React方式编写UI,支持一套代码在多端运行的框架。但是正如专栏前面所说的,大前端时代的核心诉求是跨平台和动态化,下面我们就一起来看看Flutter在这两方面的表现。
|
||||
|
||||
**1. Flutter的跨平台开发**
|
||||
|
||||
虽然React Native/Weex使用了系统原生UI组件,通过原生渲染的方式来提升渲染速度和UI流畅度,但是因为JS执行效率、JSBridge的通信代价等因素,性能依然存在瓶颈,而且我们也无法抹平不同系统的平台差异,因此这样的跨平台方案注定艰难。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/09/64ee06b802ca55df19b8f1a30e422809.png" alt="">
|
||||
|
||||
正如Eric Seidel在访谈所说,Flutter是从浏览器引擎简化而来,无论是它的布局引擎(例如也是使用CSS Flexbox布局),还是渲染流水线的设计,都跟浏览器都有很多相似之处。但是它抛弃了浏览器沉重的历史包袱和Web的兼容性,实现了在保持性能的前提下的跨平台开发。
|
||||
|
||||
回想一下在专栏第37期[《工作三年半,移动开发转型手游开发》](https://time.geekbang.org/column/article/88442)中,我们还描述过另外一套内置Runtime的跨平台方案:[Cocos引擎](https://github.com/cocos2d/cocos2d-x)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/6f/87abff685ed21a61a5b1bf89da143f6f.png" alt="">
|
||||
|
||||
在我看来,Cocos和Unity这些游戏引擎才是最早并且成熟的跨平台框架,它们对性能的要求也更加苛刻。即使是“王者荣耀”和“吃鸡”这么复杂的游戏,我们也可以做得非常流畅。
|
||||
|
||||
Flutter和游戏引擎一样,也提供了一套自绘界面的UI Toolkit。游戏引擎虽然能实现跨平台开发,但它致力于服务更有“钱途”的游戏开发。游戏引擎对于App开发,特别是混合开发支持并不完善。
|
||||
|
||||
如下图所示,我们可以看到三种跨平台方案的对比,Flutter可以说是三者中最为轻量的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/02/a297b66d9b4378679d384ef4fe378c02.jpg" alt="">
|
||||
|
||||
**2. Flutter的动态化实践**
|
||||
|
||||
在专栏第40期[《动态化实践,如何选择适合自己的方案》](https://time.geekbang.org/column/article/89555)中,我提到了一个观点:“相比跨平台能力,国内对大前端的动态化能力更加偏执”。
|
||||
|
||||
在性能、跨平台、动态性这个“铁三角”中,我们不能同时将三个都做到最优。如果Flutter在性能、跨平台和动态性都比浏览器更好,那就不会出现Flutter这个框架了,而是成为Chromium的一个跨时代版本。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/7b/b9288c3aedb11afe45957557b90f257b.png" alt="">
|
||||
|
||||
浏览器选择的是跨平台和动态性,而Flutter选择的就是性能和跨平台。Flutter正是牺牲了Web的动态性,使用Dart语言的AOT编译,使用只有5MB的轻量级引擎,才能实现浏览器做不到的高性能。Flutter的第一波受众是Android上使用Java、Kotlin,iOS上使用Objective-C、Swift的Native开发。未来Flutter是否能以此为突破口,进一步蚕食Web领域的份额,现在还不得而知。
|
||||
|
||||
那Flutter是否支持动态更新呢?由于编译成AOT代码,在iOS是绝对不可以动态更新的。对于Android,Flutter的动态更新能力其实Tinker就已经实现了。从官方的提供方案来看,其实也只是替换下面的变化文件,可以说是Tinker的简化版。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/9c/6a410383cadbaae7c8822dee6f455d9c.png" alt="">
|
||||
|
||||
官方的动态修复方案可以说是非常鸡肋的,并且也是要求应用重启才能生效。如果同时修改了Native代码,这也是不支持的。
|
||||
|
||||
更进一步说,Flutter在Google Play上是否允许动态更新也是抱有疑问的。从Google Play上面的开发者政策中心[规定](https://play.google.com/intl/zh-CN_ALL/about/privacy-security-deception/malicious-behavior/)上看,在Google Play也是不允许动态更新可执行文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/55/0369ac6b78069e2b5ea983bd1fa14d55.png" alt="">
|
||||
|
||||
从[《从Flutter的编译模式》](https://www.stephenw.cc/2018/07/30/flutter-compile-mode/)一文中,我们可以通过`flutter build apk --build-shared-library`将Dart代码编译成app.so。无论是libflutter.so,还是app.so(其实`vm_*`、`isolate_*`也是可执行文件)的动态更新,都违反了Google Play的政策。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/99/8580a8da34098dc918e4cb7c7330c199.png" alt="">
|
||||
|
||||
当然不排除Google为了推广Flutter,为它的动态更新开绿灯。最近我也在咨询Google Play的政策组,目前还没有收到答复,如果后续有进一步的结果,我也可以同步给各位同学。
|
||||
|
||||
总的来说,Flutter的动态化能力理论上只能通过JIT编译模式解决,但是这会带来性能和代码体积的巨大影响。当然,闲鱼也在探索一套Flutter的布局动态化方案,你可以参考文章[《Flutter动态化的方案对比及最佳实现》](https://mp.weixin.qq.com/s/N5ih-DY5TuKyn_a0P2mz0Q)。
|
||||
|
||||
## 面对新技术,该如何选择
|
||||
|
||||
通过上面的学习,我们总算对Flutter的方方面面都有所了解。可以说Flutter是一个性能和效率至上,但是动态化能力非常有限的框架。
|
||||
|
||||
目前[闲鱼App](https://www.yuque.com/xytech/flutter/tc8lha)、[美团外卖](https://tech.meituan.com/2018/08/09/waimai-flutter-practice.html)、[今日头条App](https://mp.weixin.qq.com/s/-vyU1JQzdGLUmLGHRImIvg)、[爱奇艺开播助手](https://mp.weixin.qq.com/s/7GSPvP_hOWCv64esLLc0iw)、[网易新闻客户端](http://mp.weixin.qq.com/s/a0in4DqB8Bay046knkRr1g)、京东[JDFlutter](https://mp.weixin.qq.com/s/UhfgfNEdogm7Busr0apAGQ)、[马蜂窝旅游App](https://mp.weixin.qq.com/s/WBnj_6sOonjR9XUnB-wZPA),都分享过他们在使用Flutter的一些心得体会。如果有兴趣接入Flutter,非常推荐你认真看看前人的经验和教训。
|
||||
|
||||
无论是Flutter,还是其他新的技术,在抉择是否跟进的时候,我们需要考虑以下一些因素:
|
||||
|
||||
<li>
|
||||
**收益**。接入新的技术或者框架,给我们带来什么收益,例如稳定性、性能、效率、安全性等方面的提升。
|
||||
</li>
|
||||
<li>
|
||||
**迁移成本**。如果想得到新技术带来的收益,需要我们付出什么代价,例如新技术的学习成本、原来架构的改造成本等。
|
||||
</li>
|
||||
<li>
|
||||
**成熟度**。简单来说,就是这个新技术是否靠谱。跟选择开源项目一样,团队规模、能力是否达标、对项目是否重视都是我们需要考虑的因素。
|
||||
</li>
|
||||
<li>
|
||||
**社区氛围**。主要是看跟进这个技术的人够不够多、文档资料是否丰富、遇到问题能否得到帮助等。
|
||||
</li>
|
||||
|
||||
**1. 对于Flutter,我是怎么看的**
|
||||
|
||||
Flutter是一个非常有前景的技术,这一点是毋庸置疑的。我曾经专门做过一次全面的评估分析,但是得出的结论是暂时不会在我负责的应用中接入,主要原因如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/ba/2316c956d3895fb2e4b2514a521e1bba.jpg" alt="">
|
||||
|
||||
目前我还没有跟进Flutter的核心原因在于收益不够巨大,如果有足够大的收益,其他几个因素都不是问题。而我负责的应用目前使用H5、小程序作为跨平台和动态化方案,通过极致优化后性能基本可以符合要求。
|
||||
|
||||
从另外一方面来说,新技术的学习和引入,无论是对历史代码、架构,还是我们个人的知识体系,都是一次非常好的重构机会。我非常希望每过一段时间,可以引入一些新的东西,打破大家对现有架构的不满。
|
||||
|
||||
新的技术或多或少有很多不完善的地方,这是挑战,也是机会。通过克服一个又一个的困难和挑战,并且在过程中不断地总结和沉淀,我们最终可能还收获了团队成员技术和其他能力的成长。以闲鱼为例,他们在Flutter落地的同时,不仅将他们的经验总结成几十篇非常高质量的文章,而且也参加了QCon、GMTC等一些技术大会,同时开源了[fish-redux](https://github.com/alibaba/fish-redux)、[FlutterBoost](https://github.com/alibaba/flutter_boost)等几个开源库,Flutter也一下成为了闲鱼的技术品牌。
|
||||
|
||||
可以相信,在过去一年,闲鱼团队在共同攻坚Flutter一个又一个难题的过程中,无论是团队的士气还是团队技术和非技术上的能力,都会有非常大的进步。
|
||||
|
||||
**2. 对于Flutter,大家又是怎么看的**
|
||||
|
||||
由于我还并没有在实际项目中使用Flutter,所以在写今天的文章之前,我也请教了很多有实际应用经验的朋友,下面我们一起来看看他们对Flutter又是怎么看的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/14/49ca149bb073fcbd0dc3b06494ddad14.jpg" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
曾几何时,我们一直对Chromium庞大的代码无从入手。而Flutter是一个完整而且比WebKit简单很多的引擎,它内部涉及从CPU到GPU,从上层到硬件的一系列知识,源码中有非常多值得我们挖掘去学习和研究的地方。
|
||||
|
||||
未来Flutter应用在小程序中也是一个非常有趣的课题,我们可以直接使用或者参考Flutter实现一个小程序渲染引擎。这样还可以带来另外一个好处,一个功能(例如微信的“附近的餐厅”)在不成熟的时候可以先以小程序的方式尝试,等到这个功能稳定之后,我们又可以无成本地转化为应用内的代码。
|
||||
|
||||
Dart语言从2011年启动以来,一直想以高性能为卖点,试图取代JavaScript,但是长期以来在Google外部使用得并不多。那在Flutter这个契机下,它未来是否可以实现弯道超车呢?这件事给我最大的感触是,机会可能随时会出现,但是需要我们时刻准备好。
|
||||
|
||||
“打铁还需自身硬”,我们还是要坚持修炼内功。对于是否要学习Flutter,我的答案是“多说无益,实践至上”。
|
||||
|
||||
## 课后作业
|
||||
|
||||
对于Flutter,你有什么看法?你是否准备在你的应用中跟进?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
Flutter作为今年最为火热的技术,里面有非常多的机遇,可以帮我们打造自己的技术品牌(例如撰写文章、参加技术会议、开源你的项目等)。对于Flutter的学习,你可以参考下面的一些资料。
|
||||
|
||||
<li>
|
||||
万物之中,[源码](https://github.com/flutter/flutter)最美
|
||||
</li>
|
||||
<li>
|
||||
[Flutter官方文档](https://flutter.dev/docs)
|
||||
</li>
|
||||
<li>
|
||||
[闲鱼的Flutter相关文章](https://www.yuque.com/xytech/flutter)
|
||||
</li>
|
||||
<li>
|
||||
各大应用的使用总结:[闲鱼App](https://www.yuque.com/xytech/flutter/tc8lha)、[美团外卖](https://tech.meituan.com/2018/08/09/waimai-flutter-practice.html)、[今日头条App](https://mp.weixin.qq.com/s/-vyU1JQzdGLUmLGHRImIvg)、[爱奇艺开播助手](https://mp.weixin.qq.com/s/7GSPvP_hOWCv64esLLc0iw)、[网易新闻客户端](http://mp.weixin.qq.com/s/a0in4DqB8Bay046knkRr1g)、京东[JDFlutter](https://mp.weixin.qq.com/s/UhfgfNEdogm7Busr0apAGQ)、[马蜂窝旅游App](https://mp.weixin.qq.com/s/WBnj_6sOonjR9XUnB-wZPA)
|
||||
</li>
|
||||
<li>
|
||||
阿里Flutter开发者帮助App:[flutter-go](https://github.com/alibaba/flutter-go)
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
77
极客时间专栏/Android开发高手课/模块三 架构演进/42 | Android开发高手课学习心得.md
Normal file
77
极客时间专栏/Android开发高手课/模块三 架构演进/42 | Android开发高手课学习心得.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<audio id="audio" title="42 | Android开发高手课学习心得" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/64/e93852e3cfbf15d19fcc05831d58eb64.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文。专栏更新至今,转眼间最后一个模块“架构演进”也已经 更新完了。从“Android开发高手课”筹备到现在将近的八个月里,非常感谢我们的学习委员孙鹏飞,不管是文章答疑还是练习Sample,鹏飞为这个专栏付出了太多太多,这里再次衷心地跟他说一声谢谢。
|
||||
|
||||
|
||||
>
|
||||
无论是我还是鹏飞,在写这个专栏的同时,也是重塑自身知识架构的过程。在专栏的最后一篇文章,我特意邀请鹏飞跟大家分享他的学习心得,也算是跟大家做一个小小的告别。鹏飞对于Android系统框架、虚拟机、Linux等底层知识非常熟悉,那他是如何做到的呢?不得不说的是,鹏飞是一个非常自律(+宅)的人,拥有坚持每天早上7点钟起床学习一个半小时的可怕技能。
|
||||
|
||||
|
||||
>
|
||||
最近鹏飞也准备奔向新的工作岗位,在此也祝愿他在新岗位上能够发挥所长。接下来我们一起来听听鹏飞学习专栏的心得和思考。
|
||||
|
||||
|
||||
大家好,我是孙鹏飞。“Android开发高手课”接近尾声,今天我来从一个学习者的角度,对专栏做一下总结。专栏涵盖了Android开发的方方面面的知识,有技术干货、心得体会、问题答疑和部分科普性质的文章,从内容上来说和平时大家看到的教程不太一样,没有过多介绍Android基础,更多的是提供新的思路和分享经验。很多同学感觉学习比较吃力,其实我也是如此,在开始准备这门课程的时候,很多东西也是从零开始学起。对于一个新的概念,从模糊到清晰的过程不会那么容易,它可能涉及Android和Linux很多细枝末节的知识点,需要阅读大量资料给予支撑才能理解。
|
||||
|
||||
下面我就总结一下我学习专栏的过程和一些思考,希望对你有所启发。
|
||||
|
||||
## 关于“高手课”的思考
|
||||
|
||||
最近我了解到一些同学对C++和Linux感觉不知道如何入手,这里说一下我的学习过程以及相关的学习资料。你可以跟着《C++ Primier Plus》学习一下基本语法和标准库里的函数使用,比如字符串操作、内存分配、I/O操作等,这里有一个很好的[网站](https://zh.cppreference.com/)学习C/C++语言,包括最新的特性都有介绍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/ed/a4969b8f63ab9df1b25f6f783bad0aed.png" alt="">
|
||||
|
||||
Linux应用开发的部分,可以一步步的参考《UNIX环境高级编程》这本书,熟悉一遍标准I/O库、系统数据文件和信息、进程环境、进程控制、进程关系、信号、线程、线程控制、守护进程,以及各种I/O、进程间通信、网络IPC的使用。同时也可以参考《Linux/UNIX系统编程手册》,这本书相对介绍得更详细一些。在了解了Linux应用开发相关内容后,可以深入了解一下Linux内核相关的知识,我推荐《Linux技术内幕》,这本书是国人写的,语言相对于一些翻译书要通顺一些,而且还有大量的图解;缺点是由于篇幅限制,有一部分内容没有介绍。
|
||||
|
||||
从专栏中你可以看到有些内容涉及ARM的汇编,ARM的汇编资料相对少一些,而且实践起来不是很方便,这里推荐一个网站[Writing ARM Assembly](https://azeria-labs.com/writing-arm-assembly-part-1/),可以学习到基本的汇编操作。关于实践可以使用[VisUAL](https://salmanarif.bitbucket.io/visual/index.html)工具来进行实验,这个工具可以逐步执行并可以实时观察寄存器和内存里的值。关于汇编相关的内容,我认为更多的时候只需要看懂即可,在实际中使用的地方可能并不是很多。
|
||||
|
||||
Android的更新迭代是很快的,在快速的更新中我们需要保持对Android新特性的了解,还要掌握兼容问题的处理方法,。现在官方文档更新的速度还是很快的,中文文档更新速度也很快,大部分功能和API新特性的描述都可以在Android官网上找到很详细的文档,比如[Android Q功能和API](https://developer.android.com/preview/features) ,并且每个版本官方都会提供很详细的[API差异报告](https://developer.android.com/sdk/api_diff/q-beta1/changes.html),这个报告将修改、新增的API都统计在一起,我们可以在这个表里发现一些新功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/53/dae40528ad994a7e4637980f787bb153.png" alt="">
|
||||
|
||||
关于虚拟机方面的更新,我更多是参考官方的提交记录来查看。具体可以看[Android Gerrit](https://android-review.googlesource.com/q/project:platform/art+status:open),这里有每一条提交记录,并且可以查看很详细的Code Diff,提交记录的描述一般都很清晰,所以可以很好的帮助我们理解代码,也可以发现一些新的工具和功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/a0/10c64f71ea7507bffc3411815f486fa0.png" alt="">
|
||||
|
||||
## 专栏学习心得
|
||||
|
||||
我经常听到很多同学说一个技术很难,其实我觉得大多时候不是技术难,而是应该说复杂。技术大多都是演进而来,其实这种演进和我们业务需求迭代的过程很类似。比如内存管理,从固定分区、动态分区机制到分段机制,然后发展成现在的分页机制,硬件也随之演进,都算是一个迭代过程。理解分页机制其实不难,但这个知识点所涉及的内容就很繁琐和复杂了,比如了解系统如何管理内存需要了解内存布局,就会涉及内存划分;内存划分又涉及CPU的运行模式,然后是物理内存和虚拟内存如何映射,会涉及页表的翻译、物理页面的分配和释放、伙伴系统算法;为了解决伙伴系统的内存碎片化问题,又衍生出迁移类型等。
|
||||
|
||||
这一系列涉及的内容就非常复杂,但每一项单拆出来去看,一层层去学习和补充,就会感觉容易很多。这一点其实在业务开发上也有体现,我们刚接手一个复杂的业务,代码庞大,注释和文档都很少,但在一段时间后你还是会对整个业务有或多或少的认识,在接到新的业务的时候也没有觉得难到无从下手,顶多是觉得复杂。底层的系统、框架也是如此,这是一个由点到面的过程。
|
||||
|
||||
**忙碌状态下的学习**
|
||||
|
||||
鸿洋写过一篇文章[《嗷嗷加班,如何保持学习能力》](https://mp.weixin.qq.com/s/gGOHF4-_tTHHGSbsX4Mptg),讲在繁忙的工作状态下如何保持学习,我看过之后也很有感触。在平时零散的时间里我们看到一篇技术文章,并不是阅读收藏后就结束了,这样你可能会在很短的时间里就忘掉了文章的内容。他将阅读一篇文章分成以下几个步骤:提取这篇文章要解决的问题;然后概括一下涉及的技术点;提取重点内容,比如问题发生的缘由、有哪几种解决方法。总体来说,这个方法是为了在短时间内提取出重点内容,然后记录下来后面再进行复习。所以我们都需要多记录、多复习,可以培养使用一些工具来帮助自己养成习惯。
|
||||
|
||||
**逃离舒适区**
|
||||
|
||||
并不是说换一个更忙碌的工作就是逃离舒适区,而是在平时工作和学习过程中保持一种焦虑感,但这种焦虑感不是迷茫和恐慌,而是清晰地认识到自己的不足,然后在工作和业余的时间里填补自己,当你集中注意力攻克一个难点的时候,你会发现这是一件有趣的事。我身边有很多同学都在持续地学习,每个人都有不同的目标,比如学到什么程度、应用到什么地方等,需要一定的压力才能产生比较好的效果,否则很容易迷失丢掉重点。我以前所在的团队有一个学习计划表,每个同学调研一个方向,每周周会的时候都会抽出一定时间去做技术分享,我觉得这是一个很好的方法,人在有一定紧张情绪的情况下注意力会相当集中,这个和考试前、面试前学习效率会很高的道理是一样的。不过我们也不能太过焦虑,我也经常会有焦虑感,我的解决方法是定制计划,半年或者一年,在一段时间专注完成一件事,你会看到自己成长了很多。
|
||||
|
||||
**刻意练习**
|
||||
|
||||
也许我们在公司里平时做的业务需求都是缝缝补补,并没有涉及很复杂的内容,大量重复的工作会让人觉得无法提升自己的技术。而平时自己学到的知识在一定时间之后可能就生疏了,而且有些技术可能从原理上看相对简单,但在实现的过程中会遇到各种问题,比如插件化和热修复,这样的技术如果不上手去实践就只能停留在理论层面。对于这种情况,可以采用一种刻意练习的方法,在知晓原理后自己尝试去实现一个类似的框架,在这个过程中你会得到比阅读源码和文章更多的实践经验,可以大大加深对一个知识的理解,也可以锻炼自己的框架设计能力。
|
||||
|
||||
**阅读源码**
|
||||
|
||||
别人写的源码分析文章我一般看得比较少,除非是自己遇到了很难理解的部分,大部分的内容都可以直接在代码里获取到。而且很多源码的分析文章就很少,比如Inline Hook ,但是框架实现比较多,那么就只能从代码里获取相关内容。针对一个功能的框架可以去找一些相同功能的多个框架进行对比,看一下实现方式是否有不同之处,同时比较一下每个实现方式的优缺点,并记录一下每个框架所使用的技术。在理解一个框架的实现后,还是建议你去自己写一个类似的框架,因为在自己的实现过程中会遇到各种问题,可以从其他框架里寻找一下方案,然后自己总结一下,这样可以了解这个框架的优势之处,如果在面试的时候被问到一个框架的优缺点,这样也可以有自己的理解,而不是网上帖子的统一描述。
|
||||
|
||||
**全栈发展**
|
||||
|
||||
在所谓的互联网寒冬下,需要持续关注其他方向的技术,提升自身竞争力。在前端的趋势热度和各个公司的发展方向中,“大前端”已经成为必要的技能,这也可以从各大公司的招聘方向中看出来。因此我们在平时学习过程中,可以更多关注一下大前端、跨平台、Flutter相关的内容。现在很多公司和部门讲究中台化和前后端闭环管理,对于后台和前端的技术都要大概有所了解才能掌握整个业务的动态,只关注一块技术在中国的互联网环境下不太利于自身的发展。换句话讲,我们关注得越多,机会也就更多一些。
|
||||
|
||||
**个人能力的提升**
|
||||
|
||||
这里的能力更多是指软实力的提升,一个是技术视野,也就是一个业务系统的全局把控,将一个自底向上的思维方式发展为从上到下的抽象能力;再有就是以技术价值为导向。以前我总是深入一些技术细节,只是觉得比较有趣,但很少考虑这个技术点能带来什么“价值”。其实在工作晋升和面试的过程中,通常关注更多的是“价值”,我们一般总说业务迭代、模块开发,但很少谈及所做功能的价值,只是觉得技术实现比较简单并没有什么可以讲的,其实我们可以从以下几个方面进行总结。
|
||||
|
||||
首先,我们开发了一个新功能、做了一些改进、引入了一些技术等,可能我们大多在做这些工作,也就是实现了一个业务需求,保证了业务功能的使用,这是功能产生的价值。在做这个需求功能的时候,我们有没有考虑过扩展性、重用性、维护性、性能、稳定性、高可用性等呢?性能的提升给用户带来体验上的价值;可扩展性、重用性带来开发效率的提升;稳定性减少了维护的成本等,这些都是技术产出的价值。我们可以更进一步从业务的角度上看,比如完成这个业务给用户体验提升了多少?促进了多少用户增长、提高了多少用户活跃度、降低了多少成本?由于我们在每个业务开发的时候,都会有一些数据统计的埋点,因此在平时的时候我们要多关注这种业务相关的数据。一般产品同学都会有各种数据报表,我们可以将他们总结起来作为自己完成一个业务所产出的价值。
|
||||
|
||||
## 写在最后
|
||||
|
||||
最后感谢各位同学能一直跟随“Android开发高手课”学习到最后,相信你一定也从专栏里学到了对自己有价值的新知识。我同样也是从专栏上线,随着专栏更新一点点学习到现在,从专栏里学到了很多思路和方法,也巩固了很多基础知识。但更多的基础知识专栏无法详细呈现,所以还需要我们以此为起点,自己在课下扩展开来,多去思考、多做总结。
|
||||
|
||||
最最后我想说,每个人在突破自己技术瓶颈时都会经历一段痛苦的时光,只有我们具有坚定的信念,并努力坚持下去,相信我等你回过头来再看曾经认为难以理解的技术和知识时,你会有一种阔然开朗、融会贯通的感觉,这就是成长和进步所带来最大的成就感。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
112
极客时间专栏/Android开发高手课/模块二 高效开发/25 | 如何提升组织与个人的研发效能?.md
Normal file
112
极客时间专栏/Android开发高手课/模块二 高效开发/25 | 如何提升组织与个人的研发效能?.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<audio id="audio" title="25 | 如何提升组织与个人的研发效能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a2/cb/a28b668c52d9f64d29cfa82f362804cb.mp3"></audio>
|
||||
|
||||
通过“高质量开发”模块的学习,相信你已经对打造一款高质量应用信心满满了。不过人们常说“提质增效”,总喜欢把质量和效率联系在一起,我们都希望在保证质量的前提下,为自己的团队提速。
|
||||
|
||||
特别是移动互联网在红海厮杀的今天,快速试错变得越来越重要,敏捷开发也被越来越多的团队所推崇。有些时候为了效率我们甚至愿意牺牲部分性能,而选择在合适的时间去偿还这些“债务”。
|
||||
|
||||
在“高质量开发”模块中,我侧重如何给应用交付的每个步骤做好“质检”。今天我们就一起来开启新的征程,从组织和个人研发效能的角度,重新帮你审视整个应用交付的过程。
|
||||
|
||||
## 组织的研发效能
|
||||
|
||||
**1. 何为研发效能**
|
||||
|
||||
在讨论如何优化组织研发效能之前,请你先思考一下什么是研发效能。
|
||||
|
||||
我们平常开发的过程,是从产品的一个需求想法,转变为功能并且发布上线。这个过程会涉及产品、设计、开发、测试,更多的时候可能还会拉上前端、后台或者算法。
|
||||
|
||||
产品的交付涉及很多的流程和人员,虽然设计人员出图很快、我们开发效率很高,但也并不能代表研发效能同样很高,研发效能是对整个产品最终交付的速度和质量负责。在[《如何衡量研发效能》](https://mp.weixin.qq.com/s/vfhqRxLnHJz_ii2zhXofuA)一文中,将研发效能定义为**一个组织持续快速交付价值的能力**。
|
||||
|
||||
在文中,作者从流动效率、资源效率和质量进一步拆解了研发效能,并提出了研发效能的五个衡量标准。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/4b/8aee56bf643584c04582fb09a98c3c4b.png" alt="">
|
||||
|
||||
对于客户端研发来说,我们是不是只要保证按时按质实现需求就可以了呢?有很多公司,尽管实行“996”,产品、开发和测试看起来的确都很忙了,但是交付速度和质量却仍然不令人满意:产品埋怨开发效率低、开发埋怨产品需求不明确、测试埋怨开发质量差、开发埋怨测试发现不了问题等。
|
||||
|
||||
这是因为什么呢?对于研发效能这个话题,虽然我并不是这个领域的专家,但根据我多年的工作经验,我可以谈谈个人的两点思考:
|
||||
|
||||
<li>
|
||||
**提效是每个人的职责**。尽管在BAT这些大厂,会有专门的研发效能部门,但是效能的提升并不是单单只依靠效能部门,或者认为是领导的事情,而是组织里面每一个人都应该去思考的事情。例如天猫设立的效能目标是“211”,也就是2周交付周期、1周开发周期以及1小时发布时长。那团队中的每一个人都应该为这一共同目标而努力,回顾在每个发布迭代中遇到的问题以及改进的建议。
|
||||
</li>
|
||||
<li>
|
||||
**提效不仅限于写Android代码**。尽管我们是Android开发工程师,但是我们的工作不应该局限在写Android代码上,关键还是解决需求场景。无论是APM系统、大网络平台、大数据平台,这种大型的前后端一体化解决方案,还是需求流程上或者增强产研测沟通信任的优化。只要是对提升效能有帮助的,我们都可以尝试去实践。**在建立了这种整体统筹的思维之后,未来我们想转后端、前端,甚至是产品都会有很大的帮助**。
|
||||
</li>
|
||||
|
||||
微信在很早的时候就引入了Google的[OKR](https://www.zhihu.com/question/22471467)绩效考核制度,记得在2017年Android团队定了“质量”“效能”和“影响力”这三个目标。
|
||||
|
||||
接着Android团队的30多个研发也都在围绕这三个目标来制定自己的工作计划,例如针对“效能”来说,有的人抽离一个UI库或者动画库,有的人写一个监控工具,有的人提升编译速度,有的人写一个Web的值班页面,有的人优化需求评审的流程…
|
||||
|
||||
这样大家集思广益,一起思考、一起讨论,为达成组织的共同目标而努力,这也是为什么微信开发人员虽然不多,但是战斗力在业界数一数二的原因。
|
||||
|
||||
**2. 应用交付的流程**
|
||||
|
||||
前面我从整个组织的角度,定义了研发效能的含义以及衡量它的五个标准。同时也结合我在微信的经历,谈了我关于提升研发效能的两点思考。可能大部分同学还是感觉整个产品的交付流程类似产品、UI设计这些环节是研发人员无法把控的,那接下来我只从研发的流程来看如何提高效能。
|
||||
|
||||
正如我在专栏导读[《如何打造高质量的应用》](https://time.geekbang.org/column/article/70250)所说的,一个应用至少会经过开发、编译CI、测试、灰度和发布这几个阶段。下面我从效能的角度,分别看看每个阶段需要关注什么问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/bf/de7478cd66a44d2807e1c89a3a26bbbf.jpg" alt="">
|
||||
|
||||
<li>
|
||||
**开发阶段**。开发阶段解决的是如何用尽可能短的时间,完成尽可能多的需求,并且保证开发的质量,不至于后期过多的返工。项目的架构应该如何选择?例如应该采用原生开发,还是Web、React Native、Flutter这样的跨平台方案。如何提升团队人员的能力以及工具和框架的成熟度?有哪些提升团队工作效率的技巧?
|
||||
</li>
|
||||
<li>
|
||||
**编译、CI阶段**。编译CI阶段解决的是如何发现和优化开发阶段的一些编码问题,以及快速构建出最终产物。Google的Gradle、Facebook的Buck为编译速度做了哪些努力?Flutter的Hot Reload为什么可以这么快?AspectJ、ASM、ReDex这三种插桩方法的原理和差别是什么?腾讯的RDM、阿里的摩天轮这些编译构建平台有什么特别之处?
|
||||
</li>
|
||||
<li>
|
||||
**测试阶段**。测试阶段是为了发现交付过程的质量问题。测试的确不容易,自动化测试成本高,也不容易把控发布质量。那如何可以让测试覆盖更多的场景,Monkey、性能测试、UI测试应该怎么实践?腾讯的RDM、蚂蚁的伙伴是如何做到人人都是测试?网易的Airtest测试框架有哪些过人之处?
|
||||
</li>
|
||||
<li>
|
||||
**灰度、发布阶段**。灰度发布是为了验证产品的效果。发布并不是把包丢出去就可以了,我们需要对自己的产品负责。那如何准确、快速地评估产品数据?头条、快手是如何做到精准运营和A/B测试?如果遇到疑难的线上问题应该怎么解决?复杂多变的网络问题又应该怎样去定位和分析?
|
||||
</li>
|
||||
|
||||
当然为了提升在这个过程的效率,我们会用到一些很有用的第三方工具,例如用于CodeReview的Gerrit、持续集成的Jenkins、代码审计的Coverity等。
|
||||
|
||||
工具不仅可以将大量人工操作变成自动化,也可以方便团队更好的协作。例如我们把需求的流转、进度安排变得可视化,可以大大地减少产研测团队之间消息的隔阂,在这方面阿里的AONE或者腾讯的TPAD都做得非常不错。
|
||||
|
||||
项目管理、需求管理、代码托管、构建/部署、测试平台…都是我们常用的工具,类似阿里的[云效](http://cn.aliyun.com/product/yunxiao)会提供这样的一站式平台,从需求发起到分支管理、代码review,再到测试发布。在过去,这些工具都是各大公司研发效能部门多年的结晶,一般都不愿意对外提供。但是得益于云时代、TOB时代的到来,现在都愿意打包成商品供我们使用。
|
||||
|
||||
当然每个项目都会有自己特殊的情况,这些工具也不一定可以完全符合我们的需要,我们可以根据自己的情况选择合适的服务,或者直接开发自己的工具。
|
||||
|
||||
## 个人的研发效率
|
||||
|
||||
个人作为整个组织的一部分,我们效率的提升也会对组织有正向的作用。特别是对某些小团队或者独立开发者来说,个人可能就代表了整个团队。
|
||||
|
||||
关于个人效率的提升和时间的管理,有很多书籍专门在讲这个内容。下面从我看到的一些不好的现象,谈谈我个人两个比较深的体会。
|
||||
|
||||
**1. 思考**
|
||||
|
||||
>
|
||||
年轻人千万不要碰的东西之一,便是能获得短期快感的软件。它们会在不知不觉中偷走你的时间,消磨你的意志力,摧毁你向上的勇气。
|
||||
|
||||
|
||||
随着我们接触到的信息越来越多,越来越多的人很难保持对事情的专注力。工作期间经常想着去刷一下抖音、头条、微信、王者荣耀,强行把时间打破成碎片。
|
||||
|
||||
>
|
||||
跟产品开了一天的会,他的需求有了,你的代码呢?
|
||||
|
||||
|
||||
可能也有一部分同学他们不刷抖音和头条,但是在上班时间也会被各种邮件、钉钉、会议折磨得痛不欲生。针对这个问题,我的做法是每天上午和下午都会至少保留一个小时“目空一切”的时间,不看邮件、不看钉钉、不接会议。当然有的时候也是无法避免被老板当面拉回到“现实”。
|
||||
|
||||
我经常看到团队里面的一些人也存在这种现象,最终表现可能是这个人经常“996”,看起来很忙,但是产出并不高,而且个人成长也不明显。
|
||||
|
||||
每天我们应该需要有一段时间真正的静下心来工作,而且每过一段时间也要重新审视一下自己的工作,有哪些地方做的不够好?有没有什么事情是自己或者团队的人正在反复而低效在做的,是否可以优化。
|
||||
|
||||
**2. 方法**
|
||||
|
||||
关于方法,这里我只说两点,也是同学们经常会出现的问题。
|
||||
|
||||
<li>
|
||||
**做事的方法**。曾经看到一些开发人员,非常喜欢用二分法来排查问题。当测试给他报Bug时,他会非常熟练的操作Git命令,花上一两个小时打出几十个验证包,不辞劳苦地尝试。当然二分法我也使用过,在毫无头绪的时候的确可以“死马当活马医”。但是我们在使用这个“大杀器”之前,起码应该经过自己的思考,尝试正面去迎击Bug本身。
|
||||
</li>
|
||||
<li>
|
||||
**提问的方法**。在微信和QQ群里面,经常会看到有些同学在群里问一个问题,可能Google一下就可以得到答案。然后他们在群里灌了一个小时水,最后还是没有任何答案。在做Tinker开源的时候,我有时也被一些使用者问得心情不再愉悦。事实上提问题是非常体现技术和职业素养的,我们在提问题之前需要经过自己的思考和努力,在这里推荐你看看[《提问的艺术》](https://github.com/tvvocold/How-To-Ask-Questions-The-Smart-Way)。
|
||||
</li>
|
||||
|
||||
## 总结
|
||||
|
||||
“吾日三省吾身”,无论是组织的研发效能,还是个人的工作效率,我们都需要学会经常去回顾和思考,快速演进、快速迭代,争取未来做得更好。
|
||||
|
||||
你在工作和学习的效率上,遇到过哪些问题?对于如何提升工作和学习的效率,你还有什么好的方法和建议吗?欢迎留言分享给我和其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
250
极客时间专栏/Android开发高手课/模块二 高效开发/26 | 关于编译,你需要了解什么?.md
Normal file
250
极客时间专栏/Android开发高手课/模块二 高效开发/26 | 关于编译,你需要了解什么?.md
Normal file
@@ -0,0 +1,250 @@
|
||||
<audio id="audio" title="26 | 关于编译,你需要了解什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/db/c2aa66efc2e5edd49b7a9ea0c0281edb.mp3"></audio>
|
||||
|
||||
作为Android工程师,我们每天都会经历无数次编译;而对于大型项目来说,每次编译就意味着要花去一杯咖啡的时间。可能我讲具体的数字你会更有体会,当时我在微信时,全量编译Debug包需要5分钟,而编译Release包更是要超过15分钟。
|
||||
|
||||
如果每次编译可以减少1分钟,对微信整个Android团队来说就可以节约1200分钟(团队40人 × 每天编译30次 × 1分钟)。所以说优化编译速度,对于提升整个团队的开发效率是非常重要的。
|
||||
|
||||
那应该怎么样优化编译速度呢?微信、Google、Facebook等国内外大厂都做了哪些努力呢?除了编译速度之外,关于编译你还需要了解哪些知识呢?
|
||||
|
||||
## 关于编译
|
||||
|
||||
虽然我们每天都在编译,那到底什么是编译呢?
|
||||
|
||||
你可以把编译简单理解为,将高级语言转化为机器或者虚拟机所能识别的低级语言的过程。对于Android来说,这个过程就是把Java或者Kotlin转变为Android虚拟机运行的[Dalvik字节码](https://source.android.com/devices/tech/dalvik/dalvik-bytecode)的过程。
|
||||
|
||||
编译的整个过程会涉及词法分析、语法分析 、语义检查和代码优化等步骤。对于底层编译原理感兴趣的同学,你可以挑战一下编译原理的三大经典巨作:[龙书、虎书、鲸鱼书](https://www.itcodemonkey.com/article/3521.html)。
|
||||
|
||||
但今天我们的重点不是底层的编译原理,而是希望一起讨论Android编译需要解决的问题是什么,目前又遇到了哪些挑战,以及国内外大厂又给出了什么样的解决方案。
|
||||
|
||||
**1. Android编译的基础知识**
|
||||
|
||||
无论是微信的编译优化,还是Tinker项目,都涉及比较多的编译相关知识,因此我在Android编译方面研究颇多,经验也比较丰富。Android的编译构建流程主要包括代码、资源以及Native Library三部分,整个流程可以参考官方文档的[构建流程图](https://developer.android.com/studio/build/?hl=zh-cn)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/87/7e66559bb42ce21a538ab34f596c1f87.png" alt="">
|
||||
|
||||
[Gradle](https://docs.gradle.org/current/userguide/userguide.html)是Android官方的编译工具,它也是GitHub上的一个[开源项目](https://github.com/gradle/gradle)。从Gradle的[更新日志](https://gradle.org/releases/)可以看到,当前这个项目还更新得非常频繁,基本上每一两个月都会有新的版本。对于Gradle,我感觉最痛苦的还是Gradle Plugin的编写,主要是因为Gradle在这方面没有完善的文档,因此一般都只能靠看源码或者[断点调试](https://fucknmb.com/2017/07/05/%E5%8F%88%E6%8E%8C%E6%8F%A1%E4%BA%86%E4%B8%80%E9%A1%B9%E6%96%B0%E6%8A%80%E8%83%BD-%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95Gradle%E6%8F%92%E4%BB%B6/)的方法。
|
||||
|
||||
但是编译实在太重要了,每个公司的情况又各不相同,必须强行造一套自己的“轮子”。已经开源的项目有Facebook的[Buck](https://github.com/facebook/buck)以及Google的[Bazel](https://github.com/bazelbuild/bazel)。
|
||||
|
||||
为什么要自己“造轮子”呢?主要有下面几个原因:
|
||||
|
||||
<li>
|
||||
**统一编译工具**。Facebook、Google都有专门的团队负责编译工作,他们希望内部的所有项目都使用同一套构建工具,这里包括Android、Java、iOS、Go、C++等。编译工具的统一优化,所有项目都会受益。
|
||||
</li>
|
||||
<li>
|
||||
**代码组织管理架构**。Facebook和Google的代码管理有一个非常特别的地方,就是整个公司的所有项目都放到同一个仓库里面。因此整个仓库非常庞大,所以他们也不会使用Git。目前Google使用的是[Piper](http://www.ruanyifeng.com/blog/2016/07/google-monolithic-source-repository.html),Facebook是基于[HG](https://www.mercurial-scm.org/)修改的,也是一种基于分布式的文件系统。
|
||||
</li>
|
||||
<li>
|
||||
**极致的性能追求**。Buck和Bazel的性能的确比Gradle更好,内部包含它们的各种编译优化。但是它们或多或少都有一些定制的味道,例如对Maven、JCenter这样的外部依赖支持的也不是太好。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/90/fa5da989a8047e935ec15803235ecd90.png" alt="">
|
||||
|
||||
“程序员最痛恨写文档,还有别人不写文档”,所以它们的文档也是比较少的,如果想做二次定制开发会感到很痛苦。如果你想把编译工具切换到Buck和Bazel,需要下很大的决心,而且还需要考虑和其他上下游项目的协作。当然即使我们不去直接使用,它们内部的优化思路也非常值得我们学习和参考。
|
||||
|
||||
Gradle、Buck、Bazel都是以更快的编译速度、更强大的代码优化为目标,我们下面一起来看看它们做了哪些努力。
|
||||
|
||||
**2. 编译速度**
|
||||
|
||||
回想一下我们的Android开发生涯,在编译这件事情上面究竟浪费了多少时间和生命。正如前面我所说,编译速度对团队效率非常重要。
|
||||
|
||||
关于编译速度,我们最关心的可能还是编译Debug包的速度,尤其是**增量编译**(incremental build)的速度,希望可以做到更加快速的调试。正如下图所示,我们每次代码验证都要经过编译和安装两个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/56/04a94858fd23dc6d1ccce64f400c2656.png" alt="">
|
||||
|
||||
<li>
|
||||
**编译时间**。把Java或者Kotlin代码编译为“.class“文件,然后通过dx编译为Dex文件。对于增量编译,我们希望编译尽可能少的代码和资源,最理想情况是只编译变化的部分。但是由于代码之间的依赖,大部分情况这并不可行。这个时候我们只能退而求其次,希望编译更少的模块。[Android Plugin 3.0](https://developer.android.com/studio/build/dependencies)使用Implementation代替Compile,正是为了优化依赖关系。
|
||||
</li>
|
||||
<li>
|
||||
**安装时间**。我们要先经过签名校验,校验成功后会有一大堆的文件拷贝工作,例如APK文件、Library文件、Dex文件等。之后我们还需要编译Odex文件,这个过程特别是在Android 5.0和6.0会非常耗时。对于增量编译,最好的优化是直接应用新的代码,无需重新安装新的APK。
|
||||
</li>
|
||||
|
||||
对于增量编译,我先来讲讲Gradle的官方方案[Instant Run](https://developer.android.com/studio/run/?hl=zh-cn)。在Android Plugin 2.3之前,它使用的Multidex实现。在Android Plugin 2.3之后,它使用Android 5.0新增的Split APK机制。
|
||||
|
||||
如下图所示,资源和Manifest都放在Base APK中, 在Base APK中代码只有Instant Run框架,应用的本身的代码都在Split APK中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/a5/118f836da894cdc9ff16675293e44aa5.png" alt="">
|
||||
|
||||
Instant Run有三种模式,如果是热交换和温交换,我们都无需重新安装新的Split APK,它们的区别在于是否重启Activity。对于冷交换,我们需要通过`adb install-multiple -r -t`重新安装改变的Split APK,应用也需要重启。
|
||||
|
||||
虽然无论哪一种模式,我们都不需要重新安装Base APK。这让Instant Run看起来是不是很不错,但是在大型项目里面,它的性能依然非常糟糕,主要原因是:
|
||||
|
||||
<li>
|
||||
**多进程问题**。“The app was restarted since it uses multiple processes”,如果应用存在多进程,热交换和温交换都不能生效。因为大部分应用都会存在多进程的情况,Instant Run的速度也就大打折扣。
|
||||
</li>
|
||||
<li>
|
||||
**Split APK安装问题**。虽然Split APK的安装不会生成Odex文件,但是这里依然会有签名校验和文件拷贝(APK安装的乒乓机制)。这个时间需要几秒到几十秒,是不能接受的。
|
||||
</li>
|
||||
<li>
|
||||
**javac问题**。在Gradle 4.6之前,如果项目中运用了Annotation Processor。那不好意思,本次修改以及它依赖的模块都需要全量javac,而这个过程是非常慢的,可能会需要几十秒。这个问题直到[Gradle 4.7](https://docs.gradle.org/current/userguide/java_plugin.html#sec:incremental_annotation_processing)才解决,关于这个问题原因的讨论你可以参考这个[Issue](https://github.com/gradle/gradle/issues/1320)。
|
||||
</li>
|
||||
|
||||
你还可以看看这一个Issue:“[full rebuild if a class contains a constant](https://github.com/gradle/gradle/issues/2767)”,假设修改的类中包含一个“public static final”的变量,那同样也不好意思,本次修改以及它依赖的模块都需要全量javac。这是为什么呢?因为常量池是会直接把值编译到其他类中,Gradle并不知道有哪些类可能使用了这个常量。
|
||||
|
||||
询问Gradle的工作人员,他们出给的解决方案是下面这个:
|
||||
|
||||
```
|
||||
// 原来的常量定义:
|
||||
public static final int MAGIC = 23
|
||||
|
||||
// 将常量定义替换成方法:
|
||||
public static int magic() {
|
||||
return 23;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于大型项目来说,这肯定是不可行的。正如我在Issue中所写的一样,无论我们是不是真正改到这个常量,Gradle都会无脑的全量javac,这样肯定是不对的。事实上,我们可以通过比对这次代码修改,看看是否有真正改变某一个常量的值。
|
||||
|
||||
但是可能用过阿里的[Freeline](https://github.com/alibaba/freeline)或者蘑菇街的[极速编译](https://tech.meili-inc.com/233-233?from=timeline&isappinstalled=0)的同学会有疑问,它们的方案为什么不会遇到Annotation和常量的问题?
|
||||
|
||||
事实上,它们的方案在大部分情况比Instant Run更快,那是因为牺牲了正确性。也就是说它们为了追求更快的速度,直接忽略了Annotation和常量改变可能带来错误的编译产物。Instant Run作为官方方案,它优先保证的是100%的正确性。
|
||||
|
||||
**当然Google的人也发现了Instant Run的种种问题,在Android Studio 3.5之后,对于Android 8.0以后的设备将会使用新的方案“[Apply Changes](https://androidstudio.googleblog.com/2019/01/android-studio-35-canary-1-available.html)”代替Instant Run。目前我还没找到关于这套方案更多的资料,不过我认为应该是抛弃了Split APK机制**。
|
||||
|
||||
一直以来,我心目中都有一套理想的编译方案,这套方案安装的Base APK依然只是一个壳APK,真正的业务代码放到Assets的ClassesN.dex中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/e4/f02eeaf46a8a4c5a7f82544ca208ebe4.png" alt="">
|
||||
|
||||
<li>
|
||||
**无需安装**。依然使用类似Tinker热修复的方法,每次只把修改以及依赖的类插入到pathclassloader的最前方就可以,不熟悉的同学可以参考[《微信Android热补丁实践演进之路》](https://mp.weixin.qq.com/s/-NmkSwZu83HAmzKPawdTqQ)中的Qzone方案。
|
||||
</li>
|
||||
<li>
|
||||
**Oatmeal**。为了解决首次运行时Assets中ClassesN.dex的Odex耗时问题,我们可以使用“安装包优化“中讲过的ReDex中的黑科技:Oatmeal。它可以在100毫秒以内生成一个完全解释执行的Odex文件。
|
||||
</li>
|
||||
<li>
|
||||
**关闭JIT**。我们通过在AndroidManifest指定[android:vmSafeMode=“true”](https://developer.android.com/guide/topics/manifest/application-element?hl=zh-cn#vmSafeMode),关闭虚拟机的JIT优化,这样也就不会出现Tinker在[Android N混合编译遇到的问题](https://mp.weixin.qq.com/s/h9BHnEkV0RMx0yIW1wpw9g)。
|
||||
</li>
|
||||
|
||||
这套方案应该可以完全解决Instant Run当前的各种问题,我也希望对编译优化感兴趣的同学可以自行实现这一套方案,并能开源出来。
|
||||
|
||||
对于编译速度的优化,我还有几个建议:
|
||||
|
||||
<li>
|
||||
**更换编译机器**。对于实力雄厚的公司,直接更换Mac或者其他更给力的设备作为编译机,这种方式是最简单的。
|
||||
</li>
|
||||
<li>
|
||||
**Build Cache**。可以将大部分不常改变的项目拆离出去,并使用[远端Cache](http://docs.gradle.com/enterprise/tutorials/caching/)模式保留编译后的缓存。
|
||||
</li>
|
||||
<li>
|
||||
**升级Gradle和SDK Build Tools**。我们应该及时去升级最新的编译工具链,享受Google的最新优化成果。
|
||||
</li>
|
||||
<li>
|
||||
**使用Buck**。无论是Buck的exopackage,还是代码的增量编译,Buck都更加高效。但我前面也说过,一个大型项目如果要切换到Buck,其实顾虑还是比较多的。在2014年初微信就接入了Buck,但是因为跟其他项目协作的问题,导致在2015年切换回Gradle方案。相比之下,**可能目前最热的Flutter中[Hot Reload](https://juejin.im/post/5bc80ef7f265da0a857aa924)秒级编译功能会更有吸引力**。
|
||||
</li>
|
||||
|
||||
当然最近几个Android Studio版本,Google也做了大量的其他优化,例如使用[AAPT2](https://developer.android.com/studio/command-line/aapt2)替代了AAPT来编译Android资源。AAPT2实现了资源的增量编译,它将资源的编译拆分成Compile和Link两个步骤。前者资源文件以二进制形式编译Flat格式,后者合并所有的文件再打包。
|
||||
|
||||
除了AAPT2,Google还引入了d8和R8,下面分别是Google提供的一些测试数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/16/ed5dd1797ea15e58c4bbe5a52008bd16.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/2a/cd479426df64c979cda4fe35578c9f2a.png" alt="">
|
||||
|
||||
那什么是d8和R8呢?除了编译速度的优化,它们还有哪些其他的作用?
|
||||
|
||||
**3. 代码优化**
|
||||
|
||||
对于Debug包编译,我们更关心速度。但是对于Release包来说,代码的优化更加重要,因为我们会更加在意应用的性能。
|
||||
|
||||
下面我就分别讲讲ProGuard、d8、R8和ReDex这四种我们可能会用到的代码优化工具。
|
||||
|
||||
**ProGuard**
|
||||
|
||||
在微信Release包12分钟的编译过程里,单独ProGuard就需要花费8分钟。尽管ProGuard真的很慢,但是基本每个项目都会使用到它。加入了ProGuard之后,应用的构建过程流程如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/f3/b4bddb5e8cf4666b3f38f046998f33f3.png" alt="">
|
||||
|
||||
ProGuard主要有混淆、裁剪、优化这三大功能,它的整个处理流程是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/e4/031014e00f8568c5eac9256782005ee4.png" alt="">
|
||||
|
||||
其中优化包括内联、修饰符、合并类和方法等30多种,具体介绍与使用方法你可以参考[官方文档](https://www.guardsquare.com/en/products/proguard/manual/usage/optimizations)。
|
||||
|
||||
**d8**
|
||||
|
||||
Android Studio 3.0推出了[d8](https://developer.android.com/studio/command-line/d8),并在3.1正式成为默认工具。它的作用是将“.class”文件编译为Dex文件,取代之前的dx工具。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/c3/0692ef05be5dac0803819274f6c6b3c3.png" alt="">
|
||||
|
||||
d8除了更快的编译速度之外,还有一个优化是减少生成的Dex大小。根据Google的测试结果,大约会有3%~5%的优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/ee/a410c50d547c49c37640632c81ec2dee.png" alt="">
|
||||
|
||||
**R8**
|
||||
|
||||
R8在Android Studio 3.1中引入,它的志向更加高远,它的目标是取代ProGuard和d8。我们可以直接使用R8把“.class”文件变成Dex。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/ac/9de1ce2e0d821dabcdabf82844601eac.png" alt="">
|
||||
|
||||
同时,R8还支持ProGuard中混淆、裁剪、优化这三大功能。由于目前R8依然处于实验阶段,网上的介绍资料并不多,你可以参考下面这些资料:
|
||||
|
||||
- ProGuard和R8对比:[ProGuard and R8: a comparison of optimizers](https://www.guardsquare.com/en/blog/proguard-and-r8)。
|
||||
- Jake Wharton大神的博客最近有很多R8相关的文章:[https://jakewharton.com/blog/](https://jakewharton.com/blog/)。
|
||||
|
||||
R8的最终目的跟d8一样,一个是加快编译速度,一个是更强大的代码优化。
|
||||
|
||||
**ReDex**
|
||||
|
||||
如果说R8是未来想取代的ProGuard的工具,那Facebook的内部使用的[ReDex](https://github.com/facebook/redex)其实已经做到了。
|
||||
|
||||
Facebook内部的很多项目都已经全部切换到ReDex,不再使用ProGuard了。跟ProGuard不同的是,它直接输入的对象是Dex,而不是“.class”文件,也就是它是直接针对最终产物的优化,所见即所得。
|
||||
|
||||
在前面的文章中,我已经不止一次提到ReDex这个项目,因为它里面的功能实在是太强大了,具体可以参考专栏前面的文章[《包体积优化(上):如何减少安装包大小?》](https://time.geekbang.org/column/article/81202)。
|
||||
|
||||
- Interdex:类重排和文件重排、Dex分包优化。
|
||||
- Oatmeal:直接生成的Odex文件。
|
||||
- StripDebugInfo:去除Dex中的Debug信息。
|
||||
|
||||
此外,ReDex中例如[Type Erasure](https://github.com/facebook/redex/tree/master/opt/type-erasure)和去除代码中的[Aceess方法](https://github.com/facebook/redex/tree/master/opt/access-marking)也是非常不错的功能,它们无论对包体积还是应用的运行速度都有帮助,因此我也鼓励你去研究和实践一下它们的用法和效果。但是ReDex的文档也是万年不更新的,而且里面掺杂了一些Facebook内部定制的逻辑,所以它用起来的确非常不方便。目前我主要还是直接研究它的源码,参考它的原理,然后再直接单独实现。
|
||||
|
||||
事实上,Buck里面其实也还有很多好用的东西,但是文档里面依然什么都没有提到,所以还是需要“read the source code”。
|
||||
|
||||
- Library Merge和Relinker
|
||||
- 多语言拆分
|
||||
- 分包支持
|
||||
- ReDex支持
|
||||
|
||||
## 持续交付
|
||||
|
||||
Gradle、Buck、Bazel它们代表的都是狭义上的编译,我认为广义的编译应该包括打包构建、Code Review、代码工程管理、代码扫描等流程,也就是业界最近经常提起的持续集成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/a9/020fc61a0096102fa1b05be2f30b02a9.png" alt="">
|
||||
|
||||
目前最常用的持续集成工具有Jenkins、GitLab CI、Travis CI等,GitHub也有提供自己的持续集成服务。每个大公司都有自己的持续集成方案,例如腾讯的RDM、阿里的摩天轮、大众点评的[MCI](https://tech.meituan.com/2018/07/12/mci.html)等。
|
||||
|
||||
下面我来简单讲一下我对持续集成的一些经验和看法:
|
||||
|
||||
<li>
|
||||
**自定义代码检查**。每个公司都会有自己的编码规范,代码检查的目的在于防止不符合规范的代码提交到远程仓库中。比如微信就定义了一套代码规范,并且写了专门的插件来检测。例如日志规范、不能直接使用new Thread、new Handler等,而且违反者将会得到一定的惩罚。自定义代码检测可以通过完全自己实现或者扩展Findbugs插件,例如美团它们就利用Findbugs实现了[Android漏洞扫描工具Code Arbiter](https://tech.meituan.com/2017/08/17/android-code-arbiter.html)。
|
||||
</li>
|
||||
<li>
|
||||
**第三方代码检查**。业界比较常用的代码扫描工具有收费的Coverity,以及Facebook开源的[Infer](https://github.com/facebook/infer),例如空指针、多线程问题、资源泄漏等很多问题都可以扫描出来。除了增加检测流程,我最大的体会是需要同时增加人员的培训。我遇到很多开发者为了解决扫描出来的问题,空指针就直接判空、多线程就直接加锁,最后可能会造成更加严重的问题。
|
||||
</li>
|
||||
<li>
|
||||
**Code Review**。关于Code Review,集成GitLab、Phabricator或者Gerrit都是不错的选择。我们一定要重视Code Review,这也是给其他人展示我们“伟大”代码的机会。而且我们自己应该是第一个Code Reviewer,在给别人Review之前,自己先以第三者的角度审视一次代码。这样先通过自己这一关的考验,既尊重了别人的时间,也可以为自己树立良好的技术品牌。
|
||||
</li>
|
||||
|
||||
持续集成涉及的流程有很多,你需要结合自己团队的现状。如果只是一味地去增加流程,有时候可能适得其反。
|
||||
|
||||
## 总结
|
||||
|
||||
在Android 8.0,Google引入了[Dexlayout](https://source.android.com/devices/tech/dalvik/improvements)库实现类和方法的重排,Facebook的Buck也第一时间引入了AAPT2。ReDex、d8、R8其实都是相辅相成,可以看到Google也在摄取社区的知识,但同时我们也会从Google的新技术发展里寻求思路。
|
||||
|
||||
我在写今天的内容时还有另外一个体会,Google为了解决Android编译速度的问题,花了大量的力气结果却不尽如人意。我想说如果我们敢于跳出系统的制约,可能才会彻底解决这个问题,正如在Flutter上面就可以完美实现秒级编译。其实做人、做事也是如此,我们经常会陷入局部最优解的困局,或者走进“思维怪圈”,这时如果能跳出路径依赖,从更高的维度重新思考、审视全局,得到的体会可能会完全不一样。
|
||||
|
||||
## 课后作业
|
||||
|
||||
在你的工作中,遇到过哪些编译问题?有没有做过具体优化编译速度的工作?对于编译,你还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
对于Android Build System,可以说每年都会有不少的变化,也有很多新的东西出来。所以我们应该保持敏感度,你会发现很多工具都非常有用,例如Desugar、Dexlayout、JVM TI、App Bundle等。
|
||||
|
||||
今天的课后作业是,请你观看2018年Google I/O编译工具相关的视频,在留言中写下自己的心得体会。
|
||||
|
||||
<li>
|
||||
[What’s new with the Android build system (Google I/O '18)](http://v.youku.com/v_show/id_XMzYwMDQ3MDk2OA==.html?spm=a2h0k.11417342.soresults.dtitle)
|
||||
</li>
|
||||
<li>
|
||||
[What’s new in Android development tools](http://v.youku.com/v_show/id_XMzU5ODExNzQzMg==.html?spm=a2h0k.11417342.soresults.dtitle)
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
321
极客时间专栏/Android开发高手课/模块二 高效开发/27 | 编译插桩的三种方法:AspectJ、ASM、ReDex.md
Normal file
321
极客时间专栏/Android开发高手课/模块二 高效开发/27 | 编译插桩的三种方法:AspectJ、ASM、ReDex.md
Normal file
@@ -0,0 +1,321 @@
|
||||
<audio id="audio" title="27 | 编译插桩的三种方法:AspectJ、ASM、ReDex" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/c8/c7ecd38869e2f6226e91f032093522c8.mp3"></audio>
|
||||
|
||||
只要简单回顾一下前面课程的内容你就会发现,在启动耗时分析、网络监控、耗电监控中已经不止一次用到编译插桩的技术了。那什么是编译插桩呢?顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/75/be872dd6d12ab22879fbec9414fb2d75.png" alt="">
|
||||
|
||||
如上图所示,请你回忆一下Java代码的编译流程,思考一下插桩究竟是在编译流程中的哪一步工作?除了我们之前使用的一些场景,它还有哪些常见的应用场景?在实际工作中,我们应该怎样更好地使用它?现在都有哪些常用的编译插桩方法?今天我们一起来解决这些问题。
|
||||
|
||||
## 编译插桩的基础知识
|
||||
|
||||
不知道你有没有注意到,在编译期间修改和生成代码其实是很常见的行为,无论是Dagger、ButterKnife这些APT(Annotation Processing Tool)注解生成框架,还是新兴的Kotlin语言[编译器](https://github.com/JetBrains/kotlin/tree/v1.2.30/compiler/backend/src/org/jetbrains/kotlin/codegen),它们都用到了编译插桩的技术。
|
||||
|
||||
下面我们一起来看看还有哪些场景会用到编译插桩技术。
|
||||
|
||||
**1. 编译插桩的应用场景**
|
||||
|
||||
编译插桩技术非常有趣,同样也很有价值,掌握它之后,可以完成一些其他技术很难实现或无法完成的任务。学会这项技术以后,我们就可以随心所欲地操控代码,满足不同场景的需求。
|
||||
|
||||
<li>
|
||||
**代码生成**。除了Dagger、ButterKnife这些常用的注解生成框架,Protocol Buffers、数据库ORM框架也都会在编译过程生成代码。代码生成隔离了复杂的内部实现,让开发更加简单高效,而且也减少了手工重复的劳动量,降低了出错的可能性。
|
||||
</li>
|
||||
<li>
|
||||
**代码监控**。除了网络监控和耗电监控,我们可以利用编译插桩技术实现各种各样的性能监控。为什么不直接在源码中实现监控功能呢?首先我们不一定有第三方SDK的源码,其次某些调用点可能会非常分散,例如想监控代码中所有new Thread()调用,通过源码的方式并不那么容易实现。
|
||||
</li>
|
||||
<li>
|
||||
**代码修改**。我们在这个场景拥有无限的发挥空间,例如某些第三方SDK库没有源码,我们可以给它内部的一个崩溃函数增加try catch,或者说替换它的图片库等。我们也可以通过代码修改实现无痕埋点,就像网易的[HubbleData](https://neyoufan.github.io/2017/07/11/android/%E7%BD%91%E6%98%93HubbleData%E4%B9%8BAndroid%E6%97%A0%E5%9F%8B%E7%82%B9%E5%AE%9E%E8%B7%B5/)、51信用卡的[埋点实践](https://mp.weixin.qq.com/s/P95ATtgT2pgx4bSLCAzi3Q)。
|
||||
</li>
|
||||
<li>
|
||||
**代码分析**。上一期我讲到持续集成,里面的自定义代码检查就可以使用编译插桩技术实现。例如检查代码中的new Thread()调用、检查代码中的一些敏感权限使用等。事实上,Findbugs这些第三方的代码检查工具也同样使用的是编译插桩技术实现。
|
||||
</li>
|
||||
|
||||
“一千个人眼中有一千个哈姆雷特”,通过编译插桩技术,你可以大胆发挥自己的想象力,做一些对提升团队质量和效能有帮助的事情。
|
||||
|
||||
那从技术实现上看,编译插桩是从代码编译的哪个流程介入的呢?我们可以把它分为两类:
|
||||
|
||||
- **Java文件**。类似APT、AndroidAnnotation这些代码生成的场景,它们生成的都是Java文件,是在编译的最开始介入。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/76/c7f5fb2696906585f13f89eb3ac42c76.png" alt="">
|
||||
|
||||
- **字节码(Bytecode)**。对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式。可以操作“.class”的Java字节码,也可以操作“.dex”的Dalvik字节码,这取决于我们使用的插桩方法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/db/c1208262168a3686bd5d48c1080b6edb.png" alt="">
|
||||
|
||||
相对于Java文件方式,字节码操作方式功能更加强大,应用场景也更广,但是它的使用复杂度更高,所以今天我主要来讲如何通过操作字节码实现编译插桩的功能。
|
||||
|
||||
**2. 字节码**
|
||||
|
||||
对于Java平台,Java虚拟机运行的是Class文件,内部对应的是Java字节码。而针对Android这种嵌入式平台,为了优化性能,Android虚拟机运行的是[Dex文件](https://source.android.com/devices/tech/dalvik/dex-format),Google专门为其设计了一种Dalvik字节码,虽然增加了指令长度但却缩减了指令的数量,执行也更为快速。
|
||||
|
||||
那这两种字节码格式有什么不同呢?下面我们先来看一个非常简单的Java类。
|
||||
|
||||
```
|
||||
public class Sample {
|
||||
public void test() {
|
||||
System.out.print("I am a test sample!");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过下面几个命令,我们可以生成和查看这个Sample.java类的Java字节码和Dalvik字节码。
|
||||
|
||||
```
|
||||
javac Sample.java // 生成Sample.class,也就是Java字节码
|
||||
javap -v Sample // 查看Sample类的Java字节码
|
||||
|
||||
//通过Java字节码,生成Dalvik字节码
|
||||
dx --dex --output=Sample.dex Sample.class
|
||||
|
||||
dexdump -d Sample.dex // 查看Sample.dex的Dalvik的字节码
|
||||
|
||||
```
|
||||
|
||||
你可以直观地看到Java字节码和Dalvik字节码的差别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/50/8c9a2f2a6bfe9e2d42b9f1ea3a642250.png" alt="">
|
||||
|
||||
它们的格式和指令都有很明显的差异。关于Java字节码的介绍,你可以参考[JVM文档](https://docs.oracle.com/javase/specs/jvms/se11/html/index.html)。对于Dalvik字节码来说,你可以参考Android的[官方文档](https://source.android.com/devices/tech/dalvik/dalvik-bytecode)。它们的主要区别有:
|
||||
|
||||
- **体系结构**。Java虚拟机是基于栈实现,而Android虚拟机是基于寄存器实现。在ARM平台,寄存器实现性能会高于栈实现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/1c/3357723f90b34af2717e320b61dca01c.png" alt="">
|
||||
|
||||
<li>
|
||||
**格式结构**。对于Class文件,每个文件都会有自己单独的常量池以及其他一些公共字段。对于Dex文件,整个Dex中的所有Class共用同一个常量池和公共字段,所以整体结构更加紧凑,因此也大大减少了体积。
|
||||
</li>
|
||||
<li>
|
||||
**指令优化**。Dalvik字节码对大量的指令专门做了精简和优化,如下图所示,相同的代码Java字节码需要100多条,而Dalvik字节码只需要几条。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/4f/359817cf20201f045eef60a31f45ff4f.png" alt="">
|
||||
|
||||
关于Java字节码和Dalvik字节码的更多介绍,你可以参考下面的资料:
|
||||
|
||||
<li>
|
||||
[Dalvik and ART](https://github.com/AndroidAdvanceWithGeektime/Chapter27/blob/master/doucments/Dalvik%20and%20ART.pdf)
|
||||
</li>
|
||||
<li>
|
||||
[Understanding the Davlik Virtual Machine](https://github.com/AndroidAdvanceWithGeektime/Chapter27/blob/master/doucments/Understanding%20the%20Davlik%20Virtual%20Machine.pdf)
|
||||
</li>
|
||||
|
||||
## 编译插桩的三种方法
|
||||
|
||||
AspectJ和ASM框架的输入和输出都是Class文件,它们是我们最常用的Java字节码处理框架。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/2c/7d428fcc675b9308d4059a855295692c.png" alt="">
|
||||
|
||||
**1. AspectJ**
|
||||
|
||||
[AspectJ](https://www.eclipse.org/aspectj/)是Java中流行的AOP(aspect-oriented programming)编程扩展框架,网上很多文章说它处理的是Java文件,其实并不正确,它内部也是通过字节码处理技术实现的代码注入。
|
||||
|
||||
从底层实现上来看,AspectJ内部使用的是[BCEL](https://github.com/apache/commons-bcel)框架来完成的,不过这个库在最近几年没有更多的开发进展,官方也建议切换到ObjectWeb的ASM框架。关于BCEL的使用,你可以参考[《用BCEL设计字节码》](https://www.ibm.com/developerworks/cn/java/j-dyn0414/index.html)这篇文章。
|
||||
|
||||
从使用上来看,作为字节码处理元老,AspectJ的框架的确有自己的一些优势。
|
||||
|
||||
<li>
|
||||
**成熟稳定**。从字节码的格式和各种指令规则来看,字节码处理不是那么简单,如果处理出错,就会导致程序编译或者运行过程出问题。而AspectJ作为从2001年发展至今的框架,它已经很成熟,一般不用考虑插入的字节码正确性的问题。
|
||||
</li>
|
||||
<li>
|
||||
**使用简单**。AspectJ功能强大而且使用非常简单,使用者完全不需要理解任何Java字节码相关的知识,就可以使用自如。它可以在方法(包括构造方法)被调用的位置、在方法体(包括构造方法)的内部、在读写变量的位置、在静态代码块内部、在异常处理的位置等前后,插入自定义的代码,或者直接将原位置的代码替换为自定义的代码。
|
||||
</li>
|
||||
|
||||
在专栏前面文章里我提过360的性能监控框架[ArgusAPM](https://github.com/Qihoo360/ArgusAPM),它就是使用AspectJ实现性能的监控,其中[TraceActivity](https://github.com/Qihoo360/ArgusAPM/blob/master/argus-apm/argus-apm-aop/src/main/java/com/argusapm/android/aop/TraceActivity.java)是为了监控Application和Activity的生命周期。
|
||||
|
||||
```
|
||||
// 在Application onCreate执行的时候调用applicationOnCreate方法
|
||||
@Pointcut("execution(* android.app.Application.onCreate(android.content.Context)) && args(context)")
|
||||
public void applicationOnCreate(Context context) {
|
||||
|
||||
}
|
||||
// 在调用applicationOnCreate方法之后调用applicationOnCreateAdvice方法
|
||||
@After("applicationOnCreate(context)")
|
||||
public void applicationOnCreateAdvice(Context context) {
|
||||
AH.applicationOnCreate(context);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以看到,我们完全不需要关心底层Java字节码的处理流程,就可以轻松实现编译插桩功能。关于AspectJ的文章网上有很多,不过最全面的还是官方文档,你可以参考[《AspectJ程序设计指南》](https://github.com/AndroidAdvanceWithGeektime/Chapter27/blob/master/AspectJ%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1%E6%8C%87%E5%8D%97.pdf)和[The AspectJ 5 Development Kit Developer’s Notebook](https://www.eclipse.org/aspectj/doc/next/adk15notebook/index.html),这里我就不详细描述了。
|
||||
|
||||
但是从AspectJ的使用说明里也可以看出它的一些劣势,它的功能无法满足我们某些场景的需要。
|
||||
|
||||
<li>
|
||||
**切入点固定**。AspectJ只能在一些固定的切入点来进行操作,如果想要进行更细致的操作则无法完成,它不能针对一些特定规则的字节码序列做操作。
|
||||
</li>
|
||||
<li>
|
||||
**正则表达式**。AspectJ的匹配规则是类似正则表达式的规则,比如匹配Activity生命周期的onXXX方法,如果有自定义的其他以on开头的方法也会匹配到。
|
||||
</li>
|
||||
<li>
|
||||
**性能较低**。AspectJ在实现时会包装自己的一些类,逻辑比较复杂,不仅生成的字节码比较大,而且对原函数的性能也会有所影响。
|
||||
</li>
|
||||
|
||||
我举专栏第7期启动耗时[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter07)的例子,我们希望在所有的方法调用前后都增加Trace的函数。如果选择使用AspectJ,那么实现真的非常简单。
|
||||
|
||||
```
|
||||
@Before("execution(* **(..))")
|
||||
public void before(JoinPoint joinPoint) {
|
||||
Trace.beginSection(joinPoint.getSignature().toString());
|
||||
}
|
||||
|
||||
@After("execution(* **(..))")
|
||||
public void after() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但你可以看到经过AspectJ的字节码处理,它并不会直接把Trace函数直接插入到代码中,而是经过一系列自己的封装。如果想针对所有的函数都做插桩,AspectJ会带来不少的性能影响。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/9b/3cc968bb9654fbabbcc26d212ad7719b.png" alt="">
|
||||
|
||||
不过大部分情况,我们可能只会插桩某一小部分函数,这样AspectJ带来的性能影响就可以忽略不计了。如果想在Android中直接使用AspectJ,还是比较麻烦的。这里我推荐你直接使用沪江的[AspectJX](https://github.com/HujiangTechnology/gradle_plugin_android_aspectjx)框架,它不仅使用更加简便一些,而且还扩展了排除某些类和JAR包的能力。如果你想通过Annotation注解方式接入,我推荐使用Jake Wharton大神写的[Hugo](https://github.com/JakeWharton/hugo)项目。
|
||||
|
||||
虽然AspectJ使用方便,但是在使用的时候不注意的话还是会产生一些意想不到的异常。比如使用Around Advice需要注意方法返回值的问题,在Hugo里的处理方法是将joinPoint.proceed()的返回值直接返回,同时也需要注意[Advice Precedence](https://www.eclipse.org/aspectj/doc/released/progguide/semantics-advice.html)的情况。
|
||||
|
||||
**2. ASM**
|
||||
|
||||
如果说AspectJ只能满足50%的字节码处理场景,那[ASM](http://asm.ow2.org/)就是一个可以实现100%场景的Java字节码操作框架,它的功能也非常强大。使用ASM操作字节码主要的特点有:
|
||||
|
||||
<li>
|
||||
**操作灵活**。操作起来很灵活,可以根据需求自定义修改、插入、删除。
|
||||
</li>
|
||||
<li>
|
||||
**上手难**。上手比较难,需要对Java字节码有比较深入的了解。
|
||||
</li>
|
||||
|
||||
为了使用简单,相比于BCEL框架,ASM的优势是提供了一个Visitor模式的访问接口(Core API),使用者可以不用关心字节码的格式,只需要在每个Visitor的位置关心自己所修改的结构即可。但是这种模式的缺点是,一般只能在一些简单场景里实现字节码的处理。
|
||||
|
||||
事实上,专栏第7期启动耗时的Sample内部就是使用ASM的Core API,具体你可以参考[MethodTracer](https://github.com/AndroidAdvanceWithGeektime/Chapter07/blob/master/systrace-gradle-plugin/src/main/java/com/geektime/systrace/MethodTracer.java#L259)类的实现。从最终效果上来看,ASM字节码处理后的效果如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/2d/51f13d88cebf089fee2e90154206852d.png" alt="">
|
||||
|
||||
相比AspectJ,ASM更加直接高效。但是对于一些复杂情况,我们可能需要使用另外一种Tree API来完成对Class文件更直接的修改,因此这时候你要掌握一些必不可少的Java字节码知识。
|
||||
|
||||
此外,我们还需要对Java虚拟机的运行机制有所了解,前面我就讲到Java虚拟机是基于栈实现。那什么是Java虚拟机的栈呢?,引用《Java虚拟机规范》里对Java虚拟机栈的描述:
|
||||
|
||||
>
|
||||
每一条Java虚拟机线程都有自己私有的Java虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Stack Frame)。
|
||||
|
||||
|
||||
正如这句话所描述的,每个线程都有自己的栈,所以在多线程应用程序中多个线程就会有多个栈,每个栈都有自己的栈帧。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/9a/49f6179e747be98e6b36119d3151839a.png" alt="">
|
||||
|
||||
如下图所示,我们可以简单认为栈帧包含3个重要的内容:本地变量表(Local Variable Array)、操作数栈(Operand Stack)和常量池引用(Constant Pool Reference)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/00/426fb987bed676f448ebf8e938df2a00.png" alt="">
|
||||
|
||||
<li>
|
||||
**本地变量表**。在使用过程中,可以认为本地变量表是存放临时数据的,并且本地变量表有个很重要的功能就是用来传递方法调用时的参数,当调用方法的时候,参数会依次传递到本地变量表中从0开始的位置上,并且如果调用的方法是实例方法,那么我们可以通过第0个本地变量中获取当前实例的引用,也就是this所指向的对象。
|
||||
</li>
|
||||
<li>
|
||||
**操作数栈**。可以认为操作数栈是一个用于存放指令执行所需要的数据的位置,指令从操作数栈中取走数据并将操作结果重新入栈。
|
||||
</li>
|
||||
|
||||
由于本地变量表的最大数和操作数栈的最大深度是在编译时就确定的,所以在使用ASM进行字节码操作后需要调用ASM提供的visitMaxs方法来设置maxLocal和maxStack数。不过,ASM为了方便用户使用,已经提供了自动计算的方法,在实例化ClassWriter操作类的时候传入COMPUTE_MAXS后,ASM就会自动计算本地变量表和操作数栈。
|
||||
|
||||
```
|
||||
ClassWriter(ClassWriter.COMPUTE_MAXS)
|
||||
|
||||
```
|
||||
|
||||
下面以一个简单的“1+2“为例,它的操作数以LIFO(后进先出)的方式进行操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/eb/89dd2ea472be03aff9bfddba5b5ff3eb.png" alt="">
|
||||
|
||||
ICONST_1将int类型1推送栈顶,ICONST_2将int类型2推送栈顶,IADD指令将栈顶两个int类型的值相加后将结果推送至栈顶。操作数栈的最大深度也是由编译期决定的,很多时候ASM修改后的代码会增加操作数栈最大深度。不过ASM已经提供了动态计算的方法,但同时也会带来一些性能上的损耗。
|
||||
|
||||
在具体的字节码处理过程中,特别需要注意的是本地变量表和操作数栈的数据交换和try catch blcok的处理。
|
||||
|
||||
- **数据交换**。如下图所示,在经过IADD指令操作后,又通过ISTORE 0指令将栈顶int值存入第1个本地变量中,用于临时保存,在最后的加法过程中,将0和1位置的本地变量取出压入操作数栈中供IADD指令使用。关于本地变量和操作数栈数据交互的指令,你可以参考虚拟机规范,里面提供了一系列根据不同数据类型的指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/e2/f6db02fcc40462ad054f37ff36bcf1e2.png" alt="">
|
||||
|
||||
- **异常处理**。在字节码操作过程中需要特别注意异常处理对操作数栈的影响,如果在try和catch之间抛出了一个可捕获的异常,那么当前的操作数栈会被清空,并将异常对象压入这个空栈中,执行过程在catch处继续。幸运的是,如果生成了错误的字节码,编译器可以辨别出该情况并导致编译异常,ASM中也提供了[字节码Verify](https://asm.ow2.io/javadoc/org/objectweb/asm/util/CheckClassAdapter.html)的接口,可以在修改完成后验证一下字节码是否正常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/f5/ac268beb15b325d380097f64eb1eabf5.png" alt="">
|
||||
|
||||
如果想在一个方法执行完成后增加代码,ASM相对也要简单很多,可以在字节码中出现的每一条RETURN系或者ATHROW的指令前,增加处理的逻辑即可。
|
||||
|
||||
**3. ReDex**
|
||||
|
||||
ReDex不仅只是作为一款Dex优化工具,它也提供了很多的小工具和文档里没有提到的一些新奇功能。比如在ReDex里提供了一个简单的Method Tracing和Block Tracing工具,这个工具可以在所有方法或者指定方法前面插入一段跟踪代码。
|
||||
|
||||
官方提供了一个例子,用来展示这个工具的使用,具体请查看[InstrumentTest](https://github.com/facebook/redex/blob/5d0d4f429198a56c83c013b26b1093d80edc842b/test/instr/InstrumentTest.config)。这个例子会将[InstrumentAnalysis](https://github.com/facebook/redex/blob/5d0d4f429198a56c83c013b26b1093d80edc842b/test/instr/InstrumentAnalysis.java)的onMethodBegin方法插入到除黑名单以外的所有方法的开头位置。具体配置如下:
|
||||
|
||||
```
|
||||
"InstrumentPass" : {
|
||||
"analysis_class_name": "Lcom/facebook/redextest/InstrumentAnalysis;", //存在桩代码的类
|
||||
"analysis_method_name": "onMethodBegin", //存在桩代码的方法
|
||||
"instrumentation_strategy": "simple_method_tracing"
|
||||
, //插入策略,有两种方案,一种是在方法前面插入simple_method_tracing,一种是在CFG 的Block前后插入basic_block_tracing
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ReDex的这个功能并不是完整的AOP工具,但它提供了一系列指令生成API和Opcode插入API,我们可以参照这个功能实现自己的字节码注入工具,这个功能的代码在[Instrument.cpp](https://github.com/facebook/redex/blob/master/opt/instrument/Instrument.cpp)中。
|
||||
|
||||
这个类已经将各种字节码特殊情况处理得相对比较完善,我们可以直接构造一段Opcode调用其提供的Insert接口即可完成代码的插入,而不用过多考虑可能会出现的异常情况。不过这个类提供的功能依然耦合了ReDex的业务,所以我们需要提取有用的代码加以使用。
|
||||
|
||||
由于Dalvik字节码发展时间尚短,而且因为Dex格式更加紧凑,修改起来往往牵一发而动全身。并且Dalvik字节码的处理相比Java字节码会更加复杂一些,所以直接操作Dalvik字节码的工具并不是很多。
|
||||
|
||||
市面上大部分需要直接修改Dex的情况是做逆向,很多同学都采用手动书写Smali代码然后编译回去。这里我总结了一些修改Dalvik字节码的库。
|
||||
|
||||
<li>
|
||||
[ASMDEX](https://gitlab.ow2.org/asm/asmdex),开发者是ASM库的开发者,但很久未更新了。
|
||||
</li>
|
||||
<li>
|
||||
[Dexter](https://android.googlesource.com/platform/tools/dexter/+/refs/heads/master),Google官方开发的Dex操作库,更新很频繁,但使用起来很复杂。
|
||||
</li>
|
||||
<li>
|
||||
[Dexmaker](https://github.com/linkedin/dexmaker),用来生成Dalvik字节码的代码。
|
||||
</li>
|
||||
<li>
|
||||
[Soot](https://github.com/Sable/soot),修改Dex的方法很另类,是先将Dalvik字节码转成一种Jimple three-address code,然后插入Jimple Opcode后再转回Dalvik字节码,具体可以参考[例子](https://raw.githubusercontent.com/wiki/Sable/soot/code/androidinstr/AndroidInstrument.java_.txt)。
|
||||
</li>
|
||||
|
||||
## 总结
|
||||
|
||||
今天我介绍了几种比较有代表性的框架来讲解编译插桩相关的内容。代码生成、代码监控、代码魔改以及代码分析,编译插桩技术无所不能,因此需要我们充分发挥想象力。
|
||||
|
||||
对于一些常见的应用场景,前辈们付出了大量的努力将它们工具化、API化,让我们不需要懂得底层字节码原理就可以轻松使用。但是如果真要想达到随心所欲的境界,即使有类似ASM工具的帮助,也还是需要我们对底层字节码有比较深的理解和认识。
|
||||
|
||||
当然你也可以成为“前辈”,将这些场景沉淀下来,提供给后人使用。但有的时候“能力限制想象力”,如果能力不够,即使想象力到位也无可奈何。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你使用过哪些编译插桩相关的工具?使用编译插桩实现过什么功能?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是重温专栏[第7期练习Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter07)的实现原理,看看它内部是如何使用ASM完成TAG的插桩。在今天的[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter27)里,我也提供了一个使用AspectJ实现的版本。想要彻底学会编译插桩的确不容易,单单写一个高效的Gradle Plugin就不那么简单。
|
||||
|
||||
除了上面的两个Sample,我也推荐你认真看看下面的一些参考资料和项目。
|
||||
|
||||
<li>
|
||||
[一起玩转Android项目中的字节码](https://mp.weixin.qq.com/s?__biz=MzA5MzI3NjE2MA==&mid=2650244795&idx=1&sn=cdfc4acec8b0d2b5c82fd9d884f32f09&chksm=886377d4bf14fec2fc822cd2b3b6069c36cb49ea2814d9e0e2f4a6713f4e86dfc0b1bebf4d39&mpshare=1&scene=1&srcid=1217NjDpKNvdgalsqBQLJXjX%23rd)
|
||||
</li>
|
||||
<li>
|
||||
[字节码操纵技术探秘](https://www.infoq.cn/article/Living-Matrix-Bytecode-Manipulation)
|
||||
</li>
|
||||
<li>
|
||||
[ASM 6 Developer Guide](https://asm.ow2.io/developer-guide.html)
|
||||
</li>
|
||||
<li>
|
||||
[Java字节码(Bytecode)与ASM简单说明](http://blog.hakugyokurou.net/?p=409)
|
||||
</li>
|
||||
<li>
|
||||
[Dalvik and ART](https://github.com/AndroidAdvanceWithGeektime/Chapter27/blob/master/doucments/Dalvik%20and%20ART.pdf)
|
||||
</li>
|
||||
<li>
|
||||
[Understanding the Davlik Virtual Machine](https://github.com/AndroidAdvanceWithGeektime/Chapter27/blob/master/doucments/Understanding%20the%20Davlik%20Virtual%20Machine.pdf)
|
||||
</li>
|
||||
<li>
|
||||
基于ASM的字节码处理工具:[Hunter](https://github.com/Leaking/Hunter/blob/master/README_ch.md)和[Hibeaver](https://github.com/BryanSharp/hibeaver)
|
||||
</li>
|
||||
<li>
|
||||
基于Javassist的字节码处理工具:[DroidAssist](https://github.com/didi/DroidAssist/blob/master/README_CN.md)
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
181
极客时间专栏/Android开发高手课/模块二 高效开发/28 | 大数据与AI,如何高效地测试?.md
Normal file
181
极客时间专栏/Android开发高手课/模块二 高效开发/28 | 大数据与AI,如何高效地测试?.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="28 | 大数据与AI,如何高效地测试?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/f8/ab94103f51a84c84aef815957fcfdff8.mp3"></audio>
|
||||
|
||||
测试作为持续交付中重要的一个环节,它的使命是发现交付过程的质量问题。随着互联网迭代速度的加快,很多产品都是两周甚至每周一个版本,留给测试的时间越来越少。
|
||||
|
||||
那在这么短的时间,如何保障产品的质量,怎样高效地测试呢?我们研发模式在不断地变化,测试的定位又有哪些改变,而未来的测试又会发展成什么样的形态呢?
|
||||
|
||||
## 测试的演进历程
|
||||
|
||||
“专业的事情留给专业的人员”,社会各领域的分工越来越细,设计、研发、产品、测试大家各司其职,共同完成一个产品。但随着技术的发展,这样的分工并不是一成不变,从近两年大公司技术部门的组织调整来看,测试和开发的角色已经在不断融合。
|
||||
|
||||
互联网发展到今天,测试的职责发生了哪些改变?移动端测试又经历了怎么样的演进历程呢?
|
||||
|
||||
**1. 测试的田园时代**
|
||||
|
||||
在移动互联网起步之初,基本上还处在传统的软件研发阶段。产品将需求交给研发,研发实现后交给测试,测试把最终产品交付给用户。我们可以把这个阶段叫作测试的“田园时代“。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/9e/f1fbb65d6f8b77c9c7f7efafe63ca09e.png" alt="">
|
||||
|
||||
测试作为研发流程的一个环节,只是作为产品交付给用户前的一道屏障,承担着质量保证工作。在这个阶段测试有两个最重要的考核指标:每个版本发现的Bug数量和遗漏到线上的Bug数量。
|
||||
|
||||
在测试的“田园时代”,很多Bug可能会出现多次返工,产品的交付流程也很难快起来。这个阶段的主要问题有:
|
||||
|
||||
<li>
|
||||
**人员对立**。测试的KPI是尽可能地发现开发人员的Bug,他们之间非常容易发生对立。特别是出现线上事故的时候,开发埋怨测试没用,测试埋怨开发无能。
|
||||
</li>
|
||||
<li>
|
||||
**效率低下**。虽然引入了Monkey这些基本的自动化工具,但是大多数的测试方法还是依赖人工执行。尽管我们不停地加大测试与研发人员的比例,但整个过程还是问题多多,很难缩短整个产品的交付周期。
|
||||
</li>
|
||||
|
||||
**2. 测试的效能时代**
|
||||
|
||||
随着互联网竞争的加剧,交付速度成为了产品的核心能力。国内外的大公司开始适应趋势的变化,把测试团队的职责转变成为团队的效能服务。
|
||||
|
||||
测试不只是负责交付质量,还需要同时思考产品的质量和效率。正如我前面说到的“211”效能目标,也就是2周交付周期、1周开发周期以及1小时发布时长,测试人员需要直接为这个目标负责,思考如何快速并且高质量完成产品的交付。
|
||||
|
||||
>
|
||||
长兄于病视神,未有形而除之,故名不出于家。中兄治病,其在毫毛,故名不出于闾。若扁鹊者,镵血脉,投毒药,副肌肤,闲而名出闻于诸侯。
|
||||
|
||||
|
||||
这是扁鹊讲三兄弟治病的故事,说的是长兄治病,是治于病情未发作之前,由于一般人不知道他事先能铲除病因,所以他的名气无法传出去。中兄治病,是治于病情初起之时,一般人以为他只能治轻微的小病,所以他的名气只在乡里。而扁鹊是治于病情严重之时,在经脉上穿针管来放血,在皮肤上敷药,所以都以为我的医术最高明,名气因此响遍天下。
|
||||
|
||||
从这个故事来看,扁鹊认为长兄的医术最高,可以做到“治病于未发”。回到我们今天所谈的测试,很多公司已经开始提出“测试左移”,也是希望测试在更早期的阶段介入到交付过程中,不仅是发现问题,要考虑更多的是如何避免问题的出现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/89/ddcab1470783d34d3dbc4d331938a089.png" alt="">
|
||||
|
||||
测试需要深入到产品从设计到发布的各个流程,要做到“**比产品更懂技术,比研发更懂业务**”。为了顺应这个变化,各大公司在组织架构上开始把大型的测试团队打散,揉碎进各个业务开发团队中。“产研测一体化”目的在于统一团队的目标和方向,让所有人为了提升产品效能这个共同目标而努力。这样团队中所有成员成为相亲相爱的一家人,也消除了“产研测”之间的对立现象。
|
||||
|
||||
但是在[《如何衡量研发效能》](https://mp.weixin.qq.com/s/vfhqRxLnHJz_ii2zhXofuA)一文中所提到的:“在产品迭代前期,团队集中设计、编码,引入缺陷,但并未即时地集成和验证。缺陷一直掩藏在系统中,直到项目后期,团队才开始集成和测试,缺陷集中爆发”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/35/f657c510865c4718640ddd5314651a35.png" alt="">
|
||||
|
||||
虽然通过持续交付模式,可以一定程度上削减提交波峰,但是依然无法避免经常出现的“踩点”提交。在测试的效能时代,如何提升测试的效率依然是急需解决的问题。
|
||||
|
||||
在这个阶段,我给出的答案是持续集成的工具化和平台化。从需求发起到分支管理、Code Review、代码检查以及测试发布等,测试团队负责把控各式各样的工具或平台。
|
||||
|
||||
由于整个持续交付过程涉及各个阶段的平台工具,这里我只挑跟测试相关的两个平台重点来讲。
|
||||
|
||||
- **测试平台**。竞品测试、弱网络测试、启动测试、UI测试等,整个测试流程引入了大量的自动化工具。各大公司也改良或创造了不少好用的工具,例如腾讯的New Monkey工具,可以大大提升Monkey的智能度和覆盖率。除了我们比较熟悉的[Espresso](https://developer.android.com/training/testing/espresso/index.html)、[UIAutomator](https://developer.android.com/training/testing/ui-automator)、[Appium](http://appium.io/)以及[Robotium](https://github.com/RobotiumTech/robotium)自动化框架,像阿里的[Macaca](https://github.com/macacajs/macaca-android)、Facebook的[Screenshot Tests for Android](https://github.com/facebook/screenshot-tests-for-android)都是值得学习的开源测试框架。当然,也有一些公司把测试平台打包成服务对外公开,例如腾讯的[WeTest](https://wetest.qq.com/)、华为的[开发者服务](https://developer.huawei.com/consumer/cn/)等。
|
||||
|
||||
下面是这些常用工具的简单对比,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/fa/21890d63ffedd1ea983f30ec397905fa.png" alt="">
|
||||
|
||||
- **体验平台**。在测试的效能时代,自动化并不能完全取代人工测试。在产品交付的不同阶段,我们需要提高团队内外人员的参与度。无论每日的Daily Build、封版时的集中体验,还是测试期间的员工内部体验、灰度期间的外部众测平台,的目的是让尽量多的成员都参与到产品的体验中,提升团队成员对产品的认同感。当然各大公司也都有自己的体验平台,例如腾讯的RDM、蚂蚁的伙伴APP等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/f5/5e427f02ed7bef0c27c7e2a58a8961f5.png" alt="">
|
||||
|
||||
测试的效能时代也是目前大多数公司所处的阶段,据我了解很多公司的工程效能团队,也是从测试团队演进而来的。随着测试团队职能的转变以及技术深度的提升,会涌现出一大批资深的测试开发人员,也会有更多优秀的测试人员走向开发或者产品的岗位。
|
||||
|
||||
不过我发现关于测试国内外也有一些差异,例如国外十分推崇开发编写的test case,但在国内却非常不容易推行。这主要是因为国内业务的迭代更加快速,开发需求都做不完,根本没有时间去写test case,更不用说有的test case写起来可能比开发需求更费时间。
|
||||
|
||||
## 测试的智能时代
|
||||
|
||||
“人人都可以是测试”,虽然在稳定性、兼容性又或者是性能测试的一些场景上,我们做得非常不错,但是对于某些自动化测试场景,特别是UI测试,目前还达不到人工测试的水平。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/b3/49c4ccf69ef9a2bbd5048abdf77733b3.png" alt="">
|
||||
|
||||
就拿UI测试为例,由于版本迭代周期越来越短,而且UI变动又非常频繁,无论是开发还是业务测试人员,对写测试脚本和用例的积极性都不是很高。由于测试脚本的编写成本和维护成本比较高,可复用程度又比较低,所以UI测试往往费时费力,很多时候效率还不如人工测试。
|
||||
|
||||
那测试从效能时代走向下一阶段,在智能时代我们应该怎样去解决这些问题呢?
|
||||
|
||||
**1. AI在测试的应用**
|
||||
|
||||
AI技术在我们熟悉的围棋和星际争霸的人机大战中已经大放异彩了,那它在测试领域可以擦出哪些不一样的火花呢?
|
||||
|
||||
先看看我们在UI自动化测试中遇到的几个困境:
|
||||
|
||||
<li>
|
||||
**覆盖率**。自动化测试可以覆盖多少场景,如何解决登录、网络异常等各种各样情况的干扰。
|
||||
</li>
|
||||
<li>
|
||||
**效率**。我们是否可以提高写测试用例的效率,或者是降低它的编写成本,做到人人都可以写用例。
|
||||
</li>
|
||||
<li>
|
||||
**准确性**。如何提高整个自动化测试过程的稳定性,对于测试流程和最终结果,是否还需要大量的人工干预分析。
|
||||
</li>
|
||||
|
||||
网易开源的[Airtest](https://github.com/AirtestProject/Airtest)、爱奇艺的[AIon](https://mp.weixin.qq.com/s/pIdNMQb6G1BEXMiYYKrwlQ),都尝试利用AI技术解决测试用例的编写效率和门槛问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/33/6a6b138dbd18fb3ce3c70aa52b02fe33.png" alt="">
|
||||
|
||||
这里主要用到图像识别以及OCR技术,以爱奇艺的Alon为例,它的整个处理流程是:
|
||||
|
||||
<li>
|
||||
**图片处理**。首先是场景的识别,例如当前界面是否有弹出对话,是否是登陆页面等,然后通过对截屏进行图像分割。这里的难点在于文字的OCR识别以及布局的分类,例如怎么样把不同的切割图像进行分类,并且能够知道这块图像对应的内容。
|
||||
</li>
|
||||
<li>
|
||||
**结果判定**。如何判定本次UI测试的结果是否是符合预期的,相似度达到多高的程度等。因为UI测试可能遇到的情况有很多,我们需要尽可能提升测试的准确性,减少人工干预的情况。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/c4/c173d58e5d10319499b6843e2c0562c4.png" alt="">
|
||||
|
||||
在Alon的参考文章中,还提到UI2Code这样一个应用场景,也就是把一个应用截图,或者把一个UI设计图,通过图像识别生成对应的代码。其实就是[Pixel to App](http://pixeltoapp.com/)希望实现的效果,相信也是很多开发人员的梦想吧,让我们可以彻底从UI开发中解放出来,通过设计稿就可以直接生成最终的UI代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/7e/bcce8b10091f70b73170942891155b7e.png" alt="">
|
||||
|
||||
Airtest和Alon解决的核心问题是UI自动化测试的效率,但是它们依然需要人工去编写测试用例。对于稳定性测试,虽然我上面提到过腾讯的New Monkey,不过它依然存在很多缺陷:
|
||||
|
||||
<li>
|
||||
**覆盖场景**。虽然是改进版本的Monkey,但是它依然覆盖不了所有的用户场景,而且很多场景它的执行流程不同于真实的用户。
|
||||
</li>
|
||||
<li>
|
||||
**非智能**。这里你可以理解为非人类,Monkey的操作和人类的操作方式完全不同。
|
||||
</li>
|
||||
|
||||
那有没有更智能的解决方案呢?Facebook的[Sapienz](https://code.fb.com/developer-tools/sapienz-intelligent-automated-software-testing-at-scale/)尝试希望像真实用户一样去使用我们开发的应用,它通过收集真实用户的操作路径来训练测试行为。而且在测试出崩溃后,Sapienz会自动关联和定位代码,提升解决问题的效率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/3c/affdab2f6e3f743d349e3337515f9e3c.png" alt="">
|
||||
|
||||
虽然AI测试目前还无法完全取代人工测试,但随着技术的进一步成熟,相信它对测试领域的变革将会是革命性的。当然想要实现这个目标我们还有很多工作要做,这也意味着这其中还有很多技术创新的机会。
|
||||
|
||||
**2. 大数据在测试的应用**
|
||||
|
||||
现在越来越多的业务正在使用数据驱动的方式运作,测试的对象要从简单的代码转变为数据和算法。现在的业务越来越复杂,数据量越来越大,我们应该怎样及时发现产品的质量和业务问题呢?
|
||||
|
||||
国内的一些公司提出了基于大数据的“实时质量”体系,希望通过实时获取线上海量数据,完成业务数据校验和质量风险的感知。
|
||||
|
||||
这里的数据主要包括质量和业务两个方面。
|
||||
|
||||
<li>
|
||||
**质量**。崩溃、启动速度、卡顿以及联网错误等质量问题,我们希望可以做到分钟级别的实时性,同时支持更加细粒度的维度分析。例如可以通过国家、城市、版本等维度来查看网络问题。
|
||||
</li>
|
||||
<li>
|
||||
**业务**。对于产品的核心业务我们需要实现数据收集、分析与校验,打造数据监控和跟踪的能力。
|
||||
</li>
|
||||
|
||||
对于基于大数据的“实时质量”测试体系,关键在于如何保证数据的实时性与准确性,这两点我会在“数据评估”的内容中与你详细讨论。
|
||||
|
||||
除了质量和业务数据,大公司做得比较多的还有用户反馈和舆情的跟踪和分析。各大公司基本都有自己的一套系统,通过爬取用户反馈、应用市场、微博、新闻资讯等各方面的消息来源,监控产品的舆情情况,你可以参考[支付宝如何为移动端产品构建舆情分析体系](https://mp.weixin.qq.com/s/fjOEO8kKpNZ8x8FkfHUceA)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/7e/3e912bb7b93b5349ad42ba8eb315c87e.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
在APM系统搭建和持续交付优化过程,我接触了很多测试工程师,也关注了各大公司测试的现状。对于测试的职业道路发展,我个人的建议是:不管测试如何发展,测试效率如何提升,测试人员都要学会不断地变革,变革自己、变革整个研发流程。我们不能只守着自己的一亩三分田,需要尝试去做很多以前开发需要做的事情,比如性能、稳定性、安全等方面的工作。说得严重一点,如果你不能及时地更新自己的技术栈,不去往更深入的底层走,在测试的智能时代,首先淘汰的就是传统的功能测试人员。
|
||||
|
||||
在这个变革的时代,我身边也有很多独当一面的测试工程师通过平台化的机遇晋级为专家,也有一些优秀的测试转向了产品或研发,所以说还是要提高自身的能力,把机会握在自己手上。
|
||||
|
||||
“学如逆水行舟,不进则退”,对于开发人员同样如此。现在平台工具和框架越来越成熟,很多初学者拿着一大堆开源工具,也能写出炫酷的界面。如果我们不去进步,特别是在大环境不好的时候,也会很容易被淘汰。
|
||||
|
||||
今天我分享了一些我对高效测试的心得和体会,如果同学们里有测试领域的专家,非常欢迎你来谈谈对这个行业和发展的看法,分享一下你对高效测试的看法。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你所在的公司,目前测试正在处于哪个阶段?对于测试,你还有哪些疑问?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,请你观看网易、爱奇艺以及Facebook关于AI在测试领域应用的分享,在留言中写下自己的心得体会。
|
||||
|
||||
<li>
|
||||
网易:[基于AI的网易UI自动化测试方案与实践](https://github.com/AndroidAdvanceWithGeektime/Chapter28/blob/master/GMTC2018-%E3%80%8A%E5%9F%BA%E4%BA%8EAI%E7%9A%84%E7%BD%91%E6%98%93UI%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E6%96%B9%E6%A1%88%E4%B8%8E%E5%AE%9E%E8%B7%B5%E3%80%8B-%E7%86%8A%E5%8D%9A.pdf)
|
||||
</li>
|
||||
<li>
|
||||
爱奇艺:[基于AI的移动端自动化测试框架的设计与实践](https://github.com/AndroidAdvanceWithGeektime/Chapter28/blob/master/%E4%BD%95%E6%A2%81%E4%BC%9F-12.6-%E5%9F%BA%E4%BA%8EAI%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%AB%AF%E8%87%AA%E5%8A%A8%E5%8C%96%E6%B5%8B%E8%AF%95%E6%A1%86%E6%9E%B6%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E8%B7%B5.pdf)
|
||||
</li>
|
||||
<li>
|
||||
Facebook:[Automated fault-finding with Sapienz](https://www.facebook.com/atscaleevents/videos/2133728903555953/)
|
||||
</li>
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
205
极客时间专栏/Android开发高手课/模块二 高效开发/29 | 从每月到每天,如何给版本发布提速?.md
Normal file
205
极客时间专栏/Android开发高手课/模块二 高效开发/29 | 从每月到每天,如何给版本发布提速?.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="29 | 从每月到每天,如何给版本发布提速?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/00/2a5503adc8ee2b7d56d540420d99bd00.mp3"></audio>
|
||||
|
||||
还记得我们在持续交付设定的目标吗?我前面提到过,天猫的效能目标是“211”,也就是2周交付周期、1周开发周期以及1小时发布时长。对于一些更加敏捷的产品,我们可能还会加快到每周一个版本。在如此快的节奏下,我们该如何保证产品的质量?还有哪些手段可以进一步为发布“提速保质”?
|
||||
|
||||
更宽泛地说,广义的发布并不仅限于把应用提交到市场。灰度、A/B测试 、运营活动、资源配置…我们的发布类型越来越多,也越来越复杂。该如何建立稳健的发布质量保障体系,防止出现线上事故呢?
|
||||
|
||||
## APK的灰度发布
|
||||
|
||||
我们在讨论版本发布速度,是需要兼顾效率和质量。如果不考虑交付质量,每天一个版本也很轻松。在严格保证交付质量的前提下,两周发布一个版本其实并不容易。特别是出现紧急安全或者稳定性问题的时候,我们还需要有1小时的发布能力。
|
||||
|
||||
正如我在专栏“[如何高效地测试](https://time.geekbang.org/column/article/83417)”中说的,实现“高质高效”的发布需要强大的测试平台和数据验证平台的支撑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/77/a850639f20e8802296a97dfa92e3bd77.png" alt="">
|
||||
|
||||
下面我们一起来看看影响版本发布效率的那些重要因素,以及我对于提升版本发布速度的实践经验。
|
||||
|
||||
**1. APK灰度**
|
||||
|
||||
测试平台负责对发布包做各种维度的诊断测试,通常会包括Monkey测试、性能测试(启动、内存、CPU、卡顿等)、竞品测试、UI测试、弱网络测试等。但是即使通过云测平台能够同时测试几十上百台机器,本地测试依然无法覆盖所有的机型和用户路径。
|
||||
|
||||
为了安全稳定地发布新版本,我们需要先圈定少量用户安装试用,这就是灰度发布。而数据验证平台则负责收集灰度和线上版本的应用数据,这里可能包括性能数据、业务数据、用户反馈以及外部舆情等。
|
||||
|
||||
所以说,灰度效率首先被下面两个因素所影响:
|
||||
|
||||
<li>
|
||||
**测试效率**。虽然灰度发布只影响少部分用户,但是我们需要尽可能保障应用的质量,以免造成用户流失。测试平台的发布测试时间是影响发布效率的第一个因素,我们希望可以在1小时内明确待定的发布包是否达到上线标准。
|
||||
</li>
|
||||
<li>
|
||||
**数据验证效率**。数据的全面性、实时性以及准确性都会影响灰度版本的评估决策时间,是停止灰度发布,还是进一步扩大灰度的用户量级,或者可以直接发布到全量用户。对于核心数据,需要建立小时甚至分钟级别的实时监控。**比如微信,对于性能数据可以在发布后1小时内评估完毕,而业务数据可以在24小时内评估完毕**。
|
||||
</li>
|
||||
|
||||
另外一方面,如果我们的灰度发布想覆盖一万名用户,那多长时间才有足够的用户下载和安装呢?渠道的能力对灰度发布效率的影响也十分巨大,在国内主要有下面几个灰度渠道。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/50/4c6faedf13a83ad1af0312cabb68b250.png" alt="">
|
||||
|
||||
在国内由于没有统一的应用商店,灰度渠道效率的确是一个非常严峻的问题。即使是微信,如果不动用“红点提示”这个大杀器,每天灰度发布到的用户量可能还不到十万。而国际市场有统一的Google Play,可以通过[Google Beta](http://developer.android.com/distribute/best-practices/launch/beta-tests?hl=zh-cn)进行灰度。但是版本发布需要考虑GP审核的时间,目前GP审核速度相比之前有所加快,一般只需要一到两天时间。
|
||||
|
||||
通过灰度发布我们可以提前收集少部分用户新版本的性能和业务数据,但是它并不适用于精确评估业务数据的好坏。这主要是因为灰度的用户是有选择的,一般相对活跃的用户会被优先升级。
|
||||
|
||||
**2. 动态部署**
|
||||
|
||||
对于灰度发布,整个过程最大的痛点依然是灰度包的覆盖速度问题。而且传统的灰度方式还存在一个非常严重的问题,那就是无法回退。“发出去的包,就像泼出去的水”,如果出现严重问题,还可能造成灰度用户的流失。
|
||||
|
||||
Tinker动态部署框架正是为了解决这个问题而诞生的,我们希望Tinker可以成为一种新的发布方式,用来取代传统的灰度甚至是正式版本的发布。相比传统的发布方式,热修复有很多得天独厚的优势。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/42/a5e29c97ec475509526553e5b50e7442.png" alt="">
|
||||
|
||||
<li>
|
||||
**快速**。如果使用传统的发布方式,微信需要10天时间覆盖50%的用户。而通过热修复,在一天内可以覆盖80%以上的用户,在3天内可以覆盖95%以上的用户。
|
||||
</li>
|
||||
<li>
|
||||
**可回退**。当补丁出现重大问题的时候,可以及时回退补丁,让用户回到基础版本,尽可能降低损失。
|
||||
</li>
|
||||
|
||||
为了提升补丁发布的效率,微信还专门开发了TinkerBoots管理平台。TinkerBoots平台不仅支持人数、条件等参数设置,例如可以选择只针对小米的某款机型下发10000人的补丁;而且平台也会打通数据验证平台,实现自动化的控量发布,自动监控核心指标的变化情况,保证发布质量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/e0/fad6c167189e011ea725768b6dc49ee0.png" alt="">
|
||||
|
||||
Tinker发布已经两年多了,虽然热修复技术可以解决很多问题,但作为Tinker的作者,我必须承认它对国内的Android开发造成了一些不好的影响。
|
||||
|
||||
- **用户是最好的测试**。很多团队不再信奉前置的测试平台,他们认为反正有可以回退的动态部署发布,出现质量问题并不可怕,多发几个补丁就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/b5/378b7e3e2fffa666ddf0286a8eef6ab5.png" alt="">
|
||||
|
||||
- **性能低下**。正如专栏“高质量开发”模块所说的,热修复、组件化这些黑科技会对应用的性能产生不小的影响,特别是启动的耗时。
|
||||
|
||||
从现在看来,热修复并不能取代发布,它更适合使用在少量用户的灰度发布。如果不是出现重大问题,一般情况也不应该发布针对所有用户的补丁。
|
||||
|
||||
组件化回归模块化,热修复回归灰度,这是国内很多大型App为了性能不得不做出的选择。如果想真正实现“随心所欲”的发布,可能需要倒逼开发模式的变革,例如从组件化转变为Web、React Native/Weex或者小程序来实现。
|
||||
|
||||
## A/B测试
|
||||
|
||||
正如我前面所说,APK灰度是对已有功能的线上验证,它并不适合用于准确评估某个功能对业务的影响。
|
||||
|
||||
>
|
||||
<p>If you are not running experiments, you are probably not growing!<br>
|
||||
——by Sean Ellis</p>
|
||||
|
||||
|
||||
Sean Ellis是[增长黑客模型(AARRR)](https://www.jianshu.com/p/183a89c91e9f)之父,增长黑客模型中提到的一个重要思想就是“A/B测试”。 Google、 Facebook、国内的头条和快手等公司都非常热衷于A/B测试,希望通过测试进行科学的、数据驱动式的业务和产品决策。
|
||||
|
||||
那究竟什么是A/B测试?如何正确地使用A/B测试呢?
|
||||
|
||||
**1. 什么是A/B测试**
|
||||
|
||||
可能有同学会认为,A/B测试并不复杂,只是简单地把灰度用户分为A和B两部分,然后通过对比收集到的数据,分析得到测试的结论。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/92/cc9b985f2fb64170e407754700a05c92.png" alt="">
|
||||
|
||||
事实上A/B测试的难点就是A和B用户群体的圈定,我们需要保证测试方案是针对同质人群、同一时间进行,确保了除方案变量之外其他变量都是一致的,这样才能将指标数据差异归到产品方案,从而选出优胜版本在线上发布,实现数据增长。
|
||||
|
||||
<li>
|
||||
**同质**。指人群的各种特征分布的一致性。例如我们想验证产品方案对女性用户的购买意愿的影响,那么A版本和B版本选择的用户必须都是女性。当然特征分布不光是性别,还有国家、城市、使用频率、年龄、职业、新老用户等。
|
||||
</li>
|
||||
<li>
|
||||
**同时**。在不同的时间点,用户的行为可能不太一样。例如在一些重大的节日,用户活跃度会升高,那如果A方案的作用时间在节日,B方案的作用时间在非节日,很显然这种比较对于B方案是不公平的。
|
||||
</li>
|
||||
|
||||
所以实现“同质同时”,并不是那么简单,首先需要丰富和精准的用户画像能力,例如版本、国家、城市、性别、年龄、喜好等用户属性。除此之外,还需要一整套强大的后台,完成测试控制、日志处理、指标计算、统计显著性指标等工作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/e1/3bc2a204cf9e8f79bd4031c08efe8ae1.png" alt="">
|
||||
|
||||
实现了“同质同时”之后,我们接着要找到产品方案的显著性指标,也就是方案想要证明的目标。例如我们优化了弹窗的提示语,目的是吸引更多的用户点击按钮,那按钮的点击率就是这个测试的显著性指标。
|
||||
|
||||
有了这些以后,那我们是不是就可以愉快地开始测试了?不对,你还需要先思考这两个问题:
|
||||
|
||||
- **流量选择**。这个测试应该配置多少流量?配少了怕得不出准确的测试结论,配置多了可能要承担更大的风险。这个时候就需要用到[最小样本量计算器](http://www.evanmiller.org/ab-testing/sample-size.html#!99.977;95;5;0.01;0)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/d8/51b98ef396e91f77b9855f5d570ed4d8.png" alt="">
|
||||
|
||||
A/B测试作为一种抽样统计,它背后涉及大量的[统计学原理](http://help.appadhoc.com/zh/dataDecision/ABC.html),而这个计算器需要我们提供以下数值:
|
||||
|
||||
>
|
||||
<p>**基准线指标值**:请输入测试要优化的指标的基准值,比方“某按钮的点击率”,如果该值每日在9%~11%之间波动,则可输入10%。<br>
|
||||
**指标最小相对变化**:请输入你认为有意义的最小相对变动值。假设你将要优化的指标为10%时,你在最小相对变化中输入5%, 就意味着你认为绝对值变化在(9.5%, 10.5%)之间的变动没有意义,即便此时测试版本更优,你也不会采用。<br>
|
||||
**统计功效1−β**:如果设置统计功效为90%,可通俗理解为,在A/B测试中,当版本A和版本B的某项统计指标本质上存在显著差异时,可以正确地识别出版本A和版本B是有显著差异的概率是90%。<br>
|
||||
**显著性水平α**:如果统计指标的差异超过具体的差异,我们才说测试的结果是显著的。</p>
|
||||
|
||||
|
||||
- **天数选择**。A/B测试需要持续几天,我们才可以认为测试是可靠的。一般来说可以通过下面的方法计算。
|
||||
|
||||
```
|
||||
测试所需持续的天数 >= 计算获得用户数 / (场景日均流量 * 测试版本设置流量百分比)
|
||||
|
||||
```
|
||||
|
||||
**2. 如何进行A/B测试**
|
||||
|
||||
虽然各个大厂都有自己完善的A/B测试平台,但是A/B测试的科学设计并不容易,需要不断地学习,再加上大量的实践经验。
|
||||
|
||||
首先来说,所有的A/B测试都应该是有“预谋”的,也就是我们需要有对应的预期,需要设计好测试的每一个环节。一般来说,在测试开始之前,需要问清楚自己下面这些问题:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/ed/644578eb8364674e93674c1b983937ed.png" alt="">
|
||||
|
||||
为了测试埋点、分流、统计的正确性,以及增加A/B测试的结论可信度,我们还会在A/B测试的同时,增加A/A测试。A/A测试是A/B测试的“孪生兄弟”,有的互联网公司也叫空转测试。它主要用于评估我们测试本身是否科学,一般我推荐使用A/A/B的测试方式。
|
||||
|
||||
那在Android客户端有哪些实现A/B测试的方案呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/43/a8cf8e2e7b3f95a65281b746fda42f43.png" alt="">
|
||||
|
||||
用一句话来描述A/B测试的话就是,“拿到A/B测试的数据容易,拿到可信的A/B测试的数据却很难”,因此在指标设计、人群选择、时间设计、方案的设计都需要考虑清楚。同时也需要多思考、多实践,推荐你拓展阅读[《移动App A/B测试中的5种常见错误》](https://zhuanlan.zhihu.com/p/25319221)。
|
||||
|
||||
## 统一发布平台
|
||||
|
||||
我在专栏里多次提到,虽然我们做了大量的优化,依然受限于原生开发模式的各种天花板的限制。这时可以考虑跳出这些限制,例如Web、React Native/Weex、小程序和Flutter都可以是解决问题的思路。
|
||||
|
||||
但是即使我们转变为新的开发模式,灰度和发布的步骤依然是不可或缺的。此外,我们还要面对各种各样的运营活动、推送弹窗、配置下发。
|
||||
|
||||
可能很多大厂的同学都深受其苦,面对各式各样的发布平台,一不小心就可能造成运营事故。那应该如何规范发布行为,避免出现下发事故呢?
|
||||
|
||||
**1. 发布平台架构**
|
||||
|
||||
每个应用都或多或少涉及下面这些发布类型,但是往往这些发布类型都分别放在大大小小的各种平台中,而且没有统一的管理规范流程,非常容易因为操作错误造成事故。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/32/a5d98e799524d9c2275fa20e0e2f3a32.png" alt="">
|
||||
|
||||
统一发布平台需要集中管理应用所有的数据下发业务,并建立严格规范的灰度发布流程。
|
||||
|
||||
<li>
|
||||
**管理**。所有的发布都必须通过权限校验,需要经过审批。“谁发布,谁负责”,需要建立严格的事故定级制度。对于因为疏忽导致事故的人员,需要定级处理。
|
||||
</li>
|
||||
<li>
|
||||
**灰度**。所有的发布一定要经过灰度测试,慢慢扩大影响的用户范围。但是需要承认,某些下发并不容易测试,例如之前沸沸扬扬的“圣诞节改变展示样式”的事件。对于这种在特定时间生效的运营活动,很难在线上灰度验证。
|
||||
</li>
|
||||
<li>
|
||||
**监控**。统一发布平台需要对接应用的“实时数据平台”,在出现问题的时候,需要及时采取补救措施。
|
||||
</li>
|
||||
|
||||
业务已经那么艰难了,如果下发了一个导致所有用户启动崩溃的闪屏活动,对应用造成的损失就难以衡量。所以规范的流程和章程,可以一定程度上避免问题的发生。当然监控也同样重要,它可以帮助我们及时发现问题并立刻止损。
|
||||
|
||||
**2. 运营事故的应对**
|
||||
|
||||
每天都有大量各种类型的发布,感觉就像在刀尖上行走一样。“人在江湖飘,哪能不挨刀”,当我们发现线上运营问题的时候,还有哪些挽救的措施呢?
|
||||
|
||||
<li>
|
||||
[**启动安全保护**](https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&mid=2247488429&idx=1&sn=448b414a0424d06855359b3eb2ba8569&source=41#wechat_redirect)。最低限度要保证用户不会由于运营配置导致无法启动应用。
|
||||
</li>
|
||||
<li>
|
||||
**动态部署**。如果应用可以正常启动,那我们可以通过热修复的方式解决。但是热修复也存在一定的局限性,例如有3%~5%的用户会无法热修复成功,而且也无法做到立即生效。
|
||||
</li>
|
||||
<li>
|
||||
**远程控制**。在应用中,我们还需要保留一条“救命”的指令通道。通过这条通道,可以执行删除某些文件、拉取补丁、修改某些配置等操作。但是为了避免自有通道失效,这个控制指令需要同时支持厂商通道,例如某个下发资源导致应用启动后界面白屏,这个时候我们可以通过下发指令去删除有问题的资源文件。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/2b/420bcf69de8d3aaa6750b62fc59ed92b.png" alt="">
|
||||
|
||||
“万丈高楼平地起”,类似测试平台、发布平台或者数据平台这些内部系统很难短时间内做得非常完善,它们都是通过优化内部无数的小细节演进而来。所以我们需要始终保持耐心,朝着提升组织效能的方向前进。
|
||||
|
||||
## 总结
|
||||
|
||||
在过去那个做A/B测试非常艰难而且耗时耗力的年代,测试的每个环节都会经过千锤百炼。而现在通过功能强大的A/B测试系统,极大地降低测试的成本,提高了测试的效率,反而很多产品和开发人员变得有点滥用A/B测试,或者说有了“不去思考”的借口。
|
||||
|
||||
无论是研发主导的性能相关的A/B测试,还是产品主导的业务相关的A/B测试,其实很多时候都没有经过严谨的推敲,往往需要通过反反复复多次测试才能得到一个“结论”,而且还无法保证是可信的。所以无论是A/B测试,还是日常的灰度,都需要有明确的预期,真正去推敲里面的每一个环节。不要每次测试发布后,才发现实验设置不合理,或者发现这里那里漏了好几个数据打点,再反反复复进行修改,对参与测试的所有人来说都非常痛苦。
|
||||
|
||||
另外一方面,我们对某个事情的看法并不会一成不变。即使我是Tinker的作者,我也认为它只是某一阶段为了解决特定需求的产物。但是无论开发模式怎么改变,我们对质量和效率的追求是不会改变的。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你的应用是否是A/B测试的狂热分子?对于A/B测试,你有哪些好的或者坏的的经历?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,思考自己的产品或者公司在灰度发布过程中存在哪些痛点?还有哪些优化的空间?请在留言中写下自己的心得体会。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
187
极客时间专栏/Android开发高手课/模块二 高效开发/30 | 数据评估(上):如何实现高可用的上报组件?.md
Normal file
187
极客时间专栏/Android开发高手课/模块二 高效开发/30 | 数据评估(上):如何实现高可用的上报组件?.md
Normal file
@@ -0,0 +1,187 @@
|
||||
<audio id="audio" title="30 | 数据评估(上):如何实现高可用的上报组件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/6d/2a09f320f73b24ffce5f1e032ea5b56d.mp3"></audio>
|
||||
|
||||
无论是“高效测试”中的实时监控,还是“版本发布”中的数据校验平台,我都多次提到了数据的重要性。
|
||||
|
||||
对于数据评估,我们的期望是“又快又准”。“快”,表示数据的时效性。我们希望在1小时内,甚至1分钟内就可以对数据进行评估,而不需要等上1天或者几天。“准”,表示数据的准确性,保证数据可以反映业务的真实情况,不会因为数据不准确导致做出错误的产品决策。
|
||||
|
||||
但是“巧妇难为无米之炊”,数据平台的准确性和时效性依赖客户端数据采集和上报的能力。那应该如何保证客户端上报组件的实时性和准确性?如何实现一个“高可用”的上报组件呢?
|
||||
|
||||
## 统一高可用的上报组件
|
||||
|
||||
可能有同学会疑惑,究竟什么是“高可用”的上报组件?我认为至少需要达到三个目标:
|
||||
|
||||
<li>
|
||||
**数据不会丢失**。数据不会由于应用崩溃、被系统杀死这些异常情况而导致丢失。
|
||||
</li>
|
||||
<li>
|
||||
**实时性高**。无论是前台进程还是后台进程,所有的数据都可以在短时间内及时上报。
|
||||
</li>
|
||||
<li>
|
||||
**高性能**。这里主要有卡顿和流量两个维度,应用不能因为上报组件的CPU和I/O过度占用导致卡顿,也不能因为设计不合理导致用户的流量消耗过多。
|
||||
</li>
|
||||
|
||||
但是数据的完整性、实时性和性能就像天平的两端,我们无法同时把这三者都做到最好。因此我们只能在兼顾性能的同时,尽可能地保证数据不会丢失,让上报延迟更小。
|
||||
|
||||
在“网络优化”中,我不止一次的提到网络库的统一。网络库作为一个重要的基础组件,无论是应用内不同的业务,还是Android和iOS多端,都应该用同一个网络库。
|
||||
|
||||
同理,上报组件也是应用重要的基础组件,我们希望打造的是统一并且高可用的上报组件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/9a/bf769e6e280ebeb1f8d16cbe95fe5e9a.png" alt="">
|
||||
|
||||
一个数据埋点的过程,主要包括采样、存储、上报以及容灾这四个模块,下面我来依次拆解各个模块,一起看看其中的难点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/83/1933f4cf48667805a18e07ec425ea783.png" alt="">
|
||||
|
||||
**1. 采样模块**
|
||||
|
||||
某些客户端数据量可能会非常大,我们并不需要将它们全部都上报到后台。比如说卡顿和内存这些性能数据,我们只需要抽取小部分用户统计就可以了。
|
||||
|
||||
采样模块是很多同学在设计时容易忽视的,但它却是所有模块中最为复杂的一项,需要考虑下面一些策略的选择。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/70/6175884b6d62d6ad5b974baa6c723870.png" alt="">
|
||||
|
||||
大多数的组件采用的都是PV次数采样,这样的确是最简单的。但是我们更多是在性能数据埋点上采样,为了降低用户的影响面,我更加倾向于使用UV采样的方式。而且为了可以让更多的用户上报,我也希望每天都可以更换一批新的用户。
|
||||
|
||||
最终我选择的方案是“UV采样 + 用户标识随机 + 每日更换用户”的方式,但是采样还需要满足三个标准。
|
||||
|
||||
<li>
|
||||
**准确性**。如果配置了1%的采样比例,需要保证某一时刻只有1%的用户会上报这个数据。
|
||||
</li>
|
||||
<li>
|
||||
**均匀性**。如果配置了1%的采样比例,每天都会更换不同的1%用户来上报这个数据。
|
||||
</li>
|
||||
<li>
|
||||
**切换的平滑性**。用户的切换需要平滑,不能在用一个时间例如12点,所有用户同时切换,这样会导致后台数据不连贯。
|
||||
</li>
|
||||
|
||||
实现上面这三个标准并不容易,在微信中我们采用了下面这个算法:
|
||||
|
||||
```
|
||||
// id:用户标识,例如微信号、QQ号
|
||||
id_index = Hash(id) % 采样率倒数
|
||||
time_index = (unix_timestamp / (24*60*60)) % 采样率倒数
|
||||
上报用户 =(id_index == time_index)
|
||||
|
||||
```
|
||||
|
||||
每个采样持续24小时,使整个切换可以很平滑,不会出现所有用户同时在0点更换采样策略。有些用户在早上10点切换,有些用户在11点切换,会分摊到24小时中。并且从一个小时或者一天的维度来看,也都可以保证采样是准确的。
|
||||
|
||||
不同的埋点可以设置不同的采样率,它们之间是独立的、互不影响的。当然除了采样率,在采样策略里我们还可以增加其他的控制参数,例如:
|
||||
|
||||
<li>
|
||||
**上报间隔**:可以配置每个埋点的上报间隔,例如1秒、1分钟、10分钟、60分钟等。
|
||||
</li>
|
||||
<li>
|
||||
**上报网络**:控制某些点只允许WiFi上传。
|
||||
</li>
|
||||
|
||||
**2. 存储模块**
|
||||
|
||||
对于存储模块,我们的目标是在兼顾性能的同时,保证数据完全不会丢失。那应该如何实现呢?我们首先要思考进程和存储模式的选择。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/56/c279b91e975100271abb804280ed2356.png" alt="">
|
||||
|
||||
业内最常见的上报组件是“单进程写 + 文件存储 + 内存缓存”,虽然这种方式实现最为简单,但是无论是跨进程的IPC调用堆积(IPC调用总是很慢的)还是内存缓存,都可能会导致数据的丢失。
|
||||
|
||||
回顾一下在“I/O优化”中,我列出的mmap、内存与写文件的性能对比数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/8e/a898e4578fe7724cc654fa9acb3cdf8e.png" alt="">
|
||||
|
||||
你可以看到mmap的性能非常不错,所以我们最终选择的是 **“多进程写 + mmap”的方案,并且完全抛弃了内存缓存**。不过mmap的性能也并不完美,它在某些时刻依然会出现异步落盘,所以每个进程mmap操作需要放到单独的线程中处理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/87/a8dfe16bc5476e7e21b755e5a62d4c87.png" alt="">
|
||||
|
||||
**“多进程写 + mmap”的方案可以实现完全无锁、无IPC并且数据基本不会丢失**,看上去简直完美,但是真正实现时是不是像图中那么简单呢?肯定不会那么简单,因为我们需要考虑埋点数据的聚合以及上报数据优先级。
|
||||
|
||||
<li>
|
||||
**埋点数据的聚合**。为了减少上报的数据量,尤其是部分性能埋点,我们需要支持聚合上报。大部分组件都是使用上报时聚合的方式,但是这样无法解决存储时的数据量问题。由于我们使用的是mmap,可以像操作内存一样操作文件中的数据,可以实现性能更优的埋点数据的聚合功能。
|
||||
</li>
|
||||
<li>
|
||||
**上报数据优先级**。很多上报组件埋点时都会使用一个是否重要的参数,对于重要的数据采用直接落地的方式。对于我们的方案来说,已经默认所有的数据都是重要的。关于上报数据的优先级,我建议使用上报间隔来区分,例如1分钟、10分钟或者1小时。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/88/747ab81587c7c5d0ceebe4e8763aad88.png" alt="">
|
||||
|
||||
对于一些敏感数据,可能还需要支持加密。对于加密的数据,我建议使用单独的另一个mmap文件存储。
|
||||
|
||||
为什么上面我说的是数据基本不会丢失,而不是完全不会丢失呢?因为当数据还没有mmap落盘,也就是处于采样、存储内部逻辑时,这个时候如果应用崩溃依然会造成数据丢失。为了减少这种情况发生,我们做了两个优化。
|
||||
|
||||
<li>
|
||||
**精简处理逻辑**。尽量减少每个埋点的处理耗时,每个埋点的数据处理时间需要压缩到0.1毫秒以内。
|
||||
</li>
|
||||
<li>
|
||||
**KillProcess等待**。在应用主动KillProcess之前,需要调用单独的函数,先等待所有队列中的埋点处理完毕。
|
||||
</li>
|
||||
|
||||
**3. 上报模块**
|
||||
|
||||
对于上报模块,我们不仅需要满足上报实时性,还需要合理地优化流量的使用,主要需要考虑的策略有:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/66/5e995f63ce97465825cdd0cfea669466.png" alt="">
|
||||
|
||||
为了解决后台进程的上报实时性问题,我们采用了单进程上报的策略,我推荐使用保活能力比较强的进程作为唯一的上报进程。为了更加精细地控制上报间隔,我们采用更加复杂的班车制度模式。
|
||||
|
||||
后来经过仔细思考,最终的上报模块采用“多进程写 + 单进程上报”。这里有一个难点,那就是如何及时的收集所有已经停止的班车,会不会出现多进程同步的问题呢?我们是通过Linux的文件rename的原子性以及FileObserver机制,实现了一套完全无锁、高性能的文件同步模型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/cd/c67bb5cb84ecc577e00f13af3d6c9bcd.png" alt="">
|
||||
|
||||
每个进程在对应优先级的文件“停车”的时候,负责把文件rename到上报数据存放的目录中。因为rename是原子操作,所以不用担心两个进程会同时操作到同一个文件。而对应的上报进程只需要监听上报数据目录的变化,就可以实现文件状态的同步。这样就避免了多进程同步操作同一个文件的问题,整个过程也无需使用到跨进程的锁。
|
||||
|
||||
当然上报模块里的坑还有很多很多,例如合并上报文件时应该优先选择高优先级的文件;对于上报的包大小,在移动网络需要设置的比WiFi小一些,而不同优先级的文件需要合并组包,尽量吃满带宽;而且在弱网络的时候,我们需要把数据包设置得更小一些,先上报最高优先级的数据。
|
||||
|
||||
**4. 容灾模块**
|
||||
|
||||
虽然我们设计得上报模块已经很强大,但是如果使用者调用不合理,也可能会导致严重的性能问题。我曾经遇到过,某个同学在一个for循环连续埋了一百万个点;还有一次是某个用户因为长期没有网络,导致本地堆积了大量的数据。
|
||||
|
||||
一个强大的组件,它还需要具备容灾的能力,本地一般可以有下面这些策略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/3e/f3aaa8e7762bba6c6b3a9f88faacf83e.png" alt="">
|
||||
|
||||
容灾模块主要是保证即使出现开发者使用错误、组件内部异常等情况,也不会给用户的存储空间以及流量造成严重问题。
|
||||
|
||||
## 数据自监控
|
||||
|
||||
通过“多进程写 + mmap + 后台进程上报 + 班车模式”,我们实现了一套完全无锁、数据基本不会丢失、无跨进程IPC调用的高性能上报组件,并且通过容灾机制,它还可以实现异常情况的自动恢复。
|
||||
|
||||
那线上效果是不是真的这么完美?我们怎样确保上报组件的数据可靠性和时效性呢?答案依然是**监控**,我们需要建立一套完善的自监控体系,为后续进一步优化提供可靠的数据支撑。
|
||||
|
||||
**1. 质量监控**
|
||||
|
||||
上报组件的核心数据指标主要包括以下几个:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/79/9f62e5c711224b8589bce6ec125ed279.png" alt="">
|
||||
|
||||
当然,如果我们追求更高的实时性,可以选择计算小时到达率,甚至是分钟到达率。
|
||||
|
||||
**2. 容灾监控**
|
||||
|
||||
当客户端出现容灾处理时,我们也会将数据单独上报到后台监控起来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/f9/59cc80a728e17cecd23b710c29bbfdf9.png" alt="">
|
||||
|
||||
除了异常情况的监控,我们还希望将用户每日使用的移动流量和WiFi流量做更加细粒度的分区间监控,例如0~1MB的占比、1~5MB的占比等。
|
||||
|
||||
## 总结
|
||||
|
||||
网络和数据都是非常重要的基础组件,今天我们一起打造了一款跨平台、高可用的上报组件。这也是目前比较先进的方案,在各方面的质量指标都比传统的方案有非常大的提升。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/b4/022ec87c80a928d1fa536dcde99bb3b4.png" alt="">
|
||||
|
||||
当然真正落实到编码,这里面还有非常多的细节需要考虑,也还有大大小小很多暗坑。而且虽然我们使用C++实现,但是也还需要处理不同平台的些许差异,比如iOS根本不需要考虑多进程问题等。
|
||||
|
||||
在实践中我的体会是,当我们亲自动手去实现一个网络库或者上报组件的时候,才会深深体会到把一个新东西做出来并不困难,但是如果想要做到极致,那必然需要经过精雕细琢,更需要经过长时间的迭代和优化。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你所在的公司,目前正在使用哪个数据上报组件?它存在哪些问题呢?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
今天的课后作业是,在实现方案中我故意隐去了两个细节点,这里把它们当作课后作业留给你,请你在留言中写下自己的答案。
|
||||
|
||||
**1. 采样策略的更新**。当我们服务器采样策略更新的时候,如果不使用推送,怎样保证新的采样策略可以以最快速度在客户端生效?<br>
|
||||
**2. 埋点进程突然崩溃**。你有没有想到,如果Process A突然崩溃,那哪个进程、在什么时机、以哪种方式,应该负责把Process A对应的埋点数据及时rename到上报数据目录?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/cd/c67bb5cb84ecc577e00f13af3d6c9bcd.png" alt="">
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
205
极客时间专栏/Android开发高手课/模块二 高效开发/31 | 数据评估(下):什么是大数据平台?.md
Normal file
205
极客时间专栏/Android开发高手课/模块二 高效开发/31 | 数据评估(下):什么是大数据平台?.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="31 | 数据评估(下):什么是大数据平台?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/b4/b269fa8eb68e1655952084ded81070b4.mp3"></audio>
|
||||
|
||||
数据是连接产品和用户的桥梁,它反映了用户对产品的使用情况,是我们作出业务决策的重要依据。虽然通过“高可用的上报组件”,可以从源头上保障数据采集的准确性和实时性,但是随着App业务迭代的复杂化,经常会出现遗漏埋点、错误埋点、多端埋点不统一等情况,影响了业务数据的稳定性。
|
||||
|
||||
我见过很多团队的埋点文档管理得非常不规范,有的还在使用Excel来管理埋点文档,经常找不到某些埋点的定义。而随着埋点技术和流程的成熟,我们需要有一整套完整的方案来保证数据的稳定性。
|
||||
|
||||
那埋点应该遵循什么规范?如何实现对埋点整个流程的引导和监控?埋点管理、埋点开发、埋点测试验证、埋点数据监控…怎样打造一站式的埋点平台?在埋点平台之上,大数据平台又是什么样的呢?
|
||||
|
||||
## 埋点的基础知识
|
||||
|
||||
我们知道,一个业务埋点的上线需要经历需求、开发、测试等多个阶段,会涉及产品、开发和测试多方协作,而对于大型团队来说,可能还要加上专门的数据团队。
|
||||
|
||||
对于传统埋点来说,错埋、漏埋这样的问题总会反反复复出现。为了排查和解决数据的准确性问题,参与的各方团队都要耗费大量的精力。特别是如果埋点一旦出现问题,我们还需要依赖App发布新版本,可见埋点的修复周期长,而且成本也非常巨大。
|
||||
|
||||
那应该如何解决这个问题呢?请先来思考一下,我们应该如何实现一个正确的埋点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/3f/d9a2df07038e18b1f143615cf36ce73f.png" alt="">
|
||||
|
||||
如果想实现一个正确的埋点,必须要满足上面的这四个条件,需要有非常严格的埋点流程管理。因此,你需要做到:
|
||||
|
||||
<li>
|
||||
**统一的埋点规范**。应用甚至是整个公司内部,从日志的格式、参数的含义都要有统一的规则。
|
||||
</li>
|
||||
<li>
|
||||
**统一的埋点流程**。在整个埋点过程,产品、开发、测试和数据团队都要肩负起各自的职责,一起通力协作,通过统一、规范的流程来实现正确的埋点。
|
||||
</li>
|
||||
|
||||
通过统一的埋点规范和流程,希望可以减少埋点开发的成本,保障数据的准确性。下面我们一起来看看具体应该如何实践。
|
||||
|
||||
**1. 统一埋点规范**
|
||||
|
||||
在打开淘宝的主页时,不知道你有没有注意到URL后面会带有一个SPM的参数。
|
||||
|
||||
>
|
||||
[https://www.taobao.com/?spm=a21bo.2017.201857.3.5af911d9ycCIDq](https://www.taobao.com/?spm=a21bo.2017.201857.3.5af911d9ycCIDq)
|
||||
|
||||
|
||||
这个SPM代表什么含义呢?SPM全称是Super Position Model,也就是超级位置模型。简单来说,它是阿里内部统一的埋点规范协议,无论H5还是Native的Android和iOS,都要遵循这套规范。
|
||||
|
||||
正如上面的链接一样,SPM由A.B.C.D四段构成,各分段分别代表的含义如下。
|
||||
|
||||
```
|
||||
A:站点/业务, B:页面, C:页面区块, D:区块内点位
|
||||
|
||||
注:a21bo.2017.201857.3.5af911d9ycCIDq一共有5位,这是网站特有的,最后一位分配的是一个随机特征码,只是用来保证每次点击SPM值的唯一性。
|
||||
|
||||
```
|
||||
|
||||
SPM主要有页面访问、控件点击以及曝光三种类型的事件,它可以用来记录了用户点击或者查看当前页面的具体信息,还可以推算出用户来自上一个页面的哪个位置。基于SPM规范,淘宝可以得到用户每个页面PV、点击率、停留时长、转化率、用户路径等各种维度的指标。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/d8/477efc10cb1310f660321192ec92d1d8.png" alt="">
|
||||
|
||||
对于埋点规范来说,从公共参数到内部的各个业务参数,我们都需要定义完整的日志格式。目前,SPM这套规范已经推广到阿里整个集团以及外部的合作伙伴中,这样通过各个部门、各个客户端的规范统一,不仅降低了内部学习和沟通协作的成本,而且对后续的数据存储、校验、分析都会带来极大的便利。
|
||||
|
||||
“一千个读者心中有一千个哈姆雷特”,每个公司的情况可能不一定相同,所以也不能保证阿里的埋点规范适合所有的企业。但是无论我们最终决定使用哪种规范,对于公司内部,至少是应用内部来说,埋点规范应该是统一的。
|
||||
|
||||
关于SPM规范,如果你想了解更多,可以参考[《SPM参数有什么作用》](https://www.zhihu.com/question/62813754)和[《阿里巴巴的日志采集分享》](https://blog.naaln.com/2017/08/alibaba-data-track-1/)。
|
||||
|
||||
**2. 统一埋点流程**
|
||||
|
||||
埋点的整个过程涉及产品、开发、测试、数据团队等多个团队,如果内部没有完善的流程规范,非常容易出现“四国大混战”。在出现数据问题的时候,也常常会出现互相推卸责任的情况。
|
||||
|
||||
“无规矩不成方圆”,我们需要制定统一的埋点流程,严格规范整个埋点过程的各个步骤以及每个参与者在相应步骤的分工和责任。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/e7/f0cc32f276e639ebf2320d0ba0f462e7.png" alt="">
|
||||
|
||||
<li>
|
||||
**需求阶段**。在需求评审阶段,产品需要列出具体的埋点需求。如果有数据团队的话,产品的埋点需求需要数据团队review,测试同时也需要根据产品的埋点需求制定出对应的埋点测试方案。这个阶段主要由产品负责,需要保证需求方案和测试方案都是OK的。
|
||||
</li>
|
||||
<li>
|
||||
**开发阶段**。在开发阶段,开发人员根据产品的埋点需求文档,根据具体的埋点规则在客户端中埋点。开发完成后,需要在本地自测通过。这个阶段由开发负责。
|
||||
</li>
|
||||
<li>
|
||||
**测试阶段**。在测试阶段,测试人员根据产品的埋点需求和规则,通过之前指定的测试方案进行埋点的本地验收。这个阶段由测试负责。
|
||||
</li>
|
||||
<li>
|
||||
**灰度发布阶段**。在灰度发布阶段,测试人员负责对埋点建立线上的监控,查看线上的数据是否符合埋点需求和规则,产品人员需要关心埋点数据是否是符合预期。这个阶段主要是测试负责,但是产品也同样需要参与。
|
||||
</li>
|
||||
|
||||
通过统一埋点流程,我们明确规定了埋点各个阶段的任务与职责,这样可以减少埋点的成本、降低出错的概率。
|
||||
|
||||
**3. 埋点方式**
|
||||
|
||||
代码埋点、可视化埋点、声明式埋点、无痕埋点,对于埋点的方式,业界似乎有非常多的流派。在[《美团点评前端无痕埋点实践》](https://tech.meituan.com/2017/03/02/mt-mobile-analytics-practice.html)和[《网易HubbleData之Android无埋点实践》](https://neyoufan.github.io/2017/07/11/android/%E7%BD%91%E6%98%93HubbleData%E4%B9%8BAndroid%E6%97%A0%E5%9F%8B%E7%82%B9%E5%AE%9E%E8%B7%B5/)都将埋点方式归为以下三类:
|
||||
|
||||
<li>
|
||||
**代码埋点**。在需要埋点的节点调用接口直接上传埋点数据,友盟、百度统计等第三方数据统计服务商大都采用这种方案。
|
||||
</li>
|
||||
<li>
|
||||
**可视化埋点**。通过可视化工具配置采集节点,在前端自动解析配置并上报埋点数据,从而实现所谓的“无痕埋点”, 代表方案有已经开源的[Mixpanel](https://github.com/mixpanel)。
|
||||
</li>
|
||||
<li>
|
||||
**无痕埋点**。它并不是真正不需要埋点,而是自动采集全部事件并上报埋点数据,在后端数据计算时过滤出有用的数据,代表方案有国内的GrowingIO。
|
||||
</li>
|
||||
|
||||
我们平常使用最多的就是“代码埋点”方式,而对于“可视化埋点”和“无痕埋点”,它们都需要实现埋点的自动上报,需要实现事件的自动拦截。
|
||||
|
||||
怎么理解呢?你来回想一下,对于SPM方案中的页面切换事件,我们可以通过监听Activity或者Fragment的切换实现。那怎样自动监听控件的点击和曝光事件呢?
|
||||
|
||||
以监听点击事件为例,一般有下面几种方法:
|
||||
|
||||
<li>
|
||||
**插桩替换**。对于控件的点击,我们可以通过ASM全局将View.onClickListener中的onClick方法覆写成我们自己的Proxy实现,在内部添加埋点代码。
|
||||
</li>
|
||||
<li>
|
||||
**Hook替换**。通过Java反射,从RootView开始,递归遍历所有的控件View对象,并Hook它对应的OnClickListener对象,同样将它替换成我们的Proxy实现。
|
||||
</li>
|
||||
<li>
|
||||
**AccessibilityDelegate机制**。通过AccessibilityDelegate,我们可以检测到控件点击、选中、滑动、文本变化等状态。借助AccessibilityDelegate,当控件触发点击行为时,通过具体的AccessibilityEvent回调添加埋点代码。
|
||||
</li>
|
||||
<li>
|
||||
**dispatchTouchEvent机制**。dispatchTouchEvent方法是系统点击事件的分发函数,通过重写这些函数,就可以实现对所有点击事件的监听。
|
||||
</li>
|
||||
|
||||
## 大数据平台
|
||||
|
||||
虽然我们有了统一的埋点规范和流程,但是整个流程依然是依赖人工手动的。就以埋点需求管理为例,很多团队还在使用Excel来管理,随着不断的修改,文档会越来越复杂,这样也不利于对历史进行跟踪。
|
||||
|
||||
那怎样将埋点管理、埋点开发、埋点测试验证、埋点数据监控打造成一站式的埋点平台呢?
|
||||
|
||||
**1. 埋点一站式平台**
|
||||
|
||||
埋点一站式平台可以实现管理埋点定义的可视化,辅助开发和测试定位埋点相关的问题。并且自动化验证本地和线上的埋点数据,以及自动分析和告警,也可以减少埋点开发和验证的成本,提升数据质量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/ed/111b2b4389c8ae3635d45a954c1aa9ed.png" alt="">
|
||||
|
||||
如上图所示,它主要由四个子平台组成。
|
||||
|
||||
<li>
|
||||
**埋点管理平台**。对应用的整个埋点方案进行统一管理,包括埋点的各个字段的定义和规则,例如对于QQ号这个字段来说,要求是纯数字而且非空的。对于SPM规范,埋点管理平台也会记录每个页面对应的名称,例如淘宝首页会用a123来表示。
|
||||
</li>
|
||||
<li>
|
||||
**埋点开发辅助平台**。开发辅助平台是为了提升开发埋点的效率,例如我前面说到的可视化埋点。或者通过埋点管理平台的字段和规则,自动生成代码,开发者可以一键导入埋点定义的类,只需要在代码中添加调用即可。
|
||||
</li>
|
||||
<li>
|
||||
**埋点验证平台**。验证平台非常非常重要,对于开发人员的埋点测试和测试人员的本地验收,我们可以通过扫码或者其他方式,切换成数据的实时上传模式。埋点验证平台会拉取配置平台的埋点定义和规则,将客户端上报的数据进行实时展示和规则校验。比如说某个埋点漏了一个字段、多了一个字段,又或者是违反了预设的规则,例如QQ号有字母、数值为空等。
|
||||
</li>
|
||||
|
||||
因为手工测试不一定可以覆盖所有的场景,所以我们还需要依赖自动化和灰度验证。它们整体思路还是一致的,只是借助的是线上的非实时通道,每小时或者每日定期输出数据验证的报告。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/df/83a4d71e2eaf4f138350178706d97cdf.png" alt="">
|
||||
|
||||
- **埋点监控平台**。监控的目标是保证整个数据链路的健壮性,这里包括对客户端“高可用上报组件”的监控,例如上一期讲到的质量监控和容灾监控。还有对后端数据解析、存储、分析的监控,例如总日志量、日志异常量、日志的丢失量等。
|
||||
|
||||
不知道你是否注意到了,埋点管理平台还会对采样策略进行管理。回到专栏上一期我留给你课后作业的问题,当我们服务器采样策略更新的时候,如果不使用推送,怎样保证新的采样策略可以以最快速度在客户端生效呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/e0/17bcae3eb0b202c273b5257bf98d61e0.png" alt="">
|
||||
|
||||
其实非常简单,当用户更改了某个埋点的采样配置时,埋点配置平台会将采样策略版本号自增,然后将最新的策略以及版本号推送到数据采集模块。埋点SDK每次上报都会带上自己本地的策略版本号,如果本地的策略版本号小于服务器的版本号,那么数据采集模块会直接把最新的策略在回包中返回。
|
||||
|
||||
这种方式保证只要客户端有任意一个埋点上报成功,都可以拿到最新的采样策略。事实上,很多其他的配置都是采用类似的方式更新。
|
||||
|
||||
**2. 数据产品**
|
||||
|
||||
埋点的一站式平台,也只是数据平台的一小部分,它负责保证上报数据的准确性。按照我的理解,整个大数据平台的简化架构是下面这个样子的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/f5/9a56caba69587db5b89d4ee1dac3b9f5.png" alt="">
|
||||
|
||||
<li>
|
||||
**采集工具层**。应用的上报组件负责数据埋点、日志的组装上报,它需要保证数据的准确性和实时性。
|
||||
</li>
|
||||
<li>
|
||||
**数据采集层**。数据采集层对日志进行清洗、处理,可能还需要和我们的埋点一站式平台进行交互。然后需要根据数据的订阅情况将数据分发到不同的计算模块。
|
||||
</li>
|
||||
<li>
|
||||
**数据计算层**。计算层主要分为离线计算和实时计算两部分,离线计算从数据接收到结果的产出,一般至少需要一个小时以上。而实时计算可以实现秒级、分钟级的计算,一般只会用于核心业务的监控。而且因为计算量的问题,实时计算一般只会计算PV,不会计算UV结果。
|
||||
</li>
|
||||
<li>
|
||||
**数据服务层**。无论是离线计算还是实时计算,我们都会把结果存放到数据的服务层,一般都会使用DB。数据服务层主要是为了屏蔽底层的复杂实现,我们只需要从这里查询最终的计算结果就可以了。
|
||||
</li>
|
||||
<li>
|
||||
**数据产品层**。数据产品一般分为两类,一类是业务型,一类是监控型。业务型一般用来查看和分析业务数据,比如页面的访问、页面的漏斗模型、页面的流向、用户行为路径分析等。监控型主要用来监控业务数据,例如实时的流量监控、或者是非实时的业务数据监控等。
|
||||
</li>
|
||||
|
||||
数据服务层是一个非常好的设计,它让整个公司的人都可以非常简便地实现不同类型的数据产品。我们不需要关心下层复杂的数据采集和计算的实现,只要把数据拿出来,做一个满足自己的报表展示系统就可以了。
|
||||
|
||||
对于实时监控,微信的IDKey、阿里的[Sunfire](https://mp.weixin.qq.com/s/rN2v9SNfyokUL3ijeWwkfg)都是非常强大的系统,它们可以实现客户端数据分钟级甚至秒级的实时PV监控。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/39/3c3885bdef948b216c3c3a7b35c92639.png" alt="">
|
||||
|
||||
对于中小型公司,可能没有能力搭建自己一整套的大数据平台,这个时候可能需要使用第三方的服务,例如阿里云提供了一套[OneData](https://dt.alibaba.com/)服务。
|
||||
|
||||
当然我们也可以搭建一套自己的数据平台,但是对于海量大数据来说,一个稳定、高性能的数据计算层是非常复杂的,我们可以使用外部打包好的数据计算和服务层,例如阿里云的[MaxCompute](https://www.alibabacloud.com/help/zh/doc-detail/27800.htm?spm=a2c63.p38356.b99.2.472d603evPKK4j)大数据计算服务。接着在数据计算层之上,再来实现符合我们自己需求的数据产品。
|
||||
|
||||
下面是一个简单的数据平台整体架构图,你也可以参考大众点评的实现[《UAS:大众点评用户行为系统》](https://zhuanlan.zhihu.com/p/39145535)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/f2/4bbddf641eea9e1e74b56ad7b4e69ff2.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
在过去的几年里,大数据也是一个经常被提起的概念。对于大数据,或者说与之配套的大数据平台,我自己的体会主要有两点。
|
||||
|
||||
**1. 技术变革是为了解决需求**。如果淘宝没有面对每天亿级的用户访问数量,没有一次又一次的被卷入数据的黑洞中,也不会有他们对大数据方面所做的各种艰苦努力,技术是为了解决业务场景中的痛点。另一方面看,大数据的确存在门槛,如果在中小型企业可能不一定有这样锻炼的机会。
|
||||
|
||||
**2. 基础设施建设没有捷径可走**。高可用的上报组件、埋点一站式平台以及各种各样的数据产品,这些基础设施的建设需要有足够的耐心,投入足够的人力、物力。为什么要采用这样的规范和流程?为什么架构会这样设计?虽然这些方案可能不是最优的,但也是通过血与泪、通过大量的实践,慢慢演进得来的。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你所在的公司,有没有统一的埋点规范和埋点流程?对于数据相关的配套设施建设得怎么样?在数据保障方面遇到了哪些问题?欢迎留言跟我和其他同学一起讨论。
|
||||
|
||||
在数据平台的建设上面,国际的Facebook、国内的阿里都是做得非常不错的公司,我推荐你看看阿里数据专家们写的一本书《大数据之路 阿里巴巴大数据实践》。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
187
极客时间专栏/Android开发高手课/模块二 高效开发/32 | 线上疑难问题该如何排查和跟踪?.md
Normal file
187
极客时间专栏/Android开发高手课/模块二 高效开发/32 | 线上疑难问题该如何排查和跟踪?.md
Normal file
@@ -0,0 +1,187 @@
|
||||
<audio id="audio" title="32 | 线上疑难问题该如何排查和跟踪?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/82/b125c44acae518d1a340bd75a6139f82.mp3"></audio>
|
||||
|
||||
“95%以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此”,这是我在专栏“崩溃优化”中曾经夸下的海口。
|
||||
|
||||
虽然收集了尽可能丰富的崩溃现场,但总会有一些情况是事先没有预料到的,我们无法直接从崩溃日志里找到原因。事实上我们面临的难题远远不止崩溃,比如说用户投诉文件下载到99%之后无法继续,那如何确定是用户手机网络不好,是后台服务器出错,还是客户端代码的Bug?
|
||||
|
||||
我们的业务逻辑越来越复杂,应用运行的环境也变得越来越复杂,因此在实际工作中总会遇到大大小小的线上疑难问题。对于这些问题,如何将它们“抽丝剥茧”,有哪些武器可以帮助我们更好地排查和跟踪呢?
|
||||
|
||||
## 用户日志
|
||||
|
||||
对于疑难问题,我们可以把它们分为崩溃和非崩溃两类。一般有哪些传统的排查手段呢?
|
||||
|
||||
<li>
|
||||
**本地尝试复现**。无论是崩溃还是功能性的问题,只要有稳定的复现路径,我们都可以在本地采用各种各样的手段或工具进行反复分析。但是真正的疑难问题往往都很难复现,它们可能跟用户机型、本地存储数据等环境有关。
|
||||
</li>
|
||||
<li>
|
||||
**发临时包或者灰度包**。如果发临时包给用户,整个过程用户配合繁琐,而且解决问题的时间也会很长。很多时候我们根本无法联系到用户,这个时候只能通过发线上灰度包的方式。但是为了一步步缩小问题的范围,我们可能又需要一次次地灰度。
|
||||
</li>
|
||||
|
||||
我们多么希望能有一些“武器”,帮助工程师用非常低的成本,在非常短的时间内,尽可能地收集足够丰富的信息,更快速地排查和解决问题。
|
||||
|
||||
**1. Xlog**
|
||||
|
||||
在日常开发过程中,我们经常会使用Logcat日志来排查定位代码中的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/f7/1dc8e743989e6e45c5045d18e54172f7.png" alt="">
|
||||
|
||||
对于线上问题,我们也希望可以有用户的完整日志,这样即使问题不能复现,通过日志也可能定位到具体的原因。所谓“养兵千日,用兵一时”,客户端日志只有当出现问题且不容易复现时才会体现出它的重要作用。但是为了保证关键时刻有日志可用,就需要保证程序整个生命周期内都要打日志,所以日志方案的选择至关重要。
|
||||
|
||||
在过去因为性能和可靠性问题,通常我们只针对某少部分人动态打开日志开关。那如何实现一套高性能、日志不会丢失并且安全的日志方案呢?微信在2014年就实现了自己的[高性能日志模块Xlog](https://mp.weixin.qq.com/s/cnhuEodJGIbdodh0IxNeXQ?),并且在2016年作为Mars的一部分开源到[GitHub](https://github.com/Tencent/mars)。关于Xlog的更多实现细节,你可以参考[源码](https://github.com/Tencent/mars/tree/master/mars/log)或者[会议分享](https://github.com/AndroidAdvanceWithGeektime/Chapter32/blob/master/%20mars%20%E9%AB%98%E6%80%A7%E8%83%BD%E6%97%A5%E5%BF%97%E6%A8%A1%E5%9D%97%20xlog.pdf)。
|
||||
|
||||
Xlog方案的出现,可以让全量用户全天候打开日志,也不需要担心对应用性能造成太大的影响。但是Xlog只是一个高性能的日志工具,最终是否能解决我们的线上问题,还需要看我们如何去使用它。
|
||||
|
||||
所以微信制定了严格的日志规范,定期对拉取的日志作规则检查,一旦发现有违反规则的情况,会作出一定的处罚。下面是其中的一些日志规范,我选取一些分享给你。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/e2/4a088cebbd440d6668f9b4609e5ca3e2.jpg" alt="">
|
||||
|
||||
日志打点怕打太多也怕太少,担心出现问题没有足够丰富的信息去定位分析问题。应该打多少日志,如何去打日志并没有一个非常严格的准则,这需要整个团队在长期实践中慢慢去摸索。在最开始的时候,可能大家都不重视也不愿意去增加关键代码的日志,但是当我们通过日志平台解决了一些疑难问题以后,团队内部的成功案例越来越多的时候,这种习惯也就慢慢建立起来了。
|
||||
|
||||
**2. Logan**
|
||||
|
||||
对于移动应用来说,我们可能会有各种各样的日志类型,例如代码日志、崩溃日志、埋点日志、用户行为日志等。由于不同类型的日志都有自己的特点,这样会导致日志比较分散,比如我们要查一个问题,需要在各个不同的日志平台查不同的日志。美团为了解决这个问题,提出了统一日志平台的思路,也在[Github开源](https://github.com/Meituan-Dianping/Logan)了自己的[移动端基础日志库Logan](https://tech.meituan.com/2018/02/11/logan.html)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/f8/f70cc52f50ff6d8c954edbc8e4c82df8.png" alt="">
|
||||
|
||||
Logan整合了各式各样的日志平台,打造成一个统一的日志平台,进一步提升了开发人员查找问题的效率。不过无论是Logan还是Xlog,日志一般会通过下面两种方式上报。
|
||||
|
||||
- **Push拉取**。通过推送命令,只拉取特定用户的日志。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/8b/b0b26349e3ab3c0bd839e21495c2778b.png" alt="">
|
||||
|
||||
- **主动上报**。在用户反馈问题、出现崩溃等一些预设场景,主动上报日志。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/70/405f8fe3e8a368c3359cf52a9ffd6b70.png" alt="">
|
||||
|
||||
对于用户日志,我们是否已经做到尽善尽美了?手动埋点的覆盖范围有限,如果关键位置没有预先埋点,那可能就需要重新发包。所以美团在Logan基础上,还推出了[Android动态日志系统Holmes](https://tech.meituan.com/android_holmes.html)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/b6/f315e4532915e63d10ea8fc1bec942b6.png" alt="">
|
||||
|
||||
Holmes的实现跟美团的Robust热修复思路差不多,需要对每个方法进行了插桩来记录方法执行路径,也就是在方法的开头插入一段桩代码,当方法运行的时候就会记录方法签名、进程、线程、时间等形成一条完整的执行信息。但是这套方案性能的技术难点比较多,一般只会动态针对出现问题的用户开启。
|
||||
|
||||
虽然这个思路有一定的启发性,但我认为其实并不太实用。一来,对每个方法插桩,会对安装包体积和性能造成很大的影响,导致这套方案过于笨重;二来,很多疑难问题都具有偶发性,当用户出现问题后,再去打开用户日志,可能也不能保证用户的问题可以复现。
|
||||
|
||||
## 动态调试
|
||||
|
||||
“只要你能在本地复现,我就能解决”,这可能是开发对测试说过最多的话了。在本地,我们可以通过增加日志,或者使用Debugger、GDB等这样的调试工具反复进行验证。
|
||||
|
||||
针对远程用户,如果我们可以做到具备跟本地一样的动态调试能力,想想都觉得激动人心。那有没有方案能实现远程动态调试呢?
|
||||
|
||||
**1. 远程调试**
|
||||
|
||||
动态调试,又或者是动态跟踪,它们属于高级的调试技术。事实上,它并不是什么新鲜的话题,例如Linux中大名鼎鼎的DTrace和SystemTap、Java的BTrace,都是非常成熟的方案。我推荐你仔细阅读[《动态调试漫谈》](https://openresty.org/posts/dynamic-tracing/)和[《Java动态追踪技术探究》](https://tech.meituan.com/2019/02/28/java-dynamic-trace.html),特别是前者,让我有非常大的收获。
|
||||
|
||||
在Android端,我们能不能实现对用户做动态调试呢?在回答这个问题之前,请先来思考一下平时我们通过Android Studio进行调试的底层原理是什么。
|
||||
|
||||
其实我们的学习委员鹏飞之前已经讲过这块内容了,回到“[Android JVM TI机制详解](https://time.geekbang.org/column/article/74484)”中说到的Debugger Architecture。Java的调试框架是通过JPDA(Java Platform Debugger Architecture,Java平台调试体系结构),它定义了一套独立且完整的调试体系,主要由以下三部分组成:
|
||||
|
||||
- [JVM TI](https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html#whatIs):Java虚拟机工具接口(被调试者)。
|
||||
- [JDWP](http://download.oracle.com/otn_hosted_doc/jdeveloper/904preview/jdk14doc/docs/guide/jpda/jdwp-spec.html):Java调试协议(通道)。
|
||||
- [JDI](http://download.oracle.com/otn_hosted_doc/jdeveloper/904preview/jdk14doc/docs/guide/jpda/jdi/index.html):Java调试接口(调试者)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/70/f043f50f8e6e99d627604b867e767a70.jpg" alt="">
|
||||
|
||||
如果你想了解更多关于Java调试框架的信息,可以重新回顾一下“[Android JVM TI机制详解](https://time.geekbang.org/column/article/74484)”里给出的参考链接。
|
||||
|
||||
对于Android来说,它的调试框架也是在Java调试框架基础上进行的扩展,主要包括Android Studio(JDI)、ddmlib、adb server、adb daemon、Android应用这五个组成部分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/76/f34e7b16e572fe4f1a4d33b96681cd76.png" alt="">
|
||||
|
||||
如果想要实现对用户的远程调试,我们需要修改其中的两个部分。
|
||||
|
||||
<li>
|
||||
**JDWP(传输通道)**。我们不能使用系统adb的方式,而是希望将用户的调试信息经过网络通道发送给我们。
|
||||
</li>
|
||||
<li>
|
||||
**JDI(前端展示)**。对于客户端调试数据的展示,我们不太容易直接复用Android Studio,需要重新实现自己的数据展示页面。
|
||||
</li>
|
||||
|
||||
关于具体实现的细节,你可以参考美团的文章[《Android远程调试的探索与实现》](https://tech.meituan.com/2017/07/20/android-remote-debug.html),最终整体的流程如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/35/34176437ad0313c5dd057cc75f2c7b35.png" alt="">
|
||||
|
||||
当然不同于本地Debug包的调试,对于用户调试我们还需要考虑如何突破ProGuard和Debugable的影响。总的来说,这套方案有非常大的技术价值,可以加深我们对Java调试框架的理解。但是它并不实用,因为大部分的场景,我们很难在用户不配合的前提下做好调试。而且调试过程也可能会出现各种各样的情况,并不容易控制。
|
||||
|
||||
不过退而求其次,通过这个思路,我们可以在本地实现“无线调试”(无需adb),又或者是实现对混淆包的调试。
|
||||
|
||||
**2. 动态部署**
|
||||
|
||||
如果说远程调试并不很实用,那有没有其他方法让用户感知不到我们在进行调试呢?
|
||||
|
||||
用户无感知、代码更新,这不正是动态部署所具备的能力,而且动态部署天生就非常适合使用在疑难问题的排查上。
|
||||
|
||||
<li>
|
||||
**精细化**。通过发布平台,我们可以只选择某些问题用户做动态更新。也可以圈定某一批用户,例如某个问题只在华为的某款机型出现,那我可以只针对华为的这款机型下发。
|
||||
</li>
|
||||
<li>
|
||||
**场景**。对于疑难问题的排查,我们一般只会增加日志或者简单修改逻辑,动态部署在这个场景是完全可以满足的。
|
||||
</li>
|
||||
<li>
|
||||
**可重复、可回退**。对于疑难问题,我们可能需要反复尝试不同的解决思路,动态部署完全可以解决这个需求。而且在问题解决后,我们可以及时将无用的Patch回退。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/db/736d7f69fd81c92857ef1971cae83ddb.jpg" alt="">
|
||||
|
||||
我还记得曾经为了解决libhw.so的崩溃问题,我们历经一个月,一共发布了30多个动态部署,反复地增加日志、增加Hook点,最终才得以解决。
|
||||
|
||||
**3. 远程控制**
|
||||
|
||||
动态部署存在生效时间比较慢(几分钟到十几分钟)、无法覆盖100%用户(修改AndroidManifest或者用户手机没有剩余空间)等问题。对于一些特定问题,我们可以通过下发预设规则的方式来处理。
|
||||
|
||||
网络远程诊断是一个非常经典的例子,假如有个用户反馈某个网页无法打开,我们可以通过本地或者远程下发指令方式,对用户的整个网络请求过程做完整的检测,看看是不是用户的网络问题,是不是DNS的问题,在请求的哪个阶段出错了,错误码是什么等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/65/9baa7684679f40926ea93bbbb4a44865.png" alt="">
|
||||
|
||||
Mars里面也有一个专门的SDT网络诊断模块,我们可以顺便回顾一下Mars整个知识结构图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/60/51cf08e8fe7d78d7c485587da32b0460.png" alt="">
|
||||
|
||||
除了网络的远程诊断之外,网络疑难问题的排查和跟踪本身就是一个非常大的话题。它涉及业务请求从域名解析/流量调度到业务统一接入,再到业务调用的整个访问链路,属于大网络平台的一环。
|
||||
|
||||
我们可以通过客户端生成的traceId,将统一收集和整合客户端日志、服务端调用日志、自建CDN等日志,建立以用户为维度的监控平台,提供问题定位功能 。例如Google的Dapper、阿里的EagleEye、微信的点击流平台、QQ的全链路监控平台等,都是通过这个思路实现的。
|
||||
|
||||
类似网络远程诊断,又或者是删除某些文件、上报某些信息,这些预设规则是建立在我们已经踩过某个坑,或者更多情况是已经无数次踩到同一个坑,并且忍无可忍,才会搭建一套相应的诊断规则。那我们能不能不通过动态部署,也可以简单的调用某些Java代码呢?
|
||||
|
||||
这个时候就不得不提到非常强大的Lua脚本语言,iOS之前大名鼎鼎的[Wax热修复](https://github.com/alibaba/wax)、[腾讯Unity3D的热更新方案](https://infoq.cn/article/2017/01/C-NET-Lua-Unity3D),都是使用Lua来实现。Lua的VM非常小,只有200KB不到,充分保证了时间和内存开销的可控。我们可以对目标用户下发指令,动态地执行一段代码并将结果上报,或者在方法运行的时候去获取某些对象、参数的快照信息。
|
||||
|
||||
下面是Lua和Android的调用事例。
|
||||
|
||||
```
|
||||
// lua脚本函数
|
||||
function setText(textView)
|
||||
tv:setText("set by Lua."..s); //这里的s变量由java注入
|
||||
tv:setTextSize(30);
|
||||
end
|
||||
|
||||
// android调用
|
||||
lua.pushString( "from java" ); //压入欲注入变量的值
|
||||
lua.setGlobal( "s" ); //压入变量名
|
||||
lua.getGlobal( "setText" ); //获取Lua函数
|
||||
lua.pushJavaObject( textView ); //压入参数
|
||||
lua.pcall( 1, 0, 0 ); //执行函数
|
||||
|
||||
```
|
||||
|
||||
对于Lua的使用,你可以参考[官方文档](https://www.lua.org/manual/5.3/)。为了方便我们在Android更加容易的使用Lua,也有不少开源库对Lua做了更好的封装,例如[AndroLua](https://github.com/mkottman/AndroLua),阿里也有一套基于Lua实现的动态化界面框架[LuaViewSDK](https://github.com/alibaba/LuaViewSDK)。
|
||||
|
||||
美团的Holmes也利用Lua增加了DB查询、上报普通文本、ShardPreferences查询、获取Context对象、查询权限、追加埋点到本地、上传文件等综合能力。正因为Lua脚本如此强大,很多大厂App也都在Android中集成Lua。
|
||||
|
||||
## 总结
|
||||
|
||||
对于美团、支付宝、淘宝这些超级应用来说,不同的平台、不同的业务可能有上千人同时在一个应用上面开发协作。业务量大、多地区协作开发、业务类型多,每当出现问题都会感到耗时耗力,心力交瘁。
|
||||
|
||||
正因为反复“痛过”,才会有了微信的用户日志和点击流平台,才会有美团的Logan和Homles统一日志系统。所谓团队的“提质增效”,就是寻找团队中这些痛点,思考如何去改进。无论是流程的自动化,还是开发新的工具、新的平台,都是朝着这个目标前进。
|
||||
|
||||
## 课后作业
|
||||
|
||||
在你的工作中,遇到或者解决过哪些经典的疑难问题?还有哪些强大的疑难问题的排查武器?欢迎留言分享给我和其他同学。
|
||||
|
||||
无论是推送拉取用户日志,还是远程调试命令的下发,我们都需要具备区分用户的能力。对于微信这样强登录的应用,我们可以使用微信号作为用户标识。但是用户登陆之前的日志该如何收集呢?
|
||||
|
||||
对于用户唯一标识,Google有自己的[最佳实践方案](https://developer.android.com/training/articles/user-data-ids?hl=zh-cn)。对于大部分非强登录的应用,搭建自己的用户标识体系非常重要。用户唯一标识需要考虑漂移率、碰撞率以及是否跨应用等因素,业界常用的方案有阿里的UTDID、腾讯MTA ID。
|
||||
|
||||
今天的课后作业是,应用该如何实现自己的用户标识体系,请你在留言中写下自己的答案。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
93
极客时间专栏/Android开发高手课/模块二 高效开发/33 | 做一名有高度的移动开发工程师.md
Normal file
93
极客时间专栏/Android开发高手课/模块二 高效开发/33 | 做一名有高度的移动开发工程师.md
Normal file
@@ -0,0 +1,93 @@
|
||||
<audio id="audio" title="33 | 做一名有高度的移动开发工程师" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/ce/1365b92bcc5c290f636b03201572f6ce.mp3"></audio>
|
||||
|
||||
专栏更新至今,不知不觉第二模块“高效开发”也已经更新完了。稳定性、内存、卡顿、I/O、网络,“高质量开发”模块打通了从应用层、Android系统层、Linux内核层再到硬件层的优化路径,帮助我们打通“任督二脉”,成为一名Android开发高手。
|
||||
|
||||
所谓“高效开发”,可以给我们带来了什么呢?移动互联网发展到今天,所有人都说“提质增效”,但是团队效能不是靠我们封装一个工具类或者组件,给其他人低成本复用就够了。持续交付平台、测试平台、发布平台、数据平台、网络平台…我希望你可以跳出客户端的限制,去思考整个产品的研发流程有哪些痛点,不同团队的协作有哪些优化空间,尝试去提升产品的质量和团队的效率。
|
||||
|
||||
我们需要的是多想一步,哪怕只是多思考一小步,对自身的成长可能就价值巨大。想要成为一名全面的“开发高手”,不仅要具备系统性解决应用性能和架构问题的攻坚能力,也要有从全局俯视体系和流程的思维能力。这就是我在“高效开发”里希望带给你的思考,希望你可以成为一名“站在高处”的移动开发工程师。
|
||||
|
||||
## 成为有高度的移动开发工程师
|
||||
|
||||
在微信的时候,我非常推崇T型技术人才理论,所谓的“T”无非就是横向和纵向两个维度。纵向解决的是深度问题,横向解决的是广度问题。
|
||||
|
||||
一个有高度的移动开发工程师,需要能纵向深入,也要能横向全面地思考每一个问题。比如说团队希望治理数据的准确性和实时性问题,如果站在客户端的角度上看,就是思考如何去实现一套数据不会丢失、实时性高以及高性能的埋点上报组件。我们知道,这里面的进程模式、存储模型、同步机制等都很复杂,要做一个高可用的上报组件确实需要具备一定的技术深度。
|
||||
|
||||
但是如果站在更高的角度上看,你会发现上报组件的优化并不能从根本上解决团队的数据问题。埋点的规范是什么?埋点的流程是什么?产品、研发、数据、测试几个团队对于数据有哪些痛点?我们需要梳理一个埋点从产品定义、客户端埋点开发、测试验证、后端数据处理、数据展示和监控的整个过程。针对团队的数据治理,我们需要体系化的思考每一个点的问题,从更高的角度去全局思考。
|
||||
|
||||
那应该如何来提升自己的高度,站在高处去思考问题呢?下面是我的一些思考。
|
||||
|
||||
**1. 从终端到跨端**
|
||||
|
||||
在App开发最原始的时代,为了实现代码的内部复用,我们封装了各种各样的Util工具类。随着移动互联网的发展,应用越来越多也越来越复杂,代码还需要在不同应用中复用。这个时候客户端组件呈现出爆发式增长,例如图片库中的Glide、Fresco、Picasso,组件化中Atlas、DroidPlugin、RePlugin等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/dd/0eb4f07e4d28a518081b2f6ea97ddfdd.png" alt="">
|
||||
|
||||
回想一下,因为当时Android系统的不成熟和不完善,反而造就了一个百花齐放的移动开发时代。在这个时代里,我们总可以找到很多优化的点,并且持续打磨。随着应用业务复杂性和要求的提升,单纯在客户端的单点优化已经满足不了业务的诉求了,比如在直播、小程序这样的复杂场景。
|
||||
|
||||
这个时候我们第一步就要跳出自身客户端的角色限制,从更为全局的角度看问题、思考问题。你需要明白,客户端的实现只是其中一小块内容而已。假如你接到一个提升页面打开速度的任务,极致优化的基础是我们能深入研究浏览器的渲染原理和缓存机制,但是前端和后端能够做些什么,又应该做些什么呢?除此之外,页面哪里产生、如何发布、发布到哪里、如何下载、如何解析、如何渲染、如何衡量和监控页面的性能,这些全部都是我们需要思考的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/6d/551c6483089126b7acdf3c4a322ca66d.png" alt="">
|
||||
|
||||
一方面,在你的项目还没有证明它的价值之前,可能很多公司并不愿意投入很多的人力。这个时候我们只能去包办前后端,就像当年微信的日志平台、APM平台,记得还是我们用Tornado先简单搭建起来。等到这个项目证明了它的价值,才会拉更多的人参与进来。所以这就需要我们具备跨端的能力,目标是解决产品的问题,要知道客户端技术并不是唯一的选择。
|
||||
|
||||
另外一方面,针对持续交付平台、测试平台、网络平台、数据平台,很多平台客户端开发者本来就是使用方,我们应该更清楚里面的痛点是什么,有哪些可以改进的地方,所以客户端开发者应该更能主导这些平台的演进。
|
||||
|
||||
**2. 从平台到中台**
|
||||
|
||||
正如我上面说到的,组件化只是客户端技术最基本的抽象的体现。怎么理解呢?以性能组件为例,虽然我们收集了应用各个维度的性能数据,但是这些数据在后台如何聚合、如何存储、如何分析、如何报警,我们并没提供解决方案。
|
||||
|
||||
每个接入的应用还是要花很大的力气去搭建一整套系统,为了解决这个问题,集成式服务化的建设开始出现,比如以Google的Firebase为代表的各个开发者平台。为了解决应用不同的场景,我们不断地孵化出不同的服务平台。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/e3/698de0e867d4139d8db8b90864b297e3.png" alt="">
|
||||
|
||||
移动开发早就已经过了单兵作战的年代了,客户端单点的深耕细作已经不是唯一考量的因素。有没有配套的服务、服务是不是简单易用,这些因素对于开发者来说越来越重要。特别是对于大厂来说,一个公司有几十上百个应用,对于公共业务需要避免重复劳动。国内蚂蚁的mPaaS、阿里云的EMAS移动开发者平台,都是遵循这样的服务化思路。
|
||||
|
||||
但是平台化是不是就是服务的最终形态呢?你有没有体验过这种的痛苦:一个新应用需要接入公司内部十几个不同的平台,它们的账号信息、注册信息都相互独立,很多功能我们还需要单独去跟每个平台联调测试。为了解决各个平台的割裂,在平台化的基础上,又提出了中台化的思路。
|
||||
|
||||
什么是中台呢?简单的理解就是把这些分散的平台又统一为一个超大的平台。有人会想我们是不是在开历史的倒车?还记得当年我们将一个庞大的系统分拆成各个子平台是多么的艰难。事实上,这里中台的“统一”,更多是面向开发者层面的,例如都使用同一个账号、不需要重复注册、平台之间互相闭环等。
|
||||
|
||||
关于中台的更多资料,你可以参考[《从平台到中台【上】》](https://mp.weixin.qq.com/s/dpkteHsQJ4Rwl6YNl2PVeg?)和[《从平台到中台【下】》](https://mp.weixin.qq.com/s/TirTQfWo0gX9PUw_okdGjQ?)。在国内,阿里的中台是做得最好的。当然腾讯、头条这些公司也都意识到了它的重要性,最近都在积极调整组织架构,成立了专门的中台部门。但是无论是中台还是平台,都是靠无数大大小小的优化点堆积起来得,它们都需要慢慢地积累,很难在非常短的时间内建设得非常完善。
|
||||
|
||||
## 热点问题答疑
|
||||
|
||||
针对高效开发,我全面介绍了目前各大公司的做法和思路。对于持续交付、测试、发布、数据、网络、日志,它们本身涉及的知识是十分庞大的,所以就正如高质量开发一样,即使专栏的内容不能完全理解,也大可不必焦虑,可以反复多读几次专栏的文章和扩展的参考资料,相信你每次学习都会有不同的收获。
|
||||
|
||||
你可以按照自己的节奏持续地学习下去,这样我相信无论是对你的视野还是对移动开发的理解,都会有很大的收获。但是在向提升高度的方向迈进时,我们或多或少都会有一些疑问,下面我就挑选几个比较重要的问题,和你进一步聊聊我的看法。
|
||||
|
||||
**1. 如何提升个人的专注力和效率?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/43/77a9b2fc0b835ed98aea0783967e1843.png" alt="">
|
||||
|
||||
人的大脑就像CPU一样,如果频繁切换进程和线程,这个代价是非常大的。一会看一下微信,一会刷一下抖音,一会看一下头条,当我们重新切换回工作线程的时候,起码需要几分钟才能重新进入状态。
|
||||
|
||||
那靠个人的自制力能不能解决这个问题?能,但是非常遗憾的是大部分人都没有这个能力,或者说自制力不够强大。不要给自己被诱惑的机会,因为大部分人都无法承受诱惑。我的建议是,直接卸载掉这些可能影响我们工作的软件(微信可能卸载不了,这也是它强大的地方,笑)。
|
||||
|
||||
**2.个人发展与公司平台的关系**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/b8/240f82a329021fd4e8161bf9b07468b8.png" alt="">
|
||||
|
||||
可能DebugCat同学的疑问你也会有共鸣,我每天的工作就是写写界面、调调动画,面对的都是无休无止的业务,你讲的这些东西虽然高大上,但是并没有机会接触到。
|
||||
|
||||
对于小型团队来说,更多的是拿来主义,去使用一些第三方的平台。对于大型团队来说,可能你才有机会真正参与到这些平台的开发。但是也有人说在小型团队可以独当一面,但在大厂只能做一颗小小的螺丝钉。
|
||||
|
||||
我相信业务和团队这些限制因素的确客观存在,而且对我们的影响的确巨大。但是这并不是决定性的因素,你要问问自己有没有真的去努力。
|
||||
|
||||
如果你在大厂,就应该从客户端到后端,尽可能全面深入研究你参与的模块,多想想如何把你所做的模块优化到极致,并且在巨大的用户量面前依然能够稳定运行。如果你在初创团队,在业余时间也要坚持学习,持续探索自己的技术深度。这样在将来,无论是初创团队内部的晋升,还是跳到大厂,这样努力的经验都可以成为未来无数次面试、加薪的一大亮点。
|
||||
|
||||
## 总结
|
||||
|
||||
移动开发工程师想要真正站在高处,既需要有技术深度,又要有广度。很明显你下一个问题是:应该先钻研深度,还是扩展广度呢?
|
||||
|
||||
我建议你应该至少先在一个技术领域付出大量的精力,深入钻研透彻,然后再去思考广度的问题。这是因为经验丰富的程序员学新的东西都非常快,因为现在已经不那么容易出现太多全新的技术,所谓的新技术其实都是旧技术的重新组合和微创新。
|
||||
|
||||
成长是没有捷径的,我发现技术圈也有部分人喜欢在论坛写写文章或者出去授课,在业界可能还小有名气,受到不少人追捧。但是只要真正去大厂面试,可能就会被打回原形。我推荐你看看[这篇文章](https://mp.weixin.qq.com/s/iYb7itFve629ODHuzFH-5g),这里把里面的一句话推荐给你:
|
||||
|
||||
>
|
||||
老老实实看书,踏踏实实做事儿,早日兑现自己曾经吹过的牛逼。
|
||||
|
||||
|
||||
“金三银四”,最近也是找工作的高峰期。从很多同学的面试经历来看,现在只会单纯写业务代码的人找工作特别难,比如很多大厂的面试官都会针对性能优化的细节,考察你是否真正搞懂底层的机制和原理。环境的要求越来越高,所以我们也要积极转变,踏踏实实的学习。最后我也推荐你看看[《程序员成长路线》](https://mp.weixin.qq.com/s/nUtUu6e_bXHvb_06Pf_05g),希望今天讲的这些“大道理”对你有所启发。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
421
极客时间专栏/Android开发高手课/特别放送/Android JVM TI机制详解(内含福利彩蛋).md
Normal file
421
极客时间专栏/Android开发高手课/特别放送/Android JVM TI机制详解(内含福利彩蛋).md
Normal file
@@ -0,0 +1,421 @@
|
||||
<audio id="audio" title="Android JVM TI机制详解(内含福利彩蛋)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/1e/e9b2621b301ebf777d52b63f75a9441e.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。
|
||||
|
||||
在专栏[卡顿优化](http://time.geekbang.org/column/article/73277)的分析中,绍文提到可以利用JVM TI机制获得更加非常丰富的顿现场信息,包括内存申请、线程创建、类加载、GC信息等。
|
||||
|
||||
JVM TI机制究竟是什么?它为什么如此的强大?怎么样将它应用到我们的工作中?今天我们一起来解开它神秘的面纱。
|
||||
|
||||
## JVM TI介绍
|
||||
|
||||
JVM TI全名是[Java Virtual Machine Tool Interface](https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html#SpecificationIntro),是开发虚拟机监控工具使用的编程接口,它可以监控JVM内部事件的执行,也可以控制JVM的某些行为,可以实现调试、监控、线程分析、覆盖率分析工具等。
|
||||
|
||||
JVM TI属于[Java Platform Debugger Architecture](https://docs.oracle.com/javase/7/docs/technotes/guides/jpda/architecture.html)中的一员,在Debugger Architecture上JVM TI可以算作一个back-end,通过JDWP和front-end JDI去做交互。需要注意的是,Android内的JDWP并不是基于JVM TI开发的。
|
||||
|
||||
从Java SE 5开始,Java平台调试体系就使用JVM TI替代了之前的JVMPI和JVMDI。如果你对这部分背景还不熟悉,强烈推荐先阅读下面这几篇文章:
|
||||
|
||||
<li>
|
||||
[深入 Java 调试体系:第 1 部分,JPDA 体系概览](https://www.ibm.com/developerworks/cn/java/j-lo-jpda1/index.html)
|
||||
</li>
|
||||
<li>
|
||||
[深入 Java 调试体系:第 2 部分,JVMTI 和 Agent 实现](https://www.ibm.com/developerworks/cn/java/j-lo-jpda2/index.html)
|
||||
</li>
|
||||
<li>
|
||||
[深入 Java 调试体系:第 3 部分,JDWP 协议及实现](https://www.ibm.com/developerworks/cn/java/j-lo-jpda3/index.html)
|
||||
</li>
|
||||
<li>
|
||||
[深入 Java 调试体系:第 4 部分,Java 调试接口(JDI)](https://www.ibm.com/developerworks/cn/java/j-lo-jpda4/index.html)
|
||||
</li>
|
||||
|
||||
虽然Java已经使用了JVM TI很多年,但从源码上看在Android 8.0才[集成](http://androidxref.com/8.0.0_r4/xref/art/runtime/openjdkjvmti/)了JVM TI v1.2,主要是需要在Runtime中支持修改内存中的Dex和监控全局的事件。有了JVM TI的支持,我们可以实现很多调试工具没有实现的功能,或者定制我们自己的Debug工具来获取我们关心的数据。
|
||||
|
||||
现阶段已经有工具使用JVM TI技术,比如Android Studio的Profilo工具和Linkedin的[dexmaker-mockito-inline](https://github.com/linkedin/dexmaker/tree/master/dexmaker-mockito-inline)工具。Android Studio使用JVM TI机制实现了实时的内存监控,对象分配切片、GC事件、Memory Alloc Diff功能,非常强大;dexmaker使用该机制实现Mock final methods和static methods。
|
||||
|
||||
**1. JVM TI支持的功能**
|
||||
|
||||
在介绍JVM TI的实现原理之前,我们先来看一下JVM TI提供了什么功能?我们可以利用这些功能做些什么?
|
||||
|
||||
**线程相关事件 -> 监控线程创建堆栈、锁信息**
|
||||
|
||||
<li>
|
||||
ThreadStart :线程在执行方法前产生线程启动事件。
|
||||
</li>
|
||||
<li>
|
||||
ThreadEnd:线程结束事件。
|
||||
</li>
|
||||
<li>
|
||||
MonitorWait:wait方法调用后。
|
||||
</li>
|
||||
<li>
|
||||
MonitorWaited:wait方法完成等待。
|
||||
</li>
|
||||
<li>
|
||||
MonitorContendedEnter:当线程试图获取一个已经被其他线程持有的对象锁时。
|
||||
</li>
|
||||
<li>
|
||||
MonitorContendedEntered:当线程获取到对象锁继续执行时。
|
||||
</li>
|
||||
|
||||
**类加载准备事件 -> 监控类加载**
|
||||
|
||||
<li>
|
||||
ClassFileLoadHook:在类加载之前触发。
|
||||
</li>
|
||||
<li>
|
||||
ClassLoad:某个类首次被加载。
|
||||
</li>
|
||||
<li>
|
||||
ClassPrepare:某个类的准备阶段完成。
|
||||
</li>
|
||||
|
||||
**异常事件 -> 监控异常信息**
|
||||
|
||||
<li>
|
||||
Exception:有异常抛出的时候。
|
||||
</li>
|
||||
<li>
|
||||
ExceptionCatch:当捕获到一个异常时候。
|
||||
</li>
|
||||
|
||||
**调试相关**
|
||||
|
||||
<li>
|
||||
SingleStep:步进事件,可以实现相当细粒度的字节码执行序列,这个功能可以探查多线程下的字节码执行序列。
|
||||
</li>
|
||||
<li>
|
||||
Breakpoint:当线程执行到一个带断点的位置,断点可以通过JVMTI SetBreakpoint方法来设置。
|
||||
</li>
|
||||
|
||||
**方法执行**
|
||||
|
||||
<li>
|
||||
FramePop:当方法执行到retrun指令或者出现异常时候产生,手动调用NofityFramePop JVM TI函数也可产生该事件。
|
||||
</li>
|
||||
<li>
|
||||
MethodEntry:当开始执行一个Java方法的时候。
|
||||
</li>
|
||||
<li>
|
||||
MethodExit:当方法执行完成后,产生异常退出时。
|
||||
</li>
|
||||
<li>
|
||||
FieldAccess:当访问了设置了观察点的属性时产生事件,观察点使用SetFieldAccessWatch函数设置。
|
||||
</li>
|
||||
<li>
|
||||
FieldModification:当设置了观察点的属性值被修改后,观察点使用SetFieldModificationWatch设置。
|
||||
</li>
|
||||
|
||||
**GC -> 监控GC事件与时间**
|
||||
|
||||
<li>
|
||||
GarbageCollectionStart:GC启动时。
|
||||
</li>
|
||||
<li>
|
||||
GarbageCollectionFinish:GC结束后。
|
||||
</li>
|
||||
|
||||
**对象事件 -> 监控内存分配**
|
||||
|
||||
<li>
|
||||
ObjectFree:GC释放一个对象时。
|
||||
</li>
|
||||
<li>
|
||||
VMObjectAlloc:虚拟机分配一个对象的时候。
|
||||
</li>
|
||||
|
||||
**其他**
|
||||
|
||||
- NativeMethodBind:在首次调用本地方法时或者调用JNI RegisterNatives的时候产生该事件,通过该回调可以将一个JNI调用切换到指定的方法上。
|
||||
|
||||
通过上面的事件描述可以大概了解到JVM TI支持什么功能,详细的回调函数参数可以从JVM TI[规范文档](https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html)里获取到,**我们可以通过这些功能实们定制的性能监控、数据采集、行为修改等工具。**
|
||||
|
||||
**2. JVM TI实现原理**
|
||||
|
||||
JVM TI Agent的启动需要虚拟机的支持,我们的Agent和虚拟机运行在同一个进程中,虚拟机通过dlopen打开我们的Agent动态链接库,然后通过Agent_OnAttach方法来调用我们定义的初始化逻辑。
|
||||
|
||||
JVM TI的原理其实很简单,以VmObjectAlloc事件为例,当我们通过SetEventNotificationMode函数设置JVMTI_EVENT_VM_OBJECT_ALLOC回调的时候,最终会调用到art::Runtime::Current() -> GetHeap() -> SetAllocationListener(listener);
|
||||
|
||||
在这个方法中,listener是JVM TI实现的一个虚拟机提供的art::gc::AllocationListener回调,当虚拟机分配对象内存的时候会调用该回调,源码可见[heap-inl.h#194](http://androidxref.com/9.0.0_r3/xref/art/runtime/gc/heap-inl.h#194),同时在该回调函数里也会调用我们之前设置的callback方法,这样事件和相关的数据就会透传到我们的Agent里,来实现完成事件的监听。
|
||||
|
||||
类似atrace和StrictMode,JVM TI的每个事件都需要在源码中埋点支持。感兴趣的同学,可以挑选一些事件在源码中进一步跟踪。
|
||||
|
||||
## JVM TI Agent开发
|
||||
|
||||
JVM TI Agent程序使用C/C++语言开发,也可以使用其他支持C语言调用语言开发,比如Rust。
|
||||
|
||||
JVM TI所涉及的常量、函数、事件、数据类型都定义在jvmti.h文件中,我们需要下载该文件到项目中引用使用,你可以从Android项目里下载它的[头文件](http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/include/)。
|
||||
|
||||
JVM TI Agent的产出是一个so文件,在Android里通过系统提供的[Debug.attachJvmtiAgent](https://developer.android.com/reference/kotlin/android/os/Debug#attachJvmtiAgent%28kotlin.String%2C+kotlin.String%2C+java.lang.ClassLoader%29)方法来启动一个JVM TI Agent程序。
|
||||
|
||||
```
|
||||
static fun attachJvmtiAgent(library: String, options: String?, classLoader: ClassLoader?): Unit
|
||||
|
||||
```
|
||||
|
||||
library是so文件的绝对地址。需要注意的是API Level为28,而且需要应用开启了[android:debuggable](https://developer.android.com/guide/topics/manifest/application-element#debug)才可以使用,**不过我们可以通过强制开启debug来在release版里启动JVM TI功能**。
|
||||
|
||||
Android下的JVM TI Agent在被虚拟机加载后会及时调用Agent_OnAttach方法,这个方法可以当作是Agent程序的main函数,所以我们需要在程序里实现下面的函数。
|
||||
|
||||
```
|
||||
extern "C" JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM *vm, char *options,void *reserved)
|
||||
|
||||
```
|
||||
|
||||
你可以在这个方法里进行初始化操作。
|
||||
|
||||
通过JavaVM::GetEnv函数拿到jvmtiEnv*环境指针(Environment Pointer),通过该指针可以访问JVM TI提供的函数。
|
||||
|
||||
```
|
||||
jvmtiEnv *jvmti_env;jint result = vm->GetEnv((void **) &jvmti_env, JVMTI_VERSION_1_2);
|
||||
|
||||
```
|
||||
|
||||
通过AddCapabilities函数来开启需要的功能,也可以通过下面的方法开启所有的功能,不过开启所有的功能对虚拟机的性能有所影响。
|
||||
|
||||
```
|
||||
void SetAllCapabilities(jvmtiEnv *jvmti) {
|
||||
jvmtiCapabilities caps;
|
||||
jvmtiError error;
|
||||
error = jvmti->GetPotentialCapabilities(&caps);
|
||||
error = jvmti->AddCapabilities(&caps);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
GetPotentialCapabilities函数可以获取当前环境支持的功能集合,通过jvmtiCapabilities结构体返回,该结构体里标明了支持的所有功能,可以通过[jvmti.h](http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/include/jvmti.h#712)来查看,大概内容如下。
|
||||
|
||||
```
|
||||
typedef struct {
|
||||
unsigned int can_tag_objects : 1;
|
||||
unsigned int can_generate_field_modification_events : 1;
|
||||
unsigned int can_generate_field_access_events : 1;
|
||||
unsigned int can_get_bytecodes : 1;
|
||||
unsigned int can_get_synthetic_attribute : 1;
|
||||
unsigned int can_get_owned_monitor_info : 1;
|
||||
......
|
||||
} jvmtiCapabilities;
|
||||
|
||||
```
|
||||
|
||||
然后通过AddCapabilities方法来启动需要的功能,如果需要单独添加功能,则可以通过如下方法。
|
||||
|
||||
```
|
||||
jvmtiCapabilities caps;
|
||||
memset(&caps, 0, sizeof(caps));
|
||||
caps.can_tag_objects = 1;
|
||||
|
||||
```
|
||||
|
||||
到此JVM TI的初始化操作就已经完成了。
|
||||
|
||||
所有的函数和数据结构类型说明可以在[这里](https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html)找到。下面我来介绍一些常用的功能和函数。
|
||||
|
||||
**1. JVM TI事件监控**
|
||||
|
||||
JVM TI的一大功能就是可以收到虚拟机执行时候的各种事件通知。
|
||||
|
||||
首先通过SetEventCallbacks方法来设置目标事件的回调函数,如果callbacks传入nullptr则清除掉所有的回调函数。
|
||||
|
||||
```
|
||||
jvmtiEventCallbacks callbacks;
|
||||
memset(&callbacks, 0, sizeof(callbacks));
|
||||
|
||||
callbacks.GarbageCollectionStart = &GCStartCallback;
|
||||
callbacks.GarbageCollectionFinish = &GCFinishCallback;
|
||||
int error = jvmti_env->SetEventCallbacks(&callbacks, sizeof(callbacks));
|
||||
|
||||
```
|
||||
|
||||
设置了回调函数后,如果要收到目标事件的话需要通过SetEventNotificationMode,这个函数有个需要注意的地方是event_thread,如果参数event_thread参数为nullptr,则会全局启用改目标事件回调,否则只在指定的线程内生效,比如很多时候对于一些事件我们只关心主线程。
|
||||
|
||||
```
|
||||
jvmtiError SetEventNotificationMode(jvmtiEventMode mode,
|
||||
jvmtiEvent event_type,
|
||||
jthread event_thread,
|
||||
...);
|
||||
typedef enum {
|
||||
JVMTI_ENABLE = 1,//开启
|
||||
JVMTI_DISABLE = 0 .//关闭
|
||||
} jvmtiEventMode;
|
||||
|
||||
```
|
||||
|
||||
以上面的GC事件为例,上面设置了GC事件的回调函数,如果想要在回调方法里接收到事件则需要使用SetEventNotificationMode开启事件,需要说明的是SetEventNotificationMode和SetEventCallbacks方法调用没有先后顺序。
|
||||
|
||||
```
|
||||
jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr);
|
||||
jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_FINISH, nullptr);
|
||||
|
||||
```
|
||||
|
||||
通过上面的步骤就可以在虚拟机产生GC事件后在回调函数里获取到对应的函数了,这个Sample需要注意的是在gc callback里禁止使用JNI和JVM TI函数,因为虚拟机处于停止状态。
|
||||
|
||||
```
|
||||
void GCStartCallback(jvmtiEnv *jvmti) {
|
||||
LOGI("==========触发 GCStart=======");
|
||||
}
|
||||
|
||||
void GCFinishCallback(jvmtiEnv *jvmti) {
|
||||
LOGI("==========触发 GCFinish=======");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Sample效果如下。
|
||||
|
||||
```
|
||||
com.dodola.jvmti I/jvmti: ==========触发 GCStart=======
|
||||
com.dodola.jvmti I/jvmti: ==========触发 GCFinish=======
|
||||
|
||||
```
|
||||
|
||||
**2. JVM TI字节码增强**
|
||||
|
||||
JVM TI可以在虚拟机运行的状态下对字节码进行修改,可以通过下面三种方式修改字节码。
|
||||
|
||||
<li>
|
||||
Static:在虚拟机加载Class文件之前,对字节码修改。该方式一般不采用。
|
||||
</li>
|
||||
<li>
|
||||
Load-Time:在虚拟机加载某个Class时,可以通过JVM TI回调拿到该类的字节码,会触发ClassFileLoadHook回调函数,该方法由于ClassLoader机制只会触发一次,由于我们Attach Agent的时候经常是在虚拟机执行一段时间之后,所以并不能修改已经加载的Class比如Object,所以需要根据Class的加载时机选择该方法。
|
||||
</li>
|
||||
<li>
|
||||
Dynamic:对于已经载入的Class文件也可以通过JVM TI机制修改,当系统调用函数RetransformClasses时会触发ClassFileLoadHook,此时可以对字节码进行修改,该方法最为实用。
|
||||
</li>
|
||||
|
||||
传统的JVM操作的是Java Bytecode,Android里的字节码操作的是[Dalvik Bytecode](https://source.android.com/devices/tech/dalvik/dalvik-bytecode),Dalvik Bytecode是寄存器实现的,操作起来相对JavaBytecode来说要相对容易一些,可以不用处理本地变量和操作数栈的交互。
|
||||
|
||||
使用这个功能需要开启JVM TI字节码增强功能。
|
||||
|
||||
```
|
||||
jvmtiCapabilities.can_generate_all_class_hook_events=1 //开启 class hook 功能标记
|
||||
jvmtiCapabilities.can_retransform_any_class=1 //开启对任意类进行 retransform 操作
|
||||
|
||||
```
|
||||
|
||||
然后注册ClassFileLoadHook事件回调。
|
||||
|
||||
```
|
||||
jvmtiEventCallbacks callbacks;s
|
||||
callbacks.ClassFileLoadHook = &ClassTransform;
|
||||
|
||||
```
|
||||
|
||||
这里说明一下ClassFileLoadHook的函数原型,后面会讲解如何重新修改现有字节码。
|
||||
|
||||
```
|
||||
static void ClassTransform(
|
||||
jvmtiEnv *jvmti_env,//jvmtiEnv 环境指针
|
||||
JNIEnv *env,//jniEnv 环境指针
|
||||
jclass classBeingRedefined,//被重新定义的class 信息
|
||||
jobject loader,//加载该 class 的 classloader,如果该项为 nullptr 则说明是 BootClassLoader 加载的
|
||||
const char *name,//目标类的限定名
|
||||
jobject protectionDomain,//载入类的保护域
|
||||
jint classDataLen,//class 字节码的长度
|
||||
const unsigned char *classData,//class 字节码的数据
|
||||
jint *newClassDataLen,//新的类数据的长度
|
||||
unsigned char **newClassData) //新类的字节码数据
|
||||
|
||||
```
|
||||
|
||||
然后开启事件,完整的初始化逻辑可参考Sample中的代码。
|
||||
|
||||
```
|
||||
SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, NULL)
|
||||
|
||||
```
|
||||
|
||||
下面以Sample代码作为示例来讲解如何在Activity类的onCreate方法中插入一行日志调用代码。
|
||||
|
||||
通过上面的步骤后就可以在虚拟机第一次加载类的时候和在调用RetransformClasses或者RedefineClasses时,在ClassFileLoadHook回调方法里会接收到事件回调。我们目标类是Activity,它在启动应用的时候就已经触发了类加载的过程,由于这个Sample开启事件的时机很靠后,所以此时并不会收到加载Activity类的事件回调,所以需要调用RetransformClasses来触发事件回调,这个方法用于对已经载入的类进行修改,传入一个要修改类的Class数组和数组长度。
|
||||
|
||||
```
|
||||
jvmtiError RetransformClasses(jint class_count, const jclass* classes)
|
||||
|
||||
```
|
||||
|
||||
调用该方法后会在ClassFileLoadHook设置的回调,也就是上面的ClassTran sform方法中接收到回调,在这个回调方法中我们通过字节码处理工具来修改原始类的字节码。
|
||||
|
||||
类的修改会触发虚拟机使用新的方法,旧的方法将不再被调用,如果有一个方法正在栈帧上,则这个方法会继续运行旧的方法的字节码。RetransformClasses 的修改不会导致类的初始化,也就是不会重新调用<cinit>方法,类的静态变量的值和实例变量的值不会产生变化,但目标类的断点会失效。</cinit>
|
||||
|
||||
处理类有一些限制,我们可以改变方法的实现和属性,但不能添加删除重命名方法,不能改变方法签名、参数、修饰符,不能改变类的继承关系,如果产生上面的行为会导致修改失败。修改之后会触发类的校验,而且如果虚拟机里有多个相同的Class ,我们需要注意一下取到的Class需要是当前生效的Class,按照ClassLoader加载机制也就是说优先使用提前加载的类。
|
||||
|
||||
Sample中实现的效果是在Activity.onCreate方法中增加一行日志输出。
|
||||
|
||||
修改前:
|
||||
|
||||
```
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
.......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修改后:
|
||||
|
||||
```
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
com.dodola.jvmtilib.JVMTIHelper.printEnter(this,"....");
|
||||
....
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我使用的Dalvik字节码修改库是Android系统源码里提供的一套修改框架[dexter](http://androidxref.com/9.0.0_r3/xref/tools/dexter/),虽然使用起来十分灵活但比较繁琐,也可以使用[dexmaker](https://github.com/linkedin/dexmaker)框架来实现。本例还是使用dexter,框架使用C++开发,可以直接读取classdata然后进行操作,可以类比到ASM框架。下面的代码是核心的操作代码,完整的代码参考本期Sample。
|
||||
|
||||
```
|
||||
ir::Type* stringT = b.GetType("Ljava/lang/String;");
|
||||
ir::Type* jvmtiHelperT=b.GetType("Lcom/dodola/jvmtilib/JVMTIHelper;");
|
||||
lir::Instruction *fi = *(c.instructions.begin());
|
||||
VReg* v0 = c.Alloc<VReg>(0);
|
||||
addInstr(c, fi, OP_CONST_STRING,
|
||||
{v0, c.Alloc<String>(methodDesc, methodDesc->orig_index)});
|
||||
addCall(b, c, fi, OP_INVOKE_STATIC, jvmtiHelperT, "printEnter", voidT, {stringT}, {0});
|
||||
c.Assemble();
|
||||
|
||||
```
|
||||
|
||||
必须通过JVM TI函数Allocate为要修改的类数据分配内存,将new_class_data指向修改后的类bytecode数组,将new_class_data_len置为修改后的类bytecode数组的长度。若是不修改类文件,则不设置new_class_data即可。若是加载了多个JVM TI Agent都启用了该事件,则设置的new_class_data会成为下一个JVM TI Agent的class_data。
|
||||
|
||||
此时我们生成的onCreate方法里已经加上了我们添加的日志方法调用。开启新的Activity会使用新的类字节码执行,同时会使用ClassLoader加载我们注入的com.dodola.jvmtilib.JVMTIHelper类。我在前面说过,Activity是使用BootClassLoader进行加载的,然而我们的类明显不在BootClassLoader里,此时就会产生Crash。
|
||||
|
||||
```
|
||||
java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace available
|
||||
|
||||
```
|
||||
|
||||
所以需要想办法将JVMTIHelper类添加到BootClassLoader里,这里可以使用JVM TI提供的AddToBootstrapClassLoaderSearch方法来添加Dex或者APK到Class搜索目录里。Sample里是将 getPackageCodePath添加进去就可以了。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我主要讲解了JVM TI的概念和原理,以及它可以实现的功能。通过JVM TI可以完成很多平时可能需要很多“黑科技”才可以获取到的数据,比如[Thread Park Start/Finish](https://android-review.googlesource.com/c/platform/art/+/822440)事件、获取一个锁的waiters等。
|
||||
|
||||
可能在Android圈里了解JVM TI的人不多,对它的研究还没有非常深入。目前JVM TI的功能已经十分强大,后续的Android版本也会进一步增加更多的功能支持,这样它可以做的事情将会越来越多。我相信在未来,它将会是本地自动化测试,甚至是线上远程诊断的一大“杀器”。
|
||||
|
||||
在本期的[Sample](https://github.com/AndroidAdvanceWithGeektime/JVMTI_Sample)里,我们提供了一些简单的用法,你可以在这个基础之上完成扩展,实现自己想要的功能。
|
||||
|
||||
## 相关资料
|
||||
|
||||
1.[深入 Java 调试体系:第 1 部分,JPDA 体系概览](https://www.ibm.com/developerworks/cn/java/j-lo-jpda1/index.html)
|
||||
|
||||
2.[深入 Java 调试体系:第 2 部分,JVMTI 和 Agent 实现](https://www.ibm.com/developerworks/cn/java/j-lo-jpda2/index.html)
|
||||
|
||||
3.[深入 Java 调试体系:第 3 部分,JDWP 协议及实现](https://www.ibm.com/developerworks/cn/java/j-lo-jpda3/index.html)
|
||||
|
||||
4.[深入 Java 调试体系:第 4 部分,Java 调试接口(JDI)](https://www.ibm.com/developerworks/cn/java/j-lo-jpda4/index.html)
|
||||
|
||||
5.JVM TI官方文档:[https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html](https://docs.oracle.com/javase/7/docs/platform/jvmti/jvmti.html)
|
||||
|
||||
6.源码是最好的资料:[http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/](http://androidxref.com/9.0.0_r3/xref/art/openjdkjvmti/)
|
||||
|
||||
## 福利彩蛋
|
||||
|
||||
根据专栏导读里我们约定的,我和绍文会选出一些认真提交作业完成练习的同学,送出一份“学习加油礼包”。专栏更新到现在,很多同学留下了自己的思考和总结,我们选出了@Owen、@志伟、@许圣明、@小洁、@SunnyBird,送出“[极客时间周历](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2fwl2bk2x20js)”一份,希望更多同学可以加入到学习和讨论中来,与我们一起进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/ce/c91eaa4425b74b8c5d8a044e0332f8ce.png" alt=""><br>
|
||||
@Owen学习总结:[https://github.com/devzhan/Breakpad](https://github.com/devzhan/Breakpad)
|
||||
|
||||
@许圣明、@小洁、@SunnyBird 通过Pull Requests提交了练习作业[https://github.com/AndroidAdvanceWithGeektime/Chapter04/pulls](https://github.com/AndroidAdvanceWithGeektime/Chapter04/pulls)。
|
||||
|
||||
极客时间小助手会在24小时内与获奖用户取得联系,注意查看短信哦~
|
||||
|
||||
|
||||
47
极客时间专栏/Android开发高手课/特别放送/Android工程师的“面试指南”.md
Normal file
47
极客时间专栏/Android开发高手课/特别放送/Android工程师的“面试指南”.md
Normal file
@@ -0,0 +1,47 @@
|
||||
<audio id="audio" title="Android工程师的“面试指南”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/db/1db43cbf74cae275a50edf18a44d42db.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。又到了传统的“金三银四”换工作的高峰期,在互联网寒冬下,抓住机会就显得尤为重要了。那作为Android工程师我们应该从哪些方面去准备呢?例如,不太熟悉的技能要不要写在简历上、要复习哪些Android组件的知识、刷算法题目有没有用,可能在面试前你都会仔细考虑这些问题。下面我就结合自身的经验和理解,帮你梳理一下关于简历、面试和算法方面需要准备的内容,分享一些我的心得体会。
|
||||
|
||||
## 简历
|
||||
|
||||
简历在面试过程会起到至关重要的作用,我们需要非常注意简历的撰写。
|
||||
|
||||
在面试的过程中,面试官通常会非常关注你简历中的工作经历、项目介绍、技能特长这三部分的内容,如果你面试的公司没有固定题目的话,那很多问题都会围绕你简历里这三部分内容去问。这里需要注意的一点是相关技能的书写,首先你要让面试官明确你面试的定级是什么。很多时候一个职位对应了很多个职级,在投简历的时候,你的简历需要让面试官给你一个比较明确的定级,否则面试过程会比较被动,也会影响面试官对你的判断。因此这部分的内容需要突出自己的特长,也要写一些现在公司相对关心的问题,比如你对插件化、热修复、组件化、性能优化等很熟悉,就可以明确的写上,但如果不是很熟悉那么尽量不要去写。如果你对Android某部分内容很熟悉就可以写得相对详细一些,比如你对Handler、Binder机制很熟悉,就可以写“熟悉Android常见机制,比如Handler、Binder机制等”。而看到你很熟悉这部分内容,面试官可能在问问题时一层层深入,因此你肯定需要提前准备一下这部分内容如何讲解,基本可以从机制的优点、重点、难点三方面去说明。
|
||||
|
||||
关于项目介绍主要体现你在这个项目或者这个团队中的作用,突出自己的贡献和项目的难点。很多同学可能在公司一直做需求的开发,会觉得自己的项目经验没有亮点,难度也没有那么大,会觉得在这部分内容上比较吃亏。其实每个需求下来的时候你肯定会对这个需求有一个自己的设计,在这个过程中你会考虑如何对现有代码的影响最小,如何快捷清晰的实现功能,以及在开发过程中对组件、控件的封装,考虑如何优化性能,有没有新的技术可以帮助开发这个需求的…我想我们每个需求都是通过一系列的考量和设计后才实施的,你可以回去翻一下自己的代码,然后考虑一下如何把你的这些设计和思考体现在简历上,同样也是个不错的说明。
|
||||
|
||||
## 面试
|
||||
|
||||
对于Android工程师来说,面试开始的时候都会问一些算法和Android、Java的基础知识。针对Java的基础知识,我建议你看一下《码出高效:Java 开发手册》《深入理解Java虚拟机》《Java并发编程的艺术》这三本书。对于Android的面试题,大多都是跟系统原理有关的内容,但也有很多没有准确答案的问题,比如四大组件的原理这样的题目,需要你从一个宏观的角度去解释一下四大组件,或者你也可以拆分开一个个去讲解。
|
||||
|
||||
在面试前你需要提前准备一下,避免回答问题的时候没有条理,导致面试官对你的逻辑思维能力和语言表达能力产生不好的判断。一些Android经常使用到的组件一定要理解清楚,比如Handler.postDelay的机制、触摸事件机制、自定义View、如何计算View大小、容器控件如何对子控件进行布局、数据库基本操作、Binder机制、LMK机制等。还有面试官也可能会问一些开源框架的原理,我建议你也要多了解一些优秀的网络框架、图片加载框架、日志记录框架、EventBus、AAC框架的原理。对于相对复杂的插件化和热修复来说,热修复可以去看一下《深入探索Android热修复》这本书,插件化可以去看[《Android插件化原理解析》](http://weishu.me/2016/01/28/understand-plugin-framework-overview/)这个系列的文章。还有性能优化,最近几年公司对性能优化关注很多,有的同学可能做过专门的性能优化或者自己开发过一些工具总结过一些方法论,这样比较好答一些。但是大部分同学可能平时都在关注业务需求开发,性能优化的实战可能并不是很多。我建议你可以从业务开发过程中找一些点来说,比如在做一些公共的业务组件时需要在启动时初始化,那么就需要注意初始化过程中的性能;又或者在做一个列表页面的时候,在复杂的列表View下如何保证滑动性能。相信你在平时开发过程中都会有自己的思考,可以结合具体的情景讲出来。
|
||||
|
||||
面试的后面大多都会从项目入手,你需要在面试之前针对你的项目做详细的准备。比如面试官会让你介绍一下你的项目,你需要体现出这个项目的难点、你在项目中的贡献、项目的具体实现等,有可能还会问到一些具体的细节,所以建议是实事求是去讲,但一定要对自己的模块非常清晰。除了技术面试以外,有时还有可能会考察一些软技能,比如面试官会考察你跨部门协作能力、沟通能力、时间管理、任务分配和职业规划等。
|
||||
|
||||
面试更多的还是要靠平时的积累和临场的发挥,做总结是很重要的,因为很多内容不经常使用的话过一段时间之后就会忘掉,这样就会出现原本自己做过的东西,因为忘记了细节,结果在面试过程中没法很好地展现出来。就比如插件化、热修复这样的技术,其实原理相对来说比较简单,但是在开发的过程中会遇到很多很多的坑,如果没有一些关键点的文字记录,过一段时间之后可能就忘记了某段代码是用于什么目的。所以在做完一次需求之后尽量多总结项目中的难点,使用到的框架以及这个框架的原理,以及其中花费时间最长的地方。另外Bug最多的地方也要做总结一下原因,这样在面试前就不用把代码再翻一遍,了解自己的项目细节就可以做到游刃有余了。
|
||||
|
||||
对于复习,首先要对自己做一次自我了解,我是通过画脑图来进行这个过程的,我会整体默想一遍大概的知识体系,画成类似下图。回想每个知识点可能考到的内容,记录下自己模糊的地方,然后去看网上同学们总结的面试题,再对每个题目都做一下回答。这是一个迭代过程。在你预想的问题都可以回答上来的时候,就需要深入挖掘一下技术细节和深度了,比如我工作中开发了一个PLT Hook工具,这个工具可能是我参考开源项目并封装修改过来的,但对其中的细节并没有很了解,这个时候你就要对这个开源项目所涉及的内容做一次系统学习了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/1e/f040ca609fe4785bf0ed2932b4bfa01e.png" alt="">
|
||||
|
||||
另一方面是需要在面试过程中提高面试官对自己的级别评价,比如大部分人回答GC的问题都是按照《深入理解Java虚拟机》里的内容复述一遍,这种回答基本也是可以的。不过毕竟Java虚拟机和Android虚拟机的GC还是有些差别的,如果自己阅读过Android虚拟机GC相关资料或者自己分析过源码的话,可以从Android虚拟机的角度解释GC,比如Android虚拟机里MarkSweep算法的增量回收、并行回收等,后台GC和前台GC、VisitRoot的执行过程、GC的触发方式、TLAB的处理、ConcurrentGC的原理、堆的Trim过程、内存碎片的解决、Reference的处理、finalize函数调用等展开讲。如果你对一个机制很熟悉的话,可以把话题引到这上面去,然后一层层对这个知识点深入讲解,这样可以提升面试官对你的等级评价。
|
||||
|
||||
## 算法
|
||||
|
||||
算法是一定要复习的,在很多面试的过程中都会穿插算法题。面试的算法题一般不会很难,可以分为基础的数据结构,比如数组、链表、栈、队列、二叉树、堆的使用,这几种常见的数据结构的基础操作一定要很熟悉,比如链表逆置、删除、获取第K个元素、判断是否有环等,二叉树翻转、深度遍历、层级遍历、求树深度、公共父节点等。另一种是常见的搜索、排序算法,这两类算法出现频率很高,一定要知道它们常见的几种实现方式,比如排序方式有冒泡、快排、插入、归并、堆排序等。注意这里一定不要简单地去记忆算法实现,因为面试的时候可能不会直接让你写出对应的算法,会出一些使用搜索或者排序算法来实现的题目,这类题目你可以去LeetCode上通过标签过滤出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/84/38137da67092caa6f164aa4445fe9984.png" alt="">
|
||||
|
||||
另一部分的算法题可能集中在贪心、动态规划、分治算法、深搜广搜等,这一类的算法相对需要一些技巧性。但面试算法题通常不需要太多行代码就能完成,一般都是在几十行内就能完成的,所以你可以优先去找一些经典题目来做,比如爬楼梯、最大子序和等。但也会有一些相对复杂的题目是几种算法结合在一起的,比如二叉树的最大路径和就是深度搜索和动态规划一起使用的题目。除此之外,也可能会遇到通过其他问题引申出的一些算法题目,比如HashMap可能会引申出红黑树的实现等。这些都有可能需要准备,虽然它可能不会成为你整个面试的一个绊脚石,但有可能成为你获取一个高评价的筹码。
|
||||
|
||||
“临时抱佛脚”对于算法的学习和积累作用不是很大,因此需要我们在平时繁忙的工作中抽出一些时间来复习,你也可以去LeetCode、LintCode上刷刷题。另外,虽然大部分面试的算法题目都是LeetCode上的简单题目,但你同样也需要关注一些中等和困难难度的经典题目。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我并没有涉及太多具体的面试题,更多侧重的是如何准备面试,而面试的准备其实是在我们平时工作过程中一点一滴积累的,复习只是作为一种在面试前巩固知识的手段。复习的过程主要是我们对知识点的整理和总结,你可以想一下在面试的时候可能会遇到的问题,以及该如何去表达。但是我想说,虽然“临时抱佛脚”的准备可能有时有用,但是在短时间内靠“突击”是很难理解到某个知识点更加深度层次的内容,而且知识面的广度也是需要时间和经验去积累的。所以不管你是否需要面试,在平时工作过程中都需要多思考、多训练、多总结,在有需要的时候可以厚积薄发。
|
||||
|
||||
最后,如果你也在准备面试,可以在留言区分享一下你的准备情况和面试心得。当然你也可以留下你遇到的面试问题,把它分享给其他同学。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。我也为认真思考、积极分享的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。
|
||||
|
||||
|
||||
154
极客时间专栏/Android开发高手课/特别放送/Native下如何获取调用栈?.md
Normal file
154
极客时间专栏/Android开发高手课/特别放送/Native下如何获取调用栈?.md
Normal file
@@ -0,0 +1,154 @@
|
||||
<audio id="audio" title="Native下如何获取调用栈?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/15/6c87ddee93100bc7b6f330617e4bb215.mp3"></audio>
|
||||
|
||||
你好,我是simsun,曾在微信从事Android开发,也是开源爱好者、Rust语言“铁粉”。应绍文邀请,很高兴可以在“高手课”里和你分享一些编译方面的底层知识。
|
||||
|
||||
当我们在调试Native崩溃或者在做profiling的时候是十分依赖backtrace的,高质量的backtrace可以大大减少我们修复崩溃的时间。但你是否了解系统是如何生成backtrace的呢?今天我们就来探索一下backtrace背后的故事。
|
||||
|
||||
下面是一个常见的Native崩溃。通常崩溃本身并没有任何backtrace信息,可以直接获得的就是当前寄存器的值,但显然backtrace才是能够帮助我们修复Bug的关键。
|
||||
|
||||
```
|
||||
pid: 4637, tid: 4637, name: crasher >>> crasher <<<
|
||||
signal 6 (SIGABRT), code -6 (SI_TKILL), fault addr --------
|
||||
Abort message: 'some_file.c:123: some_function: assertion "false" failed'
|
||||
r0 00000000 r1 0000121d r2 00000006 r3 00000008
|
||||
r4 0000121d r5 0000121d r6 ffb44a1c r7 0000010c
|
||||
r8 00000000 r9 00000000 r10 00000000 r11 00000000
|
||||
ip ffb44c20 sp ffb44a08 lr eace2b0b pc eace2b16
|
||||
backtrace:
|
||||
#00 pc 0001cb16 /system/lib/libc.so (abort+57)
|
||||
#01 pc 0001cd8f /system/lib/libc.so (__assert2+22)
|
||||
#02 pc 00001531 /system/bin/crasher (do_action+764)
|
||||
#03 pc 00002301 /system/bin/crasher (main+68)
|
||||
#04 pc 0008a809 /system/lib/libc.so (__libc_init+48)
|
||||
#05 pc 00001097 /system/bin/crasher (_start_main+38)
|
||||
|
||||
```
|
||||
|
||||
在阅读后面的内容之前,你可以先给自己2分钟时间,思考一下系统是如何生成backtrace的呢?我们通常把生成backtrace的过程叫作unwind,unwind看似和我们平时开发并没有什么关系,但其实很多功能都是依赖unwind的。举个例子,比如你要绘制火焰图或者是在崩溃发生时得到backtrace,都需要依赖unwind。
|
||||
|
||||
## 书本中的unwind
|
||||
|
||||
**1. 函数调用过程**
|
||||
|
||||
如果你在大学时期修过汇编原理这门课程,相信你会对下面的内容还有印象。下图就是一个非常标准的函数调用的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/2a/09cd560f823de783e22af03a5f837f2a.png" alt="">
|
||||
|
||||
<li>
|
||||
首先假设我们处于函数main()并准备调用函数foo(),调用方会按倒序压入参数。此时第一个参数会在调用栈栈顶。
|
||||
</li>
|
||||
<li>
|
||||
调用invoke foo()伪指令,压入当前寄存器EIP的值到栈,然后载入函数foo()的地址到EIP。
|
||||
</li>
|
||||
<li>
|
||||
此时,由于我们已经更改了EIP的值(为foo()的地址),相当于我们已经进入了函数foo()。在执行一个函数之前,编译器都会给每个函数写一段序言(prologue),这里会压入旧的EBP值,并赋予当前EBP和ESP新的值,从而形成新的一个函数栈。
|
||||
</li>
|
||||
<li>
|
||||
下一步就行执行函数foo()本身的代码了。
|
||||
</li>
|
||||
<li>
|
||||
结束执行函数foo() 并准备返回,这里编译器也会给每个函数插入一段尾声(epilogue)用于恢复调用方的ESP和EBP来重建之前函数的栈和恢复寄存器。
|
||||
</li>
|
||||
<li>
|
||||
执行返回指令(ret),被调用函数的尾声(epilogue)已经恢复了EBP和ESP,然后我们可以从被恢复的栈中依次pop出EIP、所有的参数以及被暂存的寄存器的值。
|
||||
</li>
|
||||
|
||||
读到这里,相信如果没有学过汇编原理的同学肯定会有一些懵,我来解释一下上面提到的寄存器缩写的具体含义,上述命名均使用了x86的命名方式。讲这些是希望你对函数调用有一个初步的理解,其中有很多细节在不同体系结构、不同编译器上的行为都有所区别,所以请你放松心情,跟我一起继续向后看。
|
||||
|
||||
>
|
||||
<p>EBP:基址指针寄存器,指向栈帧的底部。<br>
|
||||
在ARM体系结构中,R11(ARM code)或者R7(Thumb code)起到了类似的作用。在ARM64中,此寄存器为X29。<br>
|
||||
ESP:栈指针寄存器,指向栈帧的栈顶 , 在ARM下寄存器为R13。<br>
|
||||
EIP:指令寄存器,存储的是CPU下次要执行的指令的地址,ARM下为PC,寄存器为R15。</p>
|
||||
|
||||
|
||||
**2. 恢复调用帧**
|
||||
|
||||
如果我们把上述过程缩小,站在更高一层视角去看,所有的函数调用栈都会形成调用帧(stack frame),每一个帧中都保存了足够的信息可以恢复调用函数的栈帧。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/45/017b900048f1be70ec870ea0750d2145.png" alt="">
|
||||
|
||||
我们这里忽略掉其他不相关的细节,重点关注一下EBP、ESP和EIP。你可以看到EBP和ESP分别指向执行函数栈的栈底和栈顶。每次函数调用都会保存EBP和EIP用于在返回时恢复函数栈帧。这里所有被保存的EBP就像一个链表指针,不断地指向调用函数的EBP。 这样我们就可以此为线索,十分优雅地恢复整个调用栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/f8/7ab1f6e1774731ec96b9c68843a73df8.png" alt="">
|
||||
|
||||
这里我们可以用下面的伪代码来恢复调用栈:
|
||||
|
||||
```
|
||||
void debugger::print_backtrace() {
|
||||
auto curr_func = get_func_from_pc(get_pc());
|
||||
output_frame(curr_func);
|
||||
|
||||
auto frame_pointer = get_register_value(m_pid, reg::rbp);
|
||||
auto return_address = read_mem(frame_pointer+8);
|
||||
|
||||
while (dwarf::at_name(curr_func) != "main") {
|
||||
curr_func = get_func_from_pc(ret_addr);
|
||||
output_frame(curr_func);
|
||||
frame_pointer = read_mem(frame_pointer);
|
||||
return_address = read_mem(frame_pointer+8);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是在ARM体系结构中,出于性能的考虑,天才的开发者为了节约R7/R11寄存器,使其可以作为通用寄存器来使用,因此无法保证保存足够的信息来形成上述调用栈的(即使你向编译器传入了“-fno-omit-frame-pointer”)。比如下面两种情况,ARM就会不遵循一般意义上的序言(prologue),感兴趣的同学可以具体查看[APCS Doc](https://www.cl.cam.ac.uk/~fms27/teaching/2001-02/arm-project/02-sort/apcs.txt#1018)。
|
||||
|
||||
<li>
|
||||
函数为叶函数,即在函数体内再没有任何函数调用。
|
||||
</li>
|
||||
<li>
|
||||
函数体非常小。
|
||||
</li>
|
||||
|
||||
## Android中的unwind
|
||||
|
||||
我们知道大部分Android手机使用的都是ARM体系结构,那在Android中需要如何进行unwind呢?我们需要分两种情况分别讨论。
|
||||
|
||||
**1. Debug版本unwind**
|
||||
|
||||
如果是Debug版本,我们可以通过“.debug_frame”(有兴趣的同学可以了解一下[DWARF](http://www.dwarfstd.org/doc/DWARF4.pdf))来帮助我们进行unwind。这种方法十分高效也十分准确,但缺点是调试信息本身很大,甚至会比程序段(.TEXT段)更大,所以我们是无法在Release版本中包含这个信息的。
|
||||
|
||||
>
|
||||
DWARF 是一种标准调试信息格式。DWARF最早与ELF文件格式一起设计, 但DWARF本身是独立的一种对象文件格式。本身DAWRF和ELF的名字并没有任何意义(侏儒、精灵,是不是像魔兽世界的角色 :)),后续为了方便宣传,才命名为Debugging With Attributed Record Formats。引自[wiki](https://en.wikipedia.org/wiki/DWARF#cite_note-eager-1)
|
||||
|
||||
|
||||
**2. Release版本unwind**
|
||||
|
||||
对于Release版本,系统使用了一种类似“.debug_frame”的段落,是更加紧凑的方法,我们可以称之为unwind table,具体来说在x86和ARM64平台上是“.eh_frame”和“.eh_frame_hdr”,在ARM32平台上为“.ARM.extab”和“.ARM.exidx”。
|
||||
|
||||
由于ARM32的标准早于DWARF的方法,所有ARM使用了自己的实现,不过它们的原理十分接近,后续我们只讨论“.eh_frame”,如果你对ARM32的实现特别感兴趣,可以参考[ARM-Unwinding-Tutorial](https://sourceware.org/binutils/docs/as/ARM-Unwinding-Tutorial.html)。
|
||||
|
||||
“.eh_frame section”也是遵循DWARF的格式的,但DWARF本身十分琐碎也十分复杂,这里我们就不深入进去了,只涉及一些比较浅显的内容,你只需要了解DAWRF使用了一个被称为DI(Debugging Information Entry)的数据结构,去表示每个变量、变量类型和函数等在debug程序时需要用到的内容。
|
||||
|
||||
“.eh_frame”使用了一种很聪明的方法构成了一个非常大的表,表中包含了每个程序段的地址对应的相应寄存器的值以及返回地址等相关信息,下面就是这张表的示例(你可以使用`readelf --debug-dump=frames-interp`去查看相应的信息,Release版中会精简一些信息,但所有帮助我们unwind的寄存器信息都会被保留)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/70/03aa1ebfe910222910dee2b23c7a5770.png" alt="">
|
||||
|
||||
“.eh_frame section”至少包含了一个CFI(Call Frame Information)。每个CFI都包含了两个条目:独立的CIE(Common Information Entry)和至少一个FDE(Frame Description Entry)。通常来讲CFI都对应一个对象文件,FDE则描述一个函数。
|
||||
|
||||
“[.eh_frame_hdr](https://refspecs.linuxfoundation.org/LSB_1.3.0/gLSB/gLSB/ehframehdr.html) section”包含了一系列属性,除了一些基础的meta信息,还包含了一列有序信息(初始地址,指向“.eh_frame”中FDE的指针),这些信息按照function排序,从而可以使用二分查找加速搜索。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/d1/ba663ea69e7a64a716e37c7f6710f4d1.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
总的来说,unwind第一个栈帧是最难的,由于ARM无法保证会压基址指针寄存器(EBP)进栈,所以我们需要借助一些额外的信息(.eh_frame)来帮助我们得到相应的基址指针寄存器的值。即使如此,生产环境还是会有各种栈破坏,所以还是有许多工作需要做,比如不同的调试器(GDB、LLDB)或者breakpad都实现了一些搜索算法去寻找潜在的栈帧,这里我们就不展开讨论了,感兴趣的同学可以查阅相关代码。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
下面给你一些外部链接,你可以阅读GCC中实现unwind的关键函数,有兴趣的同学可以在调试器中实现自己的unwinder。
|
||||
|
||||
<li>
|
||||
[_Unwind_Backtrace](https://gcc.gnu.org/git/gitweb.cgi?p=gcc.git;a=blob;f=libgcc/unwind.inc;h=12f62bca7335f3738fb723f00b1175493ef46345;hb=HEAD#l275)
|
||||
</li>
|
||||
<li>
|
||||
[uw_frame_state_for](https://gcc.gnu.org/git/gitweb.cgi?p=gcc.git;a=blob;f=libgcc/unwind-dw2.c;h=b262fd9f5b92e2d0ea4f0e65152927de0290fcbd;hb=HEAD#l1222)
|
||||
</li>
|
||||
<li>
|
||||
[uw_update_context](https://gcc.gnu.org/git/gitweb.cgi?p=gcc.git;a=blob;f=libgcc/unwind-dw2.c;h=b262fd9f5b92e2d0ea4f0e65152927de0290fcbd;hb=HEAD#l1494)
|
||||
</li>
|
||||
<li>
|
||||
[uw_update_context_1](https://gcc.gnu.org/git/gitweb.cgi?p=gcc.git;a=blob;f=libgcc/unwind-dw2.c;h=b262fd9f5b92e2d0ea4f0e65152927de0290fcbd;hb=HEAD#l1376)
|
||||
</li>
|
||||
|
||||
|
||||
49
极客时间专栏/Android开发高手课/特别放送/专栏学得苦?可能你还需要一份配套学习书单.md
Normal file
49
极客时间专栏/Android开发高手课/特别放送/专栏学得苦?可能你还需要一份配套学习书单.md
Normal file
@@ -0,0 +1,49 @@
|
||||
<audio id="audio" title="专栏学得苦?可能你还需要一份配套学习书单" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/b3/940f244c96efa4e275ec8a427a1f77b3.mp3"></audio>
|
||||
|
||||
你好,我是张绍文。专栏已经发布了一段时间,很多同学在学习专栏时问我,想补充一些基础知识可以参考哪些图书。今天我就结合专栏的编排,给你推荐几本我看过并且对我帮助很大的图书。推荐的书单不在于数量,而在于希望尽可能覆盖Android开发工程师进阶学习的路径,只有掌握牢固的基础知识,才能在进阶的道路上走得平稳。专栏把进阶的各个主题由点到线串联起来,但这背后必然少不了一些基础的、底层的知识进行支撑,而这些经典的图书涵盖的知识点比较全面,即使遇到问题时放在手边也是很好的参考书。
|
||||
|
||||
作为一名Android开发工程师,**你需要学习一些Linux的基础知识,在做优化时可以有更好的思路**。
|
||||
|
||||
关于Linux学习,我推荐:
|
||||
|
||||
[性能之巅](http://book.douban.com/subject/26586598/)
|
||||
|
||||
[最强Android书:架构大剖析](http://book.douban.com/subject/30269276/)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2flgejhhjglnc)
|
||||
|
||||
极客时间专栏:[Linux性能优化实战](http://time.geekbang.org/column/140)
|
||||
|
||||
**如果想更好地学习虚拟机以及Hook相关的知识,你需要对C++以及编译原理有一定的了解。**
|
||||
|
||||
关于虚拟机,我推荐:
|
||||
|
||||
[程序员的自我修养——链接、装载与库](http://book.douban.com/subject/3652388)
|
||||
|
||||
[垃圾回收算法手册](http://book.douban.com/subject/26740958)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2xf8slfumbdtk)
|
||||
|
||||
关于编程语言,我推荐:
|
||||
|
||||
[More Effective C++](http://book.douban.com/subject/5908727/)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2fmqzr7s3bvpk)
|
||||
|
||||
[Effective Java中文版(第3版)](http://book.douban.com/subject/30412517)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F3nffgcb3e14u0)
|
||||
|
||||
**其他的知识,例如网络、数据库的一些细分领域**,我推荐:
|
||||
|
||||
[Web性能权威指南](http://book.douban.com/subject/25856314/)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F3nswzsiy1lro8)
|
||||
|
||||
[UNIX网络编程 卷1:套接字联网API(第3版)](http://book.douban.com/subject/26434583/)
|
||||
|
||||
[戳此购买](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F27cl3htdrxi48)
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
89
极客时间专栏/Android开发高手课/特别放送/专栏学得苦?可能是方法没找对.md
Normal file
89
极客时间专栏/Android开发高手课/特别放送/专栏学得苦?可能是方法没找对.md
Normal file
@@ -0,0 +1,89 @@
|
||||
<audio id="audio" title="专栏学得苦?可能是方法没找对" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/9d/0dc170b479866380d6f84ccf3d353b9d.mp3"></audio>
|
||||
|
||||
>
|
||||
各位同学,我是《Android开发高手课》的编辑Shawn,很高兴可以和你分享专栏里同学们自己的故事。小说家马塞尔·普鲁斯特说过:“真正的发现之旅,不在于寻找新的风景,而在于有新的视角”。专栏便是给了你一个“高手”的视角,让你重新审视自己工作中处理问题的方法、掌握新的技能,进而让自己也成为真正的高手。这个过程一定是艰辛的,但只要坚持下去,一定能大有收获。
|
||||
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/39/559ec4b6bd32283500cddc2869bad639.png" alt="">
|
||||
|
||||
我是Kenny,来自广州,加入到《Android开发高手课》,是希望自己可以深入学习一些Android的高级知识。
|
||||
|
||||
学习的过程总是枯燥的,我的方法是**保持兴趣和新鲜感。**你可以把专栏的课程当作是一本武功秘籍,每一期就相当于一种招式,这样就会很有期待。我基本会在专栏更新第一时间就学习一遍,然后再结合Google查找跟专栏相关的知识点,把它们融会贯通。另外,专栏里提供的练习我也会先自己思考,然后再尝试去写demo验证,最后跟老师给的Sample进行对照,对比分析我和老师给的方案的优劣。
|
||||
|
||||
如果你正处学习的迷茫期,我建议学习前先带着问题,比如专栏更新后,先想想自己在工作中是否遇到过和这期主题相关的技术问题。然后就是兴趣了,对技术始终保持兴趣,学习的动力也会更充沛,在学习过程中也会产生更多有价值的思考。
|
||||
|
||||
我目前学习最大的收获是**思考问题、处理问题的思维方式的变化**,在对待“黑科技”也会更慎重了。最主要的是思维得到了发散,解决问题时也会多考虑如何举一反三。
|
||||
|
||||
我的2019年“小目标”:目前在从事产品性能优化的工作,期待2019能把产品做成竞品间各项性能指标第一!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/49/541f8da3cf1bb6340462e2313cc3af49.png" alt="">
|
||||
|
||||
我是ZYW,目前在从事移动端开发,从2008年开始到今天已经10个年头了。
|
||||
|
||||
我主要是在更新当天晚上看专栏的内容,看完后再做练习,遇到不会的内容会去搜索相关的知识点。
|
||||
|
||||
在当下,不论是工作还是学习,焦虑肯定是有的。**坚持,学习从来就不是一蹴而就的事情**。我毕业10年了,IT编码也10年了,现在还在坚持。IT技术更新很快,一定要在合适的时间做合适的事情,不要怕困难,更不要放弃。
|
||||
|
||||
我的2019年“小目标”:好好的工作,好好照顾家人,能在Android的道路一直走下去,坚持。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/96/29cfacdd036206fa1d6f0512b723b096.png" alt="">
|
||||
|
||||
我是Owen,工作3年做过OA和手机的项目,目前在深圳一家上市公司负责海外工具类开发。订阅这个专栏主要是听过江湖上绍文老师的“传说”,再加上我对讲的内容也比较感兴趣。
|
||||
|
||||
一般专栏更新之后,我会选择第一时间听一遍音频,或是在上下班的地铁上,或是在睡觉前。我会利用零碎时间反复听音频,把整片的时间用来反复阅读课程文章,然后根据文章的Sample自己亲自实践。**因为在实践的过程中会有很多坑要爬,需要自己补充老师提到的知识点,专栏的篇幅也有限,有些知识点就需要自己去查找资料来学习**。所以一篇文章我会反复阅读,反复查看相关知识点,反复实践。遇到不懂的就问绍文老师、同学和同事,然后形成自己的理解和总结,争取自己也能写一点总结分享出来。
|
||||
|
||||
坚持学习的过程,我感觉自己拓宽了知识面,对知识点的体会也加深了。移动开发领域大佬、高手如云,必须孜孜不倦地学习和交流,才能做到真正的理解并且达到一定的高度。
|
||||
|
||||
这个专栏整体来说适合爬坡进阶,如果坚持学完会有很多收获。有些知识点我平时没太注意,或者有些知识点平时干脆都没听过,学完给我的感觉我们并不是简单的应用开发,还有太多可以精深的东西。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/9f/bccda15e0955af195cabfcd1baa9789f.png" alt="">
|
||||
|
||||
大家好,我是Seven,来自四川成都。现在在某家医疗公司担任Android开发,主要做的是摄像头开发、实时分析相机预览数据这块,之前还做过一年的视频点播和直播的底层开发。在这两年的工作中,一直想把自己的半吊子基础给打实。
|
||||
|
||||
说来也巧,曾经的项目中用到了Tinker,也就顺理成章进入了Tinker交流群,也在群内交流了一段时间。某一天,绍文老师在群里推荐了他的极客时间专栏;与此同时,众多著名Android大佬和公众号也争相推广,我已经站不住了,技术的浪潮已经向我扑来,我非接不可,同时也希望自己能够得到一次深度成长的机会,所以我选择了订阅,这也是对自己负责吧。
|
||||
|
||||
刚开始时我是很用心的每天都听,然后课后Sample也在认真做,但当我发现Sample搞不定的时候,我就改变了学习态度:专栏更新,我也及时阅读,Sample做不出来就放在那里,反正保持同步阅读专栏,有时也在专栏里留些无关痛痒的话,以为这样就是学习。直到某一天,专栏出了一篇文章[《让Sample跑起来 | 热点问题答疑第1期》](http://time.geekbang.org/column/article/73068),我才恍然大悟,自己当前的学习状态不对,不应该这样学习,应该一丝不苟,脚踏实地才对。**那时的我只是看上去在学习而已,实际根本没有任何收获**。于是我改正了自己的态度,重新打开第1期重新学习,认真阅读并理解文中的每一个字,遇到不懂的地方就看文中给的链接或者自己查资料慢慢摸索。后来发现这样学习虽然慢,但是我心里很踏实,**而且学到的东西其实不止是专栏中的内容,因为在查资料的时候,总会查到别的东西,可以顺带学习,并且做了笔记**。我现在的学习状态大概就是这样,我还是很满意自己做出的改变。
|
||||
|
||||
专栏从开始看到现在这么久了,最主要提升的是我的**自觉性**,看到不懂的知识点我并不会害怕,而是想着要怎么去弄懂它,也不会消极的对待所谓“高深”的知识了。现在遇到一个问题,我脑袋里面想的是要去解决它,先试着自己去解决,解决不了的话说明知识不到位,需要深入学习。
|
||||
|
||||
如果你觉得专栏里的知识很难,也不要消极对待,很多大的知识点都是由许多小知识点组成的,只要分而治之,就可以很快**建立自信**。并且这些小的知识点排列组合会产生无数种知识,所以这里也建议同学们认真对待基础知识,比如专栏里提到的Linux有关的知识,这些都是保质期很长的知识,希望我们可以一起坚持下来。
|
||||
|
||||
我的2019年“小目标”:2018年已经过去,希望2019年的自己,能够对移动开发的基础知识有更深刻的理解,希望自己能够在Android源码分析研究这一块大有长进,一起加油吧!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/1f/826a24c9f4a2bb4d60c553f57fcd491f.png" alt="">
|
||||
|
||||
我是小洁,是一名工作接近4年的Android开发,坐标广东东莞,公司是做儿童电话手表的,我在团队中负责性能相关优化以及组件化优化方面工作。由于目前工作的任务专栏有所涉及,加上身边同事的推荐,所以想从专栏中借鉴学习老师的各种经验。
|
||||
|
||||
由于工作原因,我一般的学习时间主要是在晚上。我会根据自己对当前内容是否感兴趣,以及是否感觉重要而去学习并完成课后的练习。通过专栏我收获了一些老师的经验之谈和对某些方案的思考,能力也有所提升吧。
|
||||
|
||||
我认为学习还是靠**个人毅力**,每当学习感到枯燥或者遇到障碍时,可以沉下来问问自己开始学习的目的,重新调整自己再一步步看下去,终会有豁然开朗的时候。
|
||||
|
||||
我的2019年“小目标”:希望自己能实现一个性能监控分析。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/72/d3b0847e18f8bc4a5d68b4a096f87f72.png" alt="">
|
||||
|
||||
我是希夷,我订阅专栏的目的是想提高自己的眼界和能力。
|
||||
|
||||
我主要是利用空闲的时间学习专栏,并且会做些笔记,但专栏的作业和练习我坚持得不够(捂脸)。**我觉得学习专栏时感觉难是正常的,如果不觉得难就说明这个专栏对你提升有限**。我认为还是贵在坚持,一遍不懂就多看几遍、多练几遍,勤能补拙,我们学习都会有这个过程,也需要我们给自己打打气。
|
||||
|
||||
同样我觉得Android开发,**基础真的很重要**,我感觉自己有很多基础的东西要补。通过这个专栏,我看到了大公司在移动开发这块使用的比较新的技术,确实拓展了自己的视野。
|
||||
|
||||
我的2019年“小目标”:希望把Kotlin和Flutter用到项目中,把从专栏中学到的东西落地到项目实践上,也希望自己有更高的收入。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/7a/2b2f0156a5db6ba1d04505b3dd6b747a.png" alt="">
|
||||
|
||||
我是志伟,从12年开始在芯片企业从事Android ROM开发,15年开始在移动互联网企业做Android开发。我希望可以从专栏里更加全面地了解Android开发的技术,领略绍文面对亿级App各种技术问题的思考方式和处理方法。
|
||||
|
||||
专栏更新当天晚上我都会认真学习,通过文章里附带的外部资料、自己查询网上其他资料和专栏提供的Sample进行练习,从中提炼自己需要的知识。**我还会结合自己的实际工作、以前的经历,推敲一下自己在面对专栏里的问题时会如何思考,我会怎么去做**。
|
||||
|
||||
专栏涉及技术面广,又有深度,花时间深入钻研,肯定有很大的收获。比如我自己,通过学习扩展了自己的知识面,对一些技术的理解也更加深入,很多问题也有了解决方案。专栏定位 “高手”,肯定有觉得难或者不懂的知识点,这就更需要坚持,因为这正是成长的机会。另外,还可以直接在专栏留言区跟作者进行交流,从中我也获益良多。
|
||||
|
||||
绍文经历过多个App日活过亿的成长,期间面临的各种技术难题也是其他App共同存在的,但我们不是每个人都有这样的机会去经历。通过专栏,可以以高度“浓缩”的方式经历一遍,这也是十分宝贵的收获。
|
||||
|
||||
我的2019年“小目标”:朝架构师的方向“一路走到黑”。
|
||||
|
||||
>
|
||||
听完身边同学的故事,你有没有想和其他同学分享的故事呢?欢迎你在留言区也写写自己学习专栏的方法和体会,我们会在下一期答疑文章发布时,选出留言点赞数最高的3位,送出“学习加油礼包”,期待你的分享。
|
||||
|
||||
|
||||
|
||||
89
极客时间专栏/Android开发高手课/特别放送/程序员修炼之路 | 设计能力的提升途径.md
Normal file
89
极客时间专栏/Android开发高手课/特别放送/程序员修炼之路 | 设计能力的提升途径.md
Normal file
@@ -0,0 +1,89 @@
|
||||
<audio id="audio" title="程序员修炼之路 | 设计能力的提升途径" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/71/19ab12c6b85b340100840a89a22c2671.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文,今天我要和你分享我的朋友长元的一篇文章,主题是设计能力的提升途径。专栏已经进入架构演进模块,由于每个人对架构的理解都不同,在工作中也会遇到各种各样的架构设计问题,很多时候我们的架构设计能力都是靠不断的理论学习和在设计实践中不断摸索提高的,因此在成为设计高手的道路上,我们肯定或多或少有些自己的经验和体会,当然也少不了踩坑。今天长元分享的设计能力提升路径,希望可以把他的经验分享给你,你可以参考他的提升路径来强化自己的设计能力,在高手的修炼之路上少走弯路。
|
||||
|
||||
|
||||
每当我做完一次内部设计培训以后,经常有同学来问我:如何才能快速提升自己的设计能力?我觉得这个问题非常有代表性,代表了一大波程序员在艰辛修炼路上的心声。今天我就来分享一下我所理解的程序员设计能力的提升路径,也欢迎你留言写写你的思考与体会。
|
||||
|
||||
**1. 编码历练**
|
||||
|
||||
代码行经验是个非常重要的东西,当你还没有1万行代码经验的时候,如果你来问我如何提升设计能力这个问题,我只能告诉你不要太纠结,看看理论就好,老老实实先写代码吧。
|
||||
|
||||
一个程序员平均每天码代码的速度是200~300行。你可能会说,我一天怎么也要写上1000行吧?别忘了,当你码完代码后,你还需要测试、调试、优化、Bug Fix,这些时间你没法一直码代码的。
|
||||
|
||||
编码规范就不多说了,如果你的代码还是杂乱无章的状态,就先别谈什么设计与架构了,先把基础的工作做好再谈其他的吧。
|
||||
|
||||
另外,作为“代码洁癖患者”,推荐你不要在写完代码后,再做批量格式化处理,或者手工再去整理代码,而是应该每敲一个字符,都是符合规范的。习惯真的很重要,有时在招聘面试的时候,我真想添加一个环节,现场编写程序完成一个简单但容易出错的任务,考察一下你的代码基本功。
|
||||
|
||||
**2. 理论学习**
|
||||
|
||||
简单说就是看书、看博客,学习你能得到的所有资源,但前提是内容质量要高。例如图书,我推荐:《重构 改善既有代码的设计》《敏捷软件开发:原则、模式与实践》《UML和模式应用》《设计模式》等,其他你还需要学习面向对象设计原则(五大原则)。
|
||||
|
||||
《设计模式》是本很古老的书了,只有短短200页,但是可能这是最难看懂的一本书了,可能一个月都看不完(看小说的话,200页3个小时也许就看完了吧)。而且就算看完了,也不会全看懂,很可能看懂的内容不超过30%。我想说的是,看不懂没关系,认真看了就行,不用太纠结,因为这不能说明什么问题。
|
||||
|
||||
另外,我想说一下,多线程技术是程序员必须掌握的,而且需要理解透彻。现在的高级技术例如GCD,会掩盖你对多线程理解不足的问题,因为使用起来实在太简单了。另外,别说你没写过多线程依然完成了复杂的项目,更别说你随手写出的多线程代码好像也没出什么问题啊,你可以试试把你的代码给技术好的同事看看,分分钟写个Demo让它出错乃至崩溃。
|
||||
|
||||
**3. 实践**
|
||||
|
||||
现在,你已经具备了一定的编码经验,而且已经学习了足够的理论知识,接下来就是真正练手的时候了。好好反复思考你学习的这些理论知识,要如何运用到项目中去,通过身体力行的实践,一定要把那些理论搞清楚,用于指导你的实践。在实践的过程中,你要收起从前的自信,首先否定自己以前的做法,保证每次做出的东西相比以前是有进步、有改进的。
|
||||
|
||||
**4. 重温理论**
|
||||
|
||||
你已经能看到自己的进步了,发现比以前做得更好了,但是总感觉还不够,好像有瓶颈似的,恭喜你,已经可以看到你未来的潜力了。
|
||||
|
||||
重新拿起书本,重温一遍之前看的那些似懂非懂的东西,你会发现之前没弄懂的内容,现在豁然开朗了,不再有那种难于理解的晦涩感了。而且就算是以前你觉得自己已经理解的内容,再看一遍的话,通常也会有新的收获。
|
||||
|
||||
**5. 再实践**
|
||||
|
||||
这个阶段,你已经掌握了较多的知识,不但实践经验丰富,各种理论也能手到擒来了。但是,你发现你的设计依然不够专业,而且回过头去看以前写的代码,你会惊讶:天啊,这是谁写的代码,怎么能这样干!然后,就不多说了…此时,你已经进入了自省的阶段,掌握了适合自己的学习方法,之后再学习什么新东西,都不会再难住你了。
|
||||
|
||||
**6. 总结**
|
||||
|
||||
先别太得意(不信?那你去给团队分享一次讲座试试),你还需要总结,总结自己的学习方法、总结项目经验、总结设计理论的知识。
|
||||
|
||||
如果你能有自己独到的理解,而不是停留在只会使用成熟的设计模式什么的,能根据自己的经验教训总结出一些设计原则,那自然是极好的。
|
||||
|
||||
**7. 分享**
|
||||
|
||||
分享是最好的学习催化剂,当你要准备一次培训分享的时候,你会发现先前以为已经理解的东西其实并没有完全理解透彻,因为你无法把它讲清楚,实际上还是研究得不够透彻。这时会迫使你再重新深入学习,做到融汇贯通,然后你才敢走上讲台。否则,当别人提问的时候,你根本回答不上来。
|
||||
|
||||
以上,便是我认为的程序员修炼道路的必经阶段。接下来,我再分享几点其他对设计能力提升非常重要的方法。
|
||||
|
||||
- **养成先设计,再编码的习惯。**
|
||||
|
||||
几乎所有的程序员,一开始都不太愿意写文档,也不太愿意去精心设计,拿到需求总是忍不住那双躁动的手,总觉得敲在键盘上,把一行一行的代码飙出来,才有成就感,才是正确的工作姿势。
|
||||
|
||||
我的建议是,没讨论清楚不要编码,不然你一定会返工。
|
||||
|
||||
- **设计重于编码,接口重于实现。**
|
||||
|
||||
制定接口的过程,本身就是设计过程,接口一定要反复推敲,尽量做减法而不是加法,在能满足需求的情况下越简单越好。
|
||||
|
||||
另外,不要一个人冥思苦想。可以先简单做一个雏形出来,然后去找使用方沟通,直到对方满意为止。不要完全根据使用需求去设计接口,参考MVVM,ViewModel就是根据View的需要而对Model进行的再封装,不能将这些接口直接设计到Model中。
|
||||
|
||||
- **不盲从设计模式。**
|
||||
|
||||
设计模式只是一种解决问题的套路方法,你也可以有自己的方法,当然设计模式如果用好了,会让你的设计显得专业、优雅,毕竟前辈们的心血结晶是非常有价值的。但是如果滥用的话,也会导致更严重的问题,甚至可能成为灾难。我觉得面向对象设计原则更加重要,有些原则是必须遵守的(如单向依赖、SRP等),而设计模式本身都是遵守这些原则的,有些模式就是为了遵循某原则而设计出来的。
|
||||
|
||||
抽象不是万能的,在适当的地方使用,需要仔细推敲。当有更好的方案不用抽象就能解决问题时,尽量避免抽象。我见过太多抽象过火、过度设计的案例了,增加了太多维护成本,还不如按照最自然的方式去写。
|
||||
|
||||
- **空杯心态,向身边的同学学习,站在巨人的肩上,站在别人的肩上。**
|
||||
|
||||
有人提意见,先收下它(无论接受与否)。
|
||||
|
||||
很多程序员都有个“毛病”,就是觉得自己技术牛的不行,不愿意接受别人的意见,尤其是否定意见(文人相轻)。 但是无论是理论的学习,还是编码实践,向身边的同学学习是对自己影响最大的(三人行,必有我师)。
|
||||
|
||||
我自己就经常在跟团队同学讨论中获益,当百思不得其解的时候,把问题抛出来讨论一下,通常都能得到一个最佳方案。
|
||||
|
||||
另外,跟团队其他人讨论还有一个好处,就是当你的设计有妥协或有些不专业的时候,别人看到代码也不会产生质疑,因为他也参与了讨论,你不用花那么多时间去做解释。
|
||||
|
||||
设计期间一定要找其他人一起讨论,我一直比较反对一个人把设计做完、把文档写完,然后才找大家开个评审会那种模式,虽然也有效果,但是效果达不到极致。因为大家没有参与到设计中,通过一次会议的时间理解不一定有那么深,最关键的是,如果在会上发现设计有些问题,但不是致命问题的时候,通常并不会打回重新设计。
|
||||
|
||||
相反,如果前期讨论足够,大家都知道你的思路与方案,而且最后也有设计文档,当其他人阅读你的代码的时候,根本无需你再指引,这样今后在工作交接时都会很顺利,何乐而不为呢?
|
||||
|
||||
最后,我想呼吁一下,当你去修改维护别人的代码时,最好找模块负责人深入讨论沟通一下,让他明白你的需求以及你的方案,请他帮忙评估方案是否可行,是否会踩坑、埋坑等。如果你恰好是模块的负责人,请行使你的权力,拒绝有问题的不符合要求的代码提交入库。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
174
极客时间专栏/Android开发高手课/特别放送/聊聊Framework的学习方法.md
Normal file
174
极客时间专栏/Android开发高手课/特别放送/聊聊Framework的学习方法.md
Normal file
@@ -0,0 +1,174 @@
|
||||
<audio id="audio" title="聊聊Framework的学习方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/3b/434c51aa49ed6f1d4a842dc082595d3b.mp3"></audio>
|
||||
|
||||
大家好,我是陆晓明,现在在一家互联网手机公司担任Android系统开发工程师。很高兴可以在极客时间Android开发高手课专栏里,分享一些我在手机行业9年的经验以及学习Android的方法。
|
||||
|
||||
今天我要跟你分享的是Framework的学习和调试的方法。
|
||||
|
||||
首先,Android是一种基于Linux的开放源代码软件栈,为广泛的设备和机型而创建。下图是Android平台的[主要组件](https://developer.android.google.cn/guide/platform)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/df/90763fd9662c8a75553dc92a78112ddf.png" alt="">
|
||||
|
||||
从图中你可以看到主要有以下几部分组成:
|
||||
|
||||
- **Linux内核**
|
||||
- **Android Runtime**
|
||||
- **原生C/C++库**
|
||||
- Java API框架(后面我称之为Framework框架层)
|
||||
- **系统应用**
|
||||
|
||||
我们在各个应用市场看到的,大多是第三方应用,也就是安装在data区域的应用,它们可以卸载,并且权限也受到一些限制,比如不能直接设置时间日期,需要调用到系统应用设置里面再进行操作。
|
||||
|
||||
而我们在应用开发过程中使用的四大组件,便是在Framework框架层进行实现,应用通过约定俗成的规则,在AndroidMainfest.xml中进行配置,然后继承对应的基类进行复写。系统在启动过程中解析AndroidMainfest.xml,将应用的信息存储下来,随后根据用户的操作,或者系统的广播触发,启动对应的应用。
|
||||
|
||||
那么,我们先来看看Framework框架层都有哪些东西。
|
||||
|
||||
Framework框架层是应用开发过程中,调用的系统方法的内部实现,比如我们使用的TextView、Button控件,都是在这里实现的。再举几个例子,我们调用ActivityManager的getRunningAppProcesses方法查看当前运行的进程列表,还有我们使用NotificationManager的notify发送一个系统通知。
|
||||
|
||||
让我们来看看Framework相关的代码路径。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/d4/178ef00a181a85e85a3b75d4c60abcd4.jpg" alt="">
|
||||
|
||||
如何快速地学习、梳理Framework知识体系呢?常见的学习方法有下面几种:
|
||||
|
||||
- 阅读书籍(方便梳理知识体系,但对于解决问题只能提供方向)。
|
||||
- 直接阅读源码(效率低,挑战难度大)。
|
||||
- 打Log和打堆栈 (效率有所提升,但需要反复编译,添加Log和堆栈代码)。
|
||||
- 直接联调,实时便捷(需要调试版本)。
|
||||
|
||||
首先可以通过购买相关的书籍进行学习,其中主要的知识体系有Linux操作系统,比如进程、线程、进程间通信、虚拟内存,建立起自己的软件架构。在此基础上学习Android的启动过程、服务进程SystemServer的创建、各个服务线程(AMS/PMS等)的创建过程,以及Launcher的启动过程。熟悉了这些之后,你还要了解ART虚拟机的主要工作原理,以及init和Zygote的主要工作原理。之后随着在工作和实践过程中你会发现,Framework主要是围绕应用启动、显示、广播消息、按键传递、添加服务等开展,这些代码的实现主要使用的是Java和C++这两种语言。
|
||||
|
||||
通过书籍或者网络资料学习一段时间后,你会发现很多问题都没有现成的解决方案,而此时就需要我们深入源码中进行挖掘和学习。但是除了阅读官方文档外,别忘了调试Framework也是一把利刃,可以让你游刃有余快速定位和分析源码。
|
||||
|
||||
下面我们来看看调试Framework的Java部分,关于C++的部分,需要使用GDB进行调试,你可以在课下实践一下,调试的过程可以参考[《深入Android源码系列(一)》](https://mp.weixin.qq.com/s/VSVUbaEIfrmFZMB1k49fyA)。
|
||||
|
||||
我们这里使用Android Studio进行调试,在调试前我们要先掌握一些知识。Java代码的调试,主要依据两个因素,一个是你要调试的进程;一个是调试的类对应的包名路径,同时还要保证你所运行的手机环境和你要调试的代码是匹配的。只要这两个信息匹配,编译不通过也是可以进行调试的。
|
||||
|
||||
我们调试的系统服务是在SystemServer进程中,可以使用下面的命令验证(我这里使用Genymotion上安装Android对应版本镜像的环境演示)。
|
||||
|
||||
```
|
||||
ps -A |grep system_server 查看系统服务进程pid
|
||||
cat /proc/pid/maps |grep services 通过cat查看此进程的内存映射,看看是否services映射到内存里面。
|
||||
|
||||
```
|
||||
|
||||
这里我们看到信息:/system/framework/oat/x86/services.odex 。
|
||||
|
||||
odex是Android系统对于dex的进一步优化,目的是为了提升执行效率。从这个信息便可以确定,我们的services.jar确实是跑到这里了,也就是我们的系统服务相关联的代码,可以通过调试SystemServer进程进行跟踪。
|
||||
|
||||
下来我们来建立调试环境。
|
||||
|
||||
<li>
|
||||
打开Genymotion,选择下载好Android 9.0的镜像文件,启动模拟器。
|
||||
</li>
|
||||
<li>
|
||||
找到模拟器对应的ActivityManagerService.java代码。 我是从[http://androidxref.com/](http://androidxref.com/)下载Android 9.0对应的代码。
|
||||
</li>
|
||||
<li>
|
||||
打开Android Studio,File -> New -> New Project然后直接Next直到完成就行。
|
||||
</li>
|
||||
<li>
|
||||
新建一个包名,从ActivityManagerService.java文件中找到它,这里为`com.android.server.am`,然后把ActivityManagerService.java放到里面即可。
|
||||
</li>
|
||||
<li>
|
||||
在ActivityManagerService.java的startActivity方法上面设置断点,然后找到菜单的Run -> Attach debugger to Android process勾选Show all process,选中SystemServer进程确定。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/f0/ba1eb6bded9167f26ae48b34a6d792f0.png" alt="">
|
||||
|
||||
这时候我们点击Genymotion模拟器中桌面的一个图标,启动新的界面。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/45/c92b62d1065f967696dbdd2851037b45.png" alt="">
|
||||
|
||||
会发现这时候我们设定的断点已经生效。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/05/763f222e01a30c969024d8cf77dd0705.png" alt="">
|
||||
|
||||
你可以看到断下来的堆栈信息,以及一些变量值,然后我们可以一步步调试下去,跟踪启动的流程。
|
||||
|
||||
对于学习系统服务线程来讲,通过调试可以快速掌握流程,再结合阅读源码,便可以快速学习,掌握系统框架的整个逻辑,从而节省学习的时间成本。
|
||||
|
||||
以上我们验证了系统服务AMS服务代码的调试,其他服务调试方法也是一样,具体的线程信息,可以使用下面的命令查看。
|
||||
|
||||
```
|
||||
ps -T 353
|
||||
这里353是使用ps -A |grep SystemServer查出 SystemServer的进程号
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/62/a8/62d0d79e490a14f19422486c5da85fa8.png" alt="">
|
||||
|
||||
在上面图中,PID = TID的只有第一行这一行,如果PID = TID的话,也就是这个线程是主线程。下面是我们平时使用Logcat查看输出的信息。
|
||||
|
||||
```
|
||||
03-10 09:33:01.804 240 240 I hostapd : type=1400 audit(0.0:1123): avc: de
|
||||
03-10 09:33:37.320 353 1213 D WificondControl: Scan result ready event
|
||||
03-10 09:34:00.045 404 491 D hwcomposer: hw_composer sent 6 syncs in 60s
|
||||
|
||||
```
|
||||
|
||||
这里我还框了一个ActivityManager的线程,这个是线程的名称,通过查看这行的TID(368)就知道下面的Log就是这个线程输出的。
|
||||
|
||||
```
|
||||
03-10 08:47:33.574 353 368 I ActivityManager: Force stopping com.android.providers
|
||||
|
||||
```
|
||||
|
||||
学习完上面的知识,相信你应该学会了系统服务的调试。通过调试分析,我们便可以将系统服务框架进行庖丁解牛般的学习,面对大量庞杂的代码掌握起来也可以轻松一些。
|
||||
|
||||
我们回过头来,再次在终端中输入`ps -A`,看看下面这一段信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/4e/298cadbc90a1f04d02e1e116f6db464e.png" alt="">
|
||||
|
||||
你可以看到这里的第一列,代表的是当前的用户,这里有system root和u0_axx,不同的用户有不同的权限。我们当前关注的是第二列和第三列,第二列代表的是PID,也就是进程ID;第三列代表的是PPID,也就是父进程ID。
|
||||
|
||||
你发现我这里框住的都是同一个父进程,那么我们来找下这个323进程,看看它到底是谁。
|
||||
|
||||
```
|
||||
root 323 1 1089040 127540 poll_schedule_timeout f16fcbc9 S zygote
|
||||
|
||||
```
|
||||
|
||||
这个名字在学习Android系统的时候,总被反复提及,因为它是我们Android世界的孵化器,每一个上层应用的创建,都是通过Zygote调用fork创建的子进程,而子进程可以快速继承父进程已经加载的资源库,这里主要指的是应用所需的JAR包,比如/system/framework/framework.jar,因为我们应用所需的基础控件都在这里,像View、TextView、ImageView。
|
||||
|
||||
接下来我来讲解下一个调试,也就是对TextView的调试(其他Button调试方式一样)。如前面所说,这个代码被编译到/system/framework/framework.jar,那么我们通过ps命令和cat /proc/pid/maps命令在Zygote中找到它,同时它能够被每一个由Zygote创建的子进程找到,比如我们当前要调试Gallery的主界面TextView。
|
||||
|
||||
我们验证下,使用`ps -A |grep gallery3d`查到Gallery对应的进程PID,使用cat /proc/pid/maps |grep framework.jar看到如下信息:
|
||||
|
||||
```
|
||||
efcd5000-efcd6000 r--s 00000000 08:06 684 /system/framework/framework.jar
|
||||
|
||||
```
|
||||
|
||||
这说明我们要调试的应用进程在内存映射中确实存在,那么我们就需要在gallery3d进程中下断点了。
|
||||
|
||||
下来我们建立调试环境:
|
||||
|
||||
<li>
|
||||
打开Genymotion,选择下载好Android 9.0的镜像文件,启动模拟器,然后在桌面上启动Gallery图库应用。
|
||||
</li>
|
||||
<li>
|
||||
找到模拟器对应的TextView.java代码。
|
||||
</li>
|
||||
<li>
|
||||
打开Android Studio,File -> New -> New Project然后直接Next直到完成就行。
|
||||
</li>
|
||||
<li>
|
||||
新建一个包名,从TextView.java文件中找到它的包名,这里为android.widget,然后把TextView.java放到里面即可。
|
||||
</li>
|
||||
<li>
|
||||
在TextView.java的onDraw方法上面设置断点,然后找到菜单的Run -> Attach debugger to Android process勾选Show all process,选中com.android.gallery3d进程(我们已知这个主界面有TextView控件)确定。
|
||||
</li>
|
||||
|
||||
然后我们点击下这个界面左上角的菜单,随便选择一个点击,发现断点已生效,具体如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/85/c2a9a5a71d4bd4a02b5bee113d866b85.png" alt="">
|
||||
|
||||
然后我们可以使用界面上的调试按钮(或者快捷键)进行调试代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/f8/c395c9f16a7c057c1076b4619dd1b5f8.png" alt="">
|
||||
|
||||
今天我讲解了如何调试Framework中的系统服务进程的AMS服务线程,其他PMS、WMS的调试方法跟AMS一样。并且我也讲解了如何调试一个应用里面的TextView控件,其他的比如Button、ImageView调试方法跟TextView也是一样的。
|
||||
|
||||
通过今天的学习,我希望能够给你一个学习系统框架最便捷的路径。在解决系统问题的时候,你可以方便的使用调试分析,从而快速定位、修复问题。
|
||||
|
||||
|
||||
189
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | ASM插桩强化练习.md
Normal file
189
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | ASM插桩强化练习.md
Normal file
@@ -0,0 +1,189 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | ASM插桩强化练习" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/c1/1e9062439ee220c56a4420aaf498f7c1.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。
|
||||
|
||||
专栏上一期,绍文讲了编译插桩的三种方法:AspectJ、ASM、ReDex,以及它们的应用场景。学完以后你是不是有些动心,想赶快把它们应用到实际工作中去。但我也还了解到,不少同学其实接触插桩并不多,在工作中更是很少使用。由于这项技术太重要了,可以实现很多功能,所以我还是希望你通过理论 + 实践的方式尽可能掌握它。因此今天我给你安排了一期“强化训练”,希望你可以趁热打铁,保持学习的连贯性,把上一期的理论知识,应用到今天插桩的练习上。
|
||||
|
||||
为了尽量降低上手的难度,我尽量给出详细的操作步骤,相信你只要照着做,并结合专栏上期内容的学习,你一定可以掌握插桩的精髓。
|
||||
|
||||
## ASM插桩强化练习
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/07/e2f777c2fb2ed535be7367643e43c307.png" alt="">
|
||||
|
||||
在上一期里,Eateeer同学留言说得非常好,提到了一个工具,我也在使用这个工具帮助自己理解ASM。安装“ASM Bytecode Outline”也非常简单,只需要在Android Studio中的Plugin搜索即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/47/7ad456d5f6d5054d6259f66a41cb6047.png" alt="">
|
||||
|
||||
ASM Bytecode Outline插件可以快速展示当前编辑类的字节码表示,也可以展示出生成这个类的ASM代码,你可以在Android Studio源码编译框内右键选择“Show Bytecode Outline“来查看,反编译后的字节码在右侧展示。
|
||||
|
||||
我以今天强化练习中的[SampleApplication](https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM/blob/master/ASMSample/src/main/java/com/sample/asm/SampleApplication.java)类为例,具体字节码如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/bc/fd7c472e83d37fa3a55124309bcb10bc.png" alt="">
|
||||
|
||||
除了字节码模式,ASM Bytecode Outline还有一种“ASMified”模式,你可以看到SampleApplication类应该如何用ASM代码构建。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/66/f7f75f73002335d89289bf03636a6f66.png" alt="">
|
||||
|
||||
下面我们通过两个例子的练习,加深对ASM使用的理解。
|
||||
|
||||
**1. 通过ASM插桩统计方法耗时**
|
||||
|
||||
今天我们的第一个练习是:通过ASM实现统计每个方法的耗时。怎么做呢?请你先不要着急,同样以SampleApplication类为例,如下图所示,你可以先手动写一下希望实现插桩前后的对比代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/dd/f2bf3b43308b42b78a865f7b36209ddd.png" alt="">
|
||||
|
||||
那这样“差异”代码怎么样转化了ASM代码呢?ASM Bytecode Outline还有一个非常强大的功能,它可以展示相邻两次修改的代码差异,这样我们可以很清晰地看出修改的代码在字节码上的呈现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/e5/b6502906622a46a638dd9f3af10619e5.png" alt="">
|
||||
|
||||
“onCreate”方法在“ASMified”模式的前后差异代码,也就是我们需要添加的ASM代码。在真正动手去实现插桩之前,我们还是需要理解一下ASM源码中关于Core API里面ClassReader、ClassWriter、ClassVisitor等几个类的用法。
|
||||
|
||||
我们使用ASM需要先通过ClassReader读入Class文件的原始字节码,然后使用ClassWriter类基于不同的Visitor类进行修改,其中COMPUTE_MAXS和EXPAND_FRAMES都是需要特别注意的参数。
|
||||
|
||||
```
|
||||
ClassReader classReader = new ClassReader(is);
|
||||
//COMPUTE_MAXS 说明使用ASM自动计算本地变量表最大值和操作数栈的最大值
|
||||
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
|
||||
ClassVisitor classVisitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
|
||||
//EXPAND_FRAMES 说明在读取 class 的时候同时展开栈映射帧(StackMap Frame),在使用 AdviceAdapter里这项是必须打开的
|
||||
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
|
||||
|
||||
```
|
||||
|
||||
如果要统计每个方法的耗时,我们可以使用AdviceAdapter来实现。它提供了onMethodEnter()和onMethodExit()函数,非常适合实现方法的前后插桩。具体的实现,你可以参考今天强化练习中的[TraceClassAdapter](https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM/blob/master/asm-gradle-plugin/src/main/java/com/geektime/asm/ASMCode.java#L60)的实现:
|
||||
|
||||
```
|
||||
private int timeLocalIndex = 0;
|
||||
@Override
|
||||
protected void onMethodEnter() {
|
||||
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
|
||||
timeLocalIndex = newLocal(Type.LONG_TYPE); //这个是LocalVariablesSorter 提供的功能,可以尽量复用以前的局部变量
|
||||
mv.visitVarInsn(LSTORE, timeLocalIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodExit(int opcode) {
|
||||
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
|
||||
mv.visitVarInsn(LLOAD, timeLocalIndex);
|
||||
mv.visitInsn(LSUB);//此处的值在栈顶
|
||||
mv.visitVarInsn(LSTORE, timeLocalIndex);//因为后面要用到这个值所以先将其保存到本地变量表中
|
||||
int stringBuilderIndex = newLocal(Type.getType("java/lang/StringBuilder"));
|
||||
mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
|
||||
mv.visitInsn(Opcodes.DUP);
|
||||
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
|
||||
mv.visitVarInsn(Opcodes.ASTORE, stringBuilderIndex);//需要将栈顶的 stringbuilder 保存起来否则后面找不到了
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitLdcInsn(className + "." + methodName + " time:");
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
|
||||
mv.visitInsn(Opcodes.POP);//将 append 方法的返回值从栈里 pop 出去
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitVarInsn(Opcodes.LLOAD, timeLocalIndex);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
|
||||
mv.visitInsn(Opcodes.POP);//将 append 方法的返回值从栈里 pop 出去
|
||||
mv.visitLdcInsn("Geek");
|
||||
mv.visitVarInsn(Opcodes.ALOAD, stringBuilderIndex);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
|
||||
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);//注意: Log.d 方法是有返回值的,需要 pop 出去
|
||||
mv.visitInsn(Opcodes.POP);//插入字节码后要保证栈的清洁,不影响原来的逻辑,否则就会产生异常,也会对其他框架处理字节码造成影响
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
具体实现和我们在ASM Bytecode Outline看到的大同小异,但是这里需要注意局部变量的使用。在练习的例子中用到了AdviceAdapter的一个很重要的父类LocalVariablesSorter,这个类提供了一个很好用的方法newLocal,它可以分配一个本地变量的index,而不用用户考虑本地变量的分配和覆盖问题。
|
||||
|
||||
另一个需要注意的情况是,我们在最后的时候需要判断一下插入的代码是否会在栈顶上遗留不使用的数据,如果有的话需要消耗掉或者POP出去,否则就会导致后续代码的异常。
|
||||
|
||||
这样我们就可以快速地将这一大段字节码完成了。
|
||||
|
||||
**2. 替换项目中的所有的new Thread**
|
||||
|
||||
今天另一个练习是:替换项目中所有的new Thread,换为自己项目的CustomThread类。在实践中,你可以通过这个方法,在CustomThread增加统计代码,从而实现统计每个线程运行的耗时。
|
||||
|
||||
不过这也是一个相对来说坑比较多的情况,你可以提前考虑一下可能会遇到什么状况。同样我们通过修改[MainActivity](https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM/blob/master/ASMSample/src/main/java/com/sample/asm/MainActivity.java#L20)的startThread方法里面的Thread对象改变成CustomThread,通过ASM Bytecode Outline看看在字节码上面的差异:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/0a/a7579f0e2e6fc1df1fa7b880946c740a.png" alt="">
|
||||
|
||||
InvokeVirtual是根据new出来的对象来调用,所以我们只需要替换new对象的过程就可以了。这里需要处理两个指令:一个new、一个InvokeSpecial。在大多数情况下这两条指令是成对出现的,但是在一些特殊情况下,会遇到直接从其他位置传递过来一个已经存在的对象,并强制调用构造方法的情况。
|
||||
|
||||
而我们需要处理这种特殊情况,所以在例子里我们需要判断new和InvokeSpecial是否是成对出现的。
|
||||
|
||||
```
|
||||
private boolean findNew = false;//标识是否遇到了new指令
|
||||
@Override
|
||||
public void visitTypeInsn(int opcode, String s) {
|
||||
if (opcode == Opcodes.NEW && "java/lang/Thread".equals(s)) {
|
||||
findNew = true;//遇到new指令
|
||||
mv.visitTypeInsn(Opcodes.NEW, "com/sample/asm/CustomThread");//替换new指令的类名
|
||||
return;
|
||||
}
|
||||
super.visitTypeInsn(opcode, s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
|
||||
//需要排查CustomThread自己
|
||||
if ("java/lang/Thread".equals(owner) && !className.equals("com/sample/asm/CustomThread") && opcode == Opcodes.INVOKESPECIAL && findNew) {
|
||||
findNew= false;
|
||||
mv.visitMethodInsn(opcode, "com/sample/asm/CustomThread", name, desc, itf);//替换INVOKESPECIAL 的类名,其他参数和原来保持一致
|
||||
return;
|
||||
}
|
||||
super.visitMethodInsn(opcode, owner, name, desc, itf);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
new指令的形态相对特殊,比如我们可能会遇到下面的情况:
|
||||
|
||||
```
|
||||
new A(new B(2));
|
||||
|
||||
```
|
||||
|
||||
字节码如下,你会发现两个new指令连在一起。
|
||||
|
||||
```
|
||||
NEW A
|
||||
DUP
|
||||
NEW B
|
||||
DUP
|
||||
ICONST_2
|
||||
INVOKESPECIAL B.<init> (I)V
|
||||
INVOKESPECIAL A.<init> (LB;)V
|
||||
|
||||
```
|
||||
|
||||
虽然ASM Bytecode Outline工具可以帮助我们完成很多场景下的ASM需求,但是在处理字节码的时候还是需要考虑很多种可能出现的情况,这点需要你注意一下每个指令的特征。所以说在稍微复杂一些的情况下,我们依然需要对ASM字节码以及ASM源码中的一些工具类有所了解,并且需要很多次的实践,毕竟实践是最重要的。
|
||||
|
||||
最后再留给你一个思考题,如何给某个方法增加一个try catch呢?你可以尝试一下在今天强化练习的代码里根据我提供的插件示例实现一下。
|
||||
|
||||
强化练习的代码:[https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM](https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM)
|
||||
|
||||
## 福利彩蛋
|
||||
|
||||
学到这里相信你肯定会认同成为一个Android开发高手的确不容易,能够坚持学习和练习,并整理输出分享更是不易。但是也确实有同学坚持下来了。
|
||||
|
||||
还记得在专栏导读里我们的承诺吗?我们会选出坚持参与学习并分享心得的同学,送出2019年GMTC大会的门票。今天我们就来兑现承诺,送出价值4800元的GMTC门票一张。获得这个“大礼包”的同学是@唯鹿,他不仅提交了作业,更是在博客里分享了每个练习Sample实现的过程和心得,并且一直在坚持。我在文稿里贴了他的练习心得文章链接,如果你对于之前的练习Sample还有不明白的地方,可以参考唯鹿同学的实现过程。
|
||||
|
||||
<li>
|
||||
[Android 开发高手课 课后练习(1 ~ 5)](https://blog.csdn.net/qq_17766199/article/details/85716750)
|
||||
</li>
|
||||
<li>
|
||||
[Android 开发高手课 课后练习(6 ~ 8,12,17,19)](https://blog.csdn.net/qq_17766199/article/details/86770948)
|
||||
</li>
|
||||
<li>
|
||||
[专栏第4期完成作业](https://github.com/simplezhli/Chapter04)
|
||||
</li>
|
||||
<li>
|
||||
[专栏第19期完成作业](https://github.com/simplezhli/Chapter19)
|
||||
</li>
|
||||
|
||||
GMTC门票还有剩余,给自己一个进阶的机会,从现在开始一切都还来得及。
|
||||
|
||||
>
|
||||
<p>小程序、Flutter、移动AI、工程化、性能优化…大前端的下一站在哪里?GMTC 2019全球大前端技术大会将于6月北京盛大开幕,来自Google、BAT、美团、京东、滴滴等一线前端大牛将与你面对面共话前端那些事,聊聊大前端的最新技术趋势和最佳实践案例。<br>
|
||||
目前大会最低价7折购票火热进行中,讲师和议题也持续招募中,点击下方图片了解更多大会详情!</p>
|
||||
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/e6/68/e65943bb1d18357a19b7121678b78b68.png" alt="">](http://gmtc2019.geekbang.org/?utm_source=wechat&utm_medium=geektime&utm_campaign=yuedu&utm_term=0223)
|
||||
|
||||
|
||||
416
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第1辑.md
Normal file
416
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第1辑.md
Normal file
@@ -0,0 +1,416 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 唯鹿同学的练习手记 第1辑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/84/f3d7942f70a8ecc897e986d93a5f8584.mp3"></audio>
|
||||
|
||||
>
|
||||
你好,我是张绍文,今天我要跟你分享唯鹿同学完成专栏课后练习作业的“手记”。专栏承诺会为坚持完成练习作业的同学送出GMTC大会门票,唯鹿同学通过自己的努力和坚持,为自己赢得了GMTC大会的门票。
|
||||
如果你还没开始练习,我强烈建议你花一些时间在练习上,因为每个练习的Sample都是我和学习委员花费很多精力精心准备的,为的是让你在学习完后可以有机会上手实践,帮你尽快消化专栏里的知识并为自己所用。
|
||||
|
||||
|
||||
大家好,我是唯鹿,来自西安,从事Android开发也有近5年的时间了,目前在做智慧社区方面的业务。我自己坚持写博客已经有三年多的时间了,希望分享自己在工作、学习中的收获。
|
||||
|
||||
先说说我学习专栏的方法,专栏更新当天我就会去学习,但是难度真的不小。我对自己的要求并不是看一遍就要搞明白,而是遇见不懂的地方立马查阅资料,要做到大体了解整篇内容。之后在周末的时候我会集中去做Sample练习,一边复习本周发布的内容,一边用写博客的方式记录练习的结果。
|
||||
|
||||
后面我计划专栏结束后再多看、多练习几遍,不断查漏补缺。说真的,我很喜欢《Android开发高手课》的难度,让我在完成练习作业时有种翻越高山的快感。最后,希望同学们一起坚持,享受翻越高山带来的成就感。
|
||||
|
||||
最近在学习张绍文老师的《Android开发高手课》。课后作业可不是一般的难,最近几天抽空练习了一下,结合老师给的步骤和其他同学的经验,完成了前5课的内容。
|
||||
|
||||
我整理总结了一下,分享出来,希望可以帮到一起学习的同学(当然希望大家尽量靠自己解决问题)。
|
||||
|
||||
[**Chapter01**](https://github.com/AndroidAdvanceWithGeektime/Chapter01)
|
||||
|
||||
>
|
||||
例子里集成了Breakpad来获取发生Native Crash时候的系统信息和线程堆栈信息。通过一个简单的Native崩溃捕获过程,完成minidump文件的生成和解析,在实践中加深对Breakpad工作机制的认识。
|
||||
|
||||
|
||||
直接运行项目,按照README.md的步骤操作就行。
|
||||
|
||||
中间有个问题,老师提供的minidump_stackwalker工具在macOS 10.14以上无法成功执行,因为没有libstdc++.6.dylib库,所以我就下载Breakpad源码重新编译了一遍。
|
||||
|
||||
使用minidump_stackwalker工具来根据minidump文件生成堆栈跟踪log,得到的crashLog.txt文件如下:
|
||||
|
||||
```
|
||||
Operating system: Android
|
||||
0.0.0 Linux 4.9.112-perf-gb92eddd #1 SMP PREEMPT Tue Jan 1 21:35:06 CST 2019 aarch64
|
||||
CPU: arm64 // 注意点1
|
||||
8 CPUs
|
||||
|
||||
GPU: UNKNOWN
|
||||
|
||||
Crash reason: SIGSEGV /SEGV_MAPERR
|
||||
Crash address: 0x0
|
||||
Process uptime: not available
|
||||
|
||||
Thread 0 (crashed)
|
||||
0 libcrash-lib.so + 0x600 // 注意点2
|
||||
x0 = 0x00000078e0ce8460 x1 = 0x0000007fd4000314
|
||||
x2 = 0x0000007fd40003b0 x3 = 0x00000078e0237134
|
||||
x4 = 0x0000007fd40005d0 x5 = 0x00000078dca14200
|
||||
x6 = 0x0000007fd4000160 x7 = 0x00000078c8987e18
|
||||
x8 = 0x0000000000000000 x9 = 0x0000000000000001
|
||||
x10 = 0x0000000000430000 x11 = 0x00000078e05ef688
|
||||
x12 = 0x00000079664ab050 x13 = 0x0ad046ab5a65bfdf
|
||||
x14 = 0x000000796650c000 x15 = 0xffffffffffffffff
|
||||
x16 = 0x00000078c83defe8 x17 = 0x00000078c83ce5ec
|
||||
x18 = 0x0000000000000001 x19 = 0x00000078e0c14c00
|
||||
x20 = 0x0000000000000000 x21 = 0x00000078e0c14c00
|
||||
x22 = 0x0000007fd40005e0 x23 = 0x00000078c89fa661
|
||||
x24 = 0x0000000000000004 x25 = 0x00000079666cc5e0
|
||||
x26 = 0x00000078e0c14ca0 x27 = 0x0000000000000001
|
||||
x28 = 0x0000007fd4000310 fp = 0x0000007fd40002e0
|
||||
lr = 0x00000078c83ce624 sp = 0x0000007fd40002c0
|
||||
pc = 0x00000078c83ce600
|
||||
Found by: given as instruction pointer in context
|
||||
1 libcrash-lib.so + 0x620
|
||||
fp = 0x0000007fd4000310 lr = 0x00000078e051c7e4
|
||||
sp = 0x0000007fd40002f0 pc = 0x00000078c83ce624
|
||||
Found by: previous frame's frame pointer
|
||||
2 libart.so + 0x55f7e0
|
||||
fp = 0x130c0cf800000001 lr = 0x00000079666cc5e0
|
||||
sp = 0x0000007fd4000320 pc = 0x00000078e051c7e4
|
||||
Found by: previous frame's frame pointer
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
下来是符号解析,可以使用NDK中提供的`addr2line`来根据地址进行一个符号反解的过程,该工具在`$NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line`。
|
||||
|
||||
注意:此处要注意一下平台,如果是ARM 64位的so,解析是需要使用aarch64-linux-android-4.9下的工具链。
|
||||
|
||||
因为我的是ARM 64位的so。所以使用aarch64-linux-android-4.9,libcrash-lib.so在`app/build/intermediates/cmake/debug/obj/arm64-v8a`下,`0x600`为错误位置符号。
|
||||
|
||||
```
|
||||
aarch64-linux-android-addr2line -f -C -e libcrash-lib.so 0x600
|
||||
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
```
|
||||
Crash()
|
||||
/Users/weilu/Downloads/Chapter01-master/sample/.externalNativeBuild/cmake/debug/arm64-v8a/../../../../src/main/cpp/crash.cpp:10
|
||||
|
||||
```
|
||||
|
||||
可以看到输出结果与下图错误位置一致(第10行)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/ab/ffbea53bc34d05c02055f1e348594dab.png" alt="">
|
||||
|
||||
[**Chapter02**](https://github.com/AndroidAdvanceWithGeektime/Chapter02)
|
||||
|
||||
>
|
||||
该例子主要演示了如何通过关闭FinalizerWatchdogDaemon来减少TimeoutException的触发。
|
||||
|
||||
|
||||
在我的上一篇博客:[安卓开发中遇到的奇奇怪怪的问题(三)](https://blog.csdn.net/qq_17766199/article/details/84789495#t1)中有说明,就不重复赘述了。
|
||||
|
||||
[**Chapter03**](https://github.com/AndroidAdvanceWithGeektime/Chapter03)
|
||||
|
||||
>
|
||||
项目使用了Inline Hook来拦截内存对象分配时候的RecordAllocation函数,通过拦截该接口可以快速获取到当时分配对象的类名和分配的内存大小。
|
||||
在初始化的时候我们设置了一个分配对象数量的最大值,如果从start开始对象分配数量超过最大值就会触发内存dump,然后清空alloc对象列表,重新计算。该功能和Android Studio里的Allocation Tracker类似,只不过可以在代码级别更细粒度的进行控制。可以精确到方法级别。
|
||||
|
||||
|
||||
项目直接跑起来后,点击开始记录,然后点击5次生成1000对象按钮。生成对象代码如下:
|
||||
|
||||
```
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
Message msg = new Message();
|
||||
msg.what = i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为代码从点击开始记录开始,触发到5000的数据就dump到文件中,点击5次后就会在`sdcard/crashDump`下生成一个时间戳命名的文件。项目根目录下调用命令:
|
||||
|
||||
```
|
||||
java -jar tools/DumpPrinter-1.0.jar dump文件路径 > dump_log.txt
|
||||
|
||||
```
|
||||
|
||||
然后就可以在dump_log.txt中看到解析出来的数据:
|
||||
|
||||
```
|
||||
Found 5000 records:
|
||||
....
|
||||
tid=4509 android.graphics.drawable.RippleForeground (112 bytes)
|
||||
android.graphics.drawable.RippleDrawable.tryRippleEnter (RippleDrawable.java:569)
|
||||
android.graphics.drawable.RippleDrawable.setRippleActive (RippleDrawable.java:276)
|
||||
android.graphics.drawable.RippleDrawable.onStateChange (RippleDrawable.java:266)
|
||||
android.graphics.drawable.Drawable.setState (Drawable.java:778)
|
||||
android.view.View.drawableStateChanged (View.java:21137)
|
||||
android.widget.TextView.drawableStateChanged (TextView.java:5289)
|
||||
android.support.v7.widget.AppCompatButton.drawableStateChanged (AppCompatButton.java:155)
|
||||
android.view.View.refreshDrawableState (View.java:21214)
|
||||
android.view.View.setPressed (View.java:10583)
|
||||
android.view.View.setPressed (View.java:10561)
|
||||
android.view.View.onTouchEvent (View.java:13865)
|
||||
android.widget.TextView.onTouchEvent (TextView.java:10070)
|
||||
android.view.View.dispatchTouchEvent (View.java:12533)
|
||||
android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)
|
||||
android.view.ViewGroup.dispatchTouchEvent (ViewGroup.java:2662)
|
||||
android.view.ViewGroup.dispatchTransformedTouchEvent (ViewGroup.java:3032)
|
||||
tid=4515 int[] (104 bytes)
|
||||
tid=4509 android.os.BaseLooper$MessageMonitorInfo (88 bytes)
|
||||
android.os.Message.<init> (Message.java:123)
|
||||
com.dodola.alloctrack.MainActivity$4.onClick (MainActivity.java:70)
|
||||
android.view.View.performClick (View.java:6614)
|
||||
android.view.View.performClickInternal (View.java:6591)
|
||||
android.view.View.access$3100 (View.java:786)
|
||||
android.view.View$PerformClick.run (View.java:25948)
|
||||
android.os.Handler.handleCallback (Handler.java:873)
|
||||
android.os.Handler.dispatchMessage (Handler.java:99)
|
||||
android.os.Looper.loop (Looper.java:201)
|
||||
android.app.ActivityThread.main (ActivityThread.java:6806)
|
||||
java.lang.reflect.Method.invoke (Native method)
|
||||
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:547)
|
||||
com.android.internal.os.ZygoteInit.main (ZygoteInit.java:873)
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
我们用Android Profiler查找一个Message对象对比一下,一模一样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/10/ea9e03f90bc15a086d523958272fe410.png" alt="">
|
||||
|
||||
简单看一下Hook代码:
|
||||
|
||||
```
|
||||
void hookFunc() {
|
||||
LOGI("start hookFunc");
|
||||
void *handle = ndk_dlopen("libart.so", RTLD_LAZY | RTLD_GLOBAL);
|
||||
|
||||
if (!handle) {
|
||||
LOGE("libart.so open fail");
|
||||
return;
|
||||
}
|
||||
void *hookRecordAllocation26 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");
|
||||
|
||||
void *hookRecordAllocation24 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");
|
||||
|
||||
void *hookRecordAllocation23 = ndk_dlsym(handle,
|
||||
"_ZN3art3Dbg16RecordAllocationEPNS_6ThreadEPNS_6mirror5ClassEj");
|
||||
|
||||
void *hookRecordAllocation22 = ndk_dlsym(handle,
|
||||
"_ZN3art3Dbg16RecordAllocationEPNS_6mirror5ClassEj");
|
||||
|
||||
if (hookRecordAllocation26 != nullptr) {
|
||||
LOGI("Finish get symbol26");
|
||||
MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,
|
||||
(void **) &oldArtRecordAllocation26);
|
||||
|
||||
} else if (hookRecordAllocation24 != nullptr) {
|
||||
LOGI("Finish get symbol24");
|
||||
MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26,
|
||||
(void **) &oldArtRecordAllocation26);
|
||||
|
||||
} else if (hookRecordAllocation23 != NULL) {
|
||||
LOGI("Finish get symbol23");
|
||||
MSHookFunction(hookRecordAllocation23, (void *) &newArtRecordAllocation23,
|
||||
(void **) &oldArtRecordAllocation23);
|
||||
} else {
|
||||
LOGI("Finish get symbol22");
|
||||
if (hookRecordAllocation22 == NULL) {
|
||||
LOGI("error find hookRecordAllocation22");
|
||||
return;
|
||||
} else {
|
||||
MSHookFunction(hookRecordAllocation22, (void *) &newArtRecordAllocation22,
|
||||
(void **) &oldArtRecordAllocation22);
|
||||
}
|
||||
}
|
||||
dlclose(handle);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
使用了Inline Hook方案Substrate来拦截内存对象分配时候libart.so的RecordAllocation函数。首先如果我们要hook一个函数,需要知道这个函数的地址。我们也看到了代码中这个地址判断了四种不同系统。这里有一个[网页版的解析工具](http://demangler.com/)可以快速获取。下面以8.0为例。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/08/89bc383aab02af0b71d243dc10273708.png" alt="">
|
||||
|
||||
我在8.0的源码中找到了对应的方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/26/a1655f62725d8ae28a14ed717860e726.jpeg" alt="">
|
||||
|
||||
7.0方法就明显不同:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/25/3a2e76c476c6a21e6036022793521925.jpeg" alt="">
|
||||
|
||||
我也同时参看了9.0的代码,发现没有变化,所以我的测试机是9.0的也没有问题。
|
||||
|
||||
Hook新内存对象分配处理代码:
|
||||
|
||||
```
|
||||
static bool newArtRecordAllocationDoing24(Class *type, size_t byte_count) {
|
||||
|
||||
allocObjectCount++;
|
||||
//根据 class 获取类名
|
||||
char *typeName = GetDescriptor(type, &a);
|
||||
//达到 max
|
||||
if (allocObjectCount > setAllocRecordMax) {
|
||||
CMyLock lock(g_Lock);//此处需要 loc 因为对象分配的时候不知道在哪个线程,不 lock 会导致重复 dump
|
||||
allocObjectCount = 0;
|
||||
|
||||
// dump alloc 里的对象转换成 byte 数据
|
||||
jbyteArray allocData = getARTAllocationData();
|
||||
// 将alloc数据写入文件
|
||||
SaveAllocationData saveData{allocData};
|
||||
saveARTAllocationData(saveData);
|
||||
resetARTAllocRecord();
|
||||
LOGI("===========CLEAR ALLOC MAPS=============");
|
||||
|
||||
lock.Unlock();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[**Chapter04**](https://github.com/AndroidAdvanceWithGeektime/Chapter04)
|
||||
|
||||
>
|
||||
通过分析内存文件hprof快速判断内存中是否存在重复的图片,并且将这些重复图片的PNG、堆栈等信息输出。
|
||||
|
||||
|
||||
首先是获取我们需要分析的hprof文件,我们加载两张相同的图片:
|
||||
|
||||
```
|
||||
Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);
|
||||
Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.mipmap.test);
|
||||
|
||||
imageView1.setImageBitmap(bitmap1);
|
||||
imageView2.setImageBitmap(bitmap2);
|
||||
|
||||
```
|
||||
|
||||
生成hprof文件
|
||||
|
||||
```
|
||||
// 手动触发GC
|
||||
Runtime.getRuntime().gc();
|
||||
System.runFinalization();
|
||||
Debug.dumpHprofData(file.getAbsolutePath());
|
||||
|
||||
```
|
||||
|
||||
接下来就是利用[HAHA库](https://github.com/square/haha)进行文件分析的核心代码:
|
||||
|
||||
```
|
||||
// 打开hprof文件
|
||||
final HeapSnapshot heapSnapshot = new HeapSnapshot(hprofFile);
|
||||
// 获得snapshot
|
||||
final Snapshot snapshot = heapSnapshot.getSnapshot();
|
||||
// 获得Bitmap Class
|
||||
final ClassObj bitmapClass = snapshot.findClass("android.graphics.Bitmap");
|
||||
// 获得heap, 只需要分析app和default heap即可
|
||||
Collection<Heap> heaps = snapshot.getHeaps();
|
||||
|
||||
for (Heap heap : heaps) {
|
||||
// 只需要分析app和default heap即可
|
||||
if (!heap.getName().equals("app") && !heap.getName().equals("default")) {
|
||||
continue;
|
||||
}
|
||||
for (ClassObj clazz : bitmapClasses) {
|
||||
//从heap中获得所有的Bitmap实例
|
||||
List<Instance> bitmapInstances = clazz.getHeapInstances(heap.getId());
|
||||
//从Bitmap实例中获得buffer数组,宽高信息等。
|
||||
ArrayInstance buffer = HahaHelper.fieldValue(((ClassInstance) bitmapInstance).getValues(), "mBuffer");
|
||||
int bitmapHeight = fieldValue(bitmapInstance, "mHeight");
|
||||
int bitmapWidth = fieldValue(bitmapInstance, "mWidth");
|
||||
// 引用链信息
|
||||
while (bitmapInstance.getNextInstanceToGcRoot() != null) {
|
||||
print(instance.getNextInstanceToGcRoot());
|
||||
instance = instance.getNextInstanceToGcRoot();
|
||||
}
|
||||
// 根据hashcode来进行重复判断
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终的输出结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/9c/963f4a3cabf8ef5aa4faa0a61c55bd9c.jpeg" alt="">
|
||||
|
||||
我们用Studio打开hprof文件对比一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/55/049c6beae610971175f7521bcf4f8b55.jpeg" alt="">
|
||||
|
||||
可以看到信息是一摸一样的。对于更优处理引用链的信息,可以参看[LeakCanary](https://github.com/square/leakcanary)源码的实现。
|
||||
|
||||
我已经将上面的代码打成JAR包,可以直接调用:
|
||||
|
||||
```
|
||||
//调用方法:
|
||||
java -jar tools/DuplicatedBitmapAnalyzer-1.0.jar hprof文件路径
|
||||
|
||||
```
|
||||
|
||||
详细的代码我提交到了[Github](https://github.com/simplezhli/Chapter04),供大家参考。
|
||||
|
||||
[**Chapter05**](https://github.com/AndroidAdvanceWithGeektime/Chapter05)
|
||||
|
||||
>
|
||||
尝试模仿[ProcessCpuTracker.java](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/com/android/internal/os/ProcessCpuTracker.java)拿到一段时间内各个线程的耗时占比。
|
||||
|
||||
|
||||
```
|
||||
usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):
|
||||
System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle
|
||||
CPU Core: 8
|
||||
Load Average: 8.74 / 7.74 / 7.36
|
||||
|
||||
Process:com.sample.app
|
||||
50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965
|
||||
|
||||
Threads:
|
||||
43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094
|
||||
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
|
||||
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
|
||||
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
因为了解Linux不多,所以看这个有点懵逼。好在课代表孙鹏飞同学解答了相关问题,看懂了上面信息,同时学习到了一些Linux知识。
|
||||
|
||||
```
|
||||
private void testIO() {
|
||||
Thread thread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
File f = new File(getFilesDir(), "aee.txt");
|
||||
FileOutputStream fos = new FileOutputStream(f);
|
||||
byte[] data = new byte[1024 * 4 * 3000];// 此处分配一个 12mb 大小的 byte 数组
|
||||
|
||||
for (int i = 0; i < 30; i++) {// 由于 IO cache 机制的原因所以此处写入多次 cache,触发 dirty writeback 到磁盘中
|
||||
Arrays.fill(data, (byte) i);// 当执行到此处的时候产生 minor fault,并且产生 User cpu useage
|
||||
fos.write(data);
|
||||
}
|
||||
fos.flush();
|
||||
fos.close();
|
||||
|
||||
}
|
||||
});
|
||||
thread.setName("SingleThread");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上述代码就是导致的问题罪魁祸首,这种密集I/O操作集中在SingleThread线程中处理,导致发生了3094次faults、36% kernel,完全没有很好利用到8核CPU。
|
||||
|
||||
最后,通过检测CPU的使用率,可以更好地避免卡顿现象,防止ANR的发生。
|
||||
|
||||
前前后后用了两三天的时间,远远没有当初想的顺利,感觉身体被掏空。中间也爬了不少坑,虽然没有太深入实现代码,但是中间的体验过程也是收获不小。所以总不能因为难就放弃了,先做到力所能及的部分,让自己动起来!
|
||||
|
||||
**参考**
|
||||
|
||||
<li>
|
||||
[练习Sample跑起来 | 热点问题答疑第1期](https://time.geekbang.org/column/article/73068)
|
||||
</li>
|
||||
<li>
|
||||
[练习Sample跑起来 | 热点问题答疑第2期](https://time.geekbang.org/column/article/75440)
|
||||
</li>
|
||||
|
||||
|
||||
318
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第2辑.md
Normal file
318
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第2辑.md
Normal file
@@ -0,0 +1,318 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 唯鹿同学的练习手记 第2辑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/be/8b7f5c322632493d7d6e9729188cffbe.mp3"></audio>
|
||||
|
||||
你好,我是唯鹿。
|
||||
|
||||
接着上篇[练习手记](https://time.geekbang.org/column/article/83742),今天练习6~8、12、17、19这六期内容(主要针对有课后Sample练习的),相比1~5期轻松了很多。
|
||||
|
||||
[**Chapter06**](https://github.com/AndroidAdvanceWithGeektime/Chapter06)
|
||||
|
||||
>
|
||||
该项目展示了使用PLT Hook技术来获取Atrace的日志,可以学习到systrace的一些底层机制。
|
||||
|
||||
|
||||
没有什么问题,项目直接可以运行起来。运行项目后点击开启Atrace日志,然后就可以在Logcat日志中查看到捕获的日志,如下:
|
||||
|
||||
```
|
||||
11:40:07.031 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= install systrace hoook =========
|
||||
11:40:07.034 8537-8537/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|Record View#draw()
|
||||
11:40:07.034 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|DrawFrame
|
||||
11:40:07.035 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|syncFrameState
|
||||
========= B|8537|prepareTree
|
||||
========= E
|
||||
========= E
|
||||
========= B|8537|eglBeginFrame
|
||||
========= E
|
||||
========= B|8537|computeOrdering
|
||||
========= E
|
||||
========= B|8537|flush drawing commands
|
||||
========= E
|
||||
11:40:07.036 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|eglSwapBuffersWithDamageKHR
|
||||
========= B|8537|setSurfaceDamage
|
||||
========= E
|
||||
11:40:07.042 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|queueBuffer
|
||||
========= E
|
||||
11:40:07.043 8537-8552/com.dodola.atrace I/HOOOOOOOOK: ========= B|8537|dequeueBuffer
|
||||
========= E
|
||||
========= E
|
||||
========= E
|
||||
|
||||
```
|
||||
|
||||
通过B|事件和E|事件是成对出现的,这样就可以计算出应用执行每个事件使用的时间。那么上面的Log中View的draw()方法显示使用了9ms。
|
||||
|
||||
这里实现方法是使用了[Profilo](https://github.com/facebookincubator/profilo)的PLT Hook来hook libc.so的`write`与`__write_chk`方法。libc是C的基础库函数,为什么要hook这些方法,需要我们补充C、Linux相关知识。
|
||||
|
||||
同理[Chapter06-plus](https://github.com/AndroidAdvanceWithGeektime/Chapter06-plus)展示了如何使用 PLT Hook技术来获取线程创建的堆栈,README有详细的实现步骤介绍,我就不赘述了。
|
||||
|
||||
[**Chapter07**](https://github.com/AndroidAdvanceWithGeektime/Chapter07)
|
||||
|
||||
>
|
||||
这个Sample是学习如何给代码加入Trace Tag,大家可以将这个代码运用到自己的项目中,然后利用systrace查看结果。这就是所谓的systrace + 函数插桩。
|
||||
|
||||
|
||||
操作步骤:
|
||||
|
||||
<li>
|
||||
使用Android Studio打开工程Chapter07。
|
||||
</li>
|
||||
<li>
|
||||
运行Gradle Task `:systrace-gradle-plugin:buildAndPublishToLocalMaven`编译plugin插件。
|
||||
</li>
|
||||
<li>
|
||||
使用Android Studio单独打开工程systrace-sample-android。
|
||||
</li>
|
||||
<li>
|
||||
编译运行App(插桩后的class文件在目录`Chapter07/systrace-sample-android/app/build/systrace_output/classes`中查看)。
|
||||
</li>
|
||||
|
||||
对比一下插桩效果,插桩前:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/a5/6587c2a9e0d4cae3b5336cbf9ef91da5.jpeg" alt="">
|
||||
|
||||
插桩后:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/13/0895158565b8548d86d2e076325c0713.jpeg" alt="">
|
||||
|
||||
可以看到在方法执行前后插入了TraceTag,这样的话`beginSection`方法和`endSection`方法之间的代码就会被追踪。
|
||||
|
||||
```
|
||||
public class TraceTag {
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
public static void i(String name) {
|
||||
Trace.beginSection(name);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
public static void o() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实Support-Compat库中也有类似的一个[TraceCompat](https://developer.android.google.cn/reference/android/support/v4/os/TraceCompat),项目中可以直接使用。
|
||||
|
||||
然后运行项目,打开systrace:
|
||||
|
||||
```
|
||||
python $ANDROID_HOME/platform-tools/systrace/systrace.py gfx view wm am pm ss dalvik app sched -b 90960 -a com.sample.systrace -o test.log.html
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/0e/d17230ca7e70b399101b1ce66dd35e0e.jpeg" alt="">
|
||||
|
||||
最后打开生成的test.log.html文件就可以查看systrace记录:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/4b/e093fc650bfeafd053eb845a1b1d9c4b.jpeg" alt="">
|
||||
|
||||
当然,这一步我们也可以使用SDK中的Monitor,效果是一样的。
|
||||
|
||||
使用systrace + 函数插桩的方式,我们就可以很方便地观察每个方法的耗时,从而针对耗时的方法进行优化,尤其是Application的启动优化。
|
||||
|
||||
[**Chapter08**](https://github.com/AndroidAdvanceWithGeektime/Chapter08)
|
||||
|
||||
>
|
||||
该项目展示了关闭掉虚拟机的class verify后对性能的影响。
|
||||
|
||||
|
||||
在加载类的过程有一个verify class的步骤,它需要校验方法的每一个指令,是一个比较耗时的操作。这个例子就是通过Hook去掉verify这个步骤。该例子尽量在Dalvik下执行,在ART下的效果并不明显。
|
||||
|
||||
去除校验代码(可以参看阿里的[Atlas](https://github.com/alibaba/atlas)):
|
||||
|
||||
```
|
||||
AndroidRuntime runtime = AndroidRuntime.getInstance();
|
||||
runtime.init(this.getApplicationContext(), true);
|
||||
runtime.setVerificationEnabled(false);
|
||||
|
||||
```
|
||||
|
||||
具体运行效果这里我就不展示了,直接运行体验就可以了。
|
||||
|
||||
[**Chapter12**](https://github.com/AndroidAdvanceWithGeektime/Chapter12)
|
||||
|
||||
>
|
||||
通过复写Application的`getSharedPreferences`替换系统`SharedPreferences`的实现,核心的优化在于修改了Apply的实现,将多个Apply方法在内存中合并,而不是多次提交。
|
||||
|
||||
|
||||
修改`SharedPreferencesImpl`的Apply部分如下:
|
||||
|
||||
```
|
||||
public void apply() {
|
||||
// 先调用commitToMemory()
|
||||
final MemoryCommitResult mcr = commitToMemory();
|
||||
|
||||
boolean hasDiskWritesInFlight = false;
|
||||
synchronized (SharedPreferencesImpl.this) {
|
||||
// mDiskWritesInFlight大于0说明之前已经有调用过commitToMemory()了
|
||||
hasDiskWritesInFlight = mDiskWritesInFlight > 0;
|
||||
}
|
||||
// 源码没有这层判断,直接提交。
|
||||
if (!hasDiskWritesInFlight) {
|
||||
final Runnable awaitCommit = new Runnable() {
|
||||
public void run() {
|
||||
try {
|
||||
mcr.writtenToDiskLatch.await();
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
QueuedWork.add(awaitCommit);
|
||||
|
||||
|
||||
Runnable postWriteRunnable = new Runnable() {
|
||||
public void run() {
|
||||
awaitCommit.run();
|
||||
|
||||
QueuedWork.remove(awaitCommit);
|
||||
}
|
||||
};
|
||||
|
||||
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
|
||||
}
|
||||
|
||||
// Okay to notify the listeners before it's hit disk
|
||||
// because the listeners should always get the same
|
||||
// SharedPreferences instance back, which has the
|
||||
// changes reflected in memory.
|
||||
notifyListeners(mcr);
|
||||
|
||||
```
|
||||
|
||||
[**Chapter14**](https://github.com/AndroidAdvanceWithGeektime/Chapter14)
|
||||
|
||||
这个是全面解析SQLite的资料,有兴趣的可以下载看看。
|
||||
|
||||
[**Chapter17**](https://github.com/AndroidAdvanceWithGeektime/Chapter17)
|
||||
|
||||
>
|
||||
该项目展示了如何使用PLT Hook技术来获取网络请求相关信息。
|
||||
|
||||
|
||||
通过PLT Hook,代理Socket相关的几个重要函数:
|
||||
|
||||
```
|
||||
/**
|
||||
* 直接 hook 内存中的所有so,但是需要排除掉socket相关方法本身定义的libc(不然会出现循坏)
|
||||
* plt hook
|
||||
*/
|
||||
void hookLoadedLibs() {
|
||||
ALOG("hook_plt_method");
|
||||
hook_plt_method_all_lib("libc.so", "send", (hook_func) &socket_send_hook);
|
||||
hook_plt_method_all_lib("libc.so", "recv", (hook_func) &socket_recv_hook);
|
||||
hook_plt_method_all_lib("libc.so", "sendto", (hook_func) &socket_sendto_hook);
|
||||
hook_plt_method_all_lib("libc.so", "recvfrom", (hook_func) &socket_recvfrom_hook);
|
||||
hook_plt_method_all_lib("libc.so", "connect", (hook_func) &socket_connect_hook);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
int hook_plt_method_all_lib(const char* exclueLibname, const char* name, hook_func hook) {
|
||||
if (refresh_shared_libs()) {
|
||||
// Could not properly refresh the cache of shared library data
|
||||
return -1;
|
||||
}
|
||||
|
||||
int failures = 0;
|
||||
|
||||
for (auto const& lib : allSharedLibs()) {
|
||||
if (strcmp(lib.first.c_str(), exclueLibname) != 0) {
|
||||
failures += hook_plt_method(lib.first.c_str(), name, hook);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行项目,访问百度的域名[https://www.baidu.com](https://www.baidu.com),输出如下:
|
||||
|
||||
```
|
||||
17:08:28.347 12145-12163/com.dodola.socket E/HOOOOOOOOK: socket_connect_hook sa_family: 10
|
||||
17:08:28.349 12145-12163/com.dodola.socket E/HOOOOOOOOK: stack:com.dodola.socket.SocketHook.getStack(SocketHook.java:13)
|
||||
java.net.PlainSocketImpl.socketConnect(Native Method)
|
||||
java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:334)
|
||||
java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:196)
|
||||
java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:178)
|
||||
java.net.SocksSocketImpl.connect(SocksSocketImpl.java:356)
|
||||
java.net.Socket.connect(Socket.java:586)
|
||||
com.android.okhttp.internal.Platform.connectSocket(Platform.java:113)
|
||||
com.android.okhttp.Connection.connectSocket(Connection.java:196)
|
||||
com.android.okhttp.Connection.connect(Connection.java:172)
|
||||
com.android.okhttp.Connection.connectAndSetOwner(Connection.java:367)
|
||||
com.android.okhttp.OkHttpClient$1.connectAndSetOwner(OkHttpClient.java:130)
|
||||
com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:329)
|
||||
com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:246)
|
||||
com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnection
|
||||
AF_INET6 ipv6 IP===>183.232.231.173:443
|
||||
socket_connect_hook sa_family: 1
|
||||
Ignore local socket connect
|
||||
02-07 17:08:28.637 12145-12163/com.dodola.socket E/HOOOOOOOOK: respond:<!DOCTYPE html>
|
||||
<html><!--STATUS OK--><head><meta charset="utf-8"><title>百度一下,你就知道</title>
|
||||
|
||||
```
|
||||
|
||||
可以看到我们获取到了网络请求的相关信息。
|
||||
|
||||
最后,我们可以通过Connect函数的hook,实现很多需求,例如:
|
||||
|
||||
- 禁用应用网络访问
|
||||
- 过滤广告IP
|
||||
- 禁用定位功能
|
||||
|
||||
[**Chapter19**](https://github.com/AndroidAdvanceWithGeektime/Chapter19)
|
||||
|
||||
>
|
||||
使用Java Hook实现Alarm、WakeLock与GPS的耗电监控。
|
||||
|
||||
|
||||
实现原理
|
||||
|
||||
根据老师提供的提示信息,动态代理对应的[PowerManager](http://androidxref.com/7.0.0_r1/xref/frameworks/base/core/java/android/os/PowerManager.java)、[AlarmManager](http://androidxref.com/7.0.0_r1/xref/frameworks/base/core/java/android/app/AlarmManager.java)、[LocationManager](http://androidxref.com/7.0.0_r1/xref/frameworks/base/location/java/android/location/LocationManager.java)的`mService`实现,要拦截的方法在[PowerManagerService](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/power/PowerManagerService.java)、[AlarmManagerService](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/AlarmManagerService.java)、[LocationManagerService](http://androidxref.com/7.0.0_r1/xref/frameworks/base/services/core/java/com/android/server/LocationManagerService.java)中。
|
||||
|
||||
实现核心代码:
|
||||
|
||||
```
|
||||
Object oldObj = mHostContext.getSystemService(Context.XXX_SERVICE);
|
||||
Class<?> clazz = oldObj.getClass();
|
||||
Field field = clazz.getDeclaredField("mService");
|
||||
field.setAccessible(true);
|
||||
|
||||
final Object mService = field.get(oldObj);
|
||||
setProxyObj(mService);
|
||||
|
||||
Object newObj = Proxy.newProxyInstance(this.getClass().getClassLoader(), mService.getClass().getInterfaces(), this);
|
||||
field.set(oldObj, newObj)
|
||||
|
||||
```
|
||||
|
||||
写了几个调用方法去触发,通过判断对应的方法名来做堆栈信息的输出。
|
||||
|
||||
输出的堆栈信息如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/b7/43e081eacdef050b78b71043c87acdb7.png" alt="">
|
||||
|
||||
当然,强大的Studio在3.2后也有了强大的耗电量分析器,同样可以监测到这些信息,如下图所示(我使用的Studio版本为3.3)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/3d/82b2939991e4c9c729017255fb1cb73d.png" alt="">
|
||||
|
||||
实现不足之处:
|
||||
|
||||
- 可能兼容性上不是特别完善(期待老师的标准答案)。
|
||||
- 没有按照耗电监控的规则去做一些业务处理。
|
||||
|
||||
心得体会:
|
||||
|
||||
- 本身并不复杂,只是为了找到Hook点,看了对应的Service源码耗费了一些时间,对于它们的工作流程有了更深的认识。
|
||||
- 平时也很少使用动态代理,这回查漏补缺,一次用了个爽。
|
||||
|
||||
这个作业前前后后用了一天时间,之前作业还有一些同学提供PR,所以相对轻松些,但这次没有参考,走了点弯路,不过收获也是巨大的。我就不细说了,感兴趣的话可以参考我的实现。完整代码参见[GitHub](https://github.com/simplezhli/Chapter19),仅供参考。
|
||||
|
||||
**参考**
|
||||
|
||||
- [练习Sample跑起来 | 热点问题答疑第3期](https://time.geekbang.org/column/article/76413)
|
||||
- [练习Sample跑起来 | 热点问题答疑第4期](https://time.geekbang.org/column/article/79331)
|
||||
|
||||
|
||||
354
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第3辑.md
Normal file
354
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 唯鹿同学的练习手记 第3辑.md
Normal file
@@ -0,0 +1,354 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 唯鹿同学的练习手记 第3辑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/c2/df08966dbbe1afcf86c3debc177b82c2.mp3"></audio>
|
||||
|
||||
没想到之前的写的练习心得得到了老师的认可,看来我要更加认真努力练习了。今天来练习第22、27、ASM这三课的Sample。
|
||||
|
||||
[**Chapter22**](https://github.com/AndroidAdvanceWithGeektime/Chapter22)
|
||||
|
||||
>
|
||||
尝试使用Facebook ReDex库来优化我们的安装包。
|
||||
|
||||
|
||||
**准备工作**
|
||||
|
||||
首先是下载ReDex:
|
||||
|
||||
```
|
||||
git clone https://github.com/facebook/redex.git
|
||||
cd redex
|
||||
|
||||
```
|
||||
|
||||
接着是安装:
|
||||
|
||||
```
|
||||
autoreconf -ivf && ./configure && make -j4
|
||||
sudo make install
|
||||
|
||||
```
|
||||
|
||||
在安装时执行到这里,报出下图错误:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/fa/40ba14544153f1ef67bfd21a884c1efa.jpg" alt="">
|
||||
|
||||
其实就是没有安装Boost,所以执行下面的命令安装它。
|
||||
|
||||
```
|
||||
brew install boost jsoncpp
|
||||
|
||||
```
|
||||
|
||||
安装Boost完成后,再等待十几分钟时间安装ReDex。
|
||||
|
||||
下来就是编译我们的Sample,得到的安装包信息如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/0b/bcf38372f4d9315b9d288607e437040b.jpeg" alt="">
|
||||
|
||||
可以看到有三个Dex文件,APK大小为13.7MB。
|
||||
|
||||
**通过ReDex命令优化**
|
||||
|
||||
为了让我们可以更加清楚流程,你可以输出ReDex的日志。
|
||||
|
||||
```
|
||||
export TRACE=2
|
||||
|
||||
```
|
||||
|
||||
去除Debuginfo的方法,需要在项目根目录执行:
|
||||
|
||||
```
|
||||
redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/stripdebuginfo.config -P ReDexSample/proguard-rules.pro -o redex-test/strip_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk
|
||||
|
||||
```
|
||||
|
||||
上面这段很长的命令,其实可以拆解为几部分:
|
||||
|
||||
<li>
|
||||
`--sign` 签名信息
|
||||
</li>
|
||||
<li>
|
||||
`-s`(keystore)签名文件路径
|
||||
</li>
|
||||
<li>
|
||||
`-a`(keyalias)签名的别名
|
||||
</li>
|
||||
<li>
|
||||
`-p`(keypass)签名的密码
|
||||
</li>
|
||||
<li>
|
||||
`-c` 指定ReDex的配置文件路径
|
||||
</li>
|
||||
<li>
|
||||
`-P` ProGuard规则文件路径
|
||||
</li>
|
||||
<li>
|
||||
`-o` 输出的文件路径
|
||||
</li>
|
||||
<li>
|
||||
最后是要处理APK文件的路径
|
||||
</li>
|
||||
|
||||
但在使用时,我遇到了下图的问题:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/42/f942ef115b2293562b6c3d533c0abd42.png" alt="">
|
||||
|
||||
这里是找不到`Zipalign`,所以需要我们配置Android SDK的根目录路径,添加在原命令前面:
|
||||
|
||||
```
|
||||
ANDROID_SDK=/path/to/android/sdk redex [... arguments ...]
|
||||
|
||||
```
|
||||
|
||||
结果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/28/4f442a95f1518cbe38311b042cdda028.png" alt="">
|
||||
|
||||
实际的优化效果是,原Debug包为14.21MB,去除Debuginfo的方法后为12.91MB,效果还是不错的。**去除的内容就是一些调试信息及堆栈行号。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/07/fda8e0b637df6f145f9867764720ab07.jpeg" alt="">
|
||||
|
||||
不过老师在Sample的proguard-rules.pro中添加了`-keepattributes SourceFile,LineNumberTable`保留了行号信息。
|
||||
|
||||
所以处理后的包安装后进入首页,还是可以看到堆栈信息的行号。
|
||||
|
||||
**Dex重分包的方法**
|
||||
|
||||
```
|
||||
redex --sign -s ReDexSample/keystore/debug.keystore -a androiddebugkey -p android -c redex-test/interdex.config -P ReDexSample/proguard-rules.pro -o redex-test/interdex_output.apk ReDexSample/build/outputs/apk/debug/ReDexSample-debug.apk
|
||||
|
||||
```
|
||||
|
||||
和之前的命令一样,只是`-c`使用的配置文件为interdex.config。
|
||||
|
||||
输出信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/aa/293f13ab6fe75ede7d4840d04f0d56aa.jpeg" alt="">
|
||||
|
||||
优化效果为,原Debug包为14.21MB、3个Dex,优化后为13.34MB、2个Dex。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/c3/77abb69a81448e677b64bb5cbd59fec3.jpeg" alt="">
|
||||
|
||||
根据老师的介绍,**如果你的应用有4个以上的Dex,这个体积优化至少有10%**。 看来效果还是很棒棒的。至于其他问题,比如在Windows环境使用ReDex,可以参看ReDex的[使用文档](https://fbredex.com/docs/installation)。
|
||||
|
||||
[**Chapter27**](https://github.com/AndroidAdvanceWithGeektime/Chapter27)
|
||||
|
||||
>
|
||||
利用AspectJ实现插桩的例子。
|
||||
|
||||
|
||||
效果和[Chapter07](https://github.com/AndroidAdvanceWithGeektime/Chapter07)是一样的,只是Chapter07使用的是ASM方式实现的,这次是AspectJ实现。ASM与AspectJ都是Java字节码处理框架,相比较来说AspectJ使用更加简单,同样的功能实现只需下面这点代码,但是ASM比AspectJ更加高效和灵活。
|
||||
|
||||
AspectJ实现代码:
|
||||
|
||||
```
|
||||
@Aspect
|
||||
public class TraceTagAspectj {
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
@Before("execution(* **(..))")
|
||||
public void before(JoinPoint joinPoint) {
|
||||
Trace.beginSection(joinPoint.getSignature().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* hook method when it's called out.
|
||||
*/
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
@After("execution(* **(..))")
|
||||
public void after() {
|
||||
Trace.endSection();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
简单介绍下上面代码的意思:
|
||||
|
||||
<li>
|
||||
`@Aspect`:在编译时AspectJ会查找被`@Aspect`注解的类,然后执行我们的AOP实现。
|
||||
</li>
|
||||
<li>
|
||||
`@Before`:可以简单理解为方法执行前。
|
||||
</li>
|
||||
<li>
|
||||
`@After`:可以简单理解为方法执行后。
|
||||
</li>
|
||||
<li>
|
||||
`execution`:方法执行。
|
||||
</li>
|
||||
<li>
|
||||
`* **(..)`:第一个星号代表任意返回类型,第二个星号代表任意类,第三个代表任意方法,括号内为方法参数无限制。星号和括号内都是可以替换为具体值,比如String TestClass.test(String)。
|
||||
</li>
|
||||
|
||||
知道了相关注解的含义,那么实现的代码含义就是,**所有方法在执行前后插入相应指定操作**。
|
||||
|
||||
效果对比如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/77/644381974bcd1e3b2d468cdeb432ed77.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/02/ca/02b99a9e7fd70da8d9fdf086f31c78ca.png" alt="">
|
||||
|
||||
下来实现给MainActivity的`onResume`方法增加try catch。
|
||||
|
||||
```
|
||||
@Aspect
|
||||
public class TryCatchAspect {
|
||||
|
||||
@Pointcut("execution(* com.sample.systrace.MainActivity.onResume())") // <- 指定类与方法
|
||||
public void methodTryCatch() {
|
||||
}
|
||||
|
||||
@Around("methodTryCatch()")
|
||||
public void aroundTryJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
|
||||
// try catch
|
||||
try {
|
||||
joinPoint.proceed(); // <- 调用原方法
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面用到了两个新注解:
|
||||
|
||||
<li>
|
||||
`@Around`:用于替换以前的代码,使用joinPoint.proceed()可以调用原方法。
|
||||
</li>
|
||||
<li>
|
||||
`@Pointcut`:指定一个切入点。
|
||||
</li>
|
||||
|
||||
实现就是指定一个切入点,利用替换原方法的思路包裹一层try catch。
|
||||
|
||||
效果对比如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/c0/7f4a5bb6995c53872966c956d7e78ec0.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/08/bc/08d123aa792c8f4fc8538fd5658cb9bc.png" alt="">
|
||||
|
||||
当然AspectJ还有很多用法,Sample中包含有《AspectJ程序设计指南》,便于我们具体了解和学习AspectJ。
|
||||
|
||||
[**Chapter-ASM**](https://github.com/AndroidAdvanceWithGeektime/Chapter-ASM)
|
||||
|
||||
>
|
||||
Sample利用ASM实现了统计方法耗时和替换项目中所有的new Thread。
|
||||
|
||||
|
||||
<li>
|
||||
运行项目首先要注掉ASMSample build.gradle的`apply plugin: 'com.geektime.asm-plugin'`和根目录build.gradle的`classpath ("com.geektime.asm:asm-gradle-plugin:1.0") { changing = true }`。
|
||||
</li>
|
||||
<li>
|
||||
运行`gradle task ":asm-gradle-plugin:buildAndPublishToLocalMaven"`编译plugin插件,编译的插件在本地`.m2\repository`目录下
|
||||
</li>
|
||||
<li>
|
||||
打开第一步注掉的内容就可以运行了。
|
||||
</li>
|
||||
|
||||
实现的大致过程是,先利用Transform遍历所有文件,再通过ASM的`visitMethod`遍历所有方法,最后通过AdviceAdapter实现最终的修改字节码。具体实现可以看代码和[《练习Sample跑起来 | ASM插桩强化练习》](https://time.geekbang.org/column/article/83148)。
|
||||
|
||||
效果对比:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/0b/ee98c9349e62d5aca66b883a89cd470b.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/3a/d0dd3c68ac2d56b6eebf6853f871c43a.png" alt="">
|
||||
|
||||
下面是两个练习:
|
||||
|
||||
1.给某个方法增加try catch
|
||||
|
||||
这里我就给MainActivity的`mm`方法进行try catch。实现很简单,直接修改ASMCode的TraceMethodAdapter。
|
||||
|
||||
```
|
||||
public static class TraceMethodAdapter extends AdviceAdapter {
|
||||
|
||||
private final String methodName;
|
||||
private final String className;
|
||||
private final Label tryStart = new Label();
|
||||
private final Label tryEnd = new Label();
|
||||
private final Label catchStart = new Label();
|
||||
private final Label catchEnd = new Label();
|
||||
|
||||
protected TraceMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {
|
||||
super(api, mv, access, name, desc);
|
||||
this.className = className;
|
||||
this.methodName = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodEnter() {
|
||||
if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {
|
||||
mv.visitTryCatchBlock(tryStart, tryEnd, catchStart, "java/lang/Exception");
|
||||
mv.visitLabel(tryStart);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMethodExit(int opcode) {
|
||||
if (className.equals("com/sample/asm/MainActivity") && methodName.equals("mm")) {
|
||||
mv.visitLabel(tryEnd);
|
||||
mv.visitJumpInsn(GOTO, catchEnd);
|
||||
mv.visitLabel(catchStart);
|
||||
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/RuntimeException", "printStackTrace", "()V", false);
|
||||
mv.visitInsn(Opcodes.RETURN);
|
||||
mv.visitLabel(catchEnd);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
`visitTryCatchBlock`方法:前三个参数均是Label实例,其中一、二表示try块的范围,三则是catch块的开始位置,第四个参数是异常类型。其他的方法及参数就不细说了,具体你可以参考[ASM文档](https://asm.ow2.io/asm4-guide.pdf)。
|
||||
|
||||
实现类似AspectJ,在方法执行开始及结束时插入我们的代码。
|
||||
|
||||
效果我就不截图了,代码如下:
|
||||
|
||||
```
|
||||
public void mm() {
|
||||
try {
|
||||
A a = new A(new B(2));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
2.查看代码中谁获取了IMEI
|
||||
|
||||
这个就更简单了,直接寻找谁使用了TelephonyManager的`getDeviceId`方法,并且在Sample中有答案。
|
||||
|
||||
```
|
||||
public class IMEIMethodAdapter extends AdviceAdapter {
|
||||
|
||||
private final String methodName;
|
||||
private final String className;
|
||||
|
||||
protected IMEIMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc, String className) {
|
||||
super(api, mv, access, name, desc);
|
||||
this.className = className;
|
||||
this.methodName = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
|
||||
super.visitMethodInsn(opcode, owner, name, desc, itf);
|
||||
|
||||
if (owner.equals("android/telephony/TelephonyManager") && name.equals("getDeviceId") && desc.equals("()Ljava/lang/String;")) {
|
||||
Log.e("asmcode", "get imei className:%s, method:%s, name:%s", className, methodName, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Build后输出如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/94/2d5c01eee4fc651b5831c0341d6e0994.png" alt="">
|
||||
|
||||
总体来说ASM的上手难度还是高于AspectJ,需要我们了解编译后的字节码,这里所使用的功能也只是冰山一角。课代表鹏飞同学推荐的ASM Bytecode Outline插件是个好帮手!最后我将我练习的代码也上传到了[GitHub](https://github.com/simplezhli/Chapter-ASM),里面还包括一份中文版的ASM文档,有兴趣的同学可以下载看看。
|
||||
|
||||
参考
|
||||
|
||||
- [练习Sample跑起来 | ASM插桩强化练](http://time.geekbang.org/column/article/83148)
|
||||
- [ASM文档](http://asm.ow2.io/asm4-guide.pdf)
|
||||
|
||||
|
||||
197
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第1期.md
Normal file
197
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第1期.md
Normal file
@@ -0,0 +1,197 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 热点问题答疑第1期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/38/037cbed0bc2bbf027adea3817931a538.mp3"></audio>
|
||||
|
||||
你好,我是专栏的“学习委员”孙鹏飞。
|
||||
|
||||
专栏上线以来很多同学反馈,说在运行练习Sample的时候遇到问题。由于这些Sample多是采用C/C++来完成的,所以在编译运行上会比传统的纯Java项目稍微复杂一些。今天我就针对第1期~第4期中,同学们集中遇到的问题做一期答疑。设置练习的目的,也是希望你在学习完专栏的内容后,可以快速上手试验一下专栏所讲的工具或方法,帮你加快掌握技术的精髓。所以希望各位同学可以多参与进来,有任何问题也可以在留言区给我们反馈,后面我还会不定期针对练习再做答疑。
|
||||
|
||||
## 编译环境配置
|
||||
|
||||
首先是同学们问得比较多的运行环境问题。
|
||||
|
||||
前几期的练习Sample大多是使用C/C++开发的,所以要运行起来需要先配置好SDK和NDK,SDK我们一般都是配置好的,NDK环境的配置有一些特殊的地方,一般我们的Sample都会使用最新的NDK版本,代码可能会使用C++11/14的语法进行编写,并且使用CMake进行编译,我这里给出NDK环境的配置项。
|
||||
|
||||
首先需要去NDK官网下载[最新版本](http://developer.android.com/ndk/downloads/),下载后可以解压到合适的地方,一般macOS可以存放在 ANDROID_SDK_HOME/ndk_bundle目录下,Android Studio可以默认找到该目录。如果放到别的目录,可能需要自己指定一下。
|
||||
|
||||
指定NDK目录的方法一般有下面两种。
|
||||
|
||||
1.在练习Sample根目录下都会有一个local.properties文件,修改其中的ndk.dir路径即可。
|
||||
|
||||
```
|
||||
ndk.dir=/Users/sample/Library/Android/sdk/ndk-bundle
|
||||
sdk.dir=/Users/sample/Library/Android/sdk
|
||||
|
||||
```
|
||||
|
||||
2.可以在Android Studio里进行配置,打开File -> Project Structure -> SDK Location进行修改。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/18/fbbd0e4051c08b39891ab3483251f618.png" alt="">
|
||||
|
||||
上面两种修改方法效果是一致的。
|
||||
|
||||
有些Sample需要降级NDK编译使用,可能需要下载旧版本的NDK,可以从[官网下载](http://developer.android.com/ndk/downloads/revision_history)。
|
||||
|
||||
之后需要安装CMake和LLDB。
|
||||
|
||||
<li>
|
||||
[CMake](http://cmake.org/):一款外部构建工具,可与Gradle搭配使用来构建原生库。
|
||||
</li>
|
||||
<li>
|
||||
[LLDB](http://lldb.llvm.org/):一种调试程序,Android Studio使用它来[调试原生代码](http://developer.android.com/studio/debug/index.html?hl=zh-cn)。
|
||||
</li>
|
||||
|
||||
**这两项都可以在Tools > Android > SDK Manager里进行安装**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/3c/119fd82b78608e6600fb8cac5275653c.png" alt="">
|
||||
|
||||
这样我们编译所需要的环境就配置好了。
|
||||
|
||||
## 热点问题答疑
|
||||
|
||||
[01 | 崩溃优化(上):关于“崩溃”那些事儿](http://time.geekbang.org/column/article/70602)
|
||||
|
||||
关于第1期的Sample,同学们遇到的最多的问题是**使用模拟器运行无法获取Crash日志的问题**。
|
||||
|
||||
引起这个问题的缘由比较深层,最直观的原因是使用Clang来编译x86平台下的Breakpad会导致运行出现异常,从而导致无法抓取日志。想要解决这个问题,我们需要先来了解一下NDK集成的编译器。
|
||||
|
||||
NDK集成了两套编译器:GCC和Clang。从NDK r11开始,官方就建议使用Clang,详情可以看[ChangeLog](http://link.zhihu.com/?target=https%3A//github.com/android-ndk/ndk/wiki/Changelog-r11),并且标记GCC为Deprecated,并且从GCC 4.8升级到4.9以后就不再进行更新了。NDK r13开始,默认使用Clang。NDK r16b以后的版本貌似强制开启GCC会引起错误,并将libc++作为默认的STL,而NDK r18干脆就完全删除了GCC。
|
||||
|
||||
由于Clang的编译会引起x86的Breakpad执行异常,所以我们需要切换到GCC下进行编译,步骤如下。
|
||||
|
||||
1.首先将NDK切换到r16b,你可以从[这里](http://developer.android.com/ndk/downloads/older_releases?hl=zh-cn)下载,在里面找到对应你操作系统平台的NDK版本。
|
||||
|
||||
2.在Android Studio里设置NDK路径为ndk-16b的路径。
|
||||
|
||||
3.在练习例子源码的sample和breakpad-build的build.gradle配置里进行如下配置。
|
||||
|
||||
```
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags "-std=c++11"
|
||||
arguments "-DANDROID_TOOLCHAIN=gcc"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个问题是**日志解析工具如何获取**。
|
||||
|
||||
解析Minidump日志主要是使用**minidump_stackwalk**工具,配合使用的工具是**dump_syms**,这个工具可以获取一个so文件的符号表。
|
||||
|
||||
这两项工具需要通过编译Breakpad来获取,有部分同学查到的文章是采用Chrome团队的[depot_tools](http://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html)来进行工具的源码下载、编译操作。depot_tools是个很好用的工具,但是在国内其服务器是无法访问的,所以我们采用直接下载源码编译的方式相对来说比较方便。
|
||||
|
||||
编译Breakpad有一些需要注意的地方,由于Android平台的内核是Linux,Android里的动态链接库的符号表导出工具**dump_syms**需要运行在Linux下(暂时没有找到交叉编译在别的平台上的办法),所以下面的步骤都是在Linux环境(Ubuntu 18.04)下进行的,步骤如下。
|
||||
|
||||
1.先下载[源码](http://github.com/google/breakpad)。
|
||||
|
||||
2.由于源码里没有附带上一些第三方的库,所以现在编译会出现异常,我们需要下载lss库到Breakpad源码目录src/third_party下面。
|
||||
|
||||
```
|
||||
git clone https://chromium.googlesource.com/linux-syscall-support
|
||||
|
||||
```
|
||||
|
||||
3.然后在源码目录下执行。
|
||||
|
||||
```
|
||||
./configure && make
|
||||
make install
|
||||
|
||||
```
|
||||
|
||||
这样我们就可以直接调用**minidump_stackwalk、dump_syms**工具了。
|
||||
|
||||
第三个问题是**如何解析抓取下来的Minidump日志**。
|
||||
|
||||
生成的Crash信息,如果授予Sdcard权限会优先存放在/sdcard/crashDump下,便于我们做进一步的分析。反之会放到目录/data/data/com.dodola.breakpad/files/crashDump下。
|
||||
|
||||
你可以通过adb pull命令拉取日志文件。
|
||||
|
||||
```
|
||||
adb pull /sdcard/crashDump/
|
||||
|
||||
```
|
||||
|
||||
1.首先我们需要从产生Crash的动态库中提取出符号表,以第1期的Sample为例,产生Crash的动态库obj路径在**Chapter01/sample/build/intermediates/cmake/debug/obj下**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/e1/83d31e7582755c8486bf43e01ece48e1.png" alt="">
|
||||
|
||||
此处需要注意一下手机平台,按照运行Sample时的平台取出libcrash-lib.so库进行符号表的dump,然后调用dump_syms工具获取符号表。
|
||||
|
||||
```
|
||||
dump_syms libcrash-lib.so > libcrash-lib.so.sym
|
||||
|
||||
```
|
||||
|
||||
2.建立符号表目录结构。首先打开刚才生成的libcrash-lib.so.syms,找到如下编码。
|
||||
|
||||
```
|
||||
MODULE Linux arm64 322FCC26DA8ED4D7676BD9A174C299630 libcrash-lib.so
|
||||
|
||||
```
|
||||
|
||||
然后建立如下结构的目录Symbol/libcrash-lib.so/322FCC26DA8ED4D7676BD9A174C299630/,将libcrash-lib.so.sym文件复制到该文件夹中。注意,目录结构不能有错,否则会导致符号表对应失败。
|
||||
|
||||
3.完成上面的步骤后,就可以来解析Crash日志了,执行minidump_stackwalk命令。
|
||||
|
||||
```
|
||||
minidump_stackwalk crash.dmp ./Symbol > dump.txt
|
||||
|
||||
```
|
||||
|
||||
4.这样我们获取的crash日志就会有符号表了,对应一下之前没有符号表时候的日志记录。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/c7/252da4578624d2a85420dfb294f0efc7.png" alt="">
|
||||
|
||||
5.如果我们没有原始的obj,那么需要通过libcrash-lib.so的导出符号来进行解析,这里用到的工具是addr2line工具,这个工具存放在$NDK_HOME/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-addr2line下。你要注意一下平台,如果是解析64位的动态库,需要使用aarch64-linux-android-4.9下的addr2line(此处是64位的)。
|
||||
|
||||
```
|
||||
aarch64-linux-android-addr2line -f -C -e libcrash-lib.so 0x5f8
|
||||
Java_com_dodola_breakpad_MainActivity_crash
|
||||
|
||||
```
|
||||
|
||||
6.可以使用GDB来根据Minidump调试出问题的动态库,这里就不展开了,你可以参考[这里](http://www.chromium.org/chromium-os/packages/crash-reporting/debugging-a-minidump)。
|
||||
|
||||
[03 | 内存优化(上):4GB内存时代,再谈内存优化](http://time.geekbang.org/column/article/71277)
|
||||
|
||||
针对这一期的Sample,很多同学询问Sample中经常使用的Hook框架的原理。
|
||||
|
||||
Sample中使用的Hook框架有两种,一种是Inline Hook方案([Substrate](http://github.com/AndroidAdvanceWithGeektime/Chapter03/tree/master/alloctrackSample/src/main/cpp/Substrate)和[HookZz](http://github.com/jmpews/HookZz)),一种是PLT Hook方案([Facebook Hook](http://github.com/facebookincubator/profilo/tree/master/deps/linker)),这两种方案各有优缺点,根据要实现功能的不同采取不同的框架。
|
||||
|
||||
PLT Hook相对Inline Hook的方案要稳定很多,但是它操作的范围只是针对出现在PLT表中的动态链接函数,而Inline Hook可以hook整个so里的所有代码。Inline Hook由于要针对各个平台进行指令修复操作,所以稳定性和兼容性要比PLT Hook差很多。
|
||||
|
||||
关于PLT Hook的内容,你可以看一下《程序员的自我修养:链接、装载与库》这本书,而Inline Hook则需要对ARM、x86汇编,以及各个平台下的过程调用标准([Procedure Call Standard](http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042f/IHI0042F_aapcs.pdf))有很深入的了解。
|
||||
|
||||
第3期里,还有部分同学询问Sample中的函数符号是如何来的。
|
||||
|
||||
首先如果我们要hook一个函数,需要知道这个函数的地址。在Linux下我们获取函数的地址可以通过[dlsym](http://linux.die.net/man/3/dlsym)函数来根据名字获取,动态库里的函数名称一般都会通过Name Mangling技术来生成一个符号名称(具体细节可以看这篇[文章](http://www.int0x80.gr/papers/name_mangling.pdf)),所以第3期的Sample里出现了很多经过转换的函数名。
|
||||
|
||||
```
|
||||
void *hookRecordAllocation26 = ndk_dlsym(handle,
|
||||
"_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj");
|
||||
|
||||
void *hookRecordAllocation24 = ndk_dlsym(handle, "_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPPNS_6mirror6ObjectEj");
|
||||
|
||||
```
|
||||
|
||||
这样的函数可以通过[c++filt](http://linux.die.net/man/1/c++filt)工具来进行反解,我在这里给你提供一个[网页版的解析工具](http://demangler.com/)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/7a/12b8af0f6363fb06f8ee10d809602c7a.png" alt="">
|
||||
|
||||
我们需要阅读系统源码来寻找Hook点,比如第3期里Hook的方法都是虚拟机内存分配相关的函数。需要注意的一点是,要先确认是否存在该函数的符号,很多时候由于强制Inline的函数或者过于短小的函数可能没有对应的符号,这时候就需要使用objdump、readelf、nm或者各种disassembly工具进行查看,根据类名、函数名查找一下有没有对应的符号。
|
||||
|
||||
## 总结
|
||||
|
||||
第1期的Breakpad的Sample主要是展示Native Crash的日志如何获取和解读。根据业务的不同,我们平时接触的很多都是Java的异常,在业务不断稳定、代码异常处理逐渐完善的情况下,Java异常的量会逐渐减少,而Native Crash的问题会逐步的显现出来。一般比较大型的应用,都会或多或少包含一些Native库,比如加密、地图、日志、Push等模块,由于多方面的原因,这些代码会产生一些异常,我们需要了解Crash日志来排查解决,又或者说绕过这些异常,进而提高应用的稳定性。
|
||||
|
||||
通过Breakpad的源码,以帮你了解到信号捕获、ptrace的使用、进程fork/clone机制、主进程子进程通信、unwind stack、system info的获取、memory maps info的获取、symbol的dump,以及symbol反解等,通过源码我们可以学习到很多东西。
|
||||
|
||||
第2期的Sample提供了解决系统异常的一种思路,使用反射或者代理机制来解决系统代码中的异常。需要说明的是FinalizerWatchdog机制并不是系统异常,而是系统的一种防护机制。很多时候我们会遇到一些系统Framework的bug产生的Crash,比如很常见的Toast异常等,这些异常虽然不属于本应用产生的,但也会影响用户的使用,解决这种异常可以考虑一下这个Sample中的思路。
|
||||
|
||||
第3期的Sample描述了一个简单的Memory Allocation Trace监控模块,这个模块主要是配合自动性能分析体系来自动发现问题,比如大对象的分配数量监控、分配对象的调用栈分析等。它可以做的事很多,同学们可以根据这个思路,根据自己的业务来开发适合自己的工具。
|
||||
|
||||
从第3期的Sample的代码,你可以学习到Inline Hook Substrate框架的使用,使用ndk_dlopen来绕过Android Classloader-Namespace Restriction机制,以及C++里的线程同步等。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
114
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第2期.md
Normal file
114
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第2期.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 热点问题答疑第2期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/ef/4ee7f58db0aeed08c3bc148e8a6fc7ef.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。今天我们基于[专栏第5期](http://time.geekbang.org/column/article/71982)的练习Sample以及热点问题,我来给你做答疑。有关上一期答疑,你可以点击[这里](http://time.geekbang.org/column/article/73068)查看。
|
||||
|
||||
为了让同学们可以进行更多的实践,专栏第5期Sample采用了让你自己实现部分功能的形式,希望可以让你把专栏里讲的原理可以真正用起来。
|
||||
|
||||
前面几期已经有同学通过Pull request提交了练习作业,这里要给每位参与练习、提交作业的同学点个赞。
|
||||
|
||||
第5期的作业是根据系统源码来完成一个CPU数据的采集工具,并且在结尾我们提供了一个案例让你进行分析。我已经将例子的实现提交到了[GitHub](http://github.com/AndroidAdvanceWithGeektime/Chapter05)上,你可以参考一下。
|
||||
|
||||
在文中提到,“当发生ANR的时候,Android系统会打印CPU相关的信息到日志中,使用的是[ProcessCpuTracker.java](http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/java/com/android/internal/os/ProcessCpuTracker.java)”。ProcessCpuTracker的实现主要依赖于Linux里的/proc伪文件系统(in-memory pseudo-file system),主要使用到了/proc/stat、/proc/loadavg、/proc/[pid]/stat、/proc/[pid]/task相关的文件来读取数据。在Linux中有很多程序都依赖/proc下的数据,比如top、netstat、ifconfig等,Android里常用的procrank、librank、procmem等也都以此作为数据来源。关于/proc目录的结构在Linux Man Pages里有很详细的说明,在《Linux/Unix系统编程手册》这本书里,也有相关的中文说明。
|
||||
|
||||
关于proc有一些需要说明的地方,在不同的Linux内核中,该目录下的内容可能会有所不同,所以如果要使用该目录下的数据,可能需要做一些版本上的兼容处理。并且由于Linux内核更新速度较快,文档的更新可能还没有跟上,这就会导致一些数据和文档中说明的不一致,尤其是大量的以空格隔开的数字数据。这些文件其实并不是真正的文件,你用ls查看会发现它们的大小都是0,这些文件都是系统虚拟出来的,读取这些文件并不会涉及文件系统的一系列操作,只有很小的性能开销,而现阶段并没有类似文件系统监听文件修改的回调,所以需要采用轮询的方式来进行数据采集。
|
||||
|
||||
下面我们来看一下专栏文章结尾的案例分析。下面是这个示例的日志数据,我会通过分析数据来猜测一下是什么原因引起,并用代码还原这个情景。
|
||||
|
||||
```
|
||||
usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):
|
||||
System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle
|
||||
CPU Core: 8
|
||||
Load Average: 8.74 / 7.74 / 7.36
|
||||
|
||||
Process:com.sample.app
|
||||
50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965
|
||||
|
||||
Threads:
|
||||
43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094
|
||||
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
|
||||
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
|
||||
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
|
||||
\.\.\.
|
||||
|
||||
```
|
||||
|
||||
上面的示例展示了一段在5秒时间内CPU的usage的情况。初看这个日志,你可以收集到几个重要信息。
|
||||
|
||||
1.在System Total部分user占用不多,CPU idle很高,消耗多在kernel和iowait。
|
||||
|
||||
2.CPU是8核的,Load Average大约也是8,表示CPU并不处于高负载情况。
|
||||
|
||||
3.在Process里展示了这段时间内sample app的CPU使用情况:user低,kernel高,并且有4965次page faults。
|
||||
|
||||
4.在Threads里展示了每个线程的usage情况,当前只有singleThread处于R状态,并且当前线程产生了3096次page faults,其他的线程包括主线程(Sample日志里可见的)都是处于S状态。
|
||||
|
||||
根据内核中的线程状态的[宏的名字](http://elixir.bootlin.com/linux/v4.8/source/include/linux/sched.h#L207)和缩写的对应,R值代表线程处于Running或者Runnable状态。Running状态说明线程当前被某个Core执行,Runnable状态说明线程当前正在处于等待队列中等待某个Core空闲下来去执行。从内核里看两个状态没有区别,线程都会持续执行。日志中的其他线程都处于S状态,S状态代表[TASK_INTERRUPTIBLE](http://elixir.bootlin.com/linux/v4.8/ident/TASK_INTERRUPTIBLE),发生这种状态是线程主动让出了CPU,如果线程调用了sleep或者其他情况导致了自愿式的上下文切换(Voluntary Context Switches)就会处于S状态。常见的发生S状态的原因,可能是要等待一个相对较长时间的I/O操作或者一个IPC操作,如果一个I/O要获取的数据不在Buffer Cache或者Page Cache里,就需要从更慢的存储设备上读取,此时系统会把线程挂起,并放入一个等待I/O完成的队列里面,在I/O操作完成后产生中断,线程重新回到调度序列中。但只根据文中这个日志,并不能判定是何原因所引起的。
|
||||
|
||||
还有就是SingleThread的各项指标都相对处于一个很高的情况,而且产生了一些faults。page faluts分为三种:minor page fault、major page fault和invalid page fault,下面我们来具体分析。
|
||||
|
||||
minor page fault是内核在分配内存的时候采用一种Lazy的方式,申请内存的时候并不进行物理内存的分配,直到内存页被使用或者写入数据的时候,内核会收到一个MMU抛出的page fault,此时内核才进行物理内存分配操作,MMU会将虚拟地址和物理地址进行映射,这种情况产生的page fault就是minor page fault。
|
||||
|
||||
major page fault产生的原因是访问的内存不在虚拟地址空间,也不在物理内存中,需要从慢速设备载入,或者从Swap分区读取到物理内存中。需要注意的是,如果系统不支持[zRAM](http://source.android.com/devices/tech/perf/low-ram)来充当Swap分区,可以默认Android是没有Swap分区的,因为在Android里不会因为读取Swap而发生major page fault的情况。另一种情况是mmap一个文件后,虚拟内存区域、文件磁盘地址和物理内存做一个映射,在通过地址访问文件数据的时候发现内存中并没有文件数据,进而产生了major page fault的错误。
|
||||
|
||||
根据page fault发生的场景,虚拟页面可能有四种状态:
|
||||
|
||||
- 第一种,未分配;
|
||||
- 第二种,已经分配但是未映射到物理内存;
|
||||
- 第三种,已经分配并且已经映射到物理内存;
|
||||
- 第四种,已经分配并映射到Swap分区(在Android中此种情况基本不存在)。
|
||||
|
||||
通过上面的讲解并结合page fault数据,你可以看到SingleThread你一共发生了3094次fault,根据每个页大小为4KB,可以知道在这个过程中SingleThread总共分配了大概12MB的空间。
|
||||
|
||||
下面我们来分析iowait数据。既然有iowait的占比,就说明在5秒内肯定进行了I/O操作,并且iowait占比还是比较大的,说明当时可能进行了大量的I/O操作,或者当时由于其他原因导致I/O操作缓慢。
|
||||
|
||||
从上面的分析可以猜测一下具体实现,并且在读和写的时候都有可能发生。由于我的手机写的性能要低一些,比较容易复现,所以下面的代码基于写操作实现。
|
||||
|
||||
```
|
||||
File f = new File(getFilesDir(), "aee.txt");
|
||||
|
||||
FileOutputStream fos = new FileOutputStream(f);
|
||||
|
||||
byte[] data = new byte[1024 * 4 * 3000];//此处分配一个12mb 大小的 byte 数组
|
||||
|
||||
for (int i = 0; i < 30; i++) {//由于 IO cache 机制的原因所以此处写入多次cache,触发 dirty writeback 到磁盘中
|
||||
Arrays.fill(data, (byte) i);//当执行到此处的时候产生 minor fault,并且产生 User cpu useage
|
||||
fos.write(data);
|
||||
}
|
||||
fos.flush();
|
||||
fos.close();
|
||||
|
||||
```
|
||||
|
||||
上面的代码抓取到的CPU数据如下。
|
||||
|
||||
```
|
||||
E/ProcessCpuTracker: CPU usage from 5187ms to 121ms ago (2018-12-28 08:28:27.186 to 2018-12-28 08:28:32.252):
|
||||
40% 24155/com.sample.processtracker(R): 14% user + 26% kernel / faults: 5286 minor
|
||||
thread stats:
|
||||
35% 24184/SingleThread(S): 11% user + 24% kernel / faults: 3055 minor
|
||||
2.1% 24174/RenderThread(S): 1.3% user + 0.7% kernel / faults: 384 minor
|
||||
1.5% 24155/.processtracker(R): 1.1% user + 0.3% kernel / faults: 95 minor
|
||||
0.1% 24166/HeapTaskDaemon(S): 0.1% user + 0% kernel / faults: 1070 minor
|
||||
|
||||
100% TOTAL(): 3.8% user + 7.8% kernel + 11% iowait + 0.1% irq + 0% softirq + 76% idle
|
||||
Load: 6.31 / 6.52 / 6.66
|
||||
|
||||
```
|
||||
|
||||
可以对比Sample中给出的数据,基本一致。
|
||||
|
||||
通过上面的说明,你可以如法炮制去分析ANR日志中相关的数据来查找性能瓶颈,比如,如果产生大量的major page fault其实是不太正常的,或者iowait过高就需要关注是否有很密集的I/O操作。
|
||||
|
||||
## 相关资料
|
||||
|
||||
- [低内存配置](http://source.android.com/devices/tech/perf/low-ram)
|
||||
- [iowait的形成原因和内核分析](http://oenhan.com/iowait-wa-vmstat)
|
||||
- [page fault带来的性能问题](http://yq.aliyun.com/articles/55820)
|
||||
- [Linux工具快速教程](http://linuxtools-rst.readthedocs.io/zh_CN/latest/index.html)
|
||||
- [Android: memory management insights, part I](http://fixbugfix.blogspot.com/2015/11/android-memory-management-insights-part.html)
|
||||
- [Linux 2.6调度系统分析](http://www.ibm.com/developerworks/cn/linux/kernel/l-kn26sch/index.html?mhq=iowait&mhsrc=ibmsearch_a)
|
||||
- 《性能之巅》
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
98
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第3期.md
Normal file
98
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第3期.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 热点问题答疑第3期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/fb/6743895ed4b5615995b787a754d154fb.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。又到了答疑的时间,今天我将围绕卡顿优化这个主题,和你探讨一下专栏第6期和补充篇的两个Sample的实现。
|
||||
|
||||
专栏第6期的Sample完全来自于Facebook的性能分析框架[Profilo](https://github.com/facebookincubator/profilo),主要功能是收集线上用户的atrace日志。关于atrace相信我们都比较熟悉了,平时经常使用的systrace工具就是封装了atrace命令来开启ftrace事件,并读取ftrace缓冲区生成可视化的HTML日志。这里多说一句,ftrace是Linux下常用的内核跟踪调试工具,如果你不熟悉的话可以返回第6期文稿最后查看ftrace的介绍。Android下的atrace扩展了一些自己使用的categories和tag,这个Sample获取的就是通过atrace的同步事件。
|
||||
|
||||
Sample的实现思路其实也很简单,有两种方案。
|
||||
|
||||
第一种方案:hook掉atrace写日志时的一系列方法。以Android 9.0的代码为例写入ftrace日志的代码在[trace-dev.cpp](http://androidxref.com/9.0.0_r3/xref/system/core/libcutils/trace-dev.cpp)里,由于每个版本的代码有些区别,所以需要根据系统版本做一些区分。
|
||||
|
||||
第二种方案:也是Sample里所使用的方案,由于所有的atrace event写入都是通过[/sys/kernel/debug/tracing/trace_marker](http://androidxref.com/9.0.0_r3/xref/system/core/libcutils/trace-container.cpp#85),atrace在初始化的时候会将该路径fd的值写入[atrace_marker_fd](http://androidxref.com/9.0.0_r3/s?defs=atrace_marker_fd&project=system)全局变量中,我们可以通过dlsym轻易获取到这个fd的值。关于trace_maker这个文件我需要说明一下,这个文件涉及ftrace的一些内容,ftrace原来是内核的事件trace工具,并且ftrace文档的开头已经写道
|
||||
|
||||
>
|
||||
Ftrace is an internal tracer designed to help out developers and designers of systems to find what is going on inside the kernel.
|
||||
|
||||
|
||||
从文档中可以看出来,ftrace工具主要是用来探查outside of user-space的性能问题。不过在很多场景下,我们需要知道user space的事件调用和kernel事件的一个先后关系,所以ftrace也提供了一个解决方法,也就是提供了一个文件trace_marker,往该文件中写入内容可以产生一条ftrace记录,这样我们的事件就可以和kernel的日志拼在一起。但是这样的设计有一个不好的地方,在往文件写入内容的时候会发生system call调用,有系统调用就会产生用户态到内核态的切换。这种方式虽然没有内核直接写入那么高效,但在很多时候ftrace工具还是很有用处的。
|
||||
|
||||
由此可知,用户态的事件数据都是通过trace_marker写入的,更进一步说是通过write接口写入的,那么我们只需要hook住write接口并过滤出写入这个fd下的内容就可以了。这个方案通用性比较高,而且使用PLT Hook即可完成。
|
||||
|
||||
下一步会遇到的问题是,想要获取atrace的日志,就需要设置好atrace的category tag才能获取到。我们从源码中可以得知,判断tag是否开启,是通过atrace_enabled_tags & tag来计算的,如果大于0则认为开启,等于0则认为关闭。下面我贴出了部分atrace_tag的值,你可以看到,判定一个tag是否是开启的,只需要tag值的左偏移数的位值和atrace_enabled_tags在相同偏移数的位值是否同为1。其实也就是说,我将atrace_enabled_tags的所有位都设置为1,那么在计算时候就能匹配到任何的atrace tag。
|
||||
|
||||
```
|
||||
#define ATRACE_TAG_NEVER 0
|
||||
#define ATRACE_TAG_ALWAYS (1<<0)
|
||||
#define ATRACE_TAG_GRAPHICS (1<<1)
|
||||
#define ATRACE_TAG_INPUT (1<<2)
|
||||
#define ATRACE_TAG_VIEW (1<<3)
|
||||
#define ATRACE_TAG_WEBVIEW (1<<4)
|
||||
#define ATRACE_TAG_WINDOW_MANAGER (1<<5)
|
||||
#define ATRACE_TAG_ACTIVITY_MANAGER (1<<6)
|
||||
#define ATRACE_TAG_SYNC_MANAGER (1<<7)
|
||||
#define ATRACE_TAG_AUDIO (1<<8)
|
||||
#define ATRACE_TAG_VIDEO (1<<9)
|
||||
#define ATRACE_TAG_CAMERA (1<<10)
|
||||
#define ATRACE_TAG_HAL (1<<11)
|
||||
#define ATRACE_TAG_APP (1<<12)
|
||||
|
||||
```
|
||||
|
||||
下面是我用atrace抓下来的部分日志。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/b8/f9b273a45eeb643f976b48147ce1b3b8.png" alt="">
|
||||
|
||||
看到这里有同学会问,Begin和End是如何对应上的呢?要回答这个问题,首先要先了解一下这种记录产生的场景。这个日志在Java端是由Trace.traceBegin和Trace.traceEnd产生的,在使用上有一些硬性要求:这两个方法必须成对出现,否则就会造成日志的异常。请看下面的系统代码示例。
|
||||
|
||||
```
|
||||
void assignWindowLayers(boolean setLayoutNeeded) {
|
||||
2401 Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "assignWindowLayers");//关注此处事件开始代码
|
||||
2402 assignChildLayers(getPendingTransaction());
|
||||
2403 if (setLayoutNeeded) {
|
||||
2404 setLayoutNeeded();
|
||||
2405 }
|
||||
2406
|
||||
2411 scheduleAnimation();
|
||||
2412 Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);//事件结束
|
||||
2413 }
|
||||
2414
|
||||
|
||||
```
|
||||
|
||||
所以我们可以认为B下面紧跟的E就是事件的结束标志,但很多情况下我们会遇到上面日志中所看到的两个B连在一起,紧跟的两个E我们不知道分别对应哪个B。此时我们需要看一下产生事件的CPU是哪个,并且看一下产生事件的task_pid是哪个,也就是最前面的InputDispatcher-1944,这样我们就可以对应出来了。
|
||||
|
||||
接下来我们一起来看看补充篇的Sample,它的目的是希望让你练习一下如何监控线程创建,并且打印出创建线程的Java方法。Sample的实现比较简单,主要还是依赖PLT Hook来hook线程创建时使用的主要函数pthread_create。想要完成这个Sample你需要知道Java线程是如何创建出来的,并且还要理解Java线程的执行方式。需要特别说明的是,其实这个Sample也存在一个缺陷。从虚拟机的角度看,线程其实又分为两种,一种是Attached线程,我习惯按照.Net的叫法称其为托管线程;一种是Unattached线程,为非托管线程。但底层都是依赖POSIX Thread来实现的,从pthread_create里无法区分该线程是否是托管线程,也有可能是Native直接开启的线程,所以有可能并不能对应到创建线程时候的Java Stack。
|
||||
|
||||
关于线程,我们在日常监控中可能并不太关心线程创建时候的状况,而区分线程可以通过提前设置Thread Name来实现。举个例子,比如在出现OOM时发现是发生在pthread_create执行的时候,说明当前线程数可能过多,一般我们会在OOM的时候采集当前线程数和线程堆栈信息,可以看一下是哪个线程创建过多,如果指定了线程名称则很快就能查找出问题所在。
|
||||
|
||||
对于移动端的线程来说,我们大多时候更关心的是主线程的执行状态。因为主线程的任何耗时操作都会影响操作界面的流畅度,所以我们经常把看起来比较耗时的操作统统都往子线程里面丢,虽然这种操作虽然有时候可能很有效,但还可能会产生一些我们平时很少遇到的异常情况。比如我曾经遇到过,由于用户手机的I/O性能很低,大量的线程都在wait io;或者线程开启的太多,导致线程Context switch过高;又或者是一个方法执行过慢,导致持有锁的时间过长,其他线程无法获取到锁等一系列异常的情况,
|
||||
|
||||
虽然线程的监控很不容易,但并不是不能实现,只是实现起来比较复杂并且要考虑兼容性。比如我们可能比较关心一个Lock当前有多少线程在等待锁释放,就需要先获取到这个Object的MirrorObject,然后构造一个MonitorInfo,之后获取到waiters的列表,而这个列表里就存储了等待锁释放的线程。你看其实过程也并不复杂,只是在计算地址偏移量的时候需要做一些处理。
|
||||
|
||||
当然还有更细致的优化,比如我们都知道Java里是有轻量级锁和重量级锁的一个转换过程,在ART虚拟机里被称为ThinLocked和FatLocked,而转换过程是通过Monitor::Inflate和Monitor::Deflate函数来实现的。此时我们可以监控Monitor::Inflate调用时monitor指向的Object,来判断是哪段代码产生了“瘦锁”到“胖锁”转换的过程,从而去做一些优化。接下来要做优化,需要先知晓ART虚拟机锁转换的机制,如果当前锁是瘦锁,持有该锁的线程再一次获取这个锁只递增了lock count,并未改变锁的状态。但是lock count超过4096则会产生瘦锁到胖锁的转换,如果当前持有该锁的线程和进入MontorEnter的线程不是同一个的情况下就会产生锁争用的情况。ART虚拟机为了减少胖锁的产生做了一些优化,虚拟机先通过[sched_yield](http://man7.org/linux/man-pages/man2/sched_yield.2.html)让出当前线程的执行权,操作系统在后面的某个时间再次调度该线程执行,从调用sched_yield到再次执行的时候计算时间差,在这个时间差里占用该锁的线程可能会释放对锁的占用,那么调用线程会再次尝试获取锁,如果获取锁成功的话则会从 Unlocked状态直接转换为ThinLocked状态,不会产生FatLocked状态。这个过程持续50次,如果在50次循环内无法获取到锁则会将瘦锁转为胖锁。如果我们对某部分的多线程代码性能敏感,则希望锁尽量持续在瘦锁的状态,我们可以减少同步块代码的粒度,尽量减少很多线程同时争抢锁,可以监控Inflate函数调用情况来判断优化效果。
|
||||
|
||||
最后,还有同学对在Crash状态下获取Java线程堆栈的方法比较感兴趣,我在这里简单讲一下,后面会有专门的文章介绍这部分内容。
|
||||
|
||||
一种方案是使用ThreadList::ForEach接口间接实现,具体的逻辑可以看[这里](http://androidxref.com/9.0.0_r3/xref/art/runtime/trace.cc#286)。另一种方案是 Profilo里的[Unwinder](https://github.com/facebookincubator/profilo/blob/master/cpp/profiler/unwindc/)机制,这种实现方式就是模拟[StackVisitor](http://androidxref.com/9.0.0_r3/xref/art/runtime/stack.cc#766)的逻辑来实现。
|
||||
|
||||
这两期反馈的问题不多,答疑的内容也可以算作对正文的补充,如果有同学想多了解虚拟机的机制或者其他性能相关的问题,欢迎你给我留言,我也会在后面的文章和你聊聊这些话题,比如有同学问到的ART下GC的详细逻辑之类的问题。
|
||||
|
||||
## 相关资料
|
||||
|
||||
<li>
|
||||
[ftrace kernel doc](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/trace/ftrace.rst)
|
||||
</li>
|
||||
<li>
|
||||
[ftrace的使用](https://source.android.google.cn/devices/tech/debug/ftrace)
|
||||
</li>
|
||||
<li>
|
||||
[A look at ftrace](https://lwn.net/Articles/322666/)
|
||||
</li>
|
||||
|
||||
## 福利彩蛋
|
||||
|
||||
今天为认真提交作业完成练习的同学,送出第二波“学习加油礼包”。@Seven同学提交了第5期的[作业](https://github.com/AndroidAdvanceWithGeektime/Chapter05/pull/1),送出“极客周历”一本,其他同学如果完成了练习千万别忘了通过Pull request提交哦。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。
|
||||
|
||||
|
||||
39
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第4期.md
Normal file
39
极客时间专栏/Android开发高手课/练习Sample跑起来/练习Sample跑起来 | 热点问题答疑第4期.md
Normal file
@@ -0,0 +1,39 @@
|
||||
<audio id="audio" title="练习Sample跑起来 | 热点问题答疑第4期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/04/4a0b0d36eab51f49f932bb3a72102704.mp3"></audio>
|
||||
|
||||
你好,我是孙鹏飞。今天我们回到专栏第7期和第8期,来看看课后练习Sample的运行需要注意哪些问题。另外我结合同学们留言的疑问,也来谈谈文件顺序对I/O的影响,以及关于Linux学习我的一些方法和建议。
|
||||
|
||||
[专栏第7期](http://time.geekbang.org/column/article/73651)的Sample借助于systrace工具,通过字节码处理框架对函数插桩来获取方法执行的trace。这个Sample实现相当完整,你在日常工作也可以使用它。
|
||||
|
||||
这个Sample使用起来虽然非常简单,但其内部的实现相对来说是比较复杂的。它的实现涉及Gradle Transform、Task实现、增量处理、ASM字节码处理、mapping文件使用,以及systrace工具的使用等。
|
||||
|
||||
对于Gradle来说,我们应该比较熟悉,它是Android平台下的构建工具。对于平时使用来说,我们大多时候只需要关注Android Gradle Plugin的一些参数配置就可以实现很多功能了,官方文档已经提供了很详细的参数设置[说明](https://developer.android.com/studio/build/?hl=zh-cn)。对于一些需要侵入打包流程的操作,就需要我们实现自己的Task或者Transform代码来完成,比如处理Class和JAR包、对资源做一些处理等。
|
||||
|
||||
Gradle学习的困难更多来自于Android Gradle Plugin对Gradle做的一些封装扩展,而这部分Google并没有提供很完善的文档,并且每个版本都有一些接口上的变动。对于这部分内容的学习,我主要是去阅读别人实现的Gradle工具代码和[Android Gradle Plugin代码](https://android.googlesource.com/platform/tools/base/+/studio-3.2.1/build-system/)。
|
||||
|
||||
关于这期的Sample实现,有几个可能产生疑问的地方我们来探讨一下。
|
||||
|
||||
这个Sample的Gradle插件是发布到本地Maven库的,所以如果没有执行发布直接编译需要先发布插件库到本地Maven中才能执行编译成功。
|
||||
|
||||
另一个可能遇到问题的是,如果你想把Sample使用到其他项目,需要自己将SampleApp中其p的e.systrace.TraceTag”类移植到自己的项目中,否则会产生编译错误。
|
||||
|
||||
对于字节码处理,在Sample中主要使用了ASM框架来处理。市面上关于字节码处理的框架有很多,常见的有[ASM和Javassist框架](https://www.infoq.cn/article/Living-Matrix-Bytecode-Manipulation),其他的框架你可以使用“Java bytecode manipulation”关键字在Google上搜索。使用字节码处理框架需要对字节码有比较深入的了解,要提醒你的是这里的字节码不是Dalvik bytecode而是Java bytecode。对于字节码的学习,你可以参考[官方文档](https://docs.oracle.com/javase/specs/jvms/se8/html/index.html)和《Java虚拟机规范》,里面对字节码的执行规则和指令说明都有很详细的描述。并且还可以配合javap命令查看反编译的字节码对应的源码,这样学习下来会有很好的效果。字节码处理是一个很细微的操作,稍有失误就会产生编译错误、执行错误或者Crash的情况,里面需要注意的地方也非常多,比如Try Catch Block对操作数栈的影响、插入的代码对本地变量表和操作数栈的影响等。
|
||||
|
||||
实现AOP的另一种方是可以接操作Dex文件进行Dalvik bytecode字节码注入,关于这种实现方式可以使用[dexer](https://android.googlesource.com/platform/tools/dexter/)库来完成,在Facebook的[Redex](https://github.com/facebook/redex)中也提供了针对dex的AOP功能。
|
||||
|
||||
下面我们来看[专栏第8期](http://time.geekbang.org/column/article/74044)。我从文章留言里看到,有同学关于数据重排序对I/O性能的影响有些疑问,不太清楚优化的原理。其实这个优化原理理解起来是很容易的,在进行文件读取的操作过程中,系统会读取比预期更多的文件内容并缓存在Page Cache中,这样下一次读请求到来时,部分页面直接从Page Cache读取,而不用再从磁盘中获取数据,这样就加速了读取的操作。在[《支付宝App构建优化解析》](https://mp.weixin.qq.com/s/79tAFx6zi3JRG-ewoapIVQ)里“原理”一节中已经有比较详细的描述,我就不多赘述了。如果你对“预读”感兴趣的话,我给你提供一些资料,可以深入了解一下。
|
||||
|
||||
预读(readhead)机制的系统源码在[readhead.c](https://github.com/torvalds/linux/blob/master/mm/readahead.c)文件中。需要说明的是,预读机制可能在不同系统版本中有所变化,所以下面我提供的资料大多是基于 Linux 2.6.x的内核,在这以后的系统版本可能对 readhead 机制有修改,你需要留意一下。
|
||||
|
||||
关于预读机制详细的算法说明可以看[《Linux readahead: less tricks for more》](https://www.kernel.org/doc/ols/2007/ols2007v2-pages-273-284.pdf)和[《Sequential File Prefetching In Linux》](http://www.ece.eng.wayne.edu/~sjiang/Tsinghua-2010/linux-readahead.pdf)、[《Linux内核的文件预读(readahead)》](http://blog.51cto.com/wangergui/1841294) 这三篇文档。
|
||||
|
||||
从专栏前几篇的正文看,很多优化的内容是从Linux的机制入手的,如果你对Linux的机制和优化不了解的话,是不太容易想到这些方案的。举个例子,专栏文章提到的小文件系统是运行在用户态的代码,底层依然依赖现存文件系统提供的功能,因此需要深入了解Linux VFS、ext4的实现,以及它们的优缺点和原理,这样我们才能发现为什么大量的小文件依赖现存的文件系统管理是存在性能缺陷的,以及下一步如何填补这些性能缺陷。
|
||||
|
||||
作为Android开发工程师,我们该何学习Linux呢?我其实不建议上来就直接阅读系统源码分析相关的书,我建议是从理解操作系统概念开始,推荐两本操作系统相关的书:《深入理解计算机系统》和《计算机系统 系统架构与操作系统的高度集成》。Linux的系统实现其实和传统的操作系统概念在细节上会有不小的差别,再推荐一本解析Linux操作系统的书《操作系统之编程观察》,这本书结合源码对Linux的各方面机制都进行和很详细的分析。
|
||||
|
||||
对于从事Android开发的同学来说,确实很有必要深入了解Linux系统相关的知识,因为Android里很多特性都是依赖底层基础系统的,就比如我刚刚提到的“预读”机制,不光可以用在Android的资源加载上,也可以拓展到Flutter的资源加载上。假如我们以后面对一个不是Linux内核的系统,比如Fuchsia OS,也可以根据已经掌握的系统知识套用到现有的操作系统上,因为像内存管理、文件系统、信号机制、进程调度、系统调用、中断机制、驱动等内容都是共通的,在迁移到新的系统上的时候可以有一个全局的学习视角,帮助我们快速上手。对于操作系统内容,我的学习路线是先熟悉系统机制,然后熟悉系统提供的各个方向的接口,比如I/O操作、进程创建、信号中断处理、线程使用、epoll、通信机制等,按照《UNIX环境高级编程》这本书的内容一步步的走就可以完成这一步骤,熟悉之后可以按照自己的节奏,再去学习自己比较感兴趣的模块。此时可以找一本源码分析的书再去阅读,比如想了解fork机制的实现、I/O操作的read和write在内核态的调度执行,像这些问题就需要有目的性的进行挖掘。
|
||||
|
||||
上面这个学习路线是我在学习过程中不断踩坑总结出来的一些经验,对于操作系统我也只是个初学者,也欢迎你留言说说自己学习的经验和问题,一起切磋进步。
|
||||
|
||||
最后送出3本“极客周历”给用户故事“[专栏学得苦?可能是方法没找对](http://time.geekbang.org/column/article/77342)”留言点赞数前三的同学,分别是@坚持远方、@蜗牛、@JIA,感谢同学们的参与。
|
||||
|
||||
|
||||
55
极客时间专栏/Android开发高手课/结束语/结束语 | 移动开发的今天和明天.md
Normal file
55
极客时间专栏/Android开发高手课/结束语/结束语 | 移动开发的今天和明天.md
Normal file
@@ -0,0 +1,55 @@
|
||||
<audio id="audio" title="结束语 | 移动开发的今天和明天" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/7e/e27d61a434fc9b7254e98a4b72388a7e.mp3"></audio>
|
||||
|
||||
不知不觉,“Android开发高手课”上线更新到现在,已经陪伴了大家140多天,转眼间到了不得不说“再见”的时候。回想半年前开始筹备这个专栏时,当时焦虑和浮躁是移动开发者比较普遍的心态,而作为移动开发者的一份子,希望通过这个专栏可以分享一些我的经验,帮你克服焦虑,拥抱未来无限可能。
|
||||
|
||||
我的专栏希望通过质量、效率以及架构三个角度来说明一个移动开发高手应该具备的特质。“高质量开发”模块,希望让你知道Android开发并不是那么简单,一个Android高手需要具备从上层到底层的“通关”能力。[Flutter](https://mp.weixin.qq.com/s/RNhdYtoQ8RQcjIXJReGZWA)就是一个非常好的例子,这里建议你回顾一下[《想成为Android高手,你需要搞定这三个问题》](https://time.geekbang.org/column/article/81812)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/f7/84602997577a3461deb840f44a1a0ff7.png" alt="">
|
||||
|
||||
稳定性、内存、网络、渲染、I/O…你可以选择其中一两个点深入往底层走走,打造自身的技术壁垒,当团队内部如果遇到相关问题(比如虚拟机、渲染、数据库)的时候,第一时间就会想到你来解决。
|
||||
|
||||
而“高效开发”模块希望告诉你一个开发高手不仅仅有“深度”,还需要有“广度”。移动开发早已经过了“单兵作战”的年代了,服务化、前后端一体化越来越重要,这里也推荐你回顾一下[《做一名有高度的移动开发工程师》](https://time.geekbang.org/column/article/86258)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/b7/5d8aa64ef37634513a8acd6015184eb7.png" alt="">
|
||||
|
||||
这里要求我们学会跳出客户端的范畴,具备全链路优化的思维。一个真正的开发高手应该不仅能把性能优化多少倍,而且还能带动整个团队提升开发效率。
|
||||
|
||||
最后一个模块是“架构演进”,我希望告诉你架构是为性能和效率服务的。无论是H5、React Native/Weex、小程序、Flutter,还是类似游戏、音视频、AI这些移动开发“新大陆”,它们都是殊途同归。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/64/ac393ecb24b8604e106463bec5b98b64.png" alt="">
|
||||
|
||||
对于移动开发的今日,在学完专栏以后,我希望你能体会到下面这两点:
|
||||
|
||||
<li>
|
||||
**移动开发并不简单**。想成为一名移动开发高手并不简单,它需要非常深入地学习。当然学完专栏几十篇文章,你也不可能立刻就成为了一名高手,俗话说“师傅领进门,修行靠个人”,在专栏以外你还需要进一步深入学习更多的知识,积累更丰富的经验。
|
||||
</li>
|
||||
<li>
|
||||
**移动开发生命力依然顽强**。移动开发很有前途,而且这个领域不仅局限于Android开发。我们的出路其实有很多很多,只要我们肯拿出折腾Android的热情,用从上到下“通关”的钻研精神,无论是转向iOS,还是游戏、前端等其他的领域,我相信绝对都是“降维打击”。
|
||||
</li>
|
||||
|
||||
但是不可否认,如今的移动开发已经不再是“风口上的猪”,对于初级移动开发者的岗位越来越少,大厂的要求也随之变得越来越严格。类似微信、头条、阿里,他们都对候选人在算法和底层基础知识考究得非常深入。
|
||||
|
||||
但是我相信只要你认真学习完这个专栏,对面试大厂会有非常大的帮助。专栏里提到的很多知识点,目前其实很多大厂也还做得不够完善。正如前面所说,你可以找其中一两个点作为进入大厂的突破口。
|
||||
|
||||
而对于Android的未来,个人认为会有下面两个变化:
|
||||
|
||||
<li>
|
||||
**合规和用户隐私越发重要**。无论被称为史上最严厉的欧盟GDPR法令,还是Android Q用户唯一标识、权限等[最新限制](https://developer.android.google.cn/preview/privacy),都可以清楚看到未来对用户隐私和权限更严格限制的趋势。
|
||||
</li>
|
||||
<li>
|
||||
**中国的声音会越来越大**。以往我们总说Copy to China,现在更多的是Copy from China。无论是Google还是苹果,都无法忽视我们13亿人的巨大市场。比如说Android Q就为了中国的热修复框架,专门提供[新的接口](https://developer.android.com/reference/android/app/AppComponentFactory)。
|
||||
</li>
|
||||
|
||||
对于移动开发的未来,可能未来的新设备将颠覆手机,也许是嵌入式设备、智能眼镜、车载设备等,但这些设备仍然还是移动设备。从当下的技术发展来看,我很期待今年的Facebook [F8](https://www.f8.com/schedule)和Google I/O大会(我今年也会去现场观摩,欢迎面基~),Facebook今年计划开源很多好东西,例如React Native新版本、启动优化测量等。
|
||||
|
||||
“[三星凉了,宝洁药丸,诺基亚衰落,苹果下凡,国外品牌都挺惨…](https://mp.weixin.qq.com/s/7gboiQLPgvvQnQbs7i_rfw)”,但现实情况却是三星依然在全球称霸。在我看来,“Android开发要完,移动开发要完”也是这个道理。**我们不应该焦虑,也不需要浮躁,没有不过时的技术,不过时的只有学习能力和态度**。
|
||||
|
||||
“**唯有学习,不可辜负**”。Android开发的路很长也很宽,我们既可以往底层走,也能往大中台走,还可以去看看IoT、游戏、AI。未来的机会还有很多很多,只要我们找准方向,积极拥抱变化、迎接挑战,每个人都可以走出一条自己的“光明大道”。
|
||||
|
||||
不过在工作学习之余,我也奉劝各位同学一定要锻炼好身体,无论什么时候身体都是第一位。我们的职业生涯非常漫长,脑力、体力、心力三者缺一不可。
|
||||
|
||||
非常感谢订阅专栏和我一路走来的同学们,因为你们的鼓励让我充满激情,驱使我呈现出最好的内容。最后祝愿你们未来无论是从事哪方面的工作,都能成为一名真正的高手。
|
||||
|
||||
到了说再见的时候了,各位潜水的同学都来冒个泡吧,我希望听听你这几个月学习的心得和收获,自己在工作和生活有什么变化,2019年的Flag完成得如何了?我们可以在专栏里畅所欲言、把酒言欢。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/be/d4/bea12229a709a7ca55352acc278968d4.jpg" alt="">](http://jinshuju.net/f/HQtKEn)
|
||||
10
极客时间专栏/Android开发高手课/结束语/结课测试 | 这些Android知识,你都掌握了吗?.md
Normal file
10
极客时间专栏/Android开发高手课/结束语/结课测试 | 这些Android知识,你都掌握了吗?.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好,我是张绍文。
|
||||
|
||||
《Android开发高手课》这门课程已经完结一段时间了,在完结的这段时间里,我依然会收到很多留言,很感谢你一直以来的认真学习和支持!
|
||||
|
||||
为了帮助你检验自己的学习效果,我特别给你准备了一套结课测试题。这套测试题共有20道题目,包括12道单选题和8道多选题,满分100分。
|
||||
|
||||
还等什么,点击下面按钮开始测试吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=150&exam_id=333)
|
||||
Reference in New Issue
Block a user