mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 06:33:48 +08:00
del
This commit is contained in:
105
极客时间专栏/geek/iOS开发高手课/基础篇/01 | 建立你自己的iOS开发知识体系.md
Normal file
105
极客时间专栏/geek/iOS开发高手课/基础篇/01 | 建立你自己的iOS开发知识体系.md
Normal file
@@ -0,0 +1,105 @@
|
||||
<audio id="audio" title="01 | 建立你自己的iOS开发知识体系" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/70/2a5475fa297b540750e2c4a1e8f47c70.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
这是我们专栏的第一篇文章。所谓知己知彼,今天我们先来聊聊iOS开发需要掌握哪些知识,以及如何把这些知识融会贯通,进而形成一套成熟的知识体系。
|
||||
|
||||
我们现在所处的互联网时代,学习资料真的是非常完备。比如,GitHub上各领域的 Awesome 系列,就是专门用来搜集各类资料的,其中 [iOS 的 Awesome](https://github.com/vsouza/awesome-ios) 里面,就涉及了 iOS 开发的各个方面。
|
||||
|
||||
但知识掌握的牢固、精细程度,是根据学习资料收集的多少来衡量的吗?当然不是了。
|
||||
|
||||
相比于以前的资料匮乏,现在的情况往往是大多数人手里资料一大堆,但真正消化吸收的却是少之又少,用到相关知识时总有种“书到用时方恨少”的无奈。毕竟,人的精力是有限的,根本无法完全掌握这些被轻松收集来的资料。
|
||||
|
||||
再看看我们身边那些“厉害”角色,他们并不是样样精通,而是有擅长的领域。从我接触的这些“大神”们的成长经历来看,都是先深挖某一领域,经过大量的学习和实践后理解了编程的本质,从而可以灵活调配和运用自己已经积累的知识。在这之后,他们再探索其他领域时,就做到了既快又深,成了我们眼中的“大神”。
|
||||
|
||||
**所以,学习iOS开发这件事儿,不要一开始就求多,而要求精、求深。因为,条条大路通罗马,计算机的细分领域虽然多,但学到底层都是一样的。**
|
||||
|
||||
就比如说,很多iOS开发者,刚学会通过网络请求个数据、摆摆界面弄出 App 后,看到人工智能火了就去学一下,区块链火了又去学一下,前端火了又开始蠢蠢欲动。但结果呢?每一门技术都学不深不说,学起来还都非常费劲。
|
||||
|
||||
因此,我的建议是不要被新技术牵着鼻子走,而是努力提升自己的内功,这样才能得心应手地应对层出不穷的各种新技术。
|
||||
|
||||
接下来再回到专栏上,我希望这个专栏能够结合自己的成长经历,与你分享核心且重要的iOS开发知识。这些知识,不仅有助于你平时的开发工作,能够提高你开发 App 的质量和效率,还能够引导你将各类知识贯穿起来,进而形成一套自己的核心且有深度的知识体系。
|
||||
|
||||
形成了这套知识体系后,当你再学习新知识时,就会更加快速和容易,达到所谓的融会贯通。
|
||||
|
||||
举个例子,iOS 动态化和静态分析技术里有大量与编译相关的知识。编译作为计算机学科的基础知识,除了 iOS开发,在跨端技术 Weex、React Native,甚至前端领域(比如Babel)都被广泛使用。
|
||||
|
||||
但是,这些知识的学习也要有所取舍,毕竟精力有限,而且我们也确实需要一些“立竿见影”的效果来激励自己。那么,**我们应该先学习哪些知识,才能快速提高日后学习和工作的效率呢?**接下来,我就和你分享一下我脑海中的iOS知识体系,帮你梳理出要重点掌握的核心知识脉络。
|
||||
|
||||
iOS的知识体系,包括了基础、原理、应用开发、原生与前端四大模块。我认为好的知识体系首先需要能起到指导 iOS 应用的开发和发现并解决开发问题的作用。所以,这四大模块的设置初衷是:
|
||||
|
||||
- 基础模块的作用,就是让你具有基本的发现并解决开发问题的能力;
|
||||
- 应用开发模块,就是用来指导应用开发的;
|
||||
- 好的知识体系还要能够应对未来变革,也就是需要打好底子掌握原理、理清规律,看清方向。所以,原理模块的作用就是帮你掌握原理和理清规律,而原生与前端模块会助你看清方向。
|
||||
|
||||
接下来,我就为你一一细说这四个模块。
|
||||
|
||||
# 基础模块
|
||||
|
||||
我把iOS开发者需要掌握的整个基础知识,按照App的开发流程(开发、调试测试、发布、上线)进行了划分,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/15/d17804a152749a15e969ab9f5f6cb515.png" alt=""><br>
|
||||
我们在**开发阶段**需要掌握的基础知识,主要包括:启动流程、页面布局和架构设计。
|
||||
|
||||
- 启动的快慢,可谓App的门面,同时关乎日常的使用体验,其重要性不言而喻。而只有了解了App的启动流程,才能合理安排启动阶段对功能初始化的节奏,将启动速度优化到极致。在专栏的基础篇,我会和你一起剖析App启动时都做了哪些事儿,让你可以掌控App的启动。
|
||||
- 界面是开发App的必经之路,是一定绕不开的,如何提高界面开发的质量和效率,一直是各大团队研究的重要课题。那么,我在专栏中和你介绍、分析的目前界面开发的趋势,就可以帮你夯实界面这块内容的基础。
|
||||
- 架构怎么设计才是合理的,也是这个阶段需要探索的一个重要课题。毕竟每个团队的情况不一样,什么样的架构能够适应团队的长期发展,就是我在这个专栏里要和你好好说道说道的。
|
||||
|
||||
在**调试测试阶段**,我们需要掌握的主要就是提速调试和静态分析的内容。
|
||||
|
||||
- iOS 开发的代码都是需要编译的。那么,程序体量大了后,编译调试过程必然会变长。而有啥法子能够将这个过程的速度提高到极限,就是我要在这个专栏里面和你分享的了。
|
||||
- 对 App 质量的检查,分为人工检查和自动检查。而质量检查的自动化肯定是趋势,所以自动化静态分析代码也是这个专栏会讲的。
|
||||
|
||||
在**发布阶段**,我们需要做些和业务需求开发无关、涉及基础建设的事情。这部分工作中,最主要的就是无侵入埋点和包大小优化。
|
||||
|
||||
- iOS 安装包过大会导致4G 环境下无法下载的情况,所以对包大小的控制已经成为各大公司最头疼的事情之一。希望我在包大小瘦身上的经验能够帮到你。
|
||||
- 发布前需要加上各种埋点,这样才能让你充分地掌握 App 运行状态是否健康,同时埋点也是分析上线后遇到的各种问题的重要手段。但是,如果埋点的代码写的到处都是,修改和维护时就会举步维艰。所以我在这个专栏里,也会和你分享一些好的将埋点和业务代码解耦的无侵入埋点方案。
|
||||
|
||||
在**上线阶段**,开发质量的高低会影响上线后问题的数量和严重程度,比如有崩溃和卡顿这样导致 App 不可用的问题,也有性能和电量这样影响用户体验的问题。对于这些问题你一定不会袖手旁观,那怎么才能监控到这些问题呢?怎样才能够更准确、更全面地发现问题,同时能够收集到更多有助于分析问题的有效信息呢?
|
||||
|
||||
在这个专栏中,我会从崩溃、卡顿、内存、日志、性能、线程、电量等方面和你一一细说。
|
||||
|
||||
# 应用开发
|
||||
|
||||
应用开发部分,我们需要关注的就是一些经典库,因为这些经典库往往出自技术大拿之手,代码结构和设计思想都非常优秀,同时经过了大规模的实践,不断打磨完善,具有很高的质量保障。比如:动画库 Pop,响应式框架 RAC、RxSwift,JSON 处理库 JSONModel、Mantle等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/2b/f9d371fe15b45db8a6eddbab3fc2962b.png" alt=""><br>
|
||||
在专栏里,我会和你好好介绍下布局框架新贵 Cartography 和富文本霸王 YYText 、DTCoreText ,分享我的使用经验,让你也能够快速上手并应用到你的 App 中。
|
||||
|
||||
应用开发中和视觉表现相关的 GUI 框架、动画、布局框架、富文本等部分知识掌握好了,直接能够让用户感知到你 App 的优秀表现。响应式框架、TDD/BDD、编码规范等知识能够让你的开发更规范化、更有效率。我会从实践应用和实现原理的方面,带你全方位地去了解如何更好地进行应用开发。
|
||||
|
||||
有道是选择大于努力,可能你使用一个不恰当的库所做的大量努力,也不及别人用对了一个好的库轻轻松松、高质量完成的任务。
|
||||
|
||||
# 原理模块
|
||||
|
||||
说到iOS开发原理,主要就是系统内核XNU、AOP、内存管理和编译的知识。这些知识具有很强的通用性,其他任何语言、系统和领域都会涉及到。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/c1/ddfa2f3959b62291d33d8d5af1708dc1.png" alt=""><br>
|
||||
在接下来的专栏里,我会为你剖析这些知识,希望能够帮助你更好地理解它们的原理。掌握这些通用知识,可以提升你的内功,助你打通任督二脉;深挖知识,可以帮你理清楚开发知识的规律,达到融会贯通的效果;掌握通用知识,也能够让你对未来技术见招拆招。所以,你花在这部分知识上时间,绝对是超值的。
|
||||
|
||||
# 原生与前端
|
||||
|
||||
随着 Flutter 和 React Native 越来越完善,关注的人也越来越多。原生还是前端,才是移动应用的未来,谁都没法说得清楚。有句话怎么说来着,无法选择时就都选择。
|
||||
|
||||
在原生与前端这个部分,我会着重和你分析隐藏在这些时髦技术背后的解释器和渲染技术,也正是这些技术的演进造就了目前跨端方案的繁荣。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/65/076882bee87fbd25defdbde0c1d6e765.png" alt=""><br>
|
||||
值得一说的是,从 H5 到 Flutter,渲染底层图形库都使用的是 Skia。也就是说,这么多年来渲染底层技术就基本没有变过。而且,向Flutter 的演进也只是去掉了 H5 对低版本标准的支持。但,仅仅是去掉这些兼容代码,就使性能提升了数倍。
|
||||
|
||||
所以说,对于新的技术如何去看,很重要,先不要急着深入到开发细节中了,那样你会迷失在技术海洋中。你需要先建立好自己的知识体系,打好基础,努力提升自己的内功,然后找好指明灯,这样才能追着目标航行。
|
||||
|
||||
最后,我来把整个专栏中涉及到的基础、原理、应用开发和原生与前端的知识,梳理到一起,就形成了如下图所示的iOS知识体系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/1f/ec339916b408ae3c86a5ee237ae3dc1f.png" alt="">
|
||||
|
||||
# 小结
|
||||
|
||||
今天我跟你说了 iOS 开发中哪些知识是需要着重学习的,以及怎样将这些知识体系化。在学习知识的道路上,我的建议是求精、求深,基础打牢,以不变应万变。在工作上,则要注重开发效率,避免不必要地重复造轮子,理解原理和细节,同时开阔眼界,紧跟技术前沿。
|
||||
|
||||
说到底,不要急着看到啥就去学啥,有目的、有体系地去学习,效果才会更好。即使工作再忙,你也要找时间成体系地提升自己的内功,完善自己,然后反哺到工作上,让工作效率和质量达到质的提升,进而从容应对技术的更新迭代。
|
||||
|
||||
按照知识体系高效学习是很棒的,会让你成长得很快。不过,有时找个咖啡小店,随便拿起一本书翻翻,或者随便挑几篇平时收集的文章读读,再拿出小本子记记笔记,也不失为一种很佛系的学习方式,毕竟生活中总是需要点儿惊喜不是吗。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
465
极客时间专栏/geek/iOS开发高手课/基础篇/02 | App 启动速度怎么做优化与监控?.md
Normal file
465
极客时间专栏/geek/iOS开发高手课/基础篇/02 | App 启动速度怎么做优化与监控?.md
Normal file
@@ -0,0 +1,465 @@
|
||||
<audio id="audio" title="02 | App 启动速度怎么做优化与监控?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/27/04d49dab505ad8a15df955d073456527.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
我已经在专栏的第一篇文章中,和你梳理了一份iOS开发的知识体系框架图。今天,我们就正式从基础出发,开始自己的iOS开发知识体系构建之路吧。接下来,我就先和你聊聊与App启动速度密切相关的那些事儿。希望你听我说完启动速度的事儿之后,在专栏里的学习状态也能够快速地启动起来。
|
||||
|
||||
在文章开始前,我们先设想这么一个场景:假设你在排队结账时,掏出手机打开App甲准备扫码支付,结果半天进不去,后面排队的人给你压力够大吧。然后,你又打开App乙,秒进,支付完成。试想一下,以后再支付时你会选择哪个App呢。
|
||||
|
||||
不难想象,在提供的功能和服务相似的情况下,一款App的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。这就好像陌生人第一次碰面,第一感觉往往决定了他们接下来是否会继续交往。
|
||||
|
||||
由此可见,启动速度的优化必然就是App开发过程中,不可或缺的一个环节。接下来,我就先和你一起分析下App在启动时都做了哪些事儿。
|
||||
|
||||
## App 启动时都干了些什么事儿?
|
||||
|
||||
一般情况下,App的启动分为冷启动和热启动。
|
||||
|
||||
- 冷启动是指, App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
|
||||
- 热启动是指 ,App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。
|
||||
|
||||
所以,今天这篇文章,我们就只展开讲App冷启动的优化。
|
||||
|
||||
用户能感知到的启动慢,其实都发生在主线程上。而主线程慢的原因有很多,比如在主线程上执行了大文件读写操作、在渲染周期中执行了大量计算等。但是,有时你会发现即使你把首屏显示之前的这些主线程的耗时问题都解决了,还是比竞品启动得慢。
|
||||
|
||||
那么,**究竟如何才能把启动时的所有耗时都找出来呢?解决这个问题,你首先需要弄清楚 App在启动时都干了哪些事儿。**
|
||||
|
||||
一般而言,App的启动时间,指的是从用户点击App开始,到用户看到第一个界面之间的时间。总结来说,App的启动主要包括三个阶段:
|
||||
|
||||
<li>
|
||||
main() 函数执行前;
|
||||
</li>
|
||||
<li>
|
||||
main() 函数执行后;
|
||||
</li>
|
||||
<li>
|
||||
首屏渲染完成后。
|
||||
</li>
|
||||
|
||||
整个启动过程的示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/81/8af7e23cd98c8add88e2f8ed3405ed81.png" alt="">
|
||||
|
||||
### main() 函数执行前
|
||||
|
||||
在 main() 函数执行前,系统主要会做下面几件事情:
|
||||
|
||||
- 加载可执行文件(App的.o文件的集合);
|
||||
- 加载动态链接库,进行 rebase 指针调整和 bind 符号绑定;
|
||||
- Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
|
||||
- 初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。
|
||||
|
||||
相应地,这个阶段对于启动速度优化来说,可以做的事情包括:
|
||||
|
||||
- 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司建议最多使用 6 个非系统动态库。
|
||||
- 减少加载启动后不会去使用的类或者方法。
|
||||
- +load() 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这4毫秒,积少成多,执行+load() 方法对启动速度的影响会越来越大。
|
||||
- 控制C++ 全局变量的数量。
|
||||
|
||||
### main() 函数执行后
|
||||
|
||||
main() 函数执行后的阶段,指的是从main()函数执行开始,到appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。
|
||||
|
||||
首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:
|
||||
|
||||
- 首屏初始化所需配置文件的读写操作;
|
||||
- 首屏列表大数据的读取;
|
||||
- 首屏渲染的大量计算等。
|
||||
|
||||
很多时候,开发者会把各种初始化工作都放到这个阶段执行,导致渲染完成滞后。**更加优化的开发方式,应该是**从功能上梳理出哪些是首屏渲染必要的初始化功能,哪些是 App 启动必要的初始化功能,而哪些是只需要在对应功能开始使用时才需要初始化的。梳理完之后,将这些初始化功能分别放到合适的阶段进行。
|
||||
|
||||
### 首屏渲染完成后
|
||||
|
||||
首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。从函数上来看,这个阶段指的就是截止到 didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成。简单说的话,这个阶段就是从渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束。
|
||||
|
||||
这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。
|
||||
|
||||
明白了App启动阶段需要完成的工作后,我们就可以有的放矢地进行启动速度的优化了。这些优化,包括了功能级别和方法级别的启动优化。接下来,我们就从这两个角度展开看看。
|
||||
|
||||
## 功能级别的启动优化
|
||||
|
||||
我想,你所在的团队一定面临过启动阶段的代码功能堆积、无规范、难维护的问题吧。在 App 项目开发初期,开发人员不多、代码量也没那么大时,这种情况比较少见。但到了后期 ,App 业务规模扩大,团队人员水平参差不齐,各种代码问题就会爆发出来,终归需要来次全面治理。
|
||||
|
||||
而全面治理过程中的手段、方法和碰到的问题,对于后面的规范制定以及启动速度监控都有着重要的意义。那么,我们要怎样从功能级别来进行全面的启动优化治理呢?
|
||||
|
||||
功能级别的启动优化,就是要从main() 函数执行后这个阶段下手。
|
||||
|
||||
优化的思路是: main() 函数开始执行后到首屏渲染完成前只处理首屏相关的业务,其他非首屏业务的初始化、监听注册、配置文件读取等都放到首屏渲染完成后去做。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/19/f30f438d447e81132dd520e657427419.png" alt="">
|
||||
|
||||
## 方法级别的启动优化
|
||||
|
||||
经过功能级别的启动优化,也就是将非首屏业务所需的功能滞后以后,从用户点击 App 到看到首屏的时间将会有很大程度的缩短,也就达到了优化App启动速度的目的。
|
||||
|
||||
在这之后,我们需要进一步做的,是检查首屏渲染完成前主线程上有哪些耗时方法,将没必要的耗时方法滞后或者异步执行。通常情况下,耗时较长的方法主要发生在计算大量数据的情况下,具体的表现就是加载、编辑、存储图片和文件等资源。
|
||||
|
||||
那么,你觉得**是不是只需要优化对资源的操作就可以了呢?**
|
||||
|
||||
当然不是。就像 +load() 方法,一个耗时4毫秒,100个就是400毫秒,这种耗时用户也是能明显感知到的。
|
||||
|
||||
比如,我以前使用的 ReactiveCocoa框架(这是一个 iOS 上的响应式编程框架),每创建一个信号都有6毫秒的耗时。这样,稍不注意各种信号的创建就都被放在了首屏渲染完成前,进而导致App的启动速度大幅变慢。
|
||||
|
||||
类似这样单个方法耗时不多,但是由于堆积导致App启动速度大幅变慢的方法数不胜数。所以,你需要一个能够对启动方法耗时进行全面、精确检查的手段。
|
||||
|
||||
那么问题来了,有哪些监控手段?这些监控手段各有什么优缺点?你又该如何选择呢?
|
||||
|
||||
目前来看,对App启动速度的监控,主要有两种手段。
|
||||
|
||||
**第一种方法是,定时抓取主线程上的方法调用堆栈,计算一段时间里各个方法的耗时**。Xcode 工具套件里自带的 Time Profiler ,采用的就是这种方式。
|
||||
|
||||
这种方式的优点是,开发类似工具成本不高,能够快速开发后集成到你的 App 中,以便在真实环境中进行检查。
|
||||
|
||||
说到定时抓取,就会涉及到定时间隔的长短问题。
|
||||
|
||||
- 定时间隔设置得长了,会漏掉一些方法,从而导致检查出来的耗时不精确;
|
||||
- 而定时间隔设置得短了,抓取堆栈这个方法本身调用过多也会影响整体耗时,导致结果不准确。
|
||||
|
||||
这个定时间隔如果小于所有方法执行的时间(比如 0.002秒),那么基本就能监控到所有方法。但这样做的话,整体的耗时时间就不够准确。一般将这个定时间隔设置为0.01秒。这样设置,对整体耗时的影响小,不过很多方法耗时就不精确了。但因为整体耗时的数据更加重要些,单个方法耗时精度不高也是可以接受的,所以这个设置也是没问题的。
|
||||
|
||||
总结来说,定时抓取主线程调用栈的方式虽然精准度不够高,但也是够用的。
|
||||
|
||||
**第二种方法是,对 objc_msgSend 方法进行 hook 来掌握所有方法的执行耗时。**
|
||||
|
||||
hook 方法的意思是,在原方法开始执行时换成执行其他你指定的方法,或者在原有方法执行前后执行你指定的方法,来达到掌握和改变指定方法的目的。
|
||||
|
||||
hook objc_msgSend 这种方式的优点是非常精确,而缺点是只能针对 Objective-C 的方法。当然,对于 c 方法和 block 也不是没有办法,你可以使用 libffi 的 ffi_call 来达成 hook,但缺点就是编写维护相关工具门槛高。
|
||||
|
||||
关于,libffi 相关的内容,我会在后面的第35篇文章“libffi:动态调用和定义 C 函数”里和你详细说明。
|
||||
|
||||
综上,如果对于检查结果精准度要求高的话,我比较推荐你使用 hook objc_msgSend 方式来检查启动方法的执行耗时。
|
||||
|
||||
## 如何做一个方法级别启动耗时检查工具来辅助分析和监控?
|
||||
|
||||
使用 hook objc_msgSend 方式来检查启动方法的执行耗时时,我们需要实现一个称手的启动时间检查工具。那么,我们应该如何实现这个工具呢?
|
||||
|
||||
现在,我就一步一步地和你说说具体怎么做。
|
||||
|
||||
首先,你要了解**为什么 hook 了 objc_msgSend 方法,就可以 hook 全部 Objective-C 的方法?**
|
||||
|
||||
Objective-C 里每个对象都会指向一个类,每个类都会有一个方法列表,方法列表里的每个方法都是由 selector、函数指针和 metadata 组成的。
|
||||
|
||||
objc_msgSend 方法干的活儿,就是在运行时根据对象和方法的selector 去找到对应的函数指针,然后执行。也就是说,objc_msgSend 是 Objective-C 里方法执行的必经之路,能够控制所有的 Objective-C 的方法。
|
||||
|
||||
objc_msgSend 本身是用汇编语言写的,这样做的原因主要有两个:
|
||||
|
||||
- 一个原因是,objc_msgSend 的调用频次最高,在它上面进行的性能优化能够提升整个 App 生命周期的性能。而汇编语言在性能优化上属于原子级优化,能够把优化做到极致。所以,这种投入产出比无疑是最大的。
|
||||
- 另一个原因是,其他语言难以实现未知参数跳转到任意函数指针的功能。
|
||||
|
||||
现在,苹果公司已经开源了Objective-C 的运行时代码。你可以在[苹果公司的开源网站](https://opensource.apple.com/source/objc4/objc4-723/runtime/Messengers.subproj/),找到 objc_msgSend的源码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/b1/70b693ee775c26cb8b9dd3350fe1b3b1.png" alt="">
|
||||
|
||||
上图列出的是所有架构的实现,包括 x86_64 等。objc_msgSend 是 iOS 方式执行最核心的部分,编程领域的宝藏,值得你深入探究和细细品味。
|
||||
|
||||
objc_msgSend方法执行的逻辑是:先获取对象对应类的信息,再获取方法的缓存,根据方法的selector 查找函数指针,经过异常错误处理后,最后跳到对应函数的实现。
|
||||
|
||||
按照这个逻辑去看源码会更加清晰,更容易注意到实现细节。阅读 objc_msgSend 源码是编写方法级耗时工具的一个必要的环节,后面还需要编写一些对应的汇编代码。
|
||||
|
||||
**接下来,我们再看看怎么 hook objc_msgSend 方法?**
|
||||
|
||||
Facebook 开源了一个库,可以在iOS上运行的Mach-O二进制文件中动态地重新绑定符号,这个库叫 fishhook。你可以在GitHub 上,查看[fishhook的代码](https://github.com/facebook/fishhook)。
|
||||
|
||||
fishhook 实现的大致思路是,通过重新绑定符号,可以实现对 c 方法的 hook。dyld 是通过更新 Mach-O 二进制的 __DATA segment 特定的部分中的指针来绑定 lazy 和 non-lazy 符号,通过确认传递给 rebind_symbol 里每个符号名称更新的位置,就可以找出对应替换来重新绑定这些符号。
|
||||
|
||||
下面,我针对 fishhook 里的关键代码,和你具体说下 fishhook 的实现原理。
|
||||
|
||||
**首先**,遍历 dyld 里的所有image,取出 image header 和 slide。代码如下:
|
||||
|
||||
```
|
||||
if (!_rebindings_head->next) {
|
||||
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
|
||||
} else {
|
||||
uint32_t c = _dyld_image_count();
|
||||
// 遍历所有 image
|
||||
for (uint32_t i = 0; i < c; i++) {
|
||||
// 读取 image header 和 slider
|
||||
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**接下来**,找到符号表相关的 command,包括 linkedit segment command、symtab command 和 dysymtab command。代码如下:
|
||||
|
||||
```
|
||||
segment_command_t *cur_seg_cmd;
|
||||
segment_command_t *linkedit_segment = NULL;
|
||||
struct symtab_command* symtab_cmd = NULL;
|
||||
struct dysymtab_command* dysymtab_cmd = NULL;
|
||||
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
|
||||
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
|
||||
cur_seg_cmd = (segment_command_t *)cur;
|
||||
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
|
||||
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
|
||||
// linkedit segment command
|
||||
linkedit_segment = cur_seg_cmd;
|
||||
}
|
||||
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
|
||||
// symtab command
|
||||
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
|
||||
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
|
||||
// dysymtab command
|
||||
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**然后,**获得 base 和 indirect 符号表。实现代码如下:
|
||||
|
||||
```
|
||||
// 找到 base 符号表的地址
|
||||
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
|
||||
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
|
||||
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
|
||||
// 找到 indirect 符号表
|
||||
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
|
||||
|
||||
```
|
||||
|
||||
**最后,**有了符号表和传入的方法替换数组,就可以进行符号表访问指针地址的替换了,具体实现如下:
|
||||
|
||||
```
|
||||
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
|
||||
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
|
||||
for (uint i = 0; i < section->size / sizeof(void *); i++) {
|
||||
uint32_t symtab_index = indirect_symbol_indices[i];
|
||||
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
|
||||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
|
||||
continue;
|
||||
}
|
||||
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
|
||||
char *symbol_name = strtab + strtab_offset;
|
||||
if (strnlen(symbol_name, 2) < 2) {
|
||||
continue;
|
||||
}
|
||||
struct rebindings_entry *cur = rebindings;
|
||||
while (cur) {
|
||||
for (uint j = 0; j < cur->rebindings_nel; j++) {
|
||||
if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
|
||||
if (cur->rebindings[j].replaced != NULL &&
|
||||
indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
|
||||
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
|
||||
}
|
||||
// 符号表访问指针地址的替换
|
||||
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
|
||||
goto symbol_loop;
|
||||
}
|
||||
}
|
||||
cur = cur->next;
|
||||
}
|
||||
symbol_loop:;
|
||||
|
||||
```
|
||||
|
||||
以上,就是 fishhook 的实现原理了。fishhook 是对底层的操作,其中查找符号表的过程和堆栈符号化实现原理基本类似,了解了其中原理对于理解可执行文件 Mach-O 内部结构会有很大的帮助。
|
||||
|
||||
接下来,我们再看一个问题:**只靠 fishhook 就能够搞定 objc_msgSend 的 hook 了吗?**
|
||||
|
||||
当然还不够。我前面也说了,objc_msgSend 是用汇编语言实现的,所以我们还需要从汇编层面多加点料。
|
||||
|
||||
你需要先实现两个方法 pushCallRecord 和 popCallRecord,来分别记录 objc_msgSend 方法调用前后的时间,然后相减就能够得到方法的执行耗时。
|
||||
|
||||
下面我针对arm64架构,编写一个可保留未知参数并跳转到 c 中任意函数指针的汇编代码,实现对 objc_msgSend 的 Hook。
|
||||
|
||||
arm64 有31个64 bit 的整数型寄存器,分别用 x0 到 x30 表示。主要的实现思路是:
|
||||
|
||||
<li>
|
||||
入栈参数,参数寄存器是 x0~ x7。对于objc_msgSend方法来说,x0 第一个参数是传入对象,x1 第二个参数是选择器 _cmd。syscall 的 number 会放到 x8 里。
|
||||
</li>
|
||||
<li>
|
||||
交换寄存器中保存的参数,将用于返回的寄存器 lr 中的数据移到 x1 里。
|
||||
</li>
|
||||
<li>
|
||||
使用 bl label 语法调用 pushCallRecord 函数。
|
||||
</li>
|
||||
<li>
|
||||
执行原始的 objc_msgSend,保存返回值。
|
||||
</li>
|
||||
<li>
|
||||
使用 bl label 语法调用 popCallRecord 函数。
|
||||
</li>
|
||||
|
||||
具体的汇编代码,如下所示:
|
||||
|
||||
```
|
||||
static void replacementObjc_msgSend() {
|
||||
__asm__ volatile (
|
||||
// sp 是堆栈寄存器,存放栈的偏移地址,每次都指向栈顶。
|
||||
// 保存 {q0-q7} 偏移地址到 sp 寄存器
|
||||
"stp q6, q7, [sp, #-32]!\n"
|
||||
"stp q4, q5, [sp, #-32]!\n"
|
||||
"stp q2, q3, [sp, #-32]!\n"
|
||||
"stp q0, q1, [sp, #-32]!\n"
|
||||
// 保存 {x0-x8, lr}
|
||||
"stp x8, lr, [sp, #-16]!\n"
|
||||
"stp x6, x7, [sp, #-16]!\n"
|
||||
"stp x4, x5, [sp, #-16]!\n"
|
||||
"stp x2, x3, [sp, #-16]!\n"
|
||||
"stp x0, x1, [sp, #-16]!\n"
|
||||
// 交换参数.
|
||||
"mov x2, x1\n"
|
||||
"mov x1, lr\n"
|
||||
"mov x3, sp\n"
|
||||
// 调用 preObjc_msgSend,使用 bl label 语法。bl 执行一个分支链接操作,label 是无条件分支的,是和本指令的地址偏移,范围是 -128MB 到 +128MB
|
||||
"bl __Z15preObjc_msgSendP11objc_objectmP13objc_selectorP9RegState_\n"
|
||||
"mov x9, x0\n"
|
||||
"mov x10, x1\n"
|
||||
"tst x10, x10\n"
|
||||
// 读取 {x0-x8, lr} 从保存到 sp 栈顶的偏移地址读起
|
||||
"ldp x0, x1, [sp], #16\n"
|
||||
"ldp x2, x3, [sp], #16\n"
|
||||
"ldp x4, x5, [sp], #16\n"
|
||||
"ldp x6, x7, [sp], #16\n"
|
||||
"ldp x8, lr, [sp], #16\n"
|
||||
// 读取 {q0-q7}
|
||||
"ldp q0, q1, [sp], #32\n"
|
||||
"ldp q2, q3, [sp], #32\n"
|
||||
"ldp q4, q5, [sp], #32\n"
|
||||
"ldp q6, q7, [sp], #32\n"
|
||||
"b.eq Lpassthrough\n"
|
||||
// 调用原始 objc_msgSend。使用 blr xn 语法。blr 除了从指定寄存器读取新的 PC 值外效果和 bl 一样。xn 是通用寄存器的64位名称分支地址,范围是0到31
|
||||
"blr x9\n"
|
||||
// 保存 {x0-x9}
|
||||
"stp x0, x1, [sp, #-16]!\n"
|
||||
"stp x2, x3, [sp, #-16]!\n"
|
||||
"stp x4, x5, [sp, #-16]!\n"
|
||||
"stp x6, x7, [sp, #-16]!\n"
|
||||
"stp x8, x9, [sp, #-16]!\n"
|
||||
// 保存 {q0-q7}
|
||||
"stp q0, q1, [sp, #-32]!\n"
|
||||
"stp q2, q3, [sp, #-32]!\n"
|
||||
"stp q4, q5, [sp, #-32]!\n"
|
||||
"stp q6, q7, [sp, #-32]!\n"
|
||||
// 调用 postObjc_msgSend hook.
|
||||
"bl __Z16postObjc_msgSendv\n"
|
||||
"mov lr, x0\n"
|
||||
// 读取 {q0-q7}
|
||||
"ldp q6, q7, [sp], #32\n"
|
||||
"ldp q4, q5, [sp], #32\n"
|
||||
"ldp q2, q3, [sp], #32\n"
|
||||
"ldp q0, q1, [sp], #32\n"
|
||||
// 读取 {x0-x9}
|
||||
"ldp x8, x9, [sp], #16\n"
|
||||
"ldp x6, x7, [sp], #16\n"
|
||||
"ldp x4, x5, [sp], #16\n"
|
||||
"ldp x2, x3, [sp], #16\n"
|
||||
"ldp x0, x1, [sp], #16\n"
|
||||
"ret\n"
|
||||
"Lpassthrough:\n"
|
||||
// br 无条件分支到寄存器中的地址
|
||||
"br x9"
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,你就可以得到每个 Objective-C 方法的耗时了。接下来,我们再看看**怎样才能够做到像下图那样记录和展示方法调用的层级关系和顺序呢?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/62/b08872e98b3090147d22d359a2a29062.png" alt="">
|
||||
|
||||
不要着急,我来一步一步地跟你说。
|
||||
|
||||
**第一步,**设计两个结构体:CallRecord 记录调用方法详细信息,包括 obj 和 SEL 等;ThreadCallStack 里面,需要用 index 记录当前调用方法树的深度。
|
||||
|
||||
有了 SEL 再通过 NSStringFromSelector 就能够取得方法名,有了 obj 通过 object_getClass 能够得到 Class ,再用 NSStringFromClass 就能够获得类名。结构的完整代码如下:
|
||||
|
||||
```
|
||||
// Shared structures.
|
||||
typedef struct CallRecord_ {
|
||||
id obj; //通过 object_getClass 能够得到 Class 再通过 NSStringFromClass 能够得到类名
|
||||
SEL _cmd; //通过 NSStringFromSelector 方法能够得到方法名
|
||||
uintptr_t lr;
|
||||
int prevHitIndex;
|
||||
char isWatchHit;
|
||||
} CallRecord;
|
||||
typedef struct ThreadCallStack_ {
|
||||
FILE *file;
|
||||
char *spacesStr;
|
||||
CallRecord *stack;
|
||||
int allocatedLength;
|
||||
int index; //index 记录当前调用方法树的深度
|
||||
int numWatchHits;
|
||||
int lastPrintedIndex;
|
||||
int lastHitIndex;
|
||||
char isLoggingEnabled;
|
||||
char isCompleteLoggingEnabled;
|
||||
} ThreadCallStack;
|
||||
|
||||
```
|
||||
|
||||
**第二步,**pthread_setspecific() 可以将私有数据设置在指定线程上,pthread_getspecific() 用来读取这个私有数据。利用这个特性,我们就可以将 ThreadCallStack 的数据和该线程绑定在一起,随时进行数据存取。代码如下:
|
||||
|
||||
```
|
||||
static inline ThreadCallStack * getThreadCallStack() {
|
||||
ThreadCallStack *cs = (ThreadCallStack *)pthread_getspecific(threadKey); //读取
|
||||
if (cs == NULL) {
|
||||
cs = (ThreadCallStack *)malloc(sizeof(ThreadCallStack));
|
||||
#ifdef MAIN_THREAD_ONLY
|
||||
cs->file = (pthread_main_np()) ? newFileForThread() : NULL;
|
||||
#else
|
||||
cs->file = newFileForThread();
|
||||
#endif
|
||||
cs->isLoggingEnabled = (cs->file != NULL);
|
||||
cs->isCompleteLoggingEnabled = 0;
|
||||
cs->spacesStr = (char *)malloc(DEFAULT_CALLSTACK_DEPTH + 1);
|
||||
memset(cs->spacesStr, ' ', DEFAULT_CALLSTACK_DEPTH);
|
||||
cs->spacesStr[DEFAULT_CALLSTACK_DEPTH] = '\0';
|
||||
cs->stack = (CallRecord *)calloc(DEFAULT_CALLSTACK_DEPTH, sizeof(CallRecord)); //分配 CallRecord 默认空间
|
||||
cs->allocatedLength = DEFAULT_CALLSTACK_DEPTH;
|
||||
cs->index = cs->lastPrintedIndex = cs->lastHitIndex = -1;
|
||||
cs->numWatchHits = 0;
|
||||
pthread_setspecific(threadKey, cs); //保存数据
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**第三步,**因为要记录深度,而一个方法的调用里会有更多的方法调用,所以我们可以在方法的调用里增加两个方法 pushCallRecord 和 popCallRecord,分别记录方法调用的开始时间和结束时间,这样才能够在开始时对深度加一、在结束时减一。
|
||||
|
||||
```
|
||||
//开始时
|
||||
static inline void pushCallRecord(id obj, uintptr_t lr, SEL _cmd, ThreadCallStack *cs) {
|
||||
int nextIndex = (++cs->index); //增加深度
|
||||
if (nextIndex >= cs->allocatedLength) {
|
||||
cs->allocatedLength += CALLSTACK_DEPTH_INCREMENT;
|
||||
cs->stack = (CallRecord *)realloc(cs->stack, cs->allocatedLength * sizeof(CallRecord));
|
||||
cs->spacesStr = (char *)realloc(cs->spacesStr, cs->allocatedLength + 1);
|
||||
memset(cs->spacesStr, ' ', cs->allocatedLength);
|
||||
cs->spacesStr[cs->allocatedLength] = '\0';
|
||||
}
|
||||
CallRecord *newRecord = &cs->stack[nextIndex];
|
||||
newRecord->obj = obj;
|
||||
newRecord->_cmd = _cmd;
|
||||
newRecord->lr = lr;
|
||||
newRecord->isWatchHit = 0;
|
||||
}
|
||||
//结束时
|
||||
static inline CallRecord * popCallRecord(ThreadCallStack *cs) {
|
||||
return &cs->stack[cs->index--]; //减少深度
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
耗时检查的完整代码,你可以在[我的开源项目](https://github.com/ming1016/GCDFetchFeed)里查看。在需要检测耗时时间的地方调用 [SMCallTrace start],结束时调用 stop 和 save 就可以打印出方法的调用层级和耗时了。你还可以设置最大深度和最小耗时检测,来过滤不需要看到的信息。
|
||||
|
||||
有了这样一个检查方法耗时的工具,你就可以在每个版本开发结束后执行一次检查,统计总耗时以及启动阶段每个方法的耗时,有针对性地观察启动速度慢的问题。如果你在线上做个灰度开关,还可以监控线上启动慢的一些特殊情况。
|
||||
|
||||
## 小结
|
||||
|
||||
启动速度优化和监控的重要性不言而喻,加快 App 的启动速度对用户的体验提升是最大的。
|
||||
|
||||
启动速度的优化也有粗有细:粗上来讲,这需要对启动阶段功能进行分类整理,合理地将和首屏无关的功能滞后,放到首屏渲染完成之后,保证大头儿没有问题;细的来讲,这就需要些匠人精神,使用合适的工具,针对每个方法进行逐个分析、优化,每个阶段都做到极致。
|
||||
|
||||
## 课后作业
|
||||
|
||||
按照今天文中提到的 Time Profiler 工具检查方法耗时的原理,你来动手实现一个方法耗时检查工具吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
最近,我收到一些同学的反馈,说这门课的一些内容比较深,一时难以琢磨透。如果你也有这样的感受,推荐你学习极客时间刚刚上新的另一门视频课程:由腾讯高级工程师朱德权,主讲的《从 0 开发一款 iOS App》。
|
||||
|
||||
朱德权老师将会基于最新技术,从实践出发,手把手带你构建类今日头条的App。要知道,那些很牛的 iOS 开发者,往往都具备独立开发一款 App 的能力。
|
||||
|
||||
这门课正在上新优惠,欢迎点击[这里](https://time.geekbang.org/course/intro/169?utm_term=zeusKHUZ0&utm_source=app&utm_medium=geektime&utm_campaign=169-presell&utm_content=daiming)试看。
|
||||
149
极客时间专栏/geek/iOS开发高手课/基础篇/03 | Auto Layout 是怎么进行自动布局的,性能如何?.md
Normal file
149
极客时间专栏/geek/iOS开发高手课/基础篇/03 | Auto Layout 是怎么进行自动布局的,性能如何?.md
Normal file
@@ -0,0 +1,149 @@
|
||||
<audio id="audio" title="03 | Auto Layout 是怎么进行自动布局的,性能如何?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/7f/1885b4bdb388e0024ef2b4e346e7867f.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天我来跟你聊下 Auto Layout 。
|
||||
|
||||
Auto Layout ,是苹果公司提供的一个基于约束布局,动态计算视图大小和位置的库,并且已经集成到了 Xcode 开发环境里。
|
||||
|
||||
在引入 Auto Layout 这种自动布局方式之前,iOS 开发都是采用手动布局的方式。而手动布局的方式,原始落后、界面开发维护效率低,对从事过前端开发的人来说更是难以适应。所以,苹果需要提供更好的界面引擎来提升开发者的体验,Auto Layout 随之出现。
|
||||
|
||||
苹果公司早在 iOS 6 系统时就引入了 Auto Layout,但是直到现在还有很多开发者迟迟不愿使用 它,其原因就在于对其性能的担忧。即使后来,苹果公司推出了在 Auto Layout 基础上模仿前端 Flexbox 布局思路的 UIStackView工具,提高了开发体验和效率,也无法解除开发者们对其性能的顾虑。
|
||||
|
||||
那么,Auto Layout 到底是如何实现自动布局的,这种布局算法真的会影响性能吗?
|
||||
|
||||
另外,苹果公司在 WWDC 2018 的“ [WWDC 220 Session High Performance Auto Layout](https://developer.apple.com/videos/play/wwdc2018/220)”Session中介绍说: iOS 12 将大幅提高 Auto Layout 性能,使滑动达到满帧,这又是如何做到的呢?你是应该选择继续手动布局还是选择Auto Layout呢?
|
||||
|
||||
就着这三个问题,我们就来详细聊聊 Auto Layout 吧。
|
||||
|
||||
# Auto Layout的来历
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/a4/fa8980b42e736a3bbbb3fd4cfa5d48a4.png" alt="">
|
||||
|
||||
上图记录了两个时间点:
|
||||
|
||||
- 一个是1997年,Auto Layout 用到的布局算法 Cassowary 被发明了出来;
|
||||
- 另一个是2011年,苹果公司将 Cassowary 算法运用到了自家的布局引擎 Auto Layout 中。
|
||||
|
||||
Cassowary 能够有效解析线性等式系统和线性不等式系统,用来表示用户界面中那些相等关系和不等关系。基于此,Cassowary 开发了一种规则系统,通过约束来描述视图间的关系。约束就是规则,这个规则能够表示出一个视图相对于另一个视图的位置。
|
||||
|
||||
由于 Cassowary 算法让视图位置可以按照一种简单的布局思路来写,这些简单的相对位置描述可以在运行时动态地计算出视图具体的位置。视图位置的写法简化了,界面相关代码也就更易于维护。苹果公司也是看重了这一点,将其引入到了自己的系统中。
|
||||
|
||||
Cassowary 算法由 Alan Borning、Kim Marriott、Peter Stuckey 等人在“[Solving Linear Arithmetic Constraints for User Interface Applications](https://constraints.cs.washington.edu/solvers/uist97.html)”论文中提出的,为了能方便开发者更好地理解 这个算法,并将其运用到更多的开发语言中,作者还将代码发布到了他们搭建的 [Cassowary 网站](https://constraints.cs.washington.edu/cassowary/)上。
|
||||
|
||||
由于 Cassowary 算法本身的先进性,更多的开发者将 Cassowary 运用到了各个开发语言中,比如 JavaScript、.NET、Java、Smalltalk、C++都有对应的库。
|
||||
|
||||
# Auto Layout 的生命周期
|
||||
|
||||
Auto Layout 不只有布局算法 Cassowary,还包含了布局在运行时的生命周期等一整套布局引擎系统,用来统一管理布局的创建、更新和销毁。了解 Auto Layout 的生命周期,是理解它的性能相关话题的基础。这样,在遇到问题,特别是性能问题时,我们才能从根儿上找到原因,从而避免或改进类似的问题。
|
||||
|
||||
这一整套布局引擎系统叫作 Layout Engine ,是 Auto Layout 的核心,主导着整个界面布局。
|
||||
|
||||
每个视图在得到自己的布局之前,Layout Engine 会将视图、约束、优先级、固定大小通过计算转换成最终的大小和位置。在 Layout Engine 里,每当约束发生变化,就会触发 Deffered Layout Pass,完成后进入监听约束变化的状态。当再次监听到约束变化,即进入下一轮循环中。整个过程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/1c/7ca0e14ef02231c9aba7cb49c7e9271c.png" alt="">
|
||||
|
||||
图中, Constraints Change 表示的就是约束变化,添加、删除视图时会触发约束变化。Activating 或 Deactivating,设置 Constant 或 Priority 时也会触发约束变化。Layout Engine 在碰到约束变化后会重新计算布局,获取到布局后调用 superview.setNeedLayout(),然后进入 Deferred Layout Pass。
|
||||
|
||||
Deferred Layout Pass的主要作用是做容错处理。如果有些视图在更新约束时没有确定或缺失布局声明的话,会先在这里做容错处理。
|
||||
|
||||
接下来,Layout Engine会从上到下调用 layoutSubviews() ,通过 Cassowary 算法计算各个子视图的位置,算出来后将子视图的 frame 从 Layout Engine 里拷贝出来。
|
||||
|
||||
在这之后的处理,就和手写布局的绘制、渲染过程一样了。所以,使用 Auto Layout 和手写布局的区别,就是多了布局上的这个计算过程。那么,多的这个 Cassowary 布局,就是在iOS 12 之前影响 Auto Layout 性能的原因吗?
|
||||
|
||||
接下来,我就跟你分析下 Auto Layout 的性能问题。
|
||||
|
||||
# Auto Layout 性能问题
|
||||
|
||||
Auto Layout的性能是否有问题,我们先看看苹果公司自己是怎么说的吧。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/16/ef9b42666a226226b8f5fee6d9621b16.png" alt="">
|
||||
|
||||
上图是 WWDC 2018 中 [202 Session](https://developer.apple.com/videos/play/wwdc2018/202/) 里讲到的Auto Layout 在 iOS 12 中优化后的表现。可以看到,优化后的性能,已经基本和手写布局一样可以达到性能随着视图嵌套的数量呈线性增长了。而在此之前的Auto Layout,视图嵌套的数量对性能的影响是呈指数级增长的。
|
||||
|
||||
所以,你说Auto Layout对性能影响能大不大呢。但是,这个锅应该由 Cassowary 算法来背吗?
|
||||
|
||||
在1997年时,Cassowary 是以高效的界面线性方程求解算法被提出来的。它解决的是界面的线性规划问题,而线性规划问题的解法是 [Simplex 算法](https://en.wikipedia.org/wiki/Simplex_algorithm)。单从 Simplex 算法的复杂度来看,多数情况下是没有指数时间复杂度的。而Cassowary 算法又是在 Simplex算法基础上对界面关系方程进行了高效的添加、修改更新操作,不会带来时间复杂度呈指数级增长的问题。
|
||||
|
||||
那么,如果 Cassowary 算法本身没有问题的话,问题就只可能是苹果公司在 iOS 12 之前在某些情况下没有用好这个算法。
|
||||
|
||||
接下来,我们再看一下 WWDC 2018 中 202 Session 的 Auto Layout 在兄弟视图独立开布局的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/89/b2e91f2bdcb60d4a7fa4b6dbdaebca89.png" alt="">
|
||||
|
||||
可以看到,兄弟视图之间没有关系时,是不会出现性能呈指数增加问题的。这就表示 Cassowary 算法在添加时是高效的。但如果兄弟视图间有关系的话,在视图遍历时会不断处理和兄弟视图间的关系,这时会有修改更新计算。
|
||||
|
||||
由此可以看出,Auto Layout 并没有用上 Cassowary 高效修改更新的特性。
|
||||
|
||||
实际情况是,iOS 12 之前,很多约束变化时都会重新创建一个计算引擎 NSISEnginer 将约束关系重新加进来,然后重新计算。结果就是,涉及到的约束关系变多时,新的计算引擎需要重新计算,最终导致计算量呈指数级增加。
|
||||
|
||||
更详细的讲解,你可以参考 WWDC 2018 中 202 Session的内容,里面完整地分析了以前的问题,以及 iOS12 的解法。
|
||||
|
||||
总体来说, **iOS12 的Auto Layout更多地利用了 Cassowary算法的界面更新策略,使其真正完成了高效的界面线性策略计算。**
|
||||
|
||||
那么,明确了 iOS 12 使得 Auto Layout 具有了和手写布局几乎相同的高性能后,你是不是就可以放心地使用 Auto Layout 了呢?
|
||||
|
||||
答案是肯定的。
|
||||
|
||||
如果你是一名手写布局的 iOS 开发者,这是你投入 Auto Layout 布局开发的最佳时机。
|
||||
|
||||
使用 Auto Layout 一定要注意多使用 Compression Resistance Priority 和 Hugging Priority,利用优先级的设置,让布局更加灵活,代码更少,更易于维护。
|
||||
|
||||
最后,为了更好地使用Auto Layout,我再来和你说说如何提高它的易用性。
|
||||
|
||||
# Auto Layout 的易用性
|
||||
|
||||
除了性能这个心结外,很多开发者直到现在还不愿意使用 Auto Layout的另一个原因,据我了解就是它还存在原生写法不易用的问题。
|
||||
|
||||
苹果公司其实也考虑到了这点。所以,苹果公司后来又提供了 VFL (Visual Format Language) 这种 DSL(Domain Specific Language,中文可翻译为“领域特定语言”) 语言来简化 Auto Layout 的写法。
|
||||
|
||||
本质上,Auto Layout 只是一种最基础的布局思路。在前端出现了 Flexbox 这种高级的响应式布局思路后,苹果公司也紧跟其后,基于 Auto Layout 又封装了一个类似 Flexbox 的 UIStackView,用来提高 iOS 开发响应式布局的易用性。
|
||||
|
||||
UIStackView 会在父视图里设置子视图的排列方式,比如 Fill、Leading、Center,而不用在每个子视图中都设置自己和兄弟视图的关系,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/9a/955a92d094e1fdd4a647e5760ff0629a.png" alt="">
|
||||
|
||||
这样做,可以极大地减少你在约束关系设置上所做的重复工作,提升页面布局的体验。
|
||||
|
||||
我曾开发过一个 DSL 语言用来处理页面布局。我当时的想法就是,希望能够在实际工作中使用 VFL按照 UIStackView 的思路来写布局。由于那时UIStackView 系统版本要求高,所以 DSL 的内部没有使用 UIStackView,而直接使用了Auto Layout。
|
||||
|
||||
DSL 代码很简洁,如下所示:
|
||||
|
||||
```
|
||||
{
|
||||
ht(padding:10)
|
||||
[avatarImageView(imageName:avatar)]
|
||||
[
|
||||
{
|
||||
vl(padding:10)
|
||||
[(text:戴铭,color:AAA0A3)]
|
||||
[(text:Starming站长,color:E3DEE0,font:13)]
|
||||
[(text:喜欢画画编程和写小说,color:E3DEE0,font:13)]
|
||||
}
|
||||
(width:210,backColor:FAF8F9,backPaddingHorizontal:10,backPaddingVertical:10,radius:8)
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码对应的界面效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/7a/4745882ddbe68c5f120f15227578bb7a.png" alt="">
|
||||
|
||||
可以看到,嵌套视图通过简单的 DSL 也能很好地表现出来。详细的使用说明和代码实现,你可以在这里查看:[https://github.com/ming1016/STMAssembleView](https://github.com/ming1016/STMAssembleView)
|
||||
|
||||
# 小结
|
||||
|
||||
今天这篇文章,我和你说了 Auto Layout 背后使用的Cassowary算法。同时,我也和你说了苹果公司经过一番努力,终于在 iOS 12 上用到了Cassowary算法的界面更新策略,使得 Auto Layout 的性能得到了大幅提升。
|
||||
|
||||
至此,Auto Layout 性能的提升可以让你放心地使用。
|
||||
|
||||
记得上次我和一个苹果公司的技术支持人员聊到,到底应该使用苹果自己的布局还是第三方工具比如 Texture 时,他的观点是:使用苹果公司的技术得到的技术升级是持续的,而第三方不再维护的可能性是很高的。
|
||||
|
||||
其实细细想来,这非常有道理。这次 Auto Layout 的升级就是一个很好的例子,你的代码一行不变就能享受到耗时从指数级下降到线性的性能提升。而很多第三方库,会随着 iOS 系统升级失去原有的优势。
|
||||
|
||||
# 课后小作业
|
||||
|
||||
请你参考[VFL的手册](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/VisualFormatLanguage.html),编写一个基于 UIStackView 的 DSL。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/27/f5ee90aa0183a4bcc688980bd625eb27.jpg" alt="">
|
||||
359
极客时间专栏/geek/iOS开发高手课/基础篇/04 | 项目大了人员多了,架构怎么设计更合理?.md
Normal file
359
极客时间专栏/geek/iOS开发高手课/基础篇/04 | 项目大了人员多了,架构怎么设计更合理?.md
Normal file
@@ -0,0 +1,359 @@
|
||||
<audio id="audio" title="04 | 项目大了人员多了,架构怎么设计更合理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/88/aa0cda3741b3b4ee6d07714199d9b888.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天,我要跟你说说怎么设计一个能够支持大型 iOS 工程的架构。
|
||||
|
||||
记得以前所在的团队,规模大了以后,客户端团队也被按照不同业务拆分到了不同的地方。当时,所有的代码都集中在一个仓库,团队里面一百多号人,只要有一个人提交错了,那么所有要更新代码的人都得等到修复后提交。这样一天下来,整个团队的沟通和互相等待都浪费了大量时间。同时,开发完成要进行测试时,由于代码相互耦合、归属不清,也影响到了问题排查的效率,并增加了沟通时间。
|
||||
|
||||
后来,我们痛定思痛,花了很大力气去进行架构整治,将业务完全解耦,将通用功能下沉,每个业务都是一个独立的 Git 仓库,每个业务都能够生成一个 Pod 库,最后再集成到一起。这样经过架构整治以后,也就没再出现过先前的窘境,开发效率也得到了极大的提升。由此可见,合理的架构是多么得重要。
|
||||
|
||||
其实,这并不是个例。当业务需求量和团队规模达到一定程度后,任何一款App都需要考虑架构设计的合理性。
|
||||
|
||||
而谈到架构治理,就需要将老业务、老代码按照新的架构设计模式进行重构。所以,架构重构考虑得越晚,重构起来就越困难,快速迭代的需求开发和漫长的重构之间的矛盾,如同在飞行的飞机上换引擎。及早考虑架构设计就显得尤为重要。
|
||||
|
||||
那么,如何设计一个能支持大规模 App 的架构呢?接下来,我就和你说说这个话题。
|
||||
|
||||
苹果官方推荐的 App 开发模式是 MVC,随之衍生出其他很多类 MVC 的设计模式 MVP、MVVM、MVCS ,它们在不同程度上增强了视图、数据的通信方式,使得逻辑、视图、数据之间的通信更灵活、规整、易于扩展。在 App 浪潮初期,几乎所有 App 采用的都是这种类MVC的结构。原因在于,MVC 是很好的面向对象编程范式,非常适合个人开发或者小团队开发。
|
||||
|
||||
但是,项目大了,人员多了以后,这种架构就扛不住了。因为,这时候功能的量级不一样了。一个大功能,会由多个功能合并而成,每个功能都成了一个独立的业务,团队成员也会按照业务分成不同的团队。此时,简单的逻辑、视图、数据划分再也无法满足 App 大规模工程化的需求。
|
||||
|
||||
所以,接下来我们就不得不考虑模块粒度如何划分、如何分层,以及多团队如何协作这三个问题了。解决了这三个问题以后,我们就可以对模块内部做进一步优化了。模块久经考验后,就能成为通用功能对外部输出,方便更多的团队。
|
||||
|
||||
总的来说,架构是需要演进的。如果项目规模大了还不演进,必然就会拖累业务的发展速度。
|
||||
|
||||
简单架构向大型项目架构演进中,就需要解决三个问题,即:模块粒度应该如何划分?如何分层?多团队如何协作?而在这其中,模块粒度的划分是架构设计中非常关键的一步。同时,这也是一个细活,我们最好可以在不同阶段采用不同的粒度划分模块。现在,我们就带着这三个问题继续往下看吧。
|
||||
|
||||
## 大项目、多人、多团队架构思考
|
||||
|
||||
接下来,我先和你说下**模块粒度应该怎么划分**的问题。
|
||||
|
||||
**首先,**项目规模变大后,模块划分必须遵循一定的原则。如果模块划分规则不规范、不清晰,就会导致代码耦合严重的问题,并加大架构重构的难度。这些问题主要表现在:
|
||||
|
||||
<li>
|
||||
业务需求不断,业务开发不能停。重新划分模块的工作量越大,成本越高,重构技改需求排上日程的难度也就越大。
|
||||
</li>
|
||||
<li>
|
||||
老业务代码年久失修,没有注释,修改起来需要重新梳理逻辑和关系,耗时长。
|
||||
</li>
|
||||
|
||||
**其次,**我们需要搞清楚模块的粒度采用什么标准进行划分,也就是要遵循的原则是什么。
|
||||
|
||||
对于 iOS 这种面向对象编程的开发模式来说,我们应该遵循以下五个原则,即SOLID 原则。
|
||||
|
||||
<li>
|
||||
单一功能原则:对象功能要单一,不要在一个对象里添加很多功能。
|
||||
</li>
|
||||
<li>
|
||||
开闭原则:扩展是开放的,修改是封闭的。
|
||||
</li>
|
||||
<li>
|
||||
里氏替换原则:子类对象是可以替代基类对象的。
|
||||
</li>
|
||||
<li>
|
||||
接口隔离原则:接口的用途要单一,不要在一个接口上根据不同入参实现多个功能。
|
||||
</li>
|
||||
<li>
|
||||
依赖反转原则:方法应该依赖抽象,不要依赖实例。iOS 开发就是高层业务方法依赖于协议。
|
||||
</li>
|
||||
|
||||
同时,遵守这五个原则是开发出容易维护和扩展的架构的基础。
|
||||
|
||||
**最后,**我们需要选择合适的粒度。切记,大型项目的模块粒度过大或者过小都不合适。
|
||||
|
||||
其中,组件可以认为是可组装的、独立的业务单元,具有高内聚,低耦合的特性,是一种比较适中的粒度。就像用乐高拼房子一样,每个对象就是一块小积木。一个组件就是由一块一块的小积木组成的有单一功能的组合,比如门、柱子、烟囱。
|
||||
|
||||
在我看来,iOS 开发中的组件,不是 UI 的控件,也不是ViewController 这种大 UI 和功能的集合。因为,UI 控件的粒度太小,而页面的粒度又太大。**iOS 组件,应该是包含 UI 控件、相关多个小功能的合集,是一种粒度适中的模块。**
|
||||
|
||||
并且,采用组件的话,对于代码逻辑和模块间的通信方式的改动都不大,完成老代码切换也就相对容易些。我们可以先按照物理划分,也就是将多个相同功能的类移动到同一个文件夹下,然后做成 CocoaPods的包进行管理。
|
||||
|
||||
但是,仅做到这一步还不够,因为功能模块之间的耦合还是没有被解除。如果没有解除耦合关系的话,不同功能的开发还是没法独立开来,勉强开发完成后的影响范围评估也难以确定。
|
||||
|
||||
所以接下来,我们就需要**重新梳理组件之间的逻辑关系,进行改造**。
|
||||
|
||||
但是,组件解耦并不是说要求每个组件间都没有耦合,组件间也需要有上下层依赖的关系。组件间的上下层关系划分清楚了,就会容易维护和管理。而对于组件间如何分层这个问题,我认为层级最多不要超过三个,你可以这么设置:
|
||||
|
||||
<li>
|
||||
底层可以是与业务无关的基础组件,比如网络和存储等;
|
||||
</li>
|
||||
<li>
|
||||
中间层一般是通用的业务组件,比如账号、埋点、支付、购物车等;
|
||||
</li>
|
||||
<li>
|
||||
最上层是迭代业务组件,更新频率最高。
|
||||
</li>
|
||||
|
||||
这样的三层结构,尤其有利于多个团队分别开发维护。比如,一开始有两个业务团队A和B,他们在开发时既有通用的功能、账号、埋点、个人页等,也有专有的业务功能模块,每个功能都是一个组件。
|
||||
|
||||
这样,新创建的业务团队C,就能非常轻松地使用团队A和B开发出的通用组件。而且,如果两个业务团队有相同功能时,对相应的功能组件进行简单改造后,也能同时适用于两个业务团队。
|
||||
|
||||
但是,我认为不用把所有的功能都做成组件,只有那些会被多个业务或者团队使用的功能模块才需要做成组件。因为,改造成组件也是需要时间成本的,很少有公司愿意完全停下业务去进行重构,而一旦决定某业务功能模块要改成组件,就要抓住机会,严格按照 SOLID 原则去改造组件,因为返工和再优化的机会可能不会再有。
|
||||
|
||||
## 多团队之间如何分工?
|
||||
|
||||
在代码层面,我们通过组件化解决了大项目、多人、多团队架构的问题,但是架构问题还涉及到团队人员结构上的架构。当公司或者集团的 App 多了后,相应的团队也就多了,为了能够让产品快速迭代和稳定发展,也需要一个合理的团队结构。在我看来,这个**合理的团队结构应该是这样的:**
|
||||
|
||||
<li>
|
||||
首先,需要一个专门的基建团队,负责业务无关的基础功能组件和业务相关通用业务组件的开发。
|
||||
</li>
|
||||
<li>
|
||||
然后,每个业务都由一个专门的团队来负责开发。业务可以按照功能耦合度来划分,耦合度高的业务可以划分成单独的业务团队。
|
||||
</li>
|
||||
<li>
|
||||
基建团队人员应该是流动的,从业务团队里来,再回到业务团队中去。这么设计是因为业务团队和基建团队的边界不应该非常明显,否则就会出现基建团队埋头苦干,结果可能是做得过多、做得不够,或着功能不好用的问题,造成严重的资源浪费。
|
||||
</li>
|
||||
|
||||
总结来讲,我想说的是团队分工要灵活,不要把人员隔离固化了,否则各干各的,做的东西相互都不用。核心上,团队分工还是要围绕着具体业务进行功能模块提炼,去解决重复建设的问题,在这个基础上把提炼出的模块做精做扎实。否则,拉一帮子人臆想出来的东西,无人问津,那就是把自己架空了。
|
||||
|
||||
## 我心目中好的架构是什么样的?
|
||||
|
||||
现在,我们已经可以从代码内外来分析App开发的架构设计了,但也只是会分析了而已,脑海中并没有明确好的架构是什么样的,也不知道具体应该怎么设计。接下来,我们就带着这两个问题继续看下面的内容。
|
||||
|
||||
组件化是解决项目大、人员多的一种很好的手段,这在任何公司或团队都是没有歧义的。组件间关系协调却没有固定的标准,协调的优劣,成为了衡量架构优劣的一个基本标准。所以在实践中,一般分为了**协议式和中间者两种架构**设计方案。
|
||||
|
||||
**协议式架构设计主要采用的是协议式编程的思路**:在编译层面使用协议定义规范,实现可在不同地方,从而达到分布管理和维护组件的目的。这种方式也遵循了依赖反转原则,是一种很好的面向对象编程的实践。
|
||||
|
||||
但是,这个方案的缺点也很明显,主要体现在以下两个方面:
|
||||
|
||||
<li>
|
||||
由于协议式编程缺少统一调度层,导致难于集中管理,特别是项目规模变大、团队变多的情况下,架构管控就会显得越来越重要。
|
||||
</li>
|
||||
<li>
|
||||
协议式编程接口定义模式过于规范,从而使得架构的灵活性不够高。当需要引入一个新的设计模式来开发时,我们就会发现很难融入到当前架构中,缺乏架构的统一性。
|
||||
</li>
|
||||
|
||||
虽然协议式架构有这两方面的局限性,但由于其简单易用的特点依然被很多公司采用。
|
||||
|
||||
**另一种常用的架构形式是中间者架构。它采用中间者统一管理的方式,来控制 App 的整个生命周期中组件间的调用关系。**同时,iOS 对于组件接口的设计也需要保持一致性,方便中间者统一调用。
|
||||
|
||||
中间者架构如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/69/40a625a0f556aa264eee9fbb212b3469.png" alt="">
|
||||
|
||||
可以看到,拆分的组件都会依赖于中间者,但是组间之间就不存在相互依赖的关系了。由于其他组件都会依赖于这个中间者,相互间的通信都会通过中间者统一调度,所以组件间的通信也就更容易管理了。在中间者上也能够轻松添加新的设计模式,从而使得架构更容易扩展。
|
||||
|
||||
在我看来,好的架构一定是健壮的、灵活的。中间者架构的易管控带来的架构更稳固,易扩展带来的灵活性,所以我认为中间者这种架构设计模式是非常值得推荐的。casatwy 以前设计了一个 CTMediator 就是按照中间者架构思路设计的。你可以在[GitHub](https://github.com/casatwy/CTMediator)上看到它的内容。
|
||||
|
||||
CTMediator 使用的是运行时解耦,接下来我就通过开源的 CTMediator 代码,和你分享下如何使用运行时技术来解耦。解耦核心方法如下所示:
|
||||
|
||||
```
|
||||
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
|
||||
{
|
||||
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
|
||||
|
||||
// generate target
|
||||
NSString *targetClassString = nil;
|
||||
if (swiftModuleName.length > 0) {
|
||||
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
|
||||
} else {
|
||||
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
|
||||
}
|
||||
NSObject *target = self.cachedTarget[targetClassString];
|
||||
if (target == nil) {
|
||||
Class targetClass = NSClassFromString(targetClassString);
|
||||
target = [[targetClass alloc] init];
|
||||
}
|
||||
|
||||
|
||||
// generate action
|
||||
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
|
||||
SEL action = NSSelectorFromString(actionString);
|
||||
|
||||
if (target == nil) {
|
||||
// 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
|
||||
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (shouldCacheTarget) {
|
||||
self.cachedTarget[targetClassString] = target;
|
||||
}
|
||||
|
||||
|
||||
if ([target respondsToSelector:action]) {
|
||||
return [self safePerformAction:action target:target params:params];
|
||||
} else {
|
||||
// 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
|
||||
SEL action = NSSelectorFromString(@"notFound:");
|
||||
if ([target respondsToSelector:action]) {
|
||||
return [self safePerformAction:action target:target params:params];
|
||||
} else {
|
||||
// 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
|
||||
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
|
||||
[self.cachedTarget removeObjectForKey:targetClassString];
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
performTarget:action:params:shouldCacheTarget:方法主要是对 targetName 和 actionName 进行容错处理,也就是对调用方法无响应的处理。这个方法封装了 safePerformAction:target:params 方法,入参 targetName 就是调用接口的对象,actionName 是调用的方法名,params 是参数。
|
||||
|
||||
从代码中同时还能看出只有满足 Target_ 前缀的对象和 Action 的方法才能被 CTMediator 使用。这时,我们可以看出中间者架构的优势,也就是利于统一管理,可以轻松管控制定的规则。
|
||||
|
||||
下面这段代码,是使用 CTMediator 如何调用一个弹窗显示方法的代码示范:
|
||||
|
||||
```
|
||||
[self performTarget:kCTMediatorTargetA
|
||||
action:kCTMediatorActionShowAlert
|
||||
params:paramsToSend
|
||||
shouldCacheTarget:NO];
|
||||
|
||||
```
|
||||
|
||||
可以看出,指定了对象名和调用方法名,把参数封装成字典传进去就能够直接调用该方法了。
|
||||
|
||||
但是,这种运行时直接硬编码的调用方式也有些缺点,主要表现在两个方面:
|
||||
|
||||
<li>
|
||||
直接硬编码的调用方式,参数是以string的方法保存在内存里,虽然和将参数保存在Text字段里占用的内存差不多,同时还可以避免.h文件的耦合,但是其对代码编写效率的降低也比较明显。
|
||||
</li>
|
||||
<li>
|
||||
由于是在运行时才确定的调用方法,调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话,在调用时就缺少类型检查,是个很大的缺憾。因为,如果方法和参数比较多的时候,代码编写效率就会比较低。
|
||||
</li>
|
||||
|
||||
这篇文章发出后 CTMediator 的作者 casatwy 找到了我,指出文章中提到的 CTMediator 的硬编码和字典传参这两个缺点,实际上已经被完美解决了。下面是 casatwy 的原话,希望可以对你有所帮助。
|
||||
|
||||
>
|
||||
CTMediator 本质就是一个方法,用来接收 target、action、params。由于 target、action 都是字符串,params是字典,对于调用者来说十分不友好,因为调用者要写字符串,而且调用的时候若是不看文档,他也不知道这个字典里该塞什么东西。
|
||||
|
||||
|
||||
>
|
||||
所以实际情况中,调用者是不会直接调用CTMediator的方法的。那调用者怎么发起调用呢?通过响应者给CTMediator做的category或者extension发起调用。
|
||||
|
||||
|
||||
>
|
||||
category或extension以函数声明的方式,解决了参数的问题。调用者看这个函数长什么样子,就知道给哪些参数。在category或extension的方法实现中,把参数字典化,顺便把target、action这俩字符串写死在调用里。
|
||||
|
||||
|
||||
>
|
||||
于是,对于调用者来说,他就不必查文档去看参数怎么给,也不必担心target、action字符串是什么了。这个category是一个独立的Pod,由响应者业务的开发给到。
|
||||
|
||||
|
||||
>
|
||||
所以,当一个工程师开发一个业务的时候,他会开发两个Pod,一个是category Pod,一个是自己本身的业务Pod。这样就完美解决了CTMediator它自身的缺点。
|
||||
|
||||
|
||||
>
|
||||
对于调用者来说,他不会直接依赖CTMediator去发起调用,而是直接依赖category Pod去发起调用的。这么一来,CTMediator方案就完美了。
|
||||
|
||||
|
||||
>
|
||||
然后还有一点可能需要强调:基于CTMediator方案的工程,每一个组件无所谓是OC还是Swift,Pod也无所谓是category还是extension。也就是说,假设一个工程由100个组件组成,那可以是50个OC、50个Swift。因为CTMediator抹去了不同语言的组件之间的隔阂,所以大家老的OC工程可以先应用CTMediator,把组件拆出来。然后新的业务来了,用Swift写,等有空的时候再把老的OC改成Swift,或者不改,都是没问题的。
|
||||
|
||||
|
||||
不过,解耦的精髓在于业务逻辑能够独立出来,并不是形式上的解除编译上的耦合(编译上解除耦合只能算是解耦的一种手段而已)。所以,在考虑架构设计时,我们**更多的还是需要在功能逻辑和组件划分上做到同层级解耦,上下层依赖清晰,这样的结构才能够使得上层组件易插拔,下层组件更稳固。而中间者架构模式更容易维护这种结构,中间者的易管控和易扩展性,也使得整体架构能够长期保持稳健与活力。所以,中间者架构就是我心目中好的架构。**
|
||||
|
||||
## 案例分享
|
||||
|
||||
明确了中间者架构是我认为的好架构,那么如何体现其易管控和易扩展性呢?我通过一个案例来和你一起分析下。
|
||||
|
||||
这个例子的代码,在 CTMediator 的基础上进行了扩展,完整代码请点击[这个GitHub链接](https://github.com/ming1016/ArchitectureDemo) 。
|
||||
|
||||
这个范例的主要组件类名和方法名,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/62/8fe9d2bcbbb6b6f0f7be6e2da5557e62.png" alt="">
|
||||
|
||||
可以看出,这个范例在中间者架构的基础上增加了对中间件、状态机、观察者、工厂模式的支持。同时,这个案例也在使用上做了些优化,支持了链式调用,代码如下:
|
||||
|
||||
```
|
||||
self.dispatch(CdntAction.cls(@"PublishCom").mtd(@"viewInVC").pa(dic));
|
||||
|
||||
```
|
||||
|
||||
代码中的PublishCom 是组件类名,ViewInVC 是方法名。
|
||||
|
||||
下面说下**中间件模式。**在添加中间件的时候,我们只需要链式调用 addMiddlewareAction 就可以在方法执行之前插入中间件。代码如下:
|
||||
|
||||
```
|
||||
self.middleware(@"PublishCom showEmergeView").addMiddlewareAction(CdntAction.clsmtd(@"AopLogCom emergeLog").pa(Dic.create.key(@"actionState").val(@"show").done));
|
||||
|
||||
```
|
||||
|
||||
这行代码对字典参数也使用了链式方便参数的设置,使得字典设置更易于编写。改变状态使用 toSt 方法即可,状态的维护和管理都在内部实现。同一个方法不同状态的实现只需要在命名规则上做文章即可,这也是得易于中间者架构可以统一处理方法调用规则的特性。比如,confirmEmerge 方法在不同状态下的实现代码如下:
|
||||
|
||||
```
|
||||
// 状态管理
|
||||
- (void)confirmEmerge_state_focusTitle:(NSDictionary *)dic {
|
||||
NSString *title = dic[@"title"];
|
||||
[self.fromAddressBt setTitle:title forState:UIControlStateNormal];
|
||||
self.fromAddressBt.tag = 1;
|
||||
}
|
||||
- (void)confirmEmerge_state_focusContent:(NSDictionary *)dic {
|
||||
NSString *title = dic[@"title"];
|
||||
[self.toAddressBt setTitle:title forState:UIControlStateNormal];
|
||||
self.toAddressBt.tag = 1;
|
||||
}
|
||||
- (void)confirmEmerge_state_focusPrice:(NSDictionary *)dic {
|
||||
NSString *title = dic[@"title"];
|
||||
[self.peopleBt setTitle:title forState:UIControlStateNormal];
|
||||
self.peopleBt.tag = 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,我们只需要在方法名后面加上“ _state _状态名”,就能够对不同状态进行不同实现了。
|
||||
|
||||
对于**观察者模式**,使用起来也很简单清晰。比如,发布文章这个事件需要两个观察者,一个执行重置界面,一个检查是否能够发布,代码如下:
|
||||
|
||||
```
|
||||
// 观察者管理 self.observerWithIdentifier(@"publishOk").addObserver(CdntAction.clsmtd(@"PublishCom reset")).addObserver(CdntAction.clsmtd(@"PublishCom checkPublish"));
|
||||
|
||||
```
|
||||
|
||||
这样的写法非常简单清晰。在发布时,我们只需要执行如下代码:
|
||||
|
||||
```
|
||||
[self notifyObservers:@"publishOk"];
|
||||
|
||||
```
|
||||
|
||||
观察者方法添加后,也会记录在内部,它们的生命周期跟随中间者的生命周期。
|
||||
|
||||
**工厂模式的思路和状态机类似**,状态机是对方法在不同状态下的实现,而工厂模式是对类在不同设置下的不同实现。由于有了中间者,我就可以在传入类名后对其进行类似状态机中方法名的处理,以便类的不同实现可以通过命名规则来完成。我们先看看中间者处理状态机的代码:
|
||||
|
||||
```
|
||||
// State action 状态处理
|
||||
if (![self.p_currentState isEqual:@"init"]) {
|
||||
SEL stateMethod = NSSelectorFromString([NSString stringWithFormat:@"%@_state_%@:", sep[1], self.p_currentState]);
|
||||
if ([obj respondsToSelector:stateMethod]) {
|
||||
return [self executeMethod:stateMethod obj:obj parameters:parameters];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出当前的状态会记录在 p_currentState 属性中,方法调用时方法名会和当前的状态的命名拼接成一个完整的实现方法名来调用。中间者处理工厂模式的思路也类似,代码如下:
|
||||
|
||||
```
|
||||
// Factory
|
||||
// 由于后面的执行都会用到 class 所以需要优先处理 class 的变形体
|
||||
NSString *factory = [self.factories objectForKey:classStr];
|
||||
if (factory) {
|
||||
classStr = [NSString stringWithFormat:@"%@_factory_%@", classStr, factory];
|
||||
classMethod = [NSString stringWithFormat:@"%@ %@", classStr, sep[1]];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,采用了中间者这种架构设计思想后,架构就具有了很高的扩展性和可管控性。所以,我推崇这种架构设计思路。
|
||||
|
||||
## 小结
|
||||
|
||||
架构的设计绝对不是要等到工程到了燃眉之急之时,再去环顾其他公司或团队在用什么架构,然后拍脑袋拿一个过来,来次大重构。好的架构,需要在业务开发过程中及早发现开发的痛点,进行有针对性的改良,不然就会和实际开发越走越远。
|
||||
|
||||
比如,某个业务模块的逻辑非常复杂、状态有很多,这时我们就需要在架构层面考虑如何处理会更方便,改动最小的支持状态机模式,又或者在开始架构设计时就多考虑如何将架构设计的具有更高的易用性和可扩展性。
|
||||
|
||||
好的架构是能够在一定的规范内同时支持高灵活度,这种度的把握是需要架构师长期跟随团队开发,随着实际业务需求的演进进行分析和把控的。
|
||||
|
||||
在项目大了,人员多了的情况下,好的架构一定是不简单的,不通用的,但一定是接地气的,这样才能更适合自己的团队,才能够用得上。那些大而全,炫技,脱离业务开发需求的架构是没法落地的。
|
||||
|
||||
最后,我提点建议。我在面试应聘者的时候,通常都会问他所负责项目的整体架构是怎样的。结果呢,很多人都只对自己负责的那摊子事儿说的溜,而回答所在项目整体情况时却支支吾吾,也因此没能面试成功。
|
||||
|
||||
所以,作为一名普通的开发者,除了日常需求开发和技术方案调研、设计外,你还需要了解自己所在项目的整体架构是怎样的,想想架构上哪些地方是不够好需要改进的,业界有哪些好的架构思想是可以落地到自己项目中的。有了从项目整体上去思考的意识,你才能够站在更高的视角上去思考问题。这,也是对架构师的基本要求。
|
||||
|
||||
## 课后作业
|
||||
|
||||
架构如何设计众说纷纭,请你来说下你们项目目前架构是怎样的,并画出你心中理想的架构图。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
368
极客时间专栏/geek/iOS开发高手课/基础篇/05 | 链接器:符号是怎么绑定到地址上的?.md
Normal file
368
极客时间专栏/geek/iOS开发高手课/基础篇/05 | 链接器:符号是怎么绑定到地址上的?.md
Normal file
@@ -0,0 +1,368 @@
|
||||
<audio id="audio" title="05 | 链接器:符号是怎么绑定到地址上的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/98/e20542bd026412310f1e68308ce5f898.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
你是不是经常会好奇自己参与的这么些项目,为什么有的编译起来很快,有的却很慢;编译完成后,有的启动得很快,有的却很慢。其实,在理解了编译和启动时链接器所做的事儿之后,你就可以从根儿上找到这些问题的答案了。
|
||||
|
||||
所以,在今天这篇文章中,我就重点和你讲解一下链接器相关的知识。**简单地说,链接器最主要的作用,就是将符号绑定到地址上。**理解了这其中的原理后,你就可以有针对性地去调整和优化项目了。
|
||||
|
||||
同时,掌握了链接器的作用,也将有助于你理解后面文章中,关于符号表、加载相关的内容。
|
||||
|
||||
现在,我们就从 iOS 开发的起点,也就是编写代码和编译代码开始说起,看看链接器在这其中到底发挥了什么作用。
|
||||
|
||||
## iOS开发为什么使用的是编译器?
|
||||
|
||||
我们都知道,iOS 编写的代码是先使用编译器把代码编译成机器码,然后直接在 CPU 上执行机器码的。之所以不使用解释器来运行代码,是因为苹果公司希望 iPhone 的执行效率更高、运行速度能达到最快。
|
||||
|
||||
那**为什么说用解释器运行代码的速度不够快呢?**这是因为解释器会在运行时解释执行代码,获取一段代码后就会将其翻译成目标代码(就是字节码(Bytecode)),然后一句一句地执行目标代码。
|
||||
|
||||
也就是说,解释器,是在运行时才去解析代码,这样就比在运行之前通过编译器生成一份完整的机器码再去执行的效率要低。
|
||||
|
||||
这时你一定会纳闷了,既然编译器效率这么高,那为什么还有人用解释器呢?所谓事有利弊,解释器可以在运行时去执行代码,说明它具有动态性,程序运行后能够随时通过增加和更新代码来改变程序的逻辑。
|
||||
|
||||
也就是说,你写的程序跑起来后不用重新启动,就可以看到代码修改后的效果,这样就缩短了调试周期。程序发布后,你还可以随时修复问题或者增加新功能,用户也不用一定要等到发布新版本后才可以升级使用。所以说,使用解释器可以帮我们缩短整个程序的开发周期和功能更新周期。
|
||||
|
||||
那么,使用编译器和解释器执行代码的特点,我们就可以概括如下:
|
||||
|
||||
<li>
|
||||
采用编译器生成机器码执行的好处是效率高,缺点是调试周期长。
|
||||
</li>
|
||||
<li>
|
||||
解释器执行的好处是编写调试方便,缺点是执行效率低。
|
||||
</li>
|
||||
|
||||
编译器和解释器的比较图示如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/43/d7621cb8232fae96b46a52e1b7fb7643.png" alt="">
|
||||
|
||||
明确了iOS开发使用编译器的原因以后,你还需要了解 **iOS 开发使用的到底是什么编译器。**
|
||||
|
||||
现在苹果公司使用的编译器是LLVM,相比于Xcode 5版本前使用的GCC,编译速度提高了3倍。同时,苹果公司也反过来主导了 LLVM 的发展,让 LLVM 可以针对苹果公司的硬件进行更多的优化。
|
||||
|
||||
总结来说,LLVM 是编译器工具链技术的一个集合。而其中的lld 项目,就是内置链接器。编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个 Mach-O文件合并成一个。
|
||||
|
||||
LLVM 的编译过程非常复杂。如果你有兴趣的话,可以通过[官方手册](http://llvm.org/docs/)查看完整的编译过程。
|
||||
|
||||
这里,我先简单为你总结下编译的几个主要过程:
|
||||
|
||||
<li>
|
||||
首先,你写好代码后,LLVM 会预处理你的代码,比如把宏嵌入到对应的位置。
|
||||
</li>
|
||||
<li>
|
||||
预处理完后,LLVM会对代码进行词法分析和语法分析,生成 AST 。AST 是抽象语法树,结构上比代码更精简,遍历起来更快,所以使用 AST 能够更快速地进行静态检查,同时还能更快地生成 IR(中间表示)。
|
||||
</li>
|
||||
<li>
|
||||
最后 AST 会生成 IR,IR 是一种更接近机器码的语言,区别在于和平台无关,通过 IR 可以生成多份适合不同平台的机器码。对于 iOS 系统,IR 生成的可执行文件就是 Mach-O。
|
||||
</li>
|
||||
|
||||
下图展示了编译的主要过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/63/03e511d5cc6eb12889d882d6e705ac63.png" alt="">
|
||||
|
||||
## 编译时链接器做了什么?
|
||||
|
||||
Mach-O 文件里面的内容,主要就是代码和数据:代码是函数的定义;数据是全局变量的定义,包括全局变量的初始值。不管是代码还是数据,它们的实例都需要由符号将其关联起来。
|
||||
|
||||
为什么呢?因为Mach-O 文件里的那些代码,比如 if、for、while 生成的机器指令序列,要操作的数据会存储在某个地方,变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码,引用的函数符号也需要绑定到该函数的地址上。
|
||||
|
||||
而链接器的作用,就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号,就可以理解为变量名和函数名。
|
||||
|
||||
**那为什么要让链接器做符号和地址绑定这样一件事儿呢?不绑定的话,又会有什么问题?**
|
||||
|
||||
如果地址和符号不做绑定的话,要让机器知道你在操作什么内存地址,你就需要在写代码时给每个指令设好内存地址。写这样的代码的过程,就像你直接在和不同平台的机器沟通,连编译生成 AST 和 IR 的步骤都省掉了,甚至优化平台相关的代码都需要你自己编写。
|
||||
|
||||
这件事儿看起来挺酷,但可读性和可维护性都会很差,比如修改代码后对地址的维护就会让你崩溃。而这种“崩溃”的罪魁祸首就是代码和内存地址绑定得太早。
|
||||
|
||||
另外,绑定得太早除了可读性和可维护性差之外,还会有更多的重复工作。因为,你需要针对不同的平台写多份代码,而这些代码本可以通过高级语言一次编译成多份。既然这样,那我们应该怎么办呢?
|
||||
|
||||
我们首先想到的就是,用汇编语言来让这种绑定滞后。随着编程语言的进化,我们很快就发现,采用任何一种高级编程语言,都可以解决代码和内存绑定过早产生的问题,同时还能扫掉使用汇编写程序的烦恼。
|
||||
|
||||
现在,我们已经通过反证法,理解了在一个文件里把符号和地址绑定在一起的必要性。接下来,我们再看看**链接器为什么还要把项目中的多个 Mach-O文件合并成一个。**
|
||||
|
||||
其实,这个问题也好回答。
|
||||
|
||||
你肯定不希望一个项目是在一个文件里从头写到尾的吧。项目中文件之间的变量和接口函数都是相互依赖的,所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。
|
||||
|
||||
没有这个绑定过程的话,单个文件生成的 Mach-O 文件是无法正常运行起来的。因为,如果运行时碰到调用在其他文件中实现的函数的情况时,就会找不到这个调用函数的地址,从而无法继续执行。
|
||||
|
||||
链接器在链接多个目标文件的过程中,会创建一个符号表,用于记录所有已定义的和所有未定义的符号。链接时如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息;如果在其他目标文件里没有找到符号,就会提示“Undefined symbols”的错误信息。
|
||||
|
||||
说完了链接器解决的问题,我们再一起来看看**链接器对代码主要做了哪几件事儿。**
|
||||
|
||||
<li>
|
||||
去项目文件里查找目标代码文件里没有定义的变量。
|
||||
</li>
|
||||
<li>
|
||||
扫描项目中的不同文件,将所有符号定义和引用地址收集起来,并放到全局符号表中。
|
||||
</li>
|
||||
<li>
|
||||
计算合并后长度及位置,生成同类型的段进行合并,建立绑定。
|
||||
</li>
|
||||
<li>
|
||||
对项目中不同文件里的变量进行地址重定位。
|
||||
</li>
|
||||
|
||||
你在项目里为某项需求写了一些功能函数,但随着业务的发展,一些功能被下掉了或者被其他负责的同事在另一个文件里用其他函数更新了功能。那么这时,你以前写的那些函数就没有用武之地了。日长月久,无用的函数越来越多,生成的 Mach-O 文件也就越来越大。
|
||||
|
||||
这时,链接器在整理函数的符号调用关系时,就可以帮你理清有哪些函数是没被调用的,并自动去除掉。那这是怎么实现的呢?
|
||||
|
||||
链接器在整理函数的调用关系时,会以main函数为源头,跟随每个引用,并将其标记为live。跟随完成后,那些未被标记live的函数,就是无用函数。然后,链接器可以通过打开 Dead code stripping 开关,来开启自动去除无用代码的功能。并且,这个开关是默认开启的。
|
||||
|
||||
说完了编译时链接器的基本功能,接下来我们再说一说动态库链接,这也是链接器的一大作用。
|
||||
|
||||
## 动态库链接
|
||||
|
||||
在真实的 iOS 开发中,你会发现很多功能都是现成可用的,不光你能够用,其他App 也在用,比如 GUI 框架、I/O、网络等。链接这些共享库到你的 Mach-O 文件,也是通过链接器来完成的。
|
||||
|
||||
链接的共用库分为静态库和动态库:静态库是编译时链接的库,需要链接进你的 Mach-O 文件里,如果需要更新就要重新编译一次,无法动态加载和更新;而动态库是运行时链接的库,使用 dyld 就可以实现动态加载。
|
||||
|
||||
Mach-O 文件是编译后的产物,而动态库在运行时才会被链接,并没参与 Mach-O文件的编译和链接,所以 Mach-O文件中并没有包含动态库里的符号定义。也就是说,这些符号会显示为“未定义”,但它们的名字和对应的库的路径会被记录下来。运行时通过 dlopen 和 dlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。
|
||||
|
||||
dlopen 会把共享库载入运行进程的地址空间,载入的共享库也会有未定义的符号,这样会触发更多的共享库被载入。dlopen 也可以选择是立刻解析所有引用还是滞后去做。dlopen 打开动态库后返回的是引用的指针,dlsym 的作用就是通过 dlopen 返回的动态库指针和函数符号,得到函数的地址然后使用。
|
||||
|
||||
**使用dyld加载动态库,有两种方式**:有程序启动加载时绑定和符号第一次被用到时绑定。为了减少启动时间,大部分动态库使用的都是符号第一次被用到时再绑定的方式。
|
||||
|
||||
加载过程开始会修正地址偏移,iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 Clang Attribute 的 constructor 修饰函数。
|
||||
|
||||
每个函数、全局变量和类都是通过符号的形式定义和使用的,当把目标文件链接成一个 Mach-O文件时,链接器在目标文件和动态库之间对符号做解析处理。
|
||||
|
||||
下面,我们就通过一个例子来看看 dyld 的链接过程。
|
||||
|
||||
**第一步:**先编写多个文件。
|
||||
|
||||
Boy.h
|
||||
|
||||
```
|
||||
c
|
||||
#import <Foundation/Foundation.h>
|
||||
@interface Boy : NSObject
|
||||
- (void)say;
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
Boy.m
|
||||
|
||||
```
|
||||
c
|
||||
#import “Boy.h”
|
||||
@implementation Boy
|
||||
- (void)say
|
||||
{
|
||||
NSLog(@“hi there again!\n”);
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
SayHi.m
|
||||
|
||||
```
|
||||
c
|
||||
#import “Boy.h”
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
@autoreleasepool {
|
||||
Boy *boy = [[Boy alloc] init];
|
||||
[boy say];
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**第二步:**编译多个文件。
|
||||
|
||||
```
|
||||
xcrun clang -c Boy.m
|
||||
xcrun clang -c SayHi.m
|
||||
|
||||
```
|
||||
|
||||
**第三步:**将编译后的文件链接起来,这样就可以生成 a.out 可执行文件了。
|
||||
|
||||
>
|
||||
备注:a.out是编译器的默认名字。
|
||||
|
||||
|
||||
```
|
||||
xcrun clang SayHi.o Boy.o -Wl,`xcrun —show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation
|
||||
|
||||
```
|
||||
|
||||
符号表会规定它们的符号,你可以使用 nm 工具查看。
|
||||
|
||||
我们先用nm工具看一下SayHi.o文件:
|
||||
|
||||
```
|
||||
xcrun nm -nm SayHi.o
|
||||
|
||||
|
||||
(undefined) external _OBJC_CLASS_$_Boy
|
||||
(undefined) external _objc_autoreleasePoolPop
|
||||
(undefined) external _objc_autoreleasePoolPush
|
||||
(undefined) external _objc_msgSend
|
||||
0000000000000000 (__TEXT,__text) external _main
|
||||
|
||||
```
|
||||
|
||||
<li>
|
||||
_OBJC_CLASS_$_Boy ,表示 Boy 的 OC 符号。
|
||||
</li>
|
||||
<li>
|
||||
(undefined) external ,表示未实现非私有。如果是私有的话,就是 non-external。
|
||||
</li>
|
||||
<li>
|
||||
external _main ,表示 main() 函数,处理 0 地址,记录在 __TEXT,__text 区域里。
|
||||
</li>
|
||||
|
||||
接下来,我们再看看 Boy.o文件:
|
||||
|
||||
```
|
||||
xcrun nm -nm Boy.o
|
||||
|
||||
|
||||
(undefined) external _NSLog
|
||||
(undefined) external _OBJC_CLASS_$_NSObject
|
||||
(undefined) external _OBJC_METACLASS_$_NSObject
|
||||
(undefined) external ___CFConstantStringClassReference
|
||||
(undefined) external __objc_empty_cache
|
||||
0000000000000000 (__TEXT,__text) non-external -[Boy say]
|
||||
0000000000000060 (__DATA,__objc_const) non-external l_OBJC_METACLASS_RO_$_Boy
|
||||
00000000000000a8 (__DATA,__objc_const) non-external l_OBJC_$_INSTANCE_METHODS_Boy
|
||||
00000000000000c8 (__DATA,__objc_const) non-external l_OBJC_CLASS_RO_$_Boy
|
||||
0000000000000110 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
|
||||
0000000000000138 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
|
||||
|
||||
```
|
||||
|
||||
因为 undefined 符号表示的是该文件类未定义,所以在目标文件和 Foundation framework 动态库做链接处理时,链接器会尝试解析所有的 undefined 符号。
|
||||
|
||||
链接器通过动态库解析成符号会记录是通过哪个动态库解析的,路径也会一起记录下来。你可以再用 nm 工具查看 a.out 符号表,对比 boy.o 的符号表,看看链接器是怎么解析符号的。
|
||||
|
||||
```
|
||||
xcrun nm -nm a.out
|
||||
|
||||
|
||||
(undefined) external _NSLog (from Foundation)
|
||||
(undefined) external _OBJC_CLASS_$_NSObject (from CoreFoundation)
|
||||
(undefined) external _OBJC_METACLASS_$_NSObject (from CoreFoundation)
|
||||
(undefined) external ___CFConstantStringClassReference (from CoreFoundation)
|
||||
(undefined) external __objc_empty_cache (from libobjc)
|
||||
(undefined) external _objc_autoreleasePoolPop (from libobjc)
|
||||
(undefined) external _objc_autoreleasePoolPush (from libobjc)
|
||||
(undefined) external _objc_msgSend (from libobjc)
|
||||
(undefined) external dyld_stub_binder (from libSystem)
|
||||
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
|
||||
0000000100000e90 (__TEXT,__text) external _main
|
||||
0000000100000f10 (__TEXT,__text) non-external -[Boy say]
|
||||
0000000100001130 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
|
||||
0000000100001158 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
|
||||
|
||||
```
|
||||
|
||||
进行对比的时候,我们可以重点关注哪些 undefined 的符号,有了更多信息,就可以知道在哪个动态库能够找到它。
|
||||
|
||||
我们可以通过 otool工具来找到符号所需库在哪儿。
|
||||
|
||||
```
|
||||
xcrun otool -L a.out
|
||||
|
||||
a.out:
|
||||
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1349.25.0)
|
||||
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.0.0)
|
||||
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1348.28.0)
|
||||
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
|
||||
|
||||
```
|
||||
|
||||
从otool 工具输出的结果可以看到,这些 undefined 符号需要的两个库分别是 libSystem 和 libobjc。查看 libSystem库的话,你可以看到常用的 GCD 的 libdispatch,还有 Block 的 libsystem_blocks。
|
||||
|
||||
dylib 这种格式,表示是动态链接的,编译的时候不会被编译到执行文件中,在程序执行的时候才 link,这样就不用算到包大小里,而且不更新执行程序就能够更新库。
|
||||
|
||||
我们可以打印看看什么库被加载了:
|
||||
|
||||
```
|
||||
(export DYLD_PRINT_LIBRARIES=; ./a.out )
|
||||
|
||||
|
||||
dyld: loaded: /Users/didi/Downloads/./a.out
|
||||
dyld: loaded: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
|
||||
dyld: loaded: /usr/lib/libSystem.B.dylib
|
||||
dyld: loaded: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
|
||||
…
|
||||
|
||||
```
|
||||
|
||||
数一下,被加载的库还挺多的。
|
||||
|
||||
因为 Foundation 还会依赖一些其他动态库,这些依赖的其他库还会再依赖更多的库,所以相互依赖的符号会很多,需要处理的时间也会比较长。
|
||||
|
||||
这里系统上的动态链接器会使用共享缓存,共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时,动态链接器会先检查是否有共享缓存。每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化App启动速度的作用。
|
||||
|
||||
而关于动态链接器的作用顺序是怎样的,你可以先看看 Mike Ash 写的这篇关于 dyld 的博客: [Dynamic Linking On OS X](https://www.mikeash.com/pyblog/friday-qa-2012-11-09-dyld-dynamic-linking-on-os-x.html)。这篇博客里面,很详细地讲解了 dyld 所做的事情。
|
||||
|
||||
简单来说, dyld做了这么几件事儿:
|
||||
|
||||
<li>
|
||||
先执行 Mach-O文件,根据 Mach-O文件里 undefined 的符号加载对应的动态库,系统会设置一个共享缓存来解决加载的递归依赖问题;
|
||||
</li>
|
||||
<li>
|
||||
加载后,将 undefined 的符号绑定到动态库里对应的地址上;
|
||||
</li>
|
||||
<li>
|
||||
最后再处理 +load 方法,main 函数返回后运行 static terminator。
|
||||
</li>
|
||||
|
||||
调用 +load 方法是通过 runtime 库处理的。你可以通过一个[可编译的开源 runtime 库](https://github.com/RetVal/objc-runtime)来了解 runtime,从源码层面去看程序启动时 runtime 做了哪些事情。在 debug-objc 下创建一个类,在 +load 方法里断点查看走到这里调用的堆栈如下:
|
||||
|
||||
```
|
||||
0 +[someclass load]
|
||||
1 call_class_loads()
|
||||
2 ::call_load_methods
|
||||
3 ::load_images(const char *path __unused, const struct mach_header *mh)
|
||||
4 dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)
|
||||
11 _dyld_start
|
||||
|
||||
```
|
||||
|
||||
在 load_images 方法里断点 p path 可以打印出所有加载的动态链接库,这个方法的 hasLoadMethods 用于快速判断是否有 +load 方法。
|
||||
|
||||
prepare_load_methods 这个方法会获取所有类的列表然后收集其中的 +load 方法,在代码里可以发现 Class 的 +load 是先执行的,然后执行 Category 。
|
||||
|
||||
为什么这样做呢?我们通过 prepare_load_methods 这个方法可以看出,在遍历 Class 的 +load 方法时会执行 schedule_class_load 方法,这个方法会递归到根节点来满足 Class 收集完整关系树的需求。
|
||||
|
||||
最后, call_load_methods 会创建一个 autoreleasePool 使用函数指针来动态调用类和 Category 的 +load 方法。
|
||||
|
||||
如果你想了解 Cocoa 的 Foundation 库的话,可以通过 GNUStep 源码来学习。比如 ,NSNotificationCenter 发送通知是按什么顺序发送的,你可以查看 NSNotificationCenter.m 里的 addObserver 方法和 postNotification 方法,看看观察者是怎么添加的,以及是怎么被遍历通知到的。
|
||||
|
||||
最后说一句,dyld 是开源的,地址是:[https://github.com/opensource-apple/dyld](https://github.com/opensource-apple/dyld)
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我与你介绍了链接器是什么,为什么需要链接器,以及链接器在编译时和程序启动时会做的事情。总体来看,从编译、链接、执行、动态库加载到 main 函数开始执行的过程如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/91/37861720c57d41c723a9118ad120da91.png" alt=""><br>
|
||||
编译阶段由于有了链接器,你的代码可以写在不同的文件里,每个文件都能够独立编成 Mach-O 文件进行标记。编译器可以根据你修改的文件范围来减少编译,通过这种方式提高每次编译的速度。
|
||||
|
||||
了解了这种链接机制,你也能够明白,文件越多,链接器链接 Mach-O文件所需绑定的遍历操作就会越多,编译速度也会越慢。
|
||||
|
||||
了解程序运行阶段的动态库链接原理,会让你更多地了解程序在启动时做的事情,同时还能够对你有一些启发。
|
||||
|
||||
比如,在开发调试阶段,是不是代码改完以后可以先不去链接项目里的所有文件,只编译当前修改的文件动态库,通过运行时加载动态库及时更新,看到修改的结果。这样调试的速度,不就能够得到质的提升了么。而具体怎么做,我会在第6篇文章“App 如何通过注入动态库的方式实现极速编译调试?”中和你详细说明。
|
||||
|
||||
再比如,你可以逆向找出其他 App 里你感兴趣功能的使用方法,然后在自己的程序里直接调用它,最后将那个 App 作为动态库加载到自己的 App 里。这样,你感兴趣的这个功能,就能够在你自己的程序里起作用了。
|
||||
|
||||
其实,使用链接器不仅能提高开发效率,还可以让你发挥想象力再去做些其他有趣的事情。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
请你写一段代码,在 App 运行时通过 dlopen 和 dlsym 链接加载 bundle 里的动态库。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
最近,我收到一些同学的反馈,说这门课的一些内容比较深,一时难以琢磨透。如果你也有这样的感受,推荐你学习极客时间刚刚上新的另一门视频课程:由腾讯高级工程师朱德权,主讲的《从 0 开发一款 iOS App》。
|
||||
|
||||
朱德权老师将会基于最新技术,从实践出发,手把手带你构建类今日头条的App。要知道,那些很牛的 iOS 开发者,往往都具备独立开发一款 App 的能力。
|
||||
|
||||
这门课正在上新优惠,欢迎点击[这里](https://time.geekbang.org/course/intro/169?utm_term=zeusKHUZ0&utm_source=app&utm_medium=geektime&utm_campaign=169-presell&utm_content=daiming)试看。
|
||||
167
极客时间专栏/geek/iOS开发高手课/基础篇/06 | App 如何通过注入动态库的方式实现极速编译调试?.md
Normal file
167
极客时间专栏/geek/iOS开发高手课/基础篇/06 | App 如何通过注入动态库的方式实现极速编译调试?.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="06 | App 如何通过注入动态库的方式实现极速编译调试?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/fe/2e82135e45c225a4c9347a67338b7bfe.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
在上一篇文章中,我和你分享了链接器的基础知识。今天我们再继续聊聊,动态库链接器的实际应用,也就是编译调试的提速问题。
|
||||
|
||||
iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 App 来进行的。所以,项目代码量越大,编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启 App,需要再走一遍调试流程。
|
||||
|
||||
对于开发者来说,提高编译调试的速度就是提高生产效率。试想一下,如果上线前一天突然发现了一个严重的bug,每次编译调试都要耗费几十分钟,结果这一天的黄金时间,一晃就过去了。到最后,可能就是上线时间被延误。这个责任可不轻啊。
|
||||
|
||||
那么问题来了,原生代码怎样才能够实现动态极速调试,以此来大幅提高编译调试速度呢?在回答这个问题之前,我们先看看有哪些工具是这么玩儿的。了解了它们的玩法,我们也就自然清楚这个问题的答案了。
|
||||
|
||||
## Swift Playground
|
||||
|
||||
说到iOS代码动态极速调试的工具,你首先能想到的估计就是 Playground。它是 Xcode 里集成的一个能够快速、实时调试程序的工具,可以实现所见即所得的效果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/01/46007bcd100b7b23edccd46b760e5b01.png" alt="">
|
||||
|
||||
可以看到,任何的代码修改都能够实时地在右侧反馈出来。
|
||||
|
||||
## Flutter Hot Reload
|
||||
|
||||
Flutter 是 Google 开发的一个跨平台开发框架,调试也是快速实时的。官方的效果动画如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/1d/6d8b83e4e063dbccf279adfe2b66dd1d.gif" alt="">
|
||||
|
||||
可以看到,在 Flutter 编辑器中修改文字 clicked 为 tapped 后点击 reload,模拟器中的文字立刻就改变了,程序没有重启。同样地,修改按钮图标也会立刻生效。
|
||||
|
||||
接下来,我们先看看 Flutter 是怎么实现实时编译的。
|
||||
|
||||
Flutter 会在点击 reload 时去查看自上次编译以后改动过的代码,重新编译涉及到的代码库,还包括主库,以及主库的相关联库。所有这些重新编译过的库都会转换成内核文件发到 Dart VM 里,Dart VM 会重新加载新的内核文件,加载后会让 Flutter framework 触发所有的Widgets 和 Render Objects 进行重建、重布局、重绘。
|
||||
|
||||
Flutter 为了能够支持跨平台开发,使用了自研的 Dart 语言配合在 App 内集成 Dart VM 的方式运行 Flutter 程序。目前 Flutter 还没有达到 Cocoa 框架那样的普及程度,所以如果你不是使用 Flutter 来开发 iOS 程序的话,想要达到极速调试应该要怎么做呢?
|
||||
|
||||
# Injection for Xcode
|
||||
|
||||
所幸的是,John Holdsworth 开发了一个叫作 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。John Holdsworth 也提供了动画演示效果,如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/13/a239763b1a5c7226e5ee8d7481285a13.gif" alt="">
|
||||
|
||||
作者已经开源了这个工具,地址是[https://github.com/johnno1962/InjectionIII](https://github.com/johnno1962/InjectionIII) 。使用方式就是 clone 下代码,构建 InjectionPluginLite/InjectionPlugin.xcodeproj ;删除方式是,在终端里运行下面这行代码:
|
||||
|
||||
```
|
||||
rm -rf ~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins/InjectionPlugin.xcplugin
|
||||
|
||||
```
|
||||
|
||||
构建完成后,我们就可以编译项目。这时添加一个新的方法:
|
||||
|
||||
```
|
||||
- (void)injected
|
||||
{
|
||||
NSLog(@"I've been injected: %@", self);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后在这个方法中添加一个断点,按下 ctrl + = ,接下来你会发现程序运行时会停到断点处,这样你的代码就成功地被运行中的 App 执行了。那么,**Injection 是怎么做到的呢?**
|
||||
|
||||
Injection 会监听源代码文件的变化,如果文件被改动了,Injection Server 就会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting 方法通过 Socket 通知运行的 App。writeString 的代码如下:
|
||||
|
||||
```
|
||||
- (BOOL)writeString:(NSString *)string {
|
||||
const char *utf8 = string.UTF8String;
|
||||
uint32_t length = (uint32_t)strlen(utf8);
|
||||
if (write(clientSocket, &length, sizeof length) != sizeof length ||
|
||||
write(clientSocket, utf8, length) != length)
|
||||
return FALSE;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Server 会在后台发送和监听 Socket 消息,实现逻辑在 `InjectionServer.mm` 的 runInBackground 方法里。Client 也会开启一个后台去发送和监听 Socket 消息,实现逻辑在 `InjectionClient.mm`里的 runInBackground 方法里。
|
||||
|
||||
Client 接收到消息后会调用 inject(tmpfile: String) 方法,运行时进行类的动态替换。inject(tmpfile: String) 方法的具体实现代码,你可以点击[这个链接](https://github.com/johnno1962/InjectionIII/blob/master/InjectionBundle/SwiftInjection.swift)查看。
|
||||
|
||||
inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的入参 tmpfile 是动态库的文件路径,那么这个动态库是如何加载到可执行文件里的呢?具体的实现在inject(tmpfile: String) 方法开始里,如下:
|
||||
|
||||
```
|
||||
let newClasses = try SwiftEval.instance.loadAndInject(tmpfile: tmpfile)
|
||||
|
||||
```
|
||||
|
||||
你先看下 SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 这个方法的代码实现:
|
||||
|
||||
```
|
||||
@objc func loadAndInject(tmpfile: String, oldClass: AnyClass? = nil) throws -> [AnyClass] {
|
||||
|
||||
print("???? Loading .dylib - Ignore any duplicate class warning...")
|
||||
// load patched .dylib into process with new version of class
|
||||
guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
|
||||
throw evalError("dlopen() error: \(String(cString: dlerror()))")
|
||||
}
|
||||
print("???? Loaded .dylib - Ignore any duplicate class warning...")
|
||||
|
||||
if oldClass != nil {
|
||||
// find patched version of class using symbol for existing
|
||||
|
||||
var info = Dl_info()
|
||||
guard dladdr(unsafeBitCast(oldClass, to: UnsafeRawPointer.self), &info) != 0 else {
|
||||
throw evalError("Could not locate class symbol")
|
||||
}
|
||||
|
||||
debug(String(cString: info.dli_sname))
|
||||
guard let newSymbol = dlsym(dl, info.dli_sname) else {
|
||||
throw evalError("Could not locate newly loaded class symbol")
|
||||
}
|
||||
|
||||
return [unsafeBitCast(newSymbol, to: AnyClass.self)]
|
||||
}
|
||||
else {
|
||||
// grep out symbols for classes being injected from object file
|
||||
|
||||
try injectGenerics(tmpfile: tmpfile, handle: dl)
|
||||
|
||||
guard shell(command: """
|
||||
\(xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/nm \(tmpfile).o | grep -E ' S _OBJC_CLASS_\\$_| _(_T0|\\$S).*CN$' | awk '{print $3}' >\(tmpfile).classes
|
||||
""") else {
|
||||
throw evalError("Could not list class symbols")
|
||||
}
|
||||
guard var symbols = (try? String(contentsOfFile: "\(tmpfile).classes"))?.components(separatedBy: "\n") else {
|
||||
throw evalError("Could not load class symbol list")
|
||||
}
|
||||
symbols.removeLast()
|
||||
|
||||
return Set(symbols.flatMap { dlsym(dl, String($0.dropFirst())) }).map { unsafeBitCast($0, to: AnyClass.self) }
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,你是不是看到你所熟悉的动态库加载函数 dlopen 了呢?
|
||||
|
||||
```
|
||||
guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
|
||||
throw evalError("dlopen() error: \(String(cString: dlerror()))")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。接下来,dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。dlsym 调用对应代码如下:
|
||||
|
||||
```
|
||||
guard let newSymbol = dlsym(dl, info.dli_sname) else {
|
||||
throw evalError("Could not locate newly loaded class symbol")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当类的方法都被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重启 App,至此使用动态库方式极速调试的目的就达成了。
|
||||
|
||||
我把Injection的工作原理用一张图表示了出来,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/c9/4f49ea2047d2dd2d5c4646b0ba55b8c9.png" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我和你详细分享了动态库链接器的一个非常实用的应用场景:如何使用动态库加载方式进行极速调试。由此我们可以看出,类似链接器这样的底层知识是非常重要的。
|
||||
|
||||
当然了,这只是一个场景,还有更多的场景等待着我们去发掘。比如把 Injection 技术扩展开想,每当你修改了另一个人负责的代码就给那个人发条消息,同时将修改的代码编译、打包成动态库直接让对方看到修改的情况,这样不仅是提高了自己的效率,还提高了整个团队的沟通效率。怎么样?是不是有种想立刻尝试的感觉,心动不如行动,动手写起来吧。
|
||||
|
||||
所以,打好了底层知识的基础以后,我们才可以利用它们去提高开发效率,为用户提供更稳定、性能更好的 App 。
|
||||
|
||||
今天这篇文章最后,我留给你的一个小作业是,思考一下底层知识还有哪些运用场景,并在评论区分享出来吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/89/6c844d233e74aec08417be65e4ef1d89.jpg" alt="">
|
||||
@@ -0,0 +1,421 @@
|
||||
<audio id="audio" title="07 | Clang、Infer 和 OCLint ,我们应该使用谁来做静态分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/76/0668e2d7aebfe872327ee3fdb7319c76.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
随着业务开发迭代速度越来越快,完全依赖人工保证工程质量也变得越来越不牢靠。所以,静态分析,这种可以帮助我们在编写代码的阶段就能及时发现代码错误,从而在根儿上保证工程质量的技术,就成为了iOS开发者最常用到的一种代码调试技术。
|
||||
|
||||
Xcode 自带的静态分析工具 Analyze,通过静态语法分析能够找出在代码层面就能发现的内存泄露问题,还可以通过上下文分析出是否存在变量无用等问题。但是,Analyze 的功能还是有限,还是无法帮助我们在编写代码的阶段发现更多的问题。所以,这才诞生出了功能更全、定制化高、效率高的第三方静态检查工具。比如,OCLint、Infer、Clang静态分析器等。
|
||||
|
||||
一款优秀的静态分析器,能够帮助我们更加全面的发现人工测试中的盲点,提高检查问题的效率,寻找潜在的可用性问题,比如空指针访问、资源和内存泄露等等。
|
||||
|
||||
同时,静态分析器还可以检查代码规范和代码可维护性的问题,根据一些指标就能够找出哪些代码需要优化和重构。这里有三个常用的复杂度指标,可以帮助我们度量是否需要优化和重构代码。
|
||||
|
||||
<li>
|
||||
<p>圈复杂度高。圈复杂度,指的是遍历一个模块时的复杂度,这个复杂度是由分支语句比如 if、case、while、for,还有运算符比如 &&、||,以及决策点,共同确定的。一般来说,圈复杂度在以 4 以内是低复杂度,5到7是中复杂度,8到10是高复杂度,11以上时复杂度就非常高了,这时需要考虑重构,不然就会因为测试用例的数量过高而难以维护。<br>
|
||||
而这个圈复杂度的值,是很难通过人工分析出来的。而静态分析器就可以根据圈复杂度规则,来监控圈复杂度,及时发现代码是否过于复杂,发现问题后及早解决,以免造成代码过于复杂难以维护。</p>
|
||||
</li>
|
||||
<li>
|
||||
NPath 复杂度高。NPath 度量是指一个方法所有可能执行的路径数量。一般高于200就需要考虑降低复杂度了。
|
||||
</li>
|
||||
<li>
|
||||
NCSS 度量高。NCSS 度量是指不包含注释的源码行数,方法和类过大会导致代码维护时阅读困难,大的 NCSS 值表示方法或类做的事情太多,应该拆分或重构。一般方法行数不过百,类的行数不过千。
|
||||
</li>
|
||||
|
||||
但是,使用静态分析技术来保证工程质量,也并不尽如人意,还有如下**两大缺陷**:
|
||||
|
||||
<li>
|
||||
<p>需要耗费更长的时间。相比于编译过程,使用静态分析技术发现深层次程序错误时,会对当前分析的方法、参数、变量去和整个工程关联代码一起做分析。所以,随着工程代码量的增加,每一步分析所依赖的影响面都会增大,所需耗时就更长。<br>
|
||||
虽然我们在设计静态分析器时,就已经对其速度做了很多优化,但还是达不到程序编译的速度。因为静态分析本身就包含了编译最耗时的 IO 和语法分析阶段,而且静态分析的内容多于编译,所以再怎么优化,即使是最好的情况也会比编译过程来得要慢。</p>
|
||||
</li>
|
||||
<li>
|
||||
静态分析器只能检查出那些专门设计好的、可查找的错误。对于特定类型的错误分析,还需要开发者靠自己的能力写一些插件并添加进去。
|
||||
</li>
|
||||
|
||||
好了,现在我们已经了解了静态分析器的优缺点,那么面对繁多的iOS 的静态代码检查工具,我们到底应该选择哪一个呢?
|
||||
|
||||
接下来,我选择了3款主流的静态分析工具OCLint、Clang静态分析器、Infer,和你说说如何选择的问题。
|
||||
|
||||
## OCLint
|
||||
|
||||
OCLint 是基于 Clang Tooling 开发的静态分析工具,主要用来发现编译器检查不到的那些潜在的关键技术问题。2017年9月份新发布的 OCLint 0.13版本中,包含了71条规则。
|
||||
|
||||
这些规则已经基本覆盖了具有通用性的规则,主要包括语法上的基础规则、Cocoa 库相关规则、一些约定俗成的规则、各种空语句检查、是否按新语法改写的检查、命名上长变量名短变量名检查、无用的语句变量和参数的检查。
|
||||
|
||||
除此之外,还包括了和代码量大小是否合理相关的一些规则,比如过大的类、类里方法是否太多、参数是否过多、Block 嵌套是否太深、方法里代码是否过多、圈复杂度的检查等。
|
||||
|
||||
你可以在[官方规则索引](http://docs.oclint.org/en/stable/rules/index.html)中,查看完整的规则说明。
|
||||
|
||||
这些规则可以在运行时被动态地加载到系统中,规则配置灵活、可扩展性好、方便自定义。
|
||||
|
||||
说到OCLint的安装方式,我建议你使用 Homebrew的方式。Homebrew 是 macOS 下专门用来进行软件包管理的一个工具,使用起来很方便,让你无需关心一些依赖和路径配置。
|
||||
|
||||
使用 Homebrew的方式安装时,我们需要首先设置brew的第三方仓库,然后安装OCLint。安装方法是在终端输入:
|
||||
|
||||
```
|
||||
brew tap oclint/formulae
|
||||
brew install oclint
|
||||
|
||||
```
|
||||
|
||||
安装完成,先编写一个 Hello world 代码来测试下,创建一个 Hello.m 文件来编写代码,使用 OCLint 来检查下前面编写的 Hello.m ,在终端输入如下命令:
|
||||
|
||||
```
|
||||
oclint Hello.m
|
||||
|
||||
```
|
||||
|
||||
然后,我们可以使用下面的命令,将检查结果生成为一个HTML格式的报告:
|
||||
|
||||
```
|
||||
oclint -report-type html -o report.html Hello.m
|
||||
|
||||
```
|
||||
|
||||
## Clang 静态分析器
|
||||
|
||||
Clang 静态分析器(Clang Static Analyzer)是一个用 C++ 开发的,用来分析 C、C++ 和 Objective-C 的开源工具,是 Clang 项目的一部分,构建在 Clang 和 LLVM 之上。Clang 静态分析器的分析引擎用的就是 Clang 的库。
|
||||
|
||||
Clang 静态分析器专门为速度做过优化,可以在保证查出错误的前提下,使用更聪明的算法减少检查的工作量。
|
||||
|
||||
你可以点击[这里下载](http://clang-analyzer.llvm.org/release_notes.html)Clang静态分析器,然后解压就可以了,不需要放到特定目录下。而卸载它的话,删除这个解压后的目录即可。
|
||||
|
||||
**在Clang静态分析器中,常用的就是 scan-build 和 scan-view这两个工具。**
|
||||
|
||||
scan-build 和 scan-view 所在的目录路径,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/a1/2e6f1b41292b3744ddc3e0dbb960a1a1.png" alt="">
|
||||
|
||||
scan-build 是用来运行分析器的命令行工具;scan-view 包含了 scan-build 工具,会在 scan-build 执行完后将结果可视化。
|
||||
|
||||
scan-build 的原理是,将编译器构建改成另一个“假的”编译器来构建,这个“假的”编译器会执行 Clang 来编译,然后执行静态分析器分析你的代码。
|
||||
|
||||
scan-build的使用方法,也很简单,你只需要到项目目录下,使用如下命令即可:
|
||||
|
||||
```
|
||||
\yourpath\scan-build -k -V make
|
||||
|
||||
```
|
||||
|
||||
关于scan-build的更多参数和使用说明,你可以点击[这个链接](http://clang-analyzer.llvm.org/scan-build)查看。
|
||||
|
||||
**Clang 静态分析器是由分析引擎(analyzer core)和 checkers 组成的。**所有的 checker 都是基于底层分析引擎之上的。通过分析引擎提供的功能,我们可以编写新的 checker。
|
||||
|
||||
checker 架构能够方便用户扩展代码检查的规则,或者通过自定义来扩展bug 类型。如果你想编写自己的 checker,可以在 Clang 项目的 lib/StaticAnalyzer/Checkers 目录下找到示例参考,比如 ObjCUnusedIVarsChecker.cpp 就是用来检查是否有定义了,但是从未使用过的变量。
|
||||
|
||||
当然,如果为了编写自定义的 checker 一开始就埋头进去看那些示例代码是很难看懂的,你甚至都不能知道编写 checker 时有哪些方法可以为我所用。所以,你需要先了解 Clang 静态分析器提供了哪些功能接口,然后再参考官方的大量实例,去了解怎么使用这些功能接口,在这之后再动手开发才会事半功倍。
|
||||
|
||||
**接下来,我就跟你聊聊开发 checker 时需要了解的 Clang 静态分析器提供的一些功能接口。**
|
||||
|
||||
checker 的官方示例代码里有一个非常实用的,也就是内存泄露检查示例 MallocChecker,你可以点击[这个链接](http://clang.llvm.org/doxygen/MallocChecker_8cpp_source.html)查看代码。
|
||||
|
||||
在这段代码开头,我们可以看到引入了 clang/AST/ 和 clang/StaticAnalyzer/Core/PathSensitive/ 目录下的头文件。这两个目录下定义的接口功能非常强大,大部分 checker 都是基于此开发的。
|
||||
|
||||
clang/AST/ 目录中,有语法树遍历 RecursiveASTVisitor,还有语法树层级遍历 StmtVisitor,遍历过程中,会有很多回调函数可以让 Checker 进行检查。比如,方法调用前的回调 checkPreCall、方法调用后的回调 checkPostCall,CFG(Control Flow Graph 控制流程图) 分支调用时的回调 checkBranchCondition、CFG 路径分析结束时的回调 checkEndAnalysis 等等。有了这些回调,我们就可以从语法树层级和路径上去做静态检查的工作了。
|
||||
|
||||
clang/StaticAnalyzer/Core/PathSensitive/ 目录里,可以让 checker 检查变量和值上的更多变化。从目录 PathSensitive,我们也能看出这些功能叫做路径敏感分析(Path-Sensitive Analyses),是从条件分支上去跟踪,而这种跟踪是跟踪每一种分支去做分析。
|
||||
|
||||
但是,要去追踪所有路径的话,就可能会碰到很多复杂情况,特别是执行循环后,问题会更复杂,需要通过路径合并来简化复杂的情况,但是简化后可能就不会分析出所有的路径。所以,考虑到合理性问题的话,我们还是需要做些取舍,让其更加合理,达到尽量输出更多信息的目的,来方便我们开发 checker,检查出更多的 bug 。
|
||||
|
||||
路径敏感分析也包含了模拟内存管理,SymbolManager 符号管理里维护着变量的生命周期分析。想要了解具体实现的话,你可以点击[这个链接](http://clang.llvm.org/doxygen/SymbolManager_8h_source.html)参看源码实现。
|
||||
|
||||
这个内存泄露检查示例 MallocChecker 里,运用了 Clang 静态分析器提供的语法树层级节点检查、变量值路径追踪以及内存管理分析功能接口,对我们编写自定义的 checker 是一个很全面、典型的示例。
|
||||
|
||||
追其根本,编写自己的 checker ,其核心还是要更多地掌握 Clang 静态分析器的内在原理。很早之前,苹果公司就在 [LLVM Developers Meeting](https://www.youtube.com/watch?v=4lUJTY373og&t=102s) 上,和我们分享过怎样通过 Clang 静态分析器去找 bug。你可以点击[这个链接](http://llvm.org/devmtg/2008-08/Kremenek_StaticAnalyzer.pdf),查看相应的PPT,这对我们了解 Clang 静态分析器的原理有很大的帮助。
|
||||
|
||||
不过,checker 架构也有不完美的地方,比如每执行完一条语句,分析引擎需要回去遍历所有 checker 中的回调函数。这样的话,随着 checker 数量的增加,整体检查的速度也会变得越来越慢。
|
||||
|
||||
如果你想列出当前 Clang 版本下的所有 checker,可以使用如下命令:
|
||||
|
||||
```
|
||||
clang —analyze -Xclang -analyzer-checker-help
|
||||
|
||||
```
|
||||
|
||||
下面显示的就是常用的 checker:
|
||||
|
||||
```
|
||||
debug.ConfigDumper 配置表
|
||||
debug.DumpCFG 显示控制流程图
|
||||
debug.DumpCallGraph 显示调用图
|
||||
debug.DumpCalls 打印引擎遍历的调用
|
||||
debug.DumpDominators 打印控制流程图的 dominance tree
|
||||
debug.DumpLiveVars 打印实时变量分析结果
|
||||
debug.DumpTraversal 打印引擎遍历的分支条件
|
||||
debug.ExprInspection 检查分析器对表达式的理解
|
||||
debug.Stats 使用分析器统计信息发出警告
|
||||
debug.TaintTest 标记污染的符号
|
||||
debug.ViewCFG 查看控制流程图
|
||||
debug.ViewCallGraph 使用 GraphViz 查看调用图
|
||||
debug.ViewExplodedGraph 使用 GraphViz 查看分解图
|
||||
|
||||
```
|
||||
|
||||
接下来,**我和你举个例子来说明如何使用 checker** 。我们先写一段代码:
|
||||
|
||||
```
|
||||
int main()
|
||||
{
|
||||
int a;
|
||||
int b = 10;
|
||||
a = b;
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们使用下面这条命令,调用 DumpCFG 这个 checker 对上面代码进行分析:
|
||||
|
||||
```
|
||||
clang -cc1 -analyze -analyzer-checker=debug.DumpCFG
|
||||
|
||||
```
|
||||
|
||||
显示结果如下:
|
||||
|
||||
```
|
||||
int main()
|
||||
[B2 (ENTRY)]
|
||||
Succs (1): B1
|
||||
|
||||
[B1]
|
||||
1: int a;
|
||||
2: 10
|
||||
3: int b = 10;
|
||||
4: b
|
||||
5: [B1.4] (ImplicitCastExpr, LValueToRValue, int)
|
||||
6: a
|
||||
7: [B1.6] = [B1.5]
|
||||
8: a
|
||||
9: [B1.8] (ImplicitCastExpr, LValueToRValue, int)
|
||||
10: return [B1.9];
|
||||
Preds (1): B2
|
||||
Succs (1): B0
|
||||
|
||||
[B0 (EXIT)]
|
||||
Preds (1): B
|
||||
|
||||
```
|
||||
|
||||
可以看出,代码的控制流程图被打印了出来。控制流程图会把程序拆得更细,可以把执行过程表现得更直观,有助于我们做静态分析。
|
||||
|
||||
## Infer
|
||||
|
||||
Infer是Facebook 开源的、使用 OCaml 语言编写的静态分析工具,可以对 C、Java 和 Objective-C 代码进行静态分析,可以检查出空指针访问、资源泄露以及内存泄露。
|
||||
|
||||
Infer 的安装,有从源码安装和直接安装 binary releases 两种方式。
|
||||
|
||||
如果想在 macOS 上编译源码进行安装的话,你需要预先安装一些工具,这些工具在后面编译时会用到,命令行指令如下:
|
||||
|
||||
```
|
||||
brew install autoconf automake cmake opam pkg-config sqlite gmp mpfr
|
||||
brew cask install java
|
||||
|
||||
```
|
||||
|
||||
你可以使用如下所示的命令,通过编译源码来安装:
|
||||
|
||||
```
|
||||
# Checkout Infer
|
||||
git clone https://github.com/facebook/infer.git
|
||||
cd infer
|
||||
# Compile Infer
|
||||
./build-infer.sh clang
|
||||
# install Infer system-wide...
|
||||
sudo make install
|
||||
# ...or, alternatively, install Infer into your PATH
|
||||
export PATH=`pwd`/infer/bin:$PATH
|
||||
|
||||
```
|
||||
|
||||
使用源码安装所需的时间会比较长,因为会编译一个特定的 Clang 版本,而 Clang 是个庞大的工程,特别是第一次编译的耗时会比较长。我在第一次编译时,就大概花了一个多小时。所以,直接安装 binary releases 会更快些,在终端输入:
|
||||
|
||||
```
|
||||
brew install infer
|
||||
|
||||
```
|
||||
|
||||
Infer就安装好了。
|
||||
|
||||
接下来,我通过一个示例和你分享下**如何使用 Infer**。我们可以先写一段Objective-C 代码:
|
||||
|
||||
```
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface Hello: NSObject
|
||||
@property NSString* s;
|
||||
@end
|
||||
|
||||
@implementation Hello
|
||||
NSString* m() {
|
||||
Hello* hello = nil;
|
||||
return hello->_s;
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
在终端输入:
|
||||
|
||||
```
|
||||
infer -- clang -c Hello.m
|
||||
|
||||
```
|
||||
|
||||
结果如下:
|
||||
|
||||
```
|
||||
Capturing in make/cc mode...
|
||||
Found 1 source file to analyze in /Users/ming/Downloads/jikeshijian/infer-out
|
||||
Starting analysis...
|
||||
|
||||
legend:
|
||||
"F" analyzing a file
|
||||
"." analyzing a procedure
|
||||
|
||||
F.
|
||||
*Found 5 issues*
|
||||
|
||||
hello.m:10: error: NULL_DEREFERENCE
|
||||
pointer `hello` last assigned on line 9 could be null and is dereferenced at line 10, column 12.
|
||||
8. NSString* m() {
|
||||
9. Hello* hello = nil;
|
||||
10. *>* return hello->_s;
|
||||
11. }
|
||||
|
||||
hello.m:10: warning: DIRECT_ATOMIC_PROPERTY_ACCESS
|
||||
Direct access to ivar `_s` of an atomic property at line 10, column 12. Accessing an ivar of an atomic property makes the property nonatomic.
|
||||
8. NSString* m() {
|
||||
9. Hello* hello = nil;
|
||||
10. *>* return hello->_s;
|
||||
11. }
|
||||
|
||||
hello.m:4: warning: ASSIGN_POINTER_WARNING
|
||||
Property `s` is a pointer type marked with the `assign` attribute at line 4, column 1. Use a different attribute like `strong` or `weak`.
|
||||
2.
|
||||
3. @interface Hello: NSObject
|
||||
4. *>*@property NSString* s;
|
||||
5. @end
|
||||
6.
|
||||
|
||||
hello.m:10: warning: DIRECT_ATOMIC_PROPERTY_ACCESS
|
||||
Direct access to ivar `_s` of an atomic property at line 10, column 12. Accessing an ivar of an atomic property makes the property nonatomic.
|
||||
8. NSString* m() {
|
||||
9. Hello* hello = nil;
|
||||
10. *>* return hello->_s;
|
||||
11. }
|
||||
|
||||
hello.m:4: warning: ASSIGN_POINTER_WARNING
|
||||
Property `s` is a pointer type marked with the `assign` attribute at line 4, column 1. Use a different attribute like `strong` or `weak`.
|
||||
2.
|
||||
3. @interface Hello: NSObject
|
||||
4. *>*@property NSString* s;
|
||||
5. @end
|
||||
6.
|
||||
|
||||
|
||||
*Summary of the reports*
|
||||
|
||||
DIRECT_ATOMIC_PROPERTY_ACCESS: 2
|
||||
ASSIGN_POINTER_WARNING: 2
|
||||
NULL_DEREF
|
||||
|
||||
```
|
||||
|
||||
可以看出,我们前面的 hello.m 代码里一共有五个问题,其中包括一个错误、四个警告。第一个错误如下:
|
||||
|
||||
```
|
||||
hello.m:10: error: NULL_DEREFERENCE
|
||||
pointer `hello` last assigned on line 9 could be null and is dereferenced at line 10, column 12.
|
||||
8. NSString* m() {
|
||||
9. Hello* hello = nil;
|
||||
10. *>* return hello->_s;
|
||||
11. }
|
||||
|
||||
```
|
||||
|
||||
这个错误的意思是, hello 可能为空,需要去掉第10行12列的引用。我把这行代码做下修改,去掉引用:
|
||||
|
||||
```
|
||||
return hello.s;
|
||||
|
||||
```
|
||||
|
||||
再到终端运行一遍 infer 命令:
|
||||
|
||||
```
|
||||
infer -- clang -c Hello.m
|
||||
|
||||
```
|
||||
|
||||
然后,就发现只剩下了一个警告:
|
||||
|
||||
```
|
||||
hello.m:4: warning: ASSIGN_POINTER_WARNING
|
||||
Property `s` is a pointer type marked with the `assign` attribute at line 4, column 1. Use a different attribute like `strong` or `weak`.
|
||||
2.
|
||||
3. @interface Hello: NSObject
|
||||
4. *>*@property NSString* s;
|
||||
5. @end
|
||||
6.
|
||||
|
||||
```
|
||||
|
||||
这个警告的意思是说,属性 s 是指针类型,需要使用 strong 或 weak 属性。这时,我将s 的属性修改为 strong:
|
||||
|
||||
```
|
||||
@property(nonatomic, strong) NSString* s;
|
||||
|
||||
```
|
||||
|
||||
运行 Infer 后,发现没有问题了。
|
||||
|
||||
```
|
||||
Capturing in make/cc mode...
|
||||
Found 1 source file to analyze in /Users/ming/Downloads/jikeshijian/infer-out
|
||||
Starting analysis...
|
||||
|
||||
legend:
|
||||
"F" analyzing a file
|
||||
"." analyzing a procedure
|
||||
|
||||
F.
|
||||
*No issues found
|
||||
|
||||
```
|
||||
|
||||
接下来,为了帮助你理解Infer的工作原理,我来梳理下**Infer 工作的流程**:
|
||||
|
||||
<li>
|
||||
第一个阶段是转化阶段,将源代码转成 Infer 内部的中间语言。类 C语言使用 Clang 进行编译,Java语言使用 javac 进行编译,编译的同时转成中间语言,输出到 infer-out 目录。
|
||||
</li>
|
||||
<li>
|
||||
<p>第二个阶段是分析阶段,分析infer-out 目录下的文件。分析每个方法,如果出现错误的话会继续分析下一个方法,不会被中断,但是会记录下出错的位置,最后将所有出错的地方进行汇总输出。<br>
|
||||
默认情况下,每次运行infer命令都会删除之前的 infer-out 文件夹。你可以通过 --incremental 参数使用增量模式。增量模式下,运行infer命令不会删除 infer-out 文件夹,但是会利用这个文件夹进行 diff,减少分析量。<br>
|
||||
一般进行全新一轮分析时直接使用默认的非增量模式,而对于只想分析修改部分情况时,就使用增量模式。</p>
|
||||
</li>
|
||||
|
||||
Infer 检查的结果,在 infer-out 目录下,是 JSON 格式的,名字叫做 report.json 。生成JSON格式的结果,通用性会更强,集成到其他系统时会更方便。
|
||||
|
||||
Infer 的工作流程图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/b2/6b1b328c130552f899c2f8cdc504fab2.png" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
在今天这篇文章中,我和你一一分析了Clang 静态分析器、Infer和OCLint 这三个 iOS 静态分析工具。对于 iOS 的静态分析,这三个工具都是基于 Clang 库开发的。
|
||||
|
||||
其中 Clang 静态分析器和 Xcode的集成度高,也支持命令行。不过,它们检查的规则少,基本都是只能检查出较大的问题,比如类型转换问题,而对内存泄露问题检查的侧重点则在于可用性。
|
||||
|
||||
OCLint 检查规则多、定制性强,能够发现很多潜在问题。但缺点也是检查规则太多,反而容易找不到重点;可定制度过高,导致易用性变差。
|
||||
|
||||
Infer 的效率高,支持增量分析,可小范围分析。可定制性不算最强,属于中等。
|
||||
|
||||
综合来看,Infer 在准确性、性能效率、规则、扩展性、易用性整体度上的把握是做得最好的,我认为这些是决定静态分析器好不好最重要的几点。所以,我比较推荐的是使用 Infer 来进行代码静态分析。
|
||||
|
||||
## 课后作业
|
||||
|
||||
我们今天提到的三款静态分析工具都是基于 Clang 库来开发的。那么请你来说下,Clang 给这三款工具提供了什么能力呢?
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
227
极客时间专栏/geek/iOS开发高手课/基础篇/08 | 如何利用 Clang 为 App 提质?.md
Normal file
227
极客时间专栏/geek/iOS开发高手课/基础篇/08 | 如何利用 Clang 为 App 提质?.md
Normal file
@@ -0,0 +1,227 @@
|
||||
<audio id="audio" title="08 | 如何利用 Clang 为 App 提质?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/bf/b5a7ca6dae019c0bd1f36660f78b74bf.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
以前在工作中,有段时间连续发生了多次线上事故,在复盘时大家都提出是因为代码不规范、代码规范执行不到位,从而导致代码质量过差,无法监管,我们才被动处理线上事故。会上牢骚发完,会后应该怎么执行呢?
|
||||
|
||||
我们都知道,监管手段是需要自己动手建设的,第三方工具无法满足所有的业务技术规范监控。在上篇文章“Clang、Infer 和 OCLint ,我们应该使用谁来做静态分析?”中,我们提到通过 Clang 提供的丰富接口功能就可以开发出静态分析工具,进而管控代码质量。
|
||||
|
||||
除此之外,基于 Clang 还可以开发出用于代码增量分析、代码可视化、代码质量报告来保障 App 质量的系统平台,比如[CodeChecker](https://github.com/Ericsson/CodeChecker)。
|
||||
|
||||
比如,当周末发现线上问题时,你会发现很多时候分析问题的人都不在电脑边,无法及时处理问题。这时,我们就需要一款在线网页代码导航工具,比如 Mozilla 开发的 [DXR](https://github.com/mozilla/dxr#dxr),方便在便携设备上去操作、分析问题,这样的工具都是基于 Clang 开发的。
|
||||
|
||||
Clang的功能如此强大,那么它到底是什么呢?Clang 做了哪些事情?Clang 还提供了什么能力可以为 App 提质呢?今天,我们就一起来看看这几个问题吧。
|
||||
|
||||
## 什么是 Clang?
|
||||
|
||||
关于Clang是什么,你可以先看一下如下所示的iOS开发的完整编译流程图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/62/65a97ea469d900a5d17b49e509cd3462.png" alt="">
|
||||
|
||||
图中,左侧黑块部分就是Clang。Clang 是 C、C++、Objective-C 的编译前端,而Swift 有自己的编译前端(也就是Swift 前端多出的SIL optimizer)。
|
||||
|
||||
接下来,我们一起看看使用Clang有哪些优势。这,可以帮助我们理解本篇文章的后半部分内容。
|
||||
|
||||
第一,对于使用者来说,Clang 编译的速度非常快,对内存的使用率非常低,并且兼容GCC。
|
||||
|
||||
第二,对于代码诊断来说, Clang 也非常强大,Xcode 也是用的 Clang。使用 Clang 编译前端,可以精确地显示出问题所在的行和具体位置,并且可以确切地说明出现这个问题的原因,并指出错误的类型是什么,使得我们可以快速掌握问题的细节。这样的话,我们不用看源码,仅通过 Clang 突出标注的问题范围也能够了解到问题的情况。
|
||||
|
||||
第三,Clang对 typedef 的保留和展开也处理得非常好。typedef 可以缩写很长的类型,保留 typedef 对于粗粒度诊断分析很有帮助。但有时候,我们还需要了解细节,对 typedef 进行展开即可。
|
||||
|
||||
第四,Fix-it 提示也是 Clang 提供的一种快捷修复源码问题的方式。在宏的处理上,很多宏都是深度嵌套的, Clang 会自动打印实例化信息和嵌套范围信息来帮助你进行宏的诊断和分析。
|
||||
|
||||
第五,Clang 的架构是模块化的。除了代码静态分析外,利用其输出的接口还可以开发用于代码转义、代码生成、代码重构的工具,方便与IDE 进行集成。
|
||||
|
||||
与Clang的强大功能相对立的是,GCC 对于 Objective-C 的支持比较差,效率和性能都没有办法达到苹果公司的要求,而且它还难以推动 GCC 团队。
|
||||
|
||||
于是,苹果公司决定自己来掌握编译相关的工具链,将天才克里斯·拉特纳(Chris Lattner)招入麾下后开发了 LLVM 工具套件,将 GCC 全面替换成了 LLVM。这,也使得 Swift这门集各种高级语言特性的语言,能够在非常高的起点上,出现在开发者面前。
|
||||
|
||||
Clang是基于C++开发的,如果你想要了解 Clang 的话,需要有一定的 C++ 基础。但是,Clang 源码本身质量非常高,有很多值得学习的地方,比如说目录清晰、功能解耦做得很好、分类清晰方便组合和复用、代码风格统一而且规范、注释量大便于阅读等。
|
||||
|
||||
我们阅读Clang的源码,除了可以帮助我们了解Clang以外,还可以给我们提供一个学习优秀代码、提升编程思维能力的机会。特别是在编写自定义插件或者工具时,如果你对用到的接口了解得不是很清楚,或者好奇接口的实现,这时候去看源码,对于你的帮助是非常大的。
|
||||
|
||||
你可以点击[这里的链接](https://code.woboq.org/llvm/clang/),在线查看 Clang 源码。
|
||||
|
||||
查看Clang的源码,你会发现它不光工程代码量巨大,而且工具也非常多,相互间的关系复杂。但是,好在 Clang 提供了一个易用性很高的黑盒 Driver,用于封装前端命令和工具链的命令,使得其易用性得到了很大的提升。
|
||||
|
||||
## Clang 做了哪些事?
|
||||
|
||||
接下来,我通过前面提到的 Driver 命令来看看 Clang 对源码做了哪些事儿?
|
||||
|
||||
我们先看看下面这段示例代码:
|
||||
|
||||
```
|
||||
int main()
|
||||
{
|
||||
int a;
|
||||
int b = 10;
|
||||
a = b;
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**首先,Clang 会对代码进行词法分析,将代码切分成 Token**。输入一个命令可以查看上面代码的所有的 Token。命令如下:
|
||||
|
||||
```
|
||||
clang -fmodules -E -Xclang -dump-tokens main.m
|
||||
|
||||
```
|
||||
|
||||
这个命令的作用是,显示每个 Token 的类型、值,以及位置。你可以在[这个链接](https://opensource.apple.com//source/lldb/lldb-69/llvm/tools/clang/include/clang/Basic/TokenKinds.def)中,看到Clang 定义的所有Token 类型。我们可以把这些Token类型,分为下面这4类。
|
||||
|
||||
<li>
|
||||
关键字:语法中的关键字,比如 if、else、while、for 等;
|
||||
</li>
|
||||
<li>
|
||||
标识符:变量名;
|
||||
</li>
|
||||
<li>
|
||||
字面量:值、数字、字符串;
|
||||
</li>
|
||||
<li>
|
||||
特殊符号:加减乘除等符号。
|
||||
</li>
|
||||
|
||||
**接下来,词法分析完后就会进行语法分析**,将输出的 Token 先按照语法组合成语义,生成类似 VarDecl 这样的节点,然后将这些节点按照层级关系构成抽象语法树(AST)。
|
||||
|
||||
在终端输入下面的这条命令,你就可以查看前面源码的语法树:
|
||||
|
||||
```
|
||||
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
|
||||
|
||||
```
|
||||
|
||||
打印出来效果如下:
|
||||
|
||||
```
|
||||
TranslationUnitDecl 0xc75b450 <<invalid sloc>> <invalid sloc>
|
||||
|-TypedefDecl 0xc75b740 <<invalid sloc>> <invalid sloc> implicit __builtin_va_list ‘char *’
|
||||
`-FunctionDecl 0xc75b7b0 <test.cpp:1:1, line:7:1> line:1:5 main ‘int (void)’
|
||||
`-CompoundStmt 0xc75b978 <line:2:1, line:7:1>
|
||||
|-DeclStmt 0xc75b870 <line:3:2, col:7>
|
||||
| `-VarDecl 0xc75b840 <col:2, col:6> col:6 used a ‘int’
|
||||
|-DeclStmt 0xc75b8d8 <line:4:2, col:12>
|
||||
| `-VarDecl 0xc75b890 <col:2, col:10> col:6 used b ‘int’ cinit
|
||||
| `-IntegerLiteral 0xc75b8c0 <col:10> ‘int’ 10
|
||||
|
||||
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< a = b <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||
|-BinaryOperator 0xc75b928 <line:5:2, col:6> ‘int’ lvalue ‘=‘
|
||||
| |-DeclRefExpr 0xc75b8e8 <col:2> ‘int’ lvalue Var 0xc75b840 ‘a’ ‘int’
|
||||
| `-ImplicitCastExpr 0xc75b918 <col:6> ‘int’ <LValueToRValue>
|
||||
| `-DeclRefExpr 0xc75b900 <col:6> ‘int’ lvalue Var 0xc75b890 ‘b’ ‘int’
|
||||
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||
|
||||
`-ReturnStmt 0xc75b968 <line:6:2, col:9>
|
||||
`-ImplicitCastExpr 0xc75b958 <col:9> ‘int’ <LValueToRValue>
|
||||
`-DeclRefExpr 0xc75b940 <col:9> ‘int’ lvalue Var 0xc75b840 ‘a’ ‘int
|
||||
|
||||
```
|
||||
|
||||
其中TranslationUnitDecl 是根节点,表示一个编译单元;Decl 表示一个声明;Expr 表示的是表达式;Literal 表示字面量,是一个特殊的 Expr;Stmt 表示陈述。
|
||||
|
||||
除此之外,Clang 还有众多种类的节点类型。Clang 里,节点主要分成 Type 类型、Decl 声明、Stmt 陈述这三种,其他的都是这三种的派生。通过扩展这三类节点,就能够将无限的代码形态用有限的形式来表现出来了。
|
||||
|
||||
接下来,我们再看看Clang提供了什么能力。
|
||||
|
||||
## Clang 提供了什么能力?
|
||||
|
||||
Clang 为一些需要分析代码语法、语义信息的工具提供了基础设施。这些基础设施就是 LibClang、Clang Plugin 和 LibTooling。
|
||||
|
||||
### LibClang
|
||||
|
||||
LibClang 提供了一个稳定的高级 C 接口,Xcode 使用的就是 LibClang。LibClang 可以访问 Clang 的上层高级抽象的能力,比如获取所有 Token、遍历语法树、代码补全等。由于 API 很稳定,Clang 版本更新对其影响不大。但是,LibClang 并不能完全访问到 Clang AST 信息。
|
||||
|
||||
使用 LibClang 可以直接使用它的 C API。官方也提供了 Python binding 脚本供你调用。还有开源的 node-js/ruby binding。你要是不熟悉其他语言,还有个第三方开源的 Objective-C 写的[ClangKit库](https://github.com/macmade/ClangKit)可供使用。
|
||||
|
||||
### Clang Plugins
|
||||
|
||||
Clang Plugins 可以让你在 AST 上做些操作,这些操作能够集成到编译中,成为编译的一部分。插件是在运行时由编译器加载的动态库,方便集成到构建系统中。
|
||||
|
||||
使用 Clang Plugins 一般都是希望能够完全控制 Clang AST,同时能够集成在编译流程中,可以影响编译的过程,进行中断或者提示。关于 Clang Plugins 开发的更多内容,我会在第37篇文章“如何编写 Clang 插件?”中和你详细说明。
|
||||
|
||||
### LibTooling
|
||||
|
||||
LibTooling 是一个 C++ 接口,通过 LibTooling 能够编写独立运行的语法检查和代码重构工具。LibTooling 的优势如下:
|
||||
|
||||
<li>
|
||||
所写的工具不依赖于构建系统,可以作为一个命令单独使用,比如 clang-check、clang-fixit、clang-format;
|
||||
</li>
|
||||
<li>
|
||||
可以完全控制 Clang AST;
|
||||
</li>
|
||||
<li>
|
||||
能够和 Clang Plugins 共用一份代码。
|
||||
</li>
|
||||
|
||||
与Clang Plugins 相比,LibTooling 无法影响编译过程;与 LibClang 相比,LibTooling 的接口没有那么稳定,也无法开箱即用,当 AST 的 API 升级后需要更新接口的调用。
|
||||
|
||||
但是,LibTooling 基于能够完全控制 Clang AST 和可独立运行的特点,可以做的事情就非常多了。
|
||||
|
||||
<li>
|
||||
改变代码:可以改变 Clang 生成代码的方式。基于现有代码可以做出大量的修改。还可以进行语言的转换,比如把 OC 语言转成 JavaScript 或者 Swift。
|
||||
</li>
|
||||
<li>
|
||||
做检查:检查命名规范,增加更强的类型检查,还可以按照自己的定义进行代码的检查分析。
|
||||
</li>
|
||||
<li>
|
||||
做分析:对源码做任意类型分析,甚至重写程序。给 Clang 添加一些自定义的分析,创建自己的重构器,还可以基于工程生成相关图形或文档进行分析。
|
||||
</li>
|
||||
|
||||
在 LibTooling 的基础之上有个开发人员工具合集 Clang tools,Clang tools 作为 Clang 项目的一部分,已经提供了一些工具,主要包括:
|
||||
|
||||
<li>
|
||||
语法检查工具 clang-check;
|
||||
</li>
|
||||
<li>
|
||||
自动修复编译错误工具 clang-fixit;
|
||||
</li>
|
||||
<li>
|
||||
自动代码格式工具 clang-format;
|
||||
</li>
|
||||
<li>
|
||||
新语言和新功能的迁移工具;
|
||||
</li>
|
||||
<li>
|
||||
重构工具。
|
||||
</li>
|
||||
|
||||
如果你打算基于 LibTooling 来开发工具,Clang tools 将会是很好的范例。
|
||||
|
||||
官方有一个教程叫作 [Tutorial for building tools using LibTooling and LibASTMatchers](http://clang.llvm.org/docs/LibASTMatchersTutorial.html),可以一步步地告诉你怎样使用 LibTooling 来构建一个语言转换的工具。通过这个教程,你可以掌握LibTooling 的基本使用方法。
|
||||
|
||||
## 小结
|
||||
|
||||
在今天这篇文章中,我和你说了 Clang 做了什么,以及提供了什么能力。从中可以看出,Clang 提供的能力都是基于Clang AST 接口的。
|
||||
|
||||
这个接口的功能非常强大,除了能够获取符号在源码中的位置,还可以获取方法的调用关系,类型定义和源码里的所有内容。
|
||||
|
||||
以这个接口为基础,再利用 LibClang、 Clang Plugin 和 LibTooling 这些封装好的工具,就足够我们去开发出满足静态代码分析需求的工具了。比如,我们可以使用 Clang Plugin 自动在构建阶段检查是否满足代码规范,不满足则直接无法构建成功。再比如,我们可以使用 LibTooling 自动完成代码的重构,与手动重构相比会更加高效、精确。
|
||||
|
||||
还记得我们在上一篇文章“Clang、Infer 和 OCLint ,我们应该使用谁来做静态分析?”中,提到的Clang 静态分析器的引擎吗?它使用的就是Clang AST 接口,对于节点 Stmt、Decl、Type 及其派生节点 Clang AST 都有对应的接口,特别是 RecursiveASTVisitor 接口可以完整遍历整个 AST。通过对 AST 的完整遍历以及节点数据获取,就能够对数据流进行分析,比如Iterative Data Flow Analysis、path-sensitive、path-insensitive、flow-sensitive等。
|
||||
|
||||
此外,还能够模拟内存分配进行分析,Clang 静态分析器里对应的模块是 MemRegion,其中内存模型是基于 “[A Memory Model for Static Analysis of C Programs](http://lcs.ios.ac.cn/~xuzb/canalyze/memmodel.pdf)”这篇论文而来。在Clang里的具体实现代码,你可以查看 [MemRegion.h](https://code.woboq.org/llvm/clang/include/clang/StaticAnalyzer/Core/PathSensitive/MemRegion.h.html) 和 [RegionStore.cpp](https://code.woboq.org/llvm/clang/lib/StaticAnalyzer/Core/RegionStore.cpp.html) 这两个文件。对于 Clang 静态分析器的原理描述,你可以参看[官方说明](https://github.com/llvm-mirror/clang/tree/master/lib/StaticAnalyzer)。
|
||||
|
||||
手中握有好兵器,你对App 代码质量的掌控也就有了底气。程序员开发软件的目的,就是要提高开发效率,同时也不要忽略检查代码质量时的效率。所以,对于开发者来说,我们要避免人工繁琐的 Review 代码,并减少由人工带来的低效和高差错率。我们的原则就是,能够让程序自动解决的,绝对不要人工手动完成。
|
||||
|
||||
## 课后作业
|
||||
|
||||
请你搭建 Clang 的开发环境,然后基于 LibTooling 编写一个简单语法转换工具,比如把 C 语言的方法调用转 Lisp 方法调用。
|
||||
|
||||
C 的方法调用代码:
|
||||
|
||||
```
|
||||
multiply(add(1.4, 3))
|
||||
|
||||
```
|
||||
|
||||
Lisp 的方法调用代码:
|
||||
|
||||
```
|
||||
(multiply (add 1.4 3))
|
||||
|
||||
```
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/27/f5ee90aa0183a4bcc688980bd625eb27.jpg" alt="">
|
||||
180
极客时间专栏/geek/iOS开发高手课/基础篇/09 | 无侵入的埋点方案如何实现?.md
Normal file
180
极客时间专栏/geek/iOS开发高手课/基础篇/09 | 无侵入的埋点方案如何实现?.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="09 | 无侵入的埋点方案如何实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4f/8c/4ff6a6ff101b10a4b792183f0364748c.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
在iOS开发中,埋点可以解决两大类问题:一是了解用户使用App的行为,二是降低分析线上问题的难度。目前,iOS开发中常见的埋点方式,主要包括代码埋点、可视化埋点和无埋点这三种。
|
||||
|
||||
<li>
|
||||
代码埋点主要就是通过手写代码的方式来埋点,能很精确的在需要埋点的代码处加上埋点的代码,可以很方便地记录当前环境的变量值,方便调试,并跟踪埋点内容,但存在开发工作量大,并且埋点代码到处都是,后期难以维护等问题。
|
||||
</li>
|
||||
<li>
|
||||
可视化埋点,就是将埋点增加和修改的工作可视化了,提升了增加和维护埋点的体验。
|
||||
</li>
|
||||
<li>
|
||||
无埋点,并不是不需要埋点,而更确切地说是“全埋点”,而且埋点代码不会出现在业务代码中,容易管理和维护。它的缺点在于,埋点成本高,后期的解析也比较复杂,再加上view_path的不确定性。所以,这种方案并不能解决所有的埋点需求,但对于大量通用的埋点需求来说,能够节省大量的开发和维护成本。
|
||||
</li>
|
||||
|
||||
在这其中,可视化埋点和无埋点,都属于是无侵入的埋点方案,因为它们都不需要在工程代码中写入埋点代码。所以,采用这样的无侵入埋点方案,既可以做到埋点被统一维护,又可以实现和工程代码的解耦。
|
||||
|
||||
接下来,我们就通过今天这篇文章,一起来分析一下无侵入埋点方案的实现问题吧。
|
||||
|
||||
## 运行时方法替换方式进行埋点
|
||||
|
||||
我们都知道,在iOS开发中最常见的三种埋点,就是对页面进入次数、页面停留时间、点击事件的埋点。对于这三种常见情况,我们都可以通过运行时方法替换技术来插入埋点代码,以实现无侵入的埋点方法。具体的实现方法是:先写一个运行时方法替换的类SMHook,加上替换的方法 hookClass:fromSelector:toSelector,代码如下:
|
||||
|
||||
```
|
||||
#import "SMHook.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
@implementation SMHook
|
||||
|
||||
+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
|
||||
Class class = classObject;
|
||||
// 得到被替换类的实例方法
|
||||
Method fromMethod = class_getInstanceMethod(class, fromSelector);
|
||||
// 得到替换类的实例方法
|
||||
Method toMethod = class_getInstanceMethod(class, toSelector);
|
||||
|
||||
// class_addMethod 返回成功表示被替换的方法没实现,然后会通过 class_addMethod 方法先实现;返回失败则表示被替换方法已存在,可以直接进行 IMP 指针交换
|
||||
if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
|
||||
// 进行方法的替换
|
||||
class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
|
||||
} else {
|
||||
// 交换 IMP 指针
|
||||
method_exchangeImplementations(fromMethod, toMethod);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
这个方法利用运行时 method_exchangeImplementations 接口将方法的实现进行了交换,原方法调用时就会被 hook 住,从而去执行指定的方法。
|
||||
|
||||
**页面进入次数、页面停留时间都需要对 UIViewController 生命周期进行埋点**,你可以创建一个 UIViewController 的 Category,代码如下:
|
||||
|
||||
```
|
||||
@implementation UIViewController (logger)
|
||||
+ (void)load {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// 通过 @selector 获得被替换和替换方法的 SEL,作为 SMHook:hookClass:fromeSelector:toSelector 的参数传入
|
||||
SEL fromSelectorAppear = @selector(viewWillAppear:);
|
||||
SEL toSelectorAppear = @selector(hook_viewWillAppear:);
|
||||
[SMHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
|
||||
|
||||
SEL fromSelectorDisappear = @selector(viewWillDisappear:);
|
||||
SEL toSelectorDisappear = @selector(hook_viewWillDisappear:);
|
||||
|
||||
[SMHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)hook_viewWillAppear:(BOOL)animated {
|
||||
// 先执行插入代码,再执行原 viewWillAppear 方法
|
||||
[self insertToViewWillAppear];
|
||||
[self hook_viewWillAppear:animated];
|
||||
}
|
||||
- (void)hook_viewWillDisappear:(BOOL)animated {
|
||||
// 执行插入代码,再执行原 viewWillDisappear 方法
|
||||
[self insertToViewWillDisappear];
|
||||
[self hook_viewWillDisappear:animated];
|
||||
}
|
||||
|
||||
- (void)insertToViewWillAppear {
|
||||
// 在 ViewWillAppear 时进行日志的埋点
|
||||
[[[[SMLogger create]
|
||||
message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]
|
||||
classify:ProjectClassifyOperation]
|
||||
save];
|
||||
}
|
||||
- (void)insertToViewWillDisappear {
|
||||
// 在 ViewWillDisappear 时进行日志的埋点
|
||||
[[[[SMLogger create]
|
||||
message:[NSString stringWithFormat:@"%@ Disappear",NSStringFromClass([self class])]]
|
||||
classify:ProjectClassifyOperation]
|
||||
save];
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
可以看到,Category 在 +load() 方法里使用了 SMHook 进行方法替换,在替换的方法里执行需要埋点的方法 [self insertToViewWillAppear]。这样的话,每个 UIViewController 生命周期到了 ViewWillAppear 时都会去执行 insertToViewWillAppear 方法。
|
||||
|
||||
那么,我们要怎么区别不同的 UIViewController 呢?我一般采取的做法都是,使用NSStringFromClass([self class]) 方法来取类名。这样,我就能够通过类名来区别不同的UIViewController了。
|
||||
|
||||
**对于点击事件来说,我们也可以通过运行时方法替换的方式进行无侵入埋点。**这里最主要的工作是,找到这个点击事件的方法 sendAction:to:forEvent:,然后在 +load() 方法使用 SMHook 替换成为你定义的方法。完整代码实现如下:
|
||||
|
||||
```
|
||||
+ (void)load {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// 通过 @selector 获得被替换和替换方法的 SEL,作为 SMHook:hookClass:fromeSelector:toSelector 的参数传入
|
||||
SEL fromSelector = @selector(sendAction:to:forEvent:);
|
||||
SEL toSelector = @selector(hook_sendAction:to:forEvent:);
|
||||
[SMHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)hook_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
|
||||
[self insertToSendAction:action to:target forEvent:event];
|
||||
[self hook_sendAction:action to:target forEvent:event];
|
||||
}
|
||||
- (void)insertToSendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
|
||||
// 日志记录
|
||||
if ([[[event allTouches] anyObject] phase] == UITouchPhaseEnded) {
|
||||
NSString *actionString = NSStringFromSelector(action);
|
||||
NSString *targetName = NSStringFromClass([target class]);
|
||||
[[[SMLogger create] message:[NSString stringWithFormat:@"%@ %@",targetName,actionString]] save];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
和 UIViewController 生命周期埋点不同的是,UIButton 在一个视图类中可能有多个不同的继承类,相同 UIButton 的子类在不同视图类的埋点也要区别开。所以,我们需要通过 “action 选择器名 NSStringFromSelector(action)” +“视图类名 NSStringFromClass([target class])”组合成一个唯一的标识,来进行埋点记录。
|
||||
|
||||
除了UIViewController、UIButton控件以外,Cocoa 框架的其他控件都可以使用这种方法来进行无侵入埋点。以 Cocoa 框架中最复杂的 UITableView 控件为例,你可以使用hook setDelegate 方法来实现无侵入埋点。另外,对于Cocoa 框架中的手势事件(Gesture Event),我们也可以通过hook initWithTarget:action: 方法来实现无侵入埋点。
|
||||
|
||||
## 事件唯一标识
|
||||
|
||||
通过运行时方法替换的方式,我们能够 hook 住所有的 Objective-C 方法,可以说是大而全了,能够帮助我们解决绝大部分的埋点问题。
|
||||
|
||||
但是,这种方案的精确度还不够高,还无法区分相同类在不同视图树节点的情况。比如,一个视图下相同 UIButton 的不同实例,仅仅通过 “action 选择器名”+“视图类名”的组合还不能够区分开。这时,我们就需要有一个唯一标识来区分不同的事件。接下来,我就跟你说说**如何制定出这个唯一标识**。
|
||||
|
||||
这时,我首先想到的就是,能不能通过视图层级的路径来解决这个问题。因为每个页面都有一个视图树结构,通过视图的 superview 和 subviews 的属性,我们就能够还原出每个页面的视图树。视图树的顶层是 UIWindow,每个视图都在树的子节点上。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/52/cbfb127db8ed2545fd3ce0aa3ae6f452.png" alt=""><br>
|
||||
一个视图下的子节点可能是同一个视图的不同实例,比如上图中 UIView 视图节点下的两个 UIButton 是同一个类的不同实例,所以光靠视图树的路径还是没法唯一确定出视图的标识。那么,这种情况下,我们又应该如何区别不同的视图呢?
|
||||
|
||||
这时,我们想到了索引:每个子视图在父视图中都会有自己的索引,所以如果我们再加上这个索引的话,每个视图的标识就是唯一的了。
|
||||
|
||||
接下来的一个问题是,视图层级路径加上在父视图中的索引来进行唯一标识,是不是就能够涵盖所有情况了呢?
|
||||
|
||||
当然不是。我们还需要考虑类似 UITableViewCell 这种具有可复用机制的视图,Cell 会在页面滚动时不断复用,所以加索引的方式还是没法用。
|
||||
|
||||
但这个问题也并不是无解的。UITableViewCell 需要使用 indexPath,这个值里包含了 section 和 row 的值。所以,我们可以通过 indexPath 来确定每个 Cell 的唯一性。
|
||||
|
||||
除了 UITableViewCell 这种情况之外, UIAlertController也比较特殊。它的特殊性在于视图层级的不固定,因为它可能出现在任何页面中。但是,我们都知道它的功能区分往往通过弹窗内容来决定,所以可以通过内容来确定它的唯一标识。
|
||||
|
||||
除此之外,还有更多需要特殊处理的情况,但我们总是可以通过一些办法去确定它们的唯一性,所以我在这里也就不再一一列举了。思路上来说就是,想办法找出元素间不相同的因素然后进行组合,最后形成一个能够区别于其他元素的标识来。
|
||||
|
||||
除了上面提到的这些特殊情况外,还有一种情况使得我们也难以得到准确的唯一标识。如果视图层级在运行时会被更改,比如执行 insertSubView:atIndex:、removeFromSuperView 等方法时,我们也无法得到唯一标识,即使只截取部分路径也无法保证后期代码更新时不会动到这个部分。就算是运行时视图层级不会修改,以后需求迭代页面更新频繁的话,视图唯一标识也需要同步的更新维护。
|
||||
|
||||
这种问题就不好解决了,事件唯一标识的准确性难以保障,这也是通过运行时方法替换进行无侵入埋点很难在各个公司全面铺开的原因。虽然无侵入埋点无法覆盖到所有情况,全面铺开面临挑战,但是无侵入埋点还是解决了大部分的埋点需求,也节省了大量的人力成本。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我与你分享了运行时替换方法进行无侵入埋点的方案。这套方案由于唯一标识难以维护和准确性难以保障的原因,很难被全面采用,一般都只是用于一些功能和视图稳定的地方,手动侵入式埋点方式依然占据大部分场景。
|
||||
|
||||
无侵入埋点也是业界一大难题,目前还只是初级阶段,还有很长的路要走。我认为,运行时替换方法的方式也只是一种尝试,但是现实中业务代码太过复杂。同时,为了使无侵入的埋点能够覆盖得更全、准确度更高,代价往往是对埋点所需的标识维护成本不断增大。
|
||||
|
||||
所以说,我觉得这种方案并不一定是未来的方向。我倒是觉得使用 Clang AST 的接口,在构建时遍历 AST,通过定义的规则将所需要的埋点代码直接加进去,可能会更加合适。这时,我们可以使用前一篇文章“如何利用 Clang 为 App 提质?”中提到的 LibTooling 来开发一个独立的工具,专门以静态方式插入埋点代码。这样做,既可以享受到手动埋点的精确性,还能够享受到无侵入埋点方式的统一维护、开发解耦、易维护的优势。
|
||||
|
||||
## 课后作业
|
||||
|
||||
今天我和你具体说了下 UIViewController 生命周期和 UIButton 点击事件的无侵入埋点方式,并给了具体的实现代码。那么,对于 UITableViewCell 点击事件的无侵入埋点,应该怎么来实现的代码,就当做一个课后小作业留给你来完成吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
334
极客时间专栏/geek/iOS开发高手课/基础篇/10 | 包大小:如何从资源和代码层面实现全方位瘦身?.md
Normal file
334
极客时间专栏/geek/iOS开发高手课/基础篇/10 | 包大小:如何从资源和代码层面实现全方位瘦身?.md
Normal file
@@ -0,0 +1,334 @@
|
||||
<audio id="audio" title="10 | 包大小:如何从资源和代码层面实现全方位瘦身?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/d7/bfafe4a0b0b7511e9532521c99e143d7.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天我来跟你说下如何对 App 包大小做优化。
|
||||
|
||||
对App包大小做优化的目的,就是节省用户流量,提高用户下载速度。当初,我在主持滴滴客户端的瘦身时,就是奔着对包大小进行最大化优化的目标,3个月内将包大小从106MB降到了最低64MB,半年内稳定在了70MB。当时业务还没有停,从106MB降到64MB的这3个月里如履薄冰,不同团队各显神威,几乎用到了所有手段,也做了很多创新,最终达成了目标。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/a2/f6764743beca921cb5dac4644ca092a2.png" alt="">
|
||||
|
||||
上图就是当时主流 App 的大小,可以看到最大的百度和淘宝,分别是131MB和115MB,滴滴是106MB,最小的是微信87MB。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/ea/7b7cc940287d2bbb98b97bce38f5aaea.png" alt="">
|
||||
|
||||
可以看到,经过半年的时间,除了滴滴外每个 App的安装包都增大了不少,先前最小的微信也从87MB增加到了116MB。
|
||||
|
||||
相信你的团队也曾遇到过或正在经历着对包大小进行优化的任务,特别是App Store 规定了安装包大小超过150MB的 App 不能使用 OTA(over-the-air)环境下载,也就是只能在WiFi 环境下下载。所以,150MB就成了 App 的生死线,一旦超越了这条线就很有可能会失去大量用户。
|
||||
|
||||
如果你的App要再兼容iOS7 和 iOS8 的话,苹果[官方还规定](https://help.apple.com/app-store-connect/#/dev611e0a21f)主二进制 text 段的大小不能超过60MB。如果没有达到这个标准,你甚至都没法提交 App Store。
|
||||
|
||||
而实际情况是,业务复杂的 App 轻轻松松就超过了60MB。虽然我们可以通过静态库转动态库的方式来快速避免这个限制,但是静态库转动态库后,动态库的大小差不多会增加一倍,这样150MB的限制就更难守住。
|
||||
|
||||
另外,App包体积过大,对用户更新升级率也会有很大影响。
|
||||
|
||||
综上所述,App 包过大既损害用户体验,影响升级率,还会导致无法提交 App Store 的情况和非WiFi环境无法下载这样可能影响到 App 生死的问题。那么,怎样对包大小进行瘦身和控制包大小的不合理增长就成了重中之重。
|
||||
|
||||
接下来,我就把我用过的包大小瘦身方法一个个地都说给你听。
|
||||
|
||||
## 官方 App Thinning
|
||||
|
||||
App Thinning 是由苹果公司推出的一项可以改善 App 下载进程的新技术,主要是为了解决用户下载 App 耗费过高流量的问题,同时还可以节省用户 iOS 设备的存储空间。
|
||||
|
||||
现在的 iOS 设备屏幕尺寸、分辨率越来越多样化,这样也就需要更多资源来匹配不同的尺寸和分辨率。 同时,App 也会有32位、64位不同芯片架构的优化版本。如果这些都在一个包里,那么用户下载包的大小势必就会变大。
|
||||
|
||||
App Thinning 会专门针对不同的设备来选择只适用于当前设备的内容以供下载。比如,iPhone 6 只会下载 2x 分辨率的图片资源,iPhone 6plus 则只会下载 3x 分辨率的图片资源。
|
||||
|
||||
在苹果公司使用 App Thinning 之前, 每个 App 包会包含多个芯片的指令集架构文件。以 Reveal.framework 为例,使用 du 命令查看到主文件在 Reveal.framework/Versions/A 目录下,大小有21MB。
|
||||
|
||||
```
|
||||
ming$ du -h Reveal.framework/*
|
||||
0B Reveal.framework/Headers
|
||||
0B Reveal.framework/Reveal
|
||||
16K Reveal.framework/Versions/A/Headers
|
||||
21M Reveal.framework/Versions/A
|
||||
21M Reveal.framework/Versions
|
||||
|
||||
```
|
||||
|
||||
然后,我们可以再使用 file 命令,查看 Version 目录下的Reveal 文件:
|
||||
|
||||
```
|
||||
ming$ file Reveal.framework/Versions/A/Reveal
|
||||
Reveal.framework/Versions/A/Reveal: Mach-O universal binary with 5 architectures: [i386:current ar archive] [arm64]
|
||||
Reveal.framework/Versions/A/Reveal (for architecture i386): current ar archive
|
||||
Reveal.framework/Versions/A/Reveal (for architecture armv7): current ar archive
|
||||
Reveal.framework/Versions/A/Reveal (for architecture armv7s): current ar archive
|
||||
Reveal.framework/Versions/A/Reveal (for architecture x86_64): current ar archive
|
||||
Reveal.framework/Versions/A/Reveal (for architecture arm64): current ar archive
|
||||
|
||||
```
|
||||
|
||||
可以看到, Reveal 文件里还有5个文件:
|
||||
|
||||
- x86_64 和 i386,是用于模拟器的芯片指令集架构文件;
|
||||
- arm64、armv7、armv7s ,是真机的芯片指令集架构文件。
|
||||
|
||||
下图来自[iOS Support Matrix](http://iossupportmatrix.com/),列出来的是历来各个 iOS 设备的指令集详细矩阵分布。从中,我们可以一窥所有设备的芯片指令集以及支持的最高和最低 iOS 版本。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/6b/4746f231258fb5875f72fa594463216b.png" alt="">
|
||||
|
||||
使用 App Thinning 后,用户下载时就只会下载一个适合自己设备的芯片指令集架构文件。
|
||||
|
||||
App Thinning 有三种方式,包括:App Slicing、Bitcode、On-Demand Resources。
|
||||
|
||||
- App Slicing,会在你向 iTunes Connect 上传App后,对 App 做切割,创建不同的变体,这样就可以适用到不同的设备。
|
||||
- On-Demand Resources,主要是为游戏多关卡场景服务的。它会根据用户的关卡进度下载随后几个关卡的资源,并且已经过关的资源也会被删掉,这样就可以减少初装 App 的包大小。
|
||||
- Bitcode ,是针对特定设备进行包大小优化,优化不明显。
|
||||
|
||||
那么,如何在你项目里使用 App Thinning 呢?
|
||||
|
||||
其实,这里的大部分工作都是由 Xcode 和 App Store 来帮你完成的,你只需要通过 Xcode 添加 xcassets 目录,然后将图片添加进来即可。
|
||||
|
||||
首先,新建一个文件选择 Asset Catalog 模板,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/27/1e0b0d270236842f0f1166e107965927.png" alt="">
|
||||
|
||||
然后,按照 Asset Catalog 的模板添加图片资源即可,添加的 2x 分辨率的图片和 3x分辨率的图片,会在上传到 App Store 后被创建成不同的变体以减小App安装包的大小。而芯片指令集架构文件只需要按照默认的设置, App Store 就会根据设备创建不同的变体,每个变体里只有当前设备需要的那个芯片指令集架构文件。
|
||||
|
||||
使用 App Thining 后,你可以将 2x 图和 3x 图区分开,从而达到减小App 安装包体积的目的。如果我们要进一步减小 App 包体积的话,还需要在图片和代码上继续做优化。接下来,我就跟你说说,为了减小 App 安装包的体积,我们还能在图片上做些什么?
|
||||
|
||||
## 无用图片资源
|
||||
|
||||
图片资源的优化空间,主要体现在删除无用图片和图片资源压缩这两方面。而删除无用图片,又是其中最容易、最应该先做的。像代码瘦身这样难啃的骨头,我们就留在后面吧。那么,我们是如何找到并删除这些无用图片资源的呢?
|
||||
|
||||
删除无用图片的过程,可以概括为下面这6大步。
|
||||
|
||||
<li>
|
||||
通过 find 命令获取App安装包中的所有资源文件,比如 find /Users/daiming/Project/ -name。
|
||||
</li>
|
||||
<li>
|
||||
设置用到的资源的类型,比如 jpg、gif、png、webp。
|
||||
</li>
|
||||
<li>
|
||||
使用正则匹配在源码中找出使用到的资源名,比如 pattern = @"@"(.+?)""。
|
||||
</li>
|
||||
<li>
|
||||
使用find 命令找到的所有资源文件,再去掉代码中使用到的资源文件,剩下的就是无用资源了。
|
||||
</li>
|
||||
<li>
|
||||
对于按照规则设置的资源名,我们需要在匹配使用资源的正则表达式里添加相应的规则,比如 @“image_%d”。
|
||||
</li>
|
||||
<li>
|
||||
确认无用资源后,就可以对这些无用资源执行删除操作了。这个删除操作,你可以使用 NSFileManger 系统类提供的功能来完成。
|
||||
</li>
|
||||
|
||||
整个过程如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/fc/400835aa1573a15ed4dcbf678bad82fc.png" alt="">
|
||||
|
||||
如果你不想自己重新写一个工具的话,可以选择开源的工具直接使用。我觉得目前最好用的是 [LSUnusedResources](https://github.com/tinymind/LSUnusedResources),特别是对于使用编号规则的图片来说,可以通过直接添加规则来处理。使用方式也很简单,你可以参看下面的动画演示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/ee/4c0c466cc77f66b14547aaa50bff66ee.gif" alt="">
|
||||
|
||||
## 图片资源压缩
|
||||
|
||||
无用图片资源处理完了,那么有用的图片还有瘦身的空间吗?
|
||||
|
||||
答案是有的。
|
||||
|
||||
对于 App 来说,图片资源总会在安装包里占个大头儿。对它们最好的处理,就是在不损失图片质量的前提下尽可能地作压缩。目前比较好的压缩方案是,将图片转成 WebP。[WebP](https://developers.google.com/speed/webp/) 是 Google公司的一个开源项目。
|
||||
|
||||
首先,我们一起看看**选择 WebP 的理由**:
|
||||
|
||||
- WebP压缩率高,而且肉眼看不出差异,同时支持有损和无损两种压缩模式。比如,将Gif 图转为Animated WebP ,有损压缩模式下可减少 64%大小,无损压缩模式下可减少 19%大小。
|
||||
- WebP 支持 Alpha 透明和 24-bit 颜色数,不会像 PNG8 那样因为色彩不够而出现毛边。
|
||||
|
||||
接下来,我们再看看**怎么把图片转成WebP**?
|
||||
|
||||
Google公司在开源WebP的同时,还提供了一个图片压缩工具 [cwebp](https://developers.google.com/speed/webp/docs/precompiled)来将其他图片转成 WebP。cwebp 使用起来也很简单,只要根据图片情况设置好参数就行。
|
||||
|
||||
cwebp 语法如下:
|
||||
|
||||
```
|
||||
cwebp [options] input_file -o output_file.webp
|
||||
|
||||
```
|
||||
|
||||
比如,你要选择无损压缩模式的话,可以使用如下所示的命令:
|
||||
|
||||
```
|
||||
cwebp -lossless original.png -o new.webp
|
||||
|
||||
```
|
||||
|
||||
其中,-lossless表示的是,要对输入的png图像进行无损编码,转成WebP图片。不使用 -lossless ,则表示有损压缩。
|
||||
|
||||
在cwebp语法中,还有一个比较关键的参数-q float。
|
||||
|
||||
图片色值在不同情况下,可以选择用 -q 参数来进行设置,在不损失图片质量情况下进行最大化压缩:
|
||||
|
||||
- 小于256色适合无损压缩,压缩率高,参数使用 -lossless -q 100;
|
||||
- 大于256色使用75%有损压缩,参数使用 -q 75;
|
||||
- 远大于256色使用75%以下压缩率,参数 -q 50 -m 6。
|
||||
|
||||
除了cwebp工具外,你还可以选择由腾讯公司开发的[iSparta](http://isparta.github.io)。iSpart 是一个 GUI 工具,操作方便快捷,可以实现PNG格式转WebP,同时提供批量处理和记录操作配置的功能。如果是其他格式的图片要转成WebP格式的话,需要先将其转成 PNG格式,再转成WebP格式。它的GUI 界面如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/45/89d5abb7fdb69a391b75eeba141ecc45.png" alt="">
|
||||
|
||||
图片压缩完了并不是结束,我们还需要在显示图片时使用 libwebp 进行解析。这里有一个iOS 工程使用 libwebp 的范例,你可以点击[这个链接](https://github.com/carsonmcdonald/WebP-iOS-example)查看。
|
||||
|
||||
不过,WebP 在 CPU 消耗和解码时间上会比 PNG 高两倍。所以,我们有时候还需要在性能和体积上做取舍。
|
||||
|
||||
**我的建议是,如果图片大小超过了100KB,你可以考虑使用 WebP;而小于100KB时,你可以使用网页工具 [TinyPng](https://tinypng.com/)或者GUI工具[ImageOptim](https://imageoptim.com/mac)进行图片压缩**。这两个工具的压缩率没有 WebP 那么高,不会改变图片压缩方式,所以解析时对性能损耗也不会增加。
|
||||
|
||||
## 代码瘦身
|
||||
|
||||
App的安装包主要是由资源和可执行文件组成的,所以我们在掌握了对图片资源的处理方式后,需要再一起来看看对可执行文件的瘦身方法。
|
||||
|
||||
可执行文件就是 Mach-O 文件,其大小是由代码量决定的。通常情况下,**对可执行文件进行瘦身,就是找到并删除无用代码的过程。**而查找无用代码时,我们可以按照找无用图片的思路,即:
|
||||
|
||||
- 首先,找出方法和类的全集;
|
||||
- 然后,找到使用过的方法和类;
|
||||
- 接下来,取二者的差集得到无用代码;
|
||||
- 最后,由人工确认无用代码可删除后,进行删除即可。
|
||||
|
||||
接下来,我们就看看具体的代码瘦身方法吧。
|
||||
|
||||
### LinkMap 结合 Mach-O 找无用代码
|
||||
|
||||
我先和你说下怎么快速找到方法和类的全集。
|
||||
|
||||
**我们可以通过分析 LinkMap 来获得所有的代码类和方法的信息。**获取 LinkMap 可以通过将 Build Setting 里的 Write Link Map File 设置为 Yes,然后指定 Path to Link Map File 的路径就可以得到每次编译后的 LinkMap 文件了。设置选项如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/2e/660aebe887ebd20f1b18fc685920b82e.png" alt="">
|
||||
|
||||
LinkMap文件分为三部分:Object File、Section 和 Symbols。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/07/21cf3a872d3693b1978601b7aa033607.png" alt="">
|
||||
|
||||
其中:
|
||||
|
||||
- Object File 包含了代码工程的所有文件;
|
||||
- Section 描述了代码段在生成的 Mach-O 里的偏移位置和大小;
|
||||
- Symbols 会列出每个方法、类、block,以及它们的大小。
|
||||
|
||||
通过 LinkMap ,你不光可以统计出所有的方法和类,还能够清晰地看到代码所占包大小的具体分布,进而有针对性地进行代码优化。
|
||||
|
||||
得到了代码的全集信息以后,我们还需要找到已使用的方法和类,这样才能获取到差集,找出无用代码。所以接下来,我就先和你说说**怎么通过 Mach-O 取到使用过的方法和类**。
|
||||
|
||||
我在第2篇文章“[APP 启动速度怎么做优化与监控?](https://time.geekbang.org/column/article/85331)”中,和你提到过iOS 的方法都会通过 objc_msgSend 来调用。而,objc_msgSend 在 Mach-O文件里是通过 __objc_selrefs 这个 section 来获取 selector 这个参数的。
|
||||
|
||||
所以,__objc_selrefs 里的方法一定是被调用了的。__objc_classrefs 里是被调用过的类,__objc_superrefs 是调用过 super 的类。通过 __objc_classrefs 和 __objc_superrefs,我们就可以找出使用过的类和子类。
|
||||
|
||||
那么,Mach-O文件的 __objc_selrefs、__objc_classrefs和__objc_superrefs 怎么查看呢?
|
||||
|
||||
我们可以使用 [MachOView 这个软件](https://sourceforge.net/projects/machoview/)来查看Mach-O 文件里的信息。MachOView 同时也是一款开源软件,如果你对源码感兴趣的话,可以点击[这个地址](https://github.com/gdbinit/MachOView)查看。
|
||||
|
||||
具体的查看方法,我将通过一个案例和你展开。
|
||||
|
||||
- 首先,我们需要编译一个 App。在这里,我clone了[一个GitHub上的示例](https://github.com/ming1016/GCDFetchFeed) 下来编译。
|
||||
- 然后,将生成的 GCDFetchFeed.app 包解开,取出 GCDFetchFeed。
|
||||
- 最后,我们就可以使用 MachOView 来查看Mach-O 里的信息了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/a4/0bc5431e0cd634296886ac457d4066a4.jpg" alt="">
|
||||
|
||||
如图上所示,我们可以看到 __objc_selrefs、__objc_classrefs和、__objc_superrefs 这三个 section。
|
||||
|
||||
但是,这种查看方法并不是完美的,还会有些问题。原因在于, Objective-C 是门动态语言,方法调用可以写成在运行时动态调用,这样就无法收集全所有调用的方法和类。所以,我们通过这种方法找出的无用方法和类就只能作为参考,还需要二次确认。
|
||||
|
||||
### 通过 AppCode 找出无用代码
|
||||
|
||||
那么,有什么好的工具能够找出无用的代码吗?
|
||||
|
||||
我用过不少工具,但效果其实都不是很好,都卡在了各种运用运行时调用方法的写法上。即使是大名鼎鼎的 AppCode 在这方面也做得不是很好,当代码量过百万行时 AppCode 的静态分析会“歇菜”。
|
||||
|
||||
但是,**如果工程量不是很大的话,我还是建议你直接使用 AppCode 来做分析。**毕竟代码量达到百万行的工程并不多。而,那些代码量达到百万行的团队,则会自己通过 Clang 静态分析来开发工具,去检查无用的方法和类。
|
||||
|
||||
用 AppCode 做分析的方法很简单,直接在 AppCode 里选择 Code->Inspect Code 就可以进行静态分析。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/11/a935117790e7744069cf1bf58336b711.png" alt="">
|
||||
|
||||
静态分析完以后,我们可以在 Unused code 里看到所有的无用代码,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/a4/57a96abafd30d5c15210300c2d1eaba4.png" alt="">
|
||||
|
||||
接下来,我和你说一下这些无用代码的主要类型。
|
||||
|
||||
- 无用类:Unused class 是无用类,Unused import statement 是无用类引入声明,Unused property 是无用的属性;
|
||||
- 无用方法:Unused method 是无用的方法,Unused parameter 是无用参数,Unused instance variable 是无用的实例变量,Unused local variable 是无用的局部变量,Unused value 是无用的值;
|
||||
- 无用宏:Unused macro 是无用的宏。
|
||||
- 无用全局:Unused global declaration 是无用全局声明。
|
||||
|
||||
看似AppCode 已经把所有工作都完成了,其实不然。下面,我再和你列举下 AppCode 静态检查的问题:
|
||||
|
||||
- JSONModel 里定义了未使用的协议会被判定为无用协议;
|
||||
- 如果子类使用了父类的方法,父类的这个方法不会被认为使用了;
|
||||
- 通过点的方式使用属性,该属性会被认为没有使用;
|
||||
- 使用 performSelector 方式调用的方法也检查不出来,比如 self performSelector:@selector(arrivalRefreshTime);
|
||||
- 运行时声明类的情况检查不出来。比如通过 NSClassFromString 方式调用的类会被查出为没有使用的类,比如 layerClass = NSClassFromString(@“SMFloatLayer”)。还有以[[self class] accessToken] 这样不指定类名的方式使用的类,会被认为该类没有被使用。像 UITableView 的自定义的 Cell 使用 registerClass,这样的情况也会认为这个 Cell 没有被使用。
|
||||
|
||||
基于以上种种原因,使用AppCode检查出来的无用代码,还需要人工二次确认才能够安全删除掉。
|
||||
|
||||
### 运行时检查类是否真正被使用过
|
||||
|
||||
即使你使用LinkMap 结合 Mach-O 或者 AppCode 的方式,通过静态检查已经找到并删除了无用的代码,那么就能说包里完全没有无用的代码了吗?
|
||||
|
||||
实际上,在 App 的不断迭代过程中,新人不断接手、业务功能需求不断替换,会留下很多无用代码。这些代码在执行静态检查时会被用到,但是线上可能连这些老功能的入口都没有了,更是没有机会被用户用到。也就是说,这些无用功能相关的代码也是可以删除的。
|
||||
|
||||
那么,**我们要怎么检查出这些无用代码呢?**
|
||||
|
||||
通过 ObjC 的 runtime 源码,我们可以找到怎么判断一个类是否初始化过的函数,如下:
|
||||
|
||||
```
|
||||
#define RW_INITIALIZED (1<<29)
|
||||
bool isInitialized() {
|
||||
return getMeta()->data()->flags & RW_INITIALIZED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
isInitialized 的结果会保存到元类的 class_rw_t 结构体的 flags 信息里,flags 的1<<29 位记录的就是这个类是否初始化了的信息。而flags的其他位记录的信息,你可以参看 objc runtime 的源码,如下:
|
||||
|
||||
```
|
||||
// 类的方法列表已修复
|
||||
#define RW_METHODIZED (1<<30)
|
||||
|
||||
// 类已经初始化了
|
||||
#define RW_INITIALIZED (1<<29)
|
||||
|
||||
// 类在初始化过程中
|
||||
#define RW_INITIALIZING (1<<28)
|
||||
|
||||
// class_rw_t->ro 是 class_ro_t 的堆副本
|
||||
#define RW_COPIED_RO (1<<27)
|
||||
|
||||
// 类分配了内存,但没有注册
|
||||
#define RW_CONSTRUCTING (1<<26)
|
||||
|
||||
// 类分配了内存也注册了
|
||||
#define RW_CONSTRUCTED (1<<25)
|
||||
|
||||
// GC:class有不安全的finalize方法
|
||||
#define RW_FINALIZE_ON_MAIN_THREAD (1<<24)
|
||||
|
||||
// 类的 +load 被调用了
|
||||
#define RW_LOADED (1<<23)
|
||||
|
||||
```
|
||||
|
||||
flags 采用位方式记录布尔值的方式,易于扩展、所用存储空间小、检索性能也好。所以,经常阅读优秀代码,特别有助于提高我们自己的代码质量。
|
||||
|
||||
这里,我插一句题外话。**我面试应聘者的时候,常常会问他们“苹果公司为什么要设计元类”这样的开放问题。**结果呢,就是我所见的大部分应聘者,都只能说出元类是什么。
|
||||
|
||||
因为很多人都只是奔着学习 runtime 这个知识点而学习,并没有在学习过程中多想想为什么。比如,为什么类结构要这么设计,为什么一个类要设计两个结构体等等类似的问题。在我看来,没有经过深入思考的学习是不够的,是学不到精髓的,很多优秀的代码可能就会被错过。
|
||||
|
||||
好了,现在继续回到我们的正文内容中。既然能够在运行中看到类是否初始化了,那么我们就能够找出有哪些类是没有初始化的,即找到在真实环境中没有用到的类并清理掉。
|
||||
|
||||
具体编写运行时无用类检查工具时,我们可以在线下测试环节去检查所有类,先查出哪些类没有初始化,然后上线后针对那些没有初始化的类进行多版本监测观察,看看哪些是在主流程外个别情况下会用到的,判断合理性后进行二次确认,最终得到真正没有用到的类并删掉。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我主要和你分享的是App安装包的一些瘦身方案。
|
||||
|
||||
在我看来,可以把包瘦身方案根据App的代码量等因素,划分为两种。
|
||||
|
||||
对于上线时间不长的新 App 和那些代码量不大的 App来说,做些资源上的优化,再结合使用AppCode 就能够有很好的收益。而且把这些流程加入工作流后,日常工作量也不会太大。
|
||||
|
||||
但是,对于代码量大,而且业务需求迭代时间很长的 App来说,包大小的瘦身之路依然任道重远,这个领域的研究还有待继续完善。LinkMap 加 Mach-O 取差集的结果也只能作为参考,每次人工确认的成本是非常大的,只适合突击和应急清理时使用。最后日常采用的方案,可能还是用运行时检查类的方式,这种大粒度检查的方式精度虽然不高,但是人工工作量会小很多。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
今天我提到了运行时检查类是否被使用的方案,那么你来写个使用这种方案的小工具来检查下你的 App 里有哪些类实际上是没有被初始化用到的吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/27/f5ee90aa0183a4bcc688980bd625eb27.jpg" alt="">
|
||||
172
极客时间专栏/geek/iOS开发高手课/基础篇/11 | 热点问题答疑(一):基础模块问题答疑.md
Normal file
172
极客时间专栏/geek/iOS开发高手课/基础篇/11 | 热点问题答疑(一):基础模块问题答疑.md
Normal file
@@ -0,0 +1,172 @@
|
||||
<audio id="audio" title="11 | 热点问题答疑(一):基础模块问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/a0/71573d14b35f41bbc694086f9f2844a0.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
专栏上线以来,我通过评论区收到了很多同学提出的问题、建议、心得和经验,当然提的问题居多。虽然我未在评论区对每条留言做出回复,但是我对大家提出的问题却都一一记录了下来,等待这次答疑文章的到来。其实,不光是在留言区,也有一些朋友通过私信跟我反馈了学习专栏遇到的问题。
|
||||
|
||||
所以,今天我就借此机会,汇总并整理了几个典型并重要的问题,和你详细说一说,希望可以解答你在学习前面10篇文章时的一些困惑。
|
||||
|
||||
## 动态库加载方式的相关问题
|
||||
|
||||
@五子棋在看完第5篇文章“[链接器:符号是怎么绑定到地址上的?](https://time.geekbang.org/column/article/86840)”后,关于动态库是否参与链接的问题,通过私信和我反馈了他的观点。他指出:动态库也是要参与链接的,不然就没法知道函数的标记在哪儿。
|
||||
|
||||
为了帮助大家理解这个问题,我把与这个问题相关的内容,再和你展开一下。
|
||||
|
||||
我在文章中,是这么阐述这部分内容的:
|
||||
|
||||
>
|
||||
Mach-O 文件是编译后的产物,而动态库在运行时才会被链接,并没参与 Mach-O文件的编译和链接,所以 Mach-O文件中并没有包含动态库里的符号定义。也就是说,这些符号会显示为“未定义”,但它们的名字和对应的库的路径会被记录下来。运行时通过 dlopen 和 dlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。
|
||||
|
||||
|
||||
细细想来,这种说法并不严谨。关于这个问题,更严谨的说法应该是,加载动态库的方式有两种:
|
||||
|
||||
- 一种是,在程序开始运行时通过 dyld 动态加载。通过 dyld 加载的动态库需要在编译时进行链接,链接时会做标记,绑定的地址在加载后再决定。
|
||||
<li>第二种是,显式运行时链接(Explicit Runtime Linking),即在运行时通过动态链接器提供的 API dlopen 和 dlsym 来加载。这种方式,在编译时是不需要参与链接的。<br>
|
||||
不过,通过这种运行时加载远程动态库的App,苹果公司是不允许上线 App Store 的,所以只能用于线下调试环节。关于这种方式的适用场景,我也已经在文章第6篇文章“[App 如何通过注入动态库的方式实现极速编译调试?](https://time.geekbang.org/column/article/87188)”中和你举例说明过,你可以再回顾下相关内容。</li>
|
||||
|
||||
在第5篇文章中,我将动态库的这两种加载方式混在一起说了,让你感到些许困惑,所以在这里我特地做个补充说明。
|
||||
|
||||
接下来,我们就再看看第6篇文章后的留言。这篇文章留言区的问题集中在:项目中使用了CocoaPods来开发组件,在使用InjectionIII调试时,遇到了修改源码无法进行注入的问题。
|
||||
|
||||
在这里,我首先要感谢@manajay同学在 InjectionIII 的 issue 里找到了相关的解答,并分享到了留言区。
|
||||
|
||||
其实,**关于 InjectionIII 的这部分内容,我更希望你能够了解 InjectionIII 的工作原理,从而加深对运行时动态库加载原理的理解。**然后,根据自己的工程情况动手改造或者直接造个新轮子,我相信会极大地提升你的技术水平,至少比直接使用已有轮子的效果要好得多。
|
||||
|
||||
所以,还是回到我在[开篇词](https://time.geekbang.org/column/article/85318)中和你提到的观点:动手就会碰到问题,就会思考,这个主动过程会加深你的记忆,这样后面再碰到问题时,你会更容易将相关知识串联起来,形成创新式的思考。但如果你在碰到困难时,就选择放弃那必定会抱有遗憾。
|
||||
|
||||
在第8篇文章“[如何利用 Clang 为 App 提质?](https://time.geekbang.org/column/article/87844)”中,@鹏哥同学在评论区问了我这样一个问题:
|
||||
|
||||
>
|
||||
在第1篇文章“[建立你自己的iOS开发知识体系](https://time.geekbang.org/column/article/85326)”中,你提到对某一领域的知识要做到精通的程度,而不能只是了解。那么,你在这个专栏中提到了这么多内容,我应该选择哪些内容去深入研究呢?还是说所有的内容,我都需要去深入研究?
|
||||
|
||||
|
||||
我给出的回答是,根据工作需要来选择。比如说,如果调试速度的问题,确实是你目前工作中面临的最大挑战,那我觉得你就应该在这个点上深挖,并勇敢地克服其中遇到的困难,就像我上次通过“极客时间”的平台直播时,和你分享的我自己学画画的经历一样,挑战素描的过程确实很痛苦,但挺过来了之后我会很受益并享受自己的进步。对我们这种手艺人来说,不断挑战才能不断进步。
|
||||
|
||||
最近我在看一个豆瓣评分非常高的日剧《北国之恋》,在第3集“决心”里,一位老爷爷在北海道送别朋友时说了一番话,我觉得特别有力量。所以,我把这段话放在这里和你共勉:
|
||||
|
||||
>
|
||||
不可思议啊,虽然是流行歌曲,不过呢。听到这首歌,这歌流行起来,让人回想起那个时代的往事。那年发生了很严重的冻灾,再加上农业机械的引进,农场的经营方式慢慢不一样了。一起来开荒的伙伴们,收拾行李,一个一个地从麓乡离开了。那是11月啊,亲密的伙伴们,四家一起放弃了农场,那个时候,我,当然要来送行,稀稀落落地下起了雪,那时流行北岛三郎,有四家要离开,来送行的只有我和老婆两个人,大家都一句话也不说。不过,那个时候,我真想把心里想的说出来。你们,这么做行吗?这是输了之后逃跑啊,二十多年来一直在一起努力,你们的心酸、悲哀、悔恨,一切的一切,我自以为都了解。因此,我没有对别人说三道四,没有对别人自以为是地指指点点。可是,说这句话的权利我还是有的,你们失败了逃跑了,背叛了我们。逃跑了,这一点,你们给我好好记住。
|
||||
|
||||
|
||||
好了,我们现在继续回到专栏文章上吧。
|
||||
|
||||
App启动时通过 dyld 加载动态库,就是运行时动态库加载在App启动速度优化上的一个应用场景。在专栏的第2篇文章“[App 启动速度怎么做优化与监控?](https://time.geekbang.org/column/article/85331)”中,我和你分享了动态库加载后的监控和优化,文后的评论区就有很多同学提到了,想要多了解些动态库加载方面的优化。
|
||||
|
||||
关于App开始启动到 main 函数之间的 dyld 内部细节,我推荐你去看苹果公司的 WWDC 2016 Session 406 [Optimizing App Startup Time](https://developer.apple.com/videos/play/wwdc2016/406/)视频。这个视频里面,不仅详细剖析了 dyld,还提供了构建代码的最佳实践。
|
||||
|
||||
除此之外,“[How we cut our iOS app’s launch time in half (with this one cool trick)](https://blog.automatic.com/how-we-cut-our-ios-apps-launch-time-in-half-with-this-one-cool-trick-7aca2011e2ea)”这篇博客,也是个不错的阅读资料。光看名字就很吸引人了,对吧。
|
||||
|
||||
关于App 启动速度的话题,很多同学还提出了其他问题,包括很多关于课后作业的问题。所以接下来,我就针对这个话题再专门做个答疑吧。
|
||||
|
||||
## App 启动速度的相关问题
|
||||
|
||||
专栏的第2篇文章“[App 启动速度怎么做优化与监控?](https://time.geekbang.org/column/article/85331)”中的大部分问题,我都直接在评论区回复了。下面的答疑内容,我主要是针对课后作业和汇编部分,统一做下回复。
|
||||
|
||||
### 关于课后作业
|
||||
|
||||
在这篇文章最后,我留下的课后作业是:
|
||||
|
||||
>
|
||||
按照今天文中提到的 Time Profiler 工具检查方法耗时的原理,你来动手实现一个方法耗时检查工具吧。
|
||||
|
||||
|
||||
虽然这个问题的思路,我已经在文章中提到了,但还是有很多同学感觉无从下手。接下来,我们就再一起来看看这个思考题吧。
|
||||
|
||||
关于实现思路,文中有怎么一段文字:
|
||||
|
||||
>
|
||||
定时抓取主线程上的方法调用堆栈,计算一段时间里各个方法的耗时。
|
||||
|
||||
|
||||
现在,我们再一起看一下这个实现思路(我原本未在文中详细展开,是希望多留点思考空间给你)。动手写耗时检查工具时,首先需要开启一个定时器,来定时获取方法调用堆栈。一段时间内方法调用堆栈相同,那么这段时间,就是这个方法调用堆栈的栈顶方法耗时。
|
||||
|
||||
**这个解题思路里很关键的一步,也是你最容易忽视的一步,就是应该怎么做好获取方法调用堆栈。**
|
||||
|
||||
callstackSymbols 是一种获取方法调用栈的方法,但是只能获取当前线程的调用栈,为了把对主线程的影响降到最小,获取当前线程调用栈的工作就需要在其他线程去做。所以,**这个解题思路就需要换成:**使用系统提供的 task_threads 去获取所有线程,使用thread_info 得到各个线程的详细信息,使用thread_get_state 方法去获取线程栈里的所有栈指针。
|
||||
|
||||
如果接下来立刻进行符号化去获取方法名,那么就需要去 __LINKEDIT segment 里查找栈指针地址所对应符号表的符号,特别当你设置的时间隔较小的时候,符号化过程会持续消耗较多的CPU资源,从而影响主线程。
|
||||
|
||||
所以,获取到栈指针后,我们可以不用立刻做符号化,而是先使用一个结构体将栈地址记录下来,最后再统一符号化,将对主线程的影响降到最低,这样获取的数据也会更加准确。
|
||||
|
||||
我们可以把记录栈地址的结构体设计为通用回溯结构,代码如下:
|
||||
|
||||
```
|
||||
typedef struct SMStackFrame {
|
||||
const struct SMStackFrame *const previous;
|
||||
const uintptr_t return_address;
|
||||
} SMStackFrame;
|
||||
|
||||
```
|
||||
|
||||
在这段代码中, previous 记录的是上一个栈指针的地址。考虑 CPU 性能,记录堆栈的数量也不必很多,取最近几条即可。通过栈基地址指针获取当前栈指针地址的关键代码如下:
|
||||
|
||||
```
|
||||
// 栈地址初始化
|
||||
SMStackFrame stackFrame = {0};
|
||||
// 栈基地址指针
|
||||
const uintptr_t framePointer = smMachStackBasePointerByCPU(&machineContext);
|
||||
if (framePointer == 0 || smMemCopySafely((void *)framePointer, &stackFrame, sizeof(stackFrame)) != KERN_SUCCESS) {
|
||||
return @"Fail frame pointer";
|
||||
}
|
||||
// 下面的8表示堆栈数量
|
||||
for (; i < 8; i++) {
|
||||
// 记录栈地址
|
||||
buffer[i] = stackFrame.return_address;
|
||||
if (buffer[i] == 0 || stackFrame.previous == 0 || smMemCopySafely(stackFrame.previous, &stackFrame, sizeof(stackFrame)) != KERN_SUCCESS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 关于汇编代码的学习
|
||||
|
||||
除了课后作业,在这篇文章的评论区中问到的最多的问题就是objc_msgSend 汇编的部分。@西京富贵兔在评论区留言说到:
|
||||
|
||||
>
|
||||
看完这篇文章我膨胀了,都敢去翻看 objc_msgSend的源码文件了。嗯,不出意料,一句没看懂。
|
||||
|
||||
|
||||
我想要说的是,汇编并不是必学技能,我们在日常的业务开发工作中也很少会用到。而且,现在编译器对高级语言的优化已经做得非常好了,手写出来的汇编代码性能不一定就会更好。如果你的工作不涉及到逆向和安全领域的话,能够看懂汇编代码就非常不错了。
|
||||
|
||||
但是,对于逆向和安全领域来说,掌握汇编技能还是很有必要的。**如果你想学汇编语言的话,同样也需要动手去编写和调试代码,使用 Xcode工具也没有问题。在开始学习时,你可以按照教程边学边写,其实就和学习其他编程语言的过程一样。**
|
||||
|
||||
而具体到 objc_msgSend 源码的剖析,你可以参考 Mike Ash 的 “[Dissecting objc_msgSend on ARM64](https://www.mikeash.com/pyblog/friday-qa-2017-06-30-dissecting-objc_msgsend-on-arm64.html)”这篇博客,详细讲述了objc_msgSend 的 ARM64 汇编代码。等你看完这篇博客以后,再来看我们这篇文章中的汇编代码就一定会觉得轻松很多。
|
||||
|
||||
## 关于Clang的相关问题
|
||||
|
||||
专栏已经更新的第7~第10这4篇文章中,都涉及到了Clang的知识以及应用,所以我在这里单独列出了一个问题,和你一起解决关于Clang的相关问题。
|
||||
|
||||
其实,我在第7篇文章“[Clang、Infer 和 OCLint ,我们应该使用谁来做静态分析?](https://time.geekbang.org/column/article/87477)”中,介绍的3款静态分析工具都用到了Clang,而且Clang 本身也提供了 LibTooling 这种强大的 C++ 接口来方便定制独立的工具。
|
||||
|
||||
当然了,Clang 的知识也是需要投入大量精力才能掌握好。那么,你可能会问,我掌握这些偏底层的知识有什么用呢,好像也解决不了我在现实开发工作中遇到的问题啊?
|
||||
|
||||
**在我看来,你只有掌握了某个方面的知识,在工作中碰到问题时才能够想到用这个知识去解决问题。如果你都不知道有这么一种方法,又怎么会用它去解决自己的问题呢?**
|
||||
|
||||
就比如说,你掌握了Clang的知识,那在研究[无侵入的埋点方案](https://time.geekbang.org/column/article/87925)应该如何实现时,你才能可能会想到用Clang的LibTooling 来开发一个独立的工具,专门以静态方式插入埋点的代码;只有掌握了Clang的知识,当你在面对代码量达到百万行的[App包瘦身需求](https://time.geekbang.org/column/article/88573)时,才会想到通过 Clang 静态分析来开发工具,去检查无用的方法和类。
|
||||
|
||||
当你掌握了 Clang 的相关知识后,编译前端的技术也就掌握得差不多了;在理解了编译前端的词法分析和语法分析的套路后,脱离 Clang 的接口完成第8篇文章“[如何利用 Clang 为 App 提质?](https://time.geekbang.org/column/article/87844)”的课后作业,也就没什么难度了。
|
||||
|
||||
在完成这个课后作业之前,你也可以先看看王垠在2012年的一篇博客“[怎样写一个解释器](http://www.yinwang.org/blog-cn/2012/08/01/interpreter)”。看完后这篇博客后,你一定会有撸起袖子加油干的冲劲儿。
|
||||
|
||||
关于第8篇文章的课后作业,如果你还有其他不明白的地方,欢迎继续给我留言。
|
||||
|
||||
## 小结
|
||||
|
||||
专栏更新至今,已经发布了10篇文章,大家在评论区留下很多高质量的留言,让我非常感动,在这里我也要感谢你的支持与鼓励。
|
||||
|
||||
这10篇文章学习下来,你可能会觉得这些文章so easy,也可能会觉得这些文章确实帮你解决了工作中遇到的困惑,还可能会觉得这些文章太难啃了但依旧在努力学习中,我想要和你说的就是:有的知识学起来很难,但是再坚持一下,并不断重复,只要能比昨天的自己进步一点点,终究可以掌握你想要的知识。
|
||||
|
||||
所以,在今天这篇答疑文章,也是我们专栏的第一篇答疑文章中,我不打算大而全地去回复太多的问题,只是甄选了其中其中非常重要、核心的几个问题,和你再一起巩固下我们所学的知识,并和你分享一些我的学习方法。
|
||||
|
||||
希望通过今天这篇文章,可以帮你搞明白那些让你困惑的知识点,逐步地建立起自己的知识体系。如果你还有其他的问题,欢迎你给我留言。
|
||||
|
||||
最后,虽然这是篇答疑文章,还是要留给你一个小小的思考题。
|
||||
|
||||
王垠的博客文章中,除了我在前面提到的“怎样写一个解释器”外,其他文章也都可以帮助你开阔眼界,非常值得一看。在看完他的博客后,你会发现他对编程语言本质的理解非常透彻,而你自己也能从中受益良多。
|
||||
|
||||
我在看完他所有的博客文章之后,对很多知识有了更深的理解,但同时知识量也非常大,无法一时都消化掉,感觉需要学习的地方还有很多。所以,我当时的感觉就是酸甜苦辣咸五味俱全。不知道你看完他的文章后,会有什么感觉呢?我们就把这个话题作为今天文章的思考题,请你在评论区分享一下你的阅后感吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
223
极客时间专栏/geek/iOS开发高手课/基础篇/12 | iOS 崩溃千奇百怪,如何全面监控?.md
Normal file
223
极客时间专栏/geek/iOS开发高手课/基础篇/12 | iOS 崩溃千奇百怪,如何全面监控?.md
Normal file
@@ -0,0 +1,223 @@
|
||||
<audio id="audio" title="12 | iOS 崩溃千奇百怪,如何全面监控?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/f4/cc3b02c6566055fa9f43b097081e67f4.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天我要跟你说的是崩溃监控。
|
||||
|
||||
App上线后,我们最怕出现的情况就是应用崩溃了。但是,我们线下测试好好的App,为什么上线后就发生崩溃了呢?这些崩溃日志信息是怎么采集的?能够采集的全吗?采集后又要怎么分析、解决呢?
|
||||
|
||||
接下来,通过今天这篇文章,**你就可以了解到造成崩溃的情况有哪些,以及这些崩溃的日志都是如何捕获收集到的。**
|
||||
|
||||
App 上线后,是很脆弱的,导致其崩溃的问题,不仅包括编写代码时的各种小马虎,还包括那些被系统强杀的疑难杂症。
|
||||
|
||||
下面,我们就先看看几个常见的编写代码时的小马虎,是如何让应用崩溃的。
|
||||
|
||||
- 数组越界:在取数据索引时越界,App会发生崩溃。还有一种情况,就是给数组添加了 nil 会崩溃。
|
||||
- 多线程问题:在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
|
||||
- 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。这时,崩溃问题对应的异常编码是0x8badf00d。关于这个异常编码,我还会在后文和你说明。
|
||||
- 野指针:指针指向一个已删除的对象访问内存区域时,会出现野指针崩溃。野指针问题是需要我们重点关注的,因为它是导致App崩溃的最常见,也是最难定位的一种情况。关于野指针等内存相关问题,我会在第14篇文章“临近 OOM,如何获取详细内存分配信息,分析内存问题?”里和你详细说明。
|
||||
|
||||
程序崩溃了,你的 App 就不可用了,对用户的伤害也是最大的。因此,每家公司都会非常重视自家产品的崩溃率,并且会将崩溃率(也就是一段时间内崩溃次数与启动次数之比)作为优先级最高的技术指标,比如千分位是生死线,万分位是达标线等,去衡量一个App的高可用性。
|
||||
|
||||
而崩溃率等技术指标,一般都是由崩溃监控系统来搜集。同时,崩溃监控系统收集到的堆栈信息,也为解决崩溃问题提供了最重要的信息。
|
||||
|
||||
但是,崩溃信息的收集却并没有那么简单。因为,有些崩溃日志是可以通过信号捕获到的,而很多崩溃日志却是通过信号捕获不到的。
|
||||
|
||||
你可以看一下下面这幅图,我列出了常见的部分崩溃情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/fe/f97dda3b49351f74747dd74128a0ddfe.png" alt="">
|
||||
|
||||
通过这张图片,我们可以看到, KVO问题、NSNotification 线程问题、数组越界、野指针等崩溃信息,是可以通过信号捕获的。但是,像后台任务超时、内存被打爆、主线程卡顿超阈值等信息,是无法通过信号捕捉到的。
|
||||
|
||||
但是,只有捕获到所有崩溃的情况,我们才能实现崩溃的全面监控。也就是说,只有先发现了问题,然后才能够分析问题,最后解决问题。接下来,我就一起分析下如何捕获到这两类崩溃信息。
|
||||
|
||||
## 我们先来看看信号可捕获的崩溃日志收集
|
||||
|
||||
收集崩溃日志最简单的方法,就是打开 Xcode 的菜单选择 Product -> Archive。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/86/bbabfbe28cf3bbd2bfb38d6396b28886.png" alt="">
|
||||
|
||||
然后,在提交时选上“Upload your app’s symbols to receive symbolicated reports from Apple”,以后你就可以直接在 Xcode 的 Archive 里看到符号化后的崩溃日志了。
|
||||
|
||||
但是这种查看日志的方式,每次都是纯手工的操作,而且时效性较差。所以,目前很多公司的崩溃日志监控系统,都是通过[PLCrashReporter](https://www.plcrashreporter.org/) 这样的第三方开源库捕获崩溃日志,然后上传到自己服务器上进行整体监控的。
|
||||
|
||||
而没有服务端开发能力,或者对数据不敏感的公司,则会直接使用 [Fabric](https://get.fabric.io/)或者[Bugly](https://bugly.qq.com/v2/)来监控崩溃。
|
||||
|
||||
你可能纳闷了:PLCrashReporter 和 Bugly这类工具,是怎么知道 App 什么时候崩溃的?接下来,我就和你详细分析下。
|
||||
|
||||
在崩溃日志里,你经常会看到下面这段说明:
|
||||
|
||||
```
|
||||
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
|
||||
|
||||
```
|
||||
|
||||
它表示的是,EXC_BAD_ACCESS 这个异常会通过 SIGSEGV 信号发现有问题的线程。虽然信号的种类有很多,但是都可以通过注册 signalHandler 来捕获到。其实现代码,如下所示:
|
||||
|
||||
```
|
||||
void registerSignalHandler(void) {
|
||||
signal(SIGSEGV, handleSignalException);
|
||||
signal(SIGFPE, handleSignalException);
|
||||
signal(SIGBUS, handleSignalException);
|
||||
signal(SIGPIPE, handleSignalException);
|
||||
signal(SIGHUP, handleSignalException);
|
||||
signal(SIGINT, handleSignalException);
|
||||
signal(SIGQUIT, handleSignalException);
|
||||
signal(SIGABRT, handleSignalException);
|
||||
signal(SIGILL, handleSignalException);
|
||||
}
|
||||
|
||||
void handleSignalException(int signal) {
|
||||
NSMutableString *crashString = [[NSMutableString alloc]init];
|
||||
void* callstack[128];
|
||||
int i, frames = backtrace(callstack, 128);
|
||||
char** traceChar = backtrace_symbols(callstack, frames);
|
||||
for (i = 0; i <frames; ++i) {
|
||||
[crashString appendFormat:@"%s\n", traceChar[i]];
|
||||
}
|
||||
NSLog(crashString);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码对各种信号都进行了注册,捕获到异常信号后,在处理方法 handleSignalException 里通过 backtrace_symbols 方法就能获取到当前的堆栈信息。堆栈信息可以先保存在本地,下次启动时再上传到崩溃监控服务器就可以了。
|
||||
|
||||
先将捕获到的堆栈信息保存在本地,是为了实现堆栈信息数据的持久化存储。那么,为什么要实现持久化存储呢?
|
||||
|
||||
这是因为,在保存完这些堆栈信息以后,App 就崩溃了,崩溃后内存里的数据也就都没有了。而将数据保存在本地磁盘中,就可以在App下次启动时能够很方便地读取到这些信息。
|
||||
|
||||
## 信号捕获不到的崩溃信息怎么收集?
|
||||
|
||||
你是不是经常会遇到这么一种情况,App 退到后台后,即使代码逻辑没有问题也很容易出现崩溃。而且,这些崩溃往往是因为系统强制杀掉了某些进程导致的,而系统强杀抛出的信号还由于系统限制无法被捕获到。
|
||||
|
||||
一般,在退后台时你都会把关键业务数据保存在内存中,如果保存过程中出现了崩溃就会丢失或损坏关键数据,进而数据损坏又会导致应用不可用。这种关键数据的损坏会给用户带来巨大的损失。
|
||||
|
||||
那么,后台容易崩溃的原因是什么呢?如何避免后台崩溃?怎么去收集后台信号捕获不到的那些崩溃信息呢?还有哪些信号捕获不到的崩溃情况?怎样监控其他无法通过信号捕获的崩溃信息?
|
||||
|
||||
现在,你就带着这五个问题,继续听我说。
|
||||
|
||||
首先,我们来看第一个问题,**后台容易崩溃的原因是什么?**
|
||||
|
||||
这里,我先介绍下 iOS 后台保活的5种方式:Background Mode、Background Fetch、Silent Push、PushKit、Background Task。
|
||||
|
||||
- 使用 Background Mode方式的话,App Store在审核时会提高对App 的要求。通常情况下,只有那些地图、音乐播放、VoIP 类的 App 才能通过审核。
|
||||
- Background Fetch方式的唤醒时间不稳定,而且用户可以在系统里设置关闭这种方式,导致它的使用场景很少。
|
||||
- Silent Push 是推送的一种,会在后台唤起 App 30秒。它的优先级很低,会调用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 这个 delegate,和普通的 remote push notification 推送调用的 delegate 是一样的。
|
||||
- PushKit 后台唤醒 App 后能够保活30秒。它主要用于提升 VoIP 应用的体验。
|
||||
- Background Task 方式,是使用最多的。App 退后台后,默认都会使用这种方式。
|
||||
|
||||
接下来,我们就看一下,**Background Task 方式为什么是使用最多的,它可以解决哪些问题?**
|
||||
|
||||
在你的程序退到后台以后,只有几秒钟的时间可以执行代码,接下来就会被系统挂起。进程挂起后所有线程都会暂停,不管这个线程是文件读写还是内存读写都会被暂停。但是,数据读写过程无法暂停只能被中断,中断时数据读写异常而且容易损坏文件,所以系统会选择主动杀掉 App 进程。
|
||||
|
||||
而Background Task这种方式,就是系统提供了 beginBackgroundTaskWithExpirationHandler 方法来延长后台执行时间,可以解决你退后台后还需要一些时间去处理一些任务的诉求。
|
||||
|
||||
Background Task 方式的使用方法,如下面这段代码所示:
|
||||
|
||||
```
|
||||
- (void)applicationDidEnterBackground:(UIApplication *)application {
|
||||
self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^( void) {
|
||||
[self yourTask];
|
||||
}];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,yourTask 任务最多执行3分钟,3分钟内 yourTask 运行完成,你的 App 就会挂起。 如果 yourTask 在3分钟之内没有执行完的话,系统会强制杀掉进程,从而造成崩溃,这就是为什么App退后台容易出现崩溃的原因。
|
||||
|
||||
后台崩溃造成的影响是未知的。持久化存储的数据出现了问题,就会造成你的 App 无法正常使用。
|
||||
|
||||
接下来,我们再看看第二个问题:**如何避免后台崩溃呢?**
|
||||
|
||||
你知道了, App 退后台后,如果执行时间过长就会导致被系统杀掉。那么,如果我们要想避免这种崩溃发生的话,就需要严格控制后台数据的读写操作。比如,你可以先判断需要处理的数据的大小,如果数据过大,也就是在后台限制时间内或延长后台执行时间后也处理不完的话,可以考虑在程序下次启动或后台唤醒时再进行处理。
|
||||
|
||||
同时,App退后台后,这种由于在规定时间内没有处理完而被系统强制杀掉的崩溃,是无法通过信号被捕获到的。这也说明了,随着团队规模扩大,要想保证 App 高可用的话,后台崩溃的监控就尤为重要了。
|
||||
|
||||
那么,我们又应该**怎么去收集退后台后超过保活阈值而导致信号捕获不到的那些崩溃信息呢?**
|
||||
|
||||
采用Background Task方式时,我们可以根据 beginBackgroundTaskWithExpirationHandler 会让后台保活3分钟这个阈值,先设置一个计时器,在接近3分钟时判断后台程序是否还在执行。如果还在执行的话,我们就可以判断该程序即将后台崩溃,进行上报、记录,以达到监控的效果。
|
||||
|
||||
**还有哪些信号捕获不到的崩溃情况?怎样监控其他无法通过信号捕获的崩溃信息?**
|
||||
|
||||
其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog 杀掉这两种情况。
|
||||
|
||||
其实,监控这两类崩溃的思路和监控后台崩溃类似,我们都先要找到它们的阈值,然后在临近阈值时还在执行的后台程序,判断为将要崩溃,收集信息并上报。
|
||||
|
||||
>
|
||||
备注:关于内存和卡顿阈值是怎么获取的,我会在第13篇文章“如何利用 RunLoop 原理去监控卡顿?”,以及第14篇文章“临近 OOM,如何获取详细内存分配信息,分析内存问题?”中和你详细说明。
|
||||
|
||||
|
||||
对于内存打爆信息的收集,你可以采用内存映射(mmap)的方式来保存现场。主线程卡顿时间超过阈值这种情况,你只要收集当前线程的堆栈信息就可以了。
|
||||
|
||||
## 采集到崩溃信息后如何分析并解决崩溃问题呢?
|
||||
|
||||
通过上面的内容,我们已经解决了崩溃信息采集的问题。现在,我们需要对这些信息进行分析,进而解决App的崩溃问题。
|
||||
|
||||
我们采集到的崩溃日志,主要包含的信息为:进程信息、基本信息、异常信息、线程回溯。
|
||||
|
||||
- 进程信息:崩溃进程的相关信息,比如崩溃报告唯一标识符、唯一键值、设备标识;
|
||||
- 基本信息:崩溃发生的日期、iOS 版本;
|
||||
- 异常信息:异常类型、异常编码、异常的线程;
|
||||
- 线程回溯:崩溃时的方法调用栈。
|
||||
|
||||
通常情况下,我们分析崩溃日志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯里找到那个线程;然后,分析方法调用栈,符号化后的方法调用栈可以完整地看到方法调用的过程,从而知道问题发生在哪个方法的调用上。
|
||||
|
||||
其中,方法调用栈如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/2a/93ae6e6c8275486052e08317a0d9db2a.png" alt="">
|
||||
|
||||
方法调用栈顶,就是最后导致崩溃的方法调用。完整的崩溃日志里,除了线程方法调用栈还有异常编码。异常编码,就在异常信息里。
|
||||
|
||||
一些被系统杀掉的情况,我们可以通过异常编码来分析。你可以在维基百科上,查看[完整的异常编码](https://en.wikipedia.org/wiki/Hexspeak)。这里列出了44种异常编码,但常见的就是如下三种:
|
||||
|
||||
- 0x8badf00d,表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。
|
||||
- 0xdeadfa11,表示App被用户强制退出。
|
||||
- 0xc00010ff,表示App因为运行造成设备温度太高而被杀掉。
|
||||
|
||||
0x8badf00d 这种情况是出现最多的。当出现被 watchdog 杀掉的情况时,我们就可以把范围控制在主线程被卡的情况。我会在第13篇文章“如何利用 RunLoop 原理去监控卡顿?”中,和你详细说明如何去监控这种情况来防范和快速定位到问题。
|
||||
|
||||
0xdeadfa11 的情况,是用户的主动行为,我们不用太关注。
|
||||
|
||||
0xc00010ff 这种情况,就要对每个线程 CPU 进行针对性的检查和优化。我会在第18篇文章“怎么减少 App 的电量消耗?”中,和你详细说明。
|
||||
|
||||
除了崩溃日志外,崩溃监控平台还需要对所有采集上来的日志进行统计。我以腾讯的 [Bugly 平台](https://bugly.qq.com/v2/)为例,和你一起看一下崩溃监控平台一般都会记录哪些信息,来辅助开发者追溯崩溃问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/2a/5546c31d852c3fc5c617ad2f7df08d2a.png" alt="">
|
||||
|
||||
上图展示的就是整体崩溃情况的趋势图,你可以选择 App 的不同版本查看不同时间段的趋势。这个相当于总控台,能够全局观察 App 的崩溃大盘。
|
||||
|
||||
除了崩溃率,你还可以在这个平台上能查看次数、用户数等趋势。下图展示的是某一个App的崩溃在不同 iOS 系统、不同iPhone 设备、App 版本的占比情况。这也是全局大盘观察,从不同维度来分析。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/7c/0a663cafbb223c58c0f6b0ac49c32a7c.png" alt="">
|
||||
|
||||
有了全局大盘信息,一旦出现大量崩溃,你就需要明白是哪些方法调用出现了问题,需要根据影响的用户数量按照从大到小的顺序排列出来,优先解决影响面大的问题。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/d9/03ae8ce7dd38af5d1d96a8b71f1d9dd9.png" alt="">
|
||||
|
||||
同时,每个崩溃也都有自己的崩溃趋势图、iOS 系统分布图等信息,来辅助开发者跟踪崩溃修复效果。
|
||||
|
||||
有了崩溃的方法调用堆栈后,大部分问题都能够通过方法调用堆栈,来快速地定位到具体是哪个方法调用出现了问题。有些问题仅仅通过这些堆栈还无法分析出来,这时就需要借助崩溃前用户相关行为和系统环境状况的日志来进行进一步分析。
|
||||
|
||||
关于日志如何收集协助分析问题,我会在第15篇文章“日志监控:怎样获取 App 中的全量日志?”中,和你详细说明。
|
||||
|
||||
## 小结
|
||||
|
||||
学习完今天的这篇文章,我相信你就不再是只能依赖现有工具来解决线上崩溃问题的 iOS 开发者了。在遇到那些工具无法提供信息的崩溃场景时,你也有了自己动手去收集崩溃信息的能力。
|
||||
|
||||
现有的崩溃监控系统,不管是开源的崩溃日志收集库还是类似 Bugly 的崩溃监控系统,离最优解都还有一定的距离。
|
||||
|
||||
这个“非最优”,我们需要分两个维度来看:一个维度是,怎样才能够让崩溃信息的收集效率更高,丢失率更低;另一个维度是,如何能够收集到更多的崩溃信息,特别是系统强杀带来的崩溃。
|
||||
|
||||
随着iOS 系统的迭代更新,强杀阈值和强杀种类都在不断变化,因此崩溃监控系统也需要跟上系统迭代更新的节奏,同时还要做好向下兼容。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
请你写一段代码,在 App 退后台以后执行一段超过3分钟的任务,在临近3分钟时打印出线程堆栈。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
最近,我收到一些同学的反馈,说这门课的一些内容比较深,一时难以琢磨透。如果你也有这样的感受,推荐你学习极客时间刚刚上新的另一门视频课程:由腾讯高级工程师朱德权,主讲的《从 0 开发一款 iOS App》。
|
||||
|
||||
朱德权老师将会基于最新技术,从实践出发,手把手带你构建类今日头条的App。要知道,那些很牛的 iOS 开发者,往往都具备独立开发一款 App 的能力。
|
||||
|
||||
这门课正在上新优惠,欢迎点击[这里](https://time.geekbang.org/course/intro/169?utm_term=zeusKHUZ0&utm_source=app&utm_medium=geektime&utm_campaign=169-presell&utm_content=daiming)试看。
|
||||
|
||||
|
||||
345
极客时间专栏/geek/iOS开发高手课/基础篇/13 | 如何利用 RunLoop 原理去监控卡顿?.md
Normal file
345
极客时间专栏/geek/iOS开发高手课/基础篇/13 | 如何利用 RunLoop 原理去监控卡顿?.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<audio id="audio" title="13 | 如何利用 RunLoop 原理去监控卡顿?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/43/95/43d2aecfeaa2edaf3d771a3d3e950f95.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天,我来和你说说如何监控卡顿。
|
||||
|
||||
卡顿问题,就是在主线程上无法响应用户交互的问题。如果一个 App 时不时地就给你卡一下,有时还长时间无响应,这时你还愿意继续用它吗?所以说,卡顿问题对App的伤害是巨大的,也是我们必须要重点解决的一个问题。
|
||||
|
||||
现在,我们先来看一下导致卡顿问题的几种原因:
|
||||
|
||||
- 复杂 UI 、图文混排的绘制量过大;
|
||||
- 在主线程上做网络同步请求;
|
||||
- 在主线程做大量的IO 操作;
|
||||
- 运算量过大,CPU持续高占用;
|
||||
- 死锁和主子线程抢锁。
|
||||
|
||||
那么,我们如何监控到什么时候会出现卡顿呢?是要监视 FPS 吗?
|
||||
|
||||
以前,我特别喜欢一本叫作《24格》的杂志,它主要介绍的是动画片制作的相关内容。那么,它为啥叫24格呢?这是因为,动画片中1秒钟会用到24张图片,这样肉眼看起来就是流畅的。
|
||||
|
||||
FPS 是一秒显示的帧数,也就是一秒内画面变化数量。如果按照动画片来说,动画片的 FPS 就是24,是达不到60满帧的。也就是说,对于动画片来说,24帧时虽然没有60帧时流畅,但也已经是连贯的了,所以并不能说24帧时就算是卡住了。
|
||||
|
||||
由此可见,简单地通过监视 FPS 是很难确定是否会出现卡顿问题了,所以我就果断弃了通过监视FPS 来监控卡顿的方案。
|
||||
|
||||
那么,我们到底应该使用什么方案来监控卡顿呢?
|
||||
|
||||
## RunLoop 原理
|
||||
|
||||
对于iOS开发来说,监控卡顿就是要去找到主线程上都做了哪些事儿。我们都知道,线程的消息事件是依赖于NSRunLoop 的,所以从NSRunLoop入手,就可以知道主线程上都调用了哪些方法。我们通过监听 NSRunLoop 的状态,就能够发现调用方法是否执行时间过长,从而判断出是否会出现卡顿。
|
||||
|
||||
所以,我推荐的监控卡顿的方案是:通过监控 RunLoop 的状态来判断是否会出现卡顿。
|
||||
|
||||
RunLoop是iOS开发中的一个基础概念,为了帮助你理解并用好这个对象,接下来我会先和你介绍一下它可以做哪些事儿,以及它为什么可以做成这些事儿。
|
||||
|
||||
RunLoop这个对象,在 iOS 里由CFRunLoop实现。简单来说,RunLoop 是用来监听输入源,进行调度处理的。这里的输入源可以是输入设备、网络、周期性或者延迟时间、异步回调。RunLoop 会接收两种类型的输入源:一种是来自另一个线程或者来自不同应用的异步消息;另一种是来自预订时间或者重复间隔的同步事件。
|
||||
|
||||
RunLoop 的目的是,当有事件要去处理时保持线程忙,当没有事件要处理时让线程进入休眠。所以,了解 RunLoop 原理不光能够运用到监控卡顿上,还可以提高用户的交互体验。通过将那些繁重而不紧急会大量占用 CPU 的任务(比如图片加载),放到空闲的 RunLoop 模式里执行,就可以避开在 UITrackingRunLoopMode 这个 RunLoop 模式时是执行。UITrackingRunLoopMode 是用户进行滚动操作时会切换到的 RunLoop 模式,避免在这个 RunLoop 模式执行繁重的 CPU 任务,就能避免影响用户交互操作上体验。
|
||||
|
||||
接下来,我就通过 CFRunLoop 的源码来跟你分享下 RunLoop 的原理吧。
|
||||
|
||||
### 第一步
|
||||
|
||||
通知 observers:RunLoop 要开始进入 loop 了。紧接着就进入 loop。代码如下:
|
||||
|
||||
```
|
||||
//通知 observers
|
||||
if (currentMode->_observerMask & kCFRunLoopEntry )
|
||||
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
|
||||
//进入 loop
|
||||
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
|
||||
|
||||
```
|
||||
|
||||
### 第二步
|
||||
|
||||
开启一个 do while 来保活线程。通知 Observers:RunLoop 会触发 Timer 回调、Source0 回调,接着执行加入的 block。代码如下:
|
||||
|
||||
```
|
||||
// 通知 Observers RunLoop 会触发 Timer 回调
|
||||
if (currentMode->_observerMask & kCFRunLoopBeforeTimers)
|
||||
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
|
||||
// 通知 Observers RunLoop 会触发 Source0 回调
|
||||
if (currentMode->_observerMask & kCFRunLoopBeforeSources)
|
||||
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
|
||||
// 执行 block
|
||||
__CFRunLoopDoBlocks(runloop, currentMode);
|
||||
|
||||
```
|
||||
|
||||
接下来,触发 Source0 回调,如果有 Source1 是 ready 状态的话,就会跳转到 handle_msg去处理消息。代码如下:
|
||||
|
||||
```
|
||||
if (MACH_PORT_NULL != dispatchPort ) {
|
||||
Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
|
||||
if (hasMsg) goto handle_msg;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 第三步
|
||||
|
||||
回调触发后,通知 Observers:RunLoop的线程将进入休眠(sleep)状态。代码如下:
|
||||
|
||||
```
|
||||
Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
|
||||
if (!poll && (currentMode->_observerMask & kCFRunLoopBeforeWaiting)) {
|
||||
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 第四步
|
||||
|
||||
进入休眠后,会等待 mach_port 的消息,以再次唤醒。只有在下面四个事件出现时才会被再次唤醒:
|
||||
|
||||
- 基于 port 的 Source 事件;
|
||||
- Timer 时间到;
|
||||
- RunLoop 超时;
|
||||
- 被调用者唤醒。
|
||||
|
||||
等待唤醒的代码如下:
|
||||
|
||||
```
|
||||
do {
|
||||
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
|
||||
// 基于 port 的 Source 事件、调用者唤醒
|
||||
if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
|
||||
break;
|
||||
}
|
||||
// Timer 时间到、RunLoop 超时
|
||||
if (currentMode->_timerFired) {
|
||||
break;
|
||||
}
|
||||
} while (1);
|
||||
|
||||
```
|
||||
|
||||
### 第五步
|
||||
|
||||
唤醒时通知 Observer:RunLoop 的线程刚刚被唤醒了。代码如下:
|
||||
|
||||
```
|
||||
if (!poll && (currentMode->_observerMask & kCFRunLoopAfterWaiting))
|
||||
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
|
||||
|
||||
```
|
||||
|
||||
### 第六步
|
||||
|
||||
RunLoop 被唤醒后就要开始处理消息了:
|
||||
|
||||
- 如果是 Timer 时间到的话,就触发 Timer 的回调;
|
||||
- 如果是 dispatch 的话,就执行 block;
|
||||
- 如果是 source1事件的话,就处理这个事件。
|
||||
|
||||
消息执行完后,就执行加到 loop 里的 block。代码如下:
|
||||
|
||||
```
|
||||
handle_msg:
|
||||
// 如果 Timer 时间到,就触发 Timer 回调
|
||||
if (msg-is-timer) {
|
||||
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
|
||||
}
|
||||
// 如果 dispatch 就执行 block
|
||||
else if (msg_is_dispatch) {
|
||||
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
|
||||
}
|
||||
|
||||
// Source1 事件的话,就处理这个事件
|
||||
else {
|
||||
CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
|
||||
sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
|
||||
if (sourceHandledThisLoop) {
|
||||
mach_msg(reply, MACH_SEND_MSG, reply);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 第七步
|
||||
|
||||
根据当前 RunLoop 的状态来判断是否需要走下一个 loop。当被外部强制停止或loop 超时时,就不继续下一个 loop 了,否则继续走下一个 loop 。代码如下:
|
||||
|
||||
```
|
||||
if (sourceHandledThisLoop && stopAfterHandle) {
|
||||
// 事件已处理完
|
||||
retVal = kCFRunLoopRunHandledSource;
|
||||
} else if (timeout) {
|
||||
// 超时
|
||||
retVal = kCFRunLoopRunTimedOut;
|
||||
} else if (__CFRunLoopIsStopped(runloop)) {
|
||||
// 外部调用者强制停止
|
||||
retVal = kCFRunLoopRunStopped;
|
||||
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
|
||||
// mode 为空,RunLoop 结束
|
||||
retVal = kCFRunLoopRunFinished;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
整个 RunLoop 过程,我们可以总结为如下所示的一张图片。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/7d/5f51c5e05085badb689f01b1e63e1c7d.png" alt="">
|
||||
|
||||
这里只列出了 CFRunLoop 的关键代码,你可以点击[这个链接](https://opensource.apple.com/source/CF/CF-1153.18/CFRunLoop.c.auto.html)查看完整代码。
|
||||
|
||||
### loop 的六个状态
|
||||
|
||||
通过对RunLoop原理的分析,我们可以看出在整个过程中,loop的状态包括6个,其代码定义如下:
|
||||
|
||||
```
|
||||
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
|
||||
kCFRunLoopEntry , // 进入 loop
|
||||
kCFRunLoopBeforeTimers , // 触发 Timer 回调
|
||||
kCFRunLoopBeforeSources , // 触发 Source0 回调
|
||||
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
|
||||
kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
|
||||
kCFRunLoopExit , // 退出 loop
|
||||
kCFRunLoopAllActivities // loop 所有状态改变
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果RunLoop的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。
|
||||
|
||||
所以,如果我们要利用RunLoop原理来监控卡顿的话,就是要关注这两个阶段。RunLoop在进入睡眠之前和唤醒后的两个 loop 状态定义的值,分别是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,也就是要触发 Source0 回调和接收 mach_port 消息两个状态。
|
||||
|
||||
接下来,我们就一起分析一下,如何对loop的这两个状态进行监听,以及监控的时间值如何设置才合理。
|
||||
|
||||
## 如何检查卡顿?
|
||||
|
||||
要想监听 RunLoop,你就首先需要创建一个 CFRunLoopObserverContext 观察者,代码如下:
|
||||
|
||||
```
|
||||
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
|
||||
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
|
||||
|
||||
```
|
||||
|
||||
将创建好的观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下观察。然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。
|
||||
|
||||
一旦发现进入睡眠前的 kCFRunLoopBeforeSources 状态,或者唤醒后的状态 kCFRunLoopAfterWaiting,在设置的时间阈值内一直没有变化,即可判定为卡顿。接下来,我们就可以 dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。
|
||||
|
||||
开启一个子线程监控的代码如下:
|
||||
|
||||
```
|
||||
//创建子线程监控
|
||||
dispatch_async(dispatch_get_global_queue(0, 0), ^{
|
||||
//子线程开启一个持续的 loop 用来进行监控
|
||||
while (YES) {
|
||||
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
|
||||
if (semaphoreWait != 0) {
|
||||
if (!runLoopObserver) {
|
||||
timeoutCount = 0;
|
||||
dispatchSemaphore = 0;
|
||||
runLoopActivity = 0;
|
||||
return;
|
||||
}
|
||||
//BeforeSources 和 AfterWaiting 这两个状态能够检测到是否卡顿
|
||||
if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
|
||||
//将堆栈信息上报服务器的代码放到这里
|
||||
} //end activity
|
||||
}// end semaphore wait
|
||||
timeoutCount = 0;
|
||||
}// end while
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
代码中的 NSEC_PER_SEC,代表的是触发卡顿的时间阈值,单位是秒。可以看到,我们把这个阈值设置成了3秒。那么,这个3秒的阈值是从何而来呢?这样设置合理吗?
|
||||
|
||||
其实,触发卡顿的时间阈值,我们可以根据 WatchDog 机制来设置。WatchDog 在不同状态下设置的不同时间,如下所示:
|
||||
|
||||
- 启动(Launch):20s;
|
||||
- 恢复(Resume):10s;
|
||||
- 挂起(Suspend):10s;
|
||||
- 退出(Quit):6s;
|
||||
- 后台(Background):3min(在iOS 7之前,每次申请10min; 之后改为每次申请3min,可连续申请,最多申请到10min)。
|
||||
|
||||
通过WatchDog 设置的时间,我认为可以把启动的阈值设置为10秒,其他状态则都默认设置为3秒。总的原则就是,要小于 WatchDog的限制时间。当然了,这个阈值也不用小得太多,原则就是要优先解决用户感知最明显的体验问题。
|
||||
|
||||
## 如何获取卡顿的方法堆栈信息?
|
||||
|
||||
子线程监控发现卡顿后,还需要记录当前出现卡顿的方法堆栈信息,并适时推送到服务端供开发者分析,从而解决卡顿问题。那么,在这个过程中,如何获取卡顿的方法堆栈信息呢?
|
||||
|
||||
**获取堆栈信息的一种方法是直接调用系统函数。**这种方法的优点在于,性能消耗小。但是,它只能够获取简单的信息,也没有办法配合 dSYM 来获取具体是哪行代码出了问题,而且能够获取的信息类型也有限。这种方法,因为性能比较好,所以适用于观察大盘统计卡顿情况,而不是想要找到卡顿原因的场景。
|
||||
|
||||
直接调用系统函数方法的主要思路是:用 signal 进行错误信息的获取。具体代码如下:
|
||||
|
||||
```
|
||||
static int s_fatal_signals[] = {
|
||||
SIGABRT,
|
||||
SIGBUS,
|
||||
SIGFPE,
|
||||
SIGILL,
|
||||
SIGSEGV,
|
||||
SIGTRAP,
|
||||
SIGTERM,
|
||||
SIGKILL,
|
||||
};
|
||||
|
||||
static int s_fatal_signal_num = sizeof(s_fatal_signals) / sizeof(s_fatal_signals[0]);
|
||||
|
||||
void UncaughtExceptionHandler(NSException *exception) {
|
||||
NSArray *exceptionArray = [exception callStackSymbols]; //得到当前调用栈信息
|
||||
NSString *exceptionReason = [exception reason]; //非常重要,就是崩溃的原因
|
||||
NSString *exceptionName = [exception name]; //异常类型
|
||||
}
|
||||
|
||||
void SignalHandler(int code)
|
||||
{
|
||||
NSLog(@"signal handler = %d",code);
|
||||
}
|
||||
|
||||
void InitCrashReport()
|
||||
{
|
||||
//系统错误信号捕获
|
||||
for (int i = 0; i < s_fatal_signal_num; ++i) {
|
||||
signal(s_fatal_signals[i], SignalHandler);
|
||||
}
|
||||
|
||||
//oc未捕获异常的捕获
|
||||
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
|
||||
}
|
||||
|
||||
int main(int argc, char * argv[]) {
|
||||
@autoreleasepool {
|
||||
InitCrashReport();
|
||||
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
|
||||
|
||||
```
|
||||
|
||||
**另一种方法是,直接用** [**PLCrashReporter**](https://opensource.plausible.coop/src/projects/PLCR/repos/plcrashreporter/browse)**这个开源的第三方库来获取堆栈信息。**这种方法的特点是,能够定位到问题代码的具体位置,而且性能消耗也不大。所以,也是我推荐的获取堆栈信息的方法。
|
||||
|
||||
具体如何使用 PLCrashReporter 来获取堆栈信息,代码如下所示:
|
||||
|
||||
```
|
||||
// 获取数据
|
||||
NSData *lagData = [[[PLCrashReporter alloc]
|
||||
initWithConfiguration:[[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]] generateLiveReport];
|
||||
// 转换成 PLCrashReport 对象
|
||||
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
|
||||
// 进行字符串格式化处理
|
||||
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
|
||||
//将字符串上传服务器
|
||||
NSLog(@"lag happen, detail below: \n %@",lagReportString);
|
||||
|
||||
```
|
||||
|
||||
搜集到卡顿的方法堆栈信息以后,就是由开发者来分析并解决卡顿问题了。
|
||||
|
||||
在今天这篇文章中,我们用到的从监控卡顿到收集卡顿问题信息的完整代码,你都可以点击[这个链接](https://github.com/ming1016/DecoupleDemo/blob/master/DecoupleDemo/SMLagMonitor.m)查看。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我给你介绍了使用 RunLoop 监控卡顿的方案,我还跟你说了下 RunLoop 的原理,希望能够帮助你更好地理解 RunLoop 监控卡顿的方案。
|
||||
|
||||
读到这里你可能会想,为什么要将卡顿监控放到线上做呢?其实这样做主要是为了能够更大范围的收集问题,如果仅仅通过线下收集卡顿的话,场景无法被全面覆盖。因为,总有一些卡顿问题,是由于少数用户的数据异常导致的。
|
||||
|
||||
而用户反馈的卡顿问题往往都是说在哪个页面卡住了,而具体是执行哪个方法时卡主了,我们是无从得知的。在碰到这样问题时,你一定会感觉手足无措,心中反问一百遍:“我怎么在这个页面不卡,测试也不卡,就你卡”。而且,通过日志我们也很难查出个端倪。这时候,线上监控卡顿的重要性就凸显出来了。
|
||||
|
||||
有时,某个问题看似对 App 的影响不大,但如果这个问题在某个版本中爆发出来了就会变得难以收场。所以,你需要对这样的问题进行有预见性的监控,一方面可以早发现、早解决,另一方面在遇到问题时能够快速定位原因,不至于过于被动。要知道,面对问题的响应速度往往是评判基础建设优劣的一个重要的标准。
|
||||
|
||||
以上就是我们今天的内容了。接下来,我想请你回顾一下你都碰到过哪些卡顿问题,又是如何解决的呢?
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/27/f5ee90aa0183a4bcc688980bd625eb27.jpg" alt="">
|
||||
158
极客时间专栏/geek/iOS开发高手课/基础篇/14 | 临近 OOM,如何获取详细内存分配信息,分析内存问题?.md
Normal file
158
极客时间专栏/geek/iOS开发高手课/基础篇/14 | 临近 OOM,如何获取详细内存分配信息,分析内存问题?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="14 | 临近 OOM,如何获取详细内存分配信息,分析内存问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/7d/f729614de76ba71a1ff7deb456c3597d.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天我们来聊聊,临近OOM,如何获取详细的内存分配信息,分析内存问题的话题。
|
||||
|
||||
OOM,是Out of Memory的缩写,指的是App占用的内存达到了iOS系统对单个App占用内存上限后,而被系统强杀掉的现象。这么说的话,OOM其实也属于我们在第12篇文章“[iOS 崩溃千奇百怪,如何全面监控?](https://time.geekbang.org/column/article/88600)”中提到的应用“崩溃”中的一种,是由iOS的Jetsam机制导致的一种“另类”崩溃,并且日志无法通过信号捕捉到。
|
||||
|
||||
JetSam机制,指的就是操作系统为了控制内存资源过度使用而采用的一种资源管控机制。
|
||||
|
||||
我们都知道,物理内存和 CPU 对于手机这样的便携设备来说,可谓稀缺资源。所以说,在iOS 系统的虚拟内存管理中,内存压力的管控就是一项很重要的内容。
|
||||
|
||||
接下来,我就跟你介绍一下如何获取内存上限值,以及如何监控到App因为占用内存过大而被强杀的问题?
|
||||
|
||||
## 通过 JetsamEvent 日志计算内存限制值
|
||||
|
||||
想要了解不同机器在不同系统版本的情况下,对 App 的内存限制是怎样的,有一种方法就是查看手机中以 JetsamEvent 开头的系统日志(我们可以从设置->隐私->分析中看到这些日志)。
|
||||
|
||||
在这些系统日志中,查找崩溃原因时我们需要关注 per-process-limit 部分的 rpages。rpages 表示的是 ,App 占用的内存页数量;per-process-limit 表示的是,App 占用的内存超过了系统对单个App 的内存限制。
|
||||
|
||||
这部分日志的结构如下:
|
||||
|
||||
```
|
||||
"rpages" : 89600,
|
||||
"reason" : "per-process-limit",
|
||||
|
||||
```
|
||||
|
||||
现在,我们已经知道了内存页数量 rpages 为 89600,只要再知道内存页大小的值,就可以计算出系统对单个App限制的内存是多少了。
|
||||
|
||||
内存页大小的值,我们也可以在 JetsamEvent 开头的系统日志里找到,也就是pageSize的值。如下图红框部分所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/2a/f60391e3109494b411cef7d936b4792a.png" alt=""><br>
|
||||
可以看到,内存页大小 pageSize 的值是16384。接下来,我们就可以计算出当前 App 的内存限制值:pageSize * rpages / 1024 /1024 =16384 * 89600 / 1024 / 1024 得到的值是 1400 MB,即 1.4G。
|
||||
|
||||
这些 JetsamEvent 日志,都是系统在杀掉 App 后留在手机里的。在查看这些日志时,我们就会发现,很多日志都是 iOS 系统内核强杀掉那些优先级不高,并且占用的内存超过限制的 App 后留下的。
|
||||
|
||||
这些日志属于系统级的,会存在系统目录下。App上线后开发者是没有权限获取到系统目录内容的,也就是说,被强杀掉的 App 是无法获取到系统级日志的,只能线下设备通过连接 Xcode 获取到这部分日志。获取到Jetsam 后,就能够算出系统对 App 设置的内存限制值。
|
||||
|
||||
那么,**iOS系统是怎么发现 Jetsam 的呢?**
|
||||
|
||||
iOS 系统会开启优先级最高的线程 vm_pressure_monitor 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 的进程。另外,iOS系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。
|
||||
|
||||
当监控系统内存的线程发现某 App 内存有压力了,就发出通知,内存有压力的 App 就会去执行对应的代理,也就是你所熟悉的 didReceiveMemoryWarning 代理。通过这个代理,你可以获得最后一个编写逻辑代码释放内存的机会。这段代码的执行,就有可能会避免你的App被系统强杀。
|
||||
|
||||
系统在强杀App前,会先做优先级判断。那么,这个**优先级判断的依据是什么呢?**
|
||||
|
||||
iOS系统内核里有一个数组,专门用于维护线程的优先级。这个优先级规定就是:内核用线程的优先级是最高的,操作系统的优先级其次,App 的优先级排在最后。并且,前台 App 程序的优先级是高于后台运行 App 的;线程使用优先级时,CPU 占用多的线程的优先级会被降低。
|
||||
|
||||
iOS系统在因为内存占用原因强杀掉App前,至少有6秒钟的时间可以用来做优先级判断。同时,JetSamEvent日志也是在这6秒内生成的。
|
||||
|
||||
除了JetSamEvent日志外,我们还可以通过XNU来获取内存的限制值。
|
||||
|
||||
## 通过 XNU 获取内存限制值
|
||||
|
||||
在 XNU 中,有专门用于获取内存上限值的函数和宏。我们可以通过 memorystatus_priority_entry 这个结构体,得到进程的优先级和内存限制值。结构体代码如下:
|
||||
|
||||
```
|
||||
typedef struct memorystatus_priority_entry {
|
||||
pid_t pid;
|
||||
int32_t priority;
|
||||
uint64_t user_data;
|
||||
int32_t limit;
|
||||
uint32_t state;
|
||||
} memorystatus_priority_entry_t;
|
||||
|
||||
```
|
||||
|
||||
在这个结构体中,priority 表示的是进程的优先级,limit就是我们想要的进程内存限制值。
|
||||
|
||||
## 通过内存警告获取内存限制值
|
||||
|
||||
通过XNU 的宏获取内存限制,需要有 root 权限,而App 内的权限是不够的,所以正常情况下,作为App开发者你是看不到这个信息的。那么,如果你不想越狱去获取这个权限的话,还可以利用 didReceiveMemoryWarning 这个内存压力代理事件来动态地获取内存限制值。
|
||||
|
||||
iOS系统在强杀掉App之前还有6秒钟的时间,足够你去获取记录内存信息了。那么,**如何获取当前内存使用情况呢?**
|
||||
|
||||
iOS系统提供了一个函数 task_info, 可以帮助我们获取到当前任务的信息。关键代码如下:
|
||||
|
||||
```
|
||||
struct mach_task_basic_info info;
|
||||
mach_msg_type_number_t size = sizeof(info);
|
||||
kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);
|
||||
|
||||
```
|
||||
|
||||
代码中,task_info_t 结构里包含了一个resident_size 字段,用于表示使用了多少内存。这样,我们就可以获取到发生内存警告时,当前App 占用了多少内存。代码如下:
|
||||
|
||||
```
|
||||
float used_mem = info.resident_size;
|
||||
NSLog(@"使用了 %f MB 内存", used_mem / 1024.0f / 1024.0f)
|
||||
|
||||
```
|
||||
|
||||
## 定位内存问题信息收集
|
||||
|
||||
现在,我们已经可以通过三种方法来获取内存上限值了,而且通过内存警告的方式还能够动态地获取到这个值。有了这个内存上限值以后,你就可以进行内存问题的信息收集工作了。
|
||||
|
||||
要想精确地定位问题,我们就需要 dump 出完整的内存信息,包括所有对象及其内存占用值,在内存接近上限值的时候,收集并记录下所需信息,并在合适的时机上报到服务器里,方便分析问题。
|
||||
|
||||
获取到了每个对象的内存占用量还不够,你还需要知道是谁分配的内存,这样才可以精确定位到问题的关键所在。一个对象可能会在不同的函数里被分配了内存并被创建了出来,当这个对象内存占用过大时,如果不知道是在哪个函数里创建的话,问题依然很难精确定位出来。那么,**怎样才能知道是谁分配的内存呢?**
|
||||
|
||||
这个问题,我觉得应该从根儿上去找答案。内存分配函数 malloc 和 calloc 等默认使用的是 nano_zone。nano_zone 是256B以下小内存的分配,大于256B 的时候会使用 scalable_zone 来分配。
|
||||
|
||||
在这里,我主要是针对大内存的分配监控,所以只针对 scalable_zone 进行分析,同时也可以过滤掉很多小内存分配监控。比如,malloc函数用的是 malloc_zone_malloc,calloc 用的是 malloc_zone_calloc。
|
||||
|
||||
使用scalable_zone 分配内存的函数都会调用 malloc_logger 函数,因为系统总是需要有一个地方来统计并管理内存的分配情况。
|
||||
|
||||
具体实现的话,你可以查看 malloc_zone_malloc 函数的实现,代码如下:
|
||||
|
||||
```
|
||||
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size)
|
||||
{
|
||||
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
|
||||
void *ptr;
|
||||
if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
|
||||
internal_check();
|
||||
}
|
||||
if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
|
||||
return NULL;
|
||||
}
|
||||
ptr = zone->malloc(zone, size);
|
||||
// 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录
|
||||
if (malloc_logger) {
|
||||
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
|
||||
}
|
||||
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其他使用 scalable_zone 分配内存的函数的方法也类似,所有大内存的分配,不管外部函数是怎么包装的,最终都会调用 malloc_logger 函数。这样的话,问题就好解决了,你可以使用 fishhook 去 Hook 这个函数,加上自己的统计记录就能够通盘掌握内存的分配情况。出现问题时,将内存分配记录的日志捞上来,你就能够跟踪到导致内存不合理增大的原因了。
|
||||
|
||||
## 小结
|
||||
|
||||
为了达到监控内存的目的,我们需要做两件事情:一是,能够根据不同机器和系统获取到内存有问题的那个时间点;二是,到了出现内存问题的那个时间点时,还能要取到足够多的可以分析内存问题的信息。
|
||||
|
||||
针对这两件事,我在今天这篇文章里和你分享了在 JetsamEvent 日志里、在 XNU 代码里、在 task_info 函数中怎么去找内存的上限值。然后,我和你一起分析了在内存到达上限值时,怎么通过内存分配时都会经过的 malloc_logger 函数来掌握内存分配的详细信息,从而精确定位内存问题。
|
||||
|
||||
说到这里你可能会回过头来想,为什么用于占用内存过大时会被系统强杀呢?macOS 打开一堆应用也会远超物理内存,怎么没见系统去强杀 macOS 的应用呢?
|
||||
|
||||
其实,这里涉及到的是设备资源的问题。苹果公司考虑到手持设备存储空间小的问题,在 iOS 系统里去掉了交换空间,这样虚拟内存就没有办法记录到外部的存储上。于是,苹果公司引入了 MemoryStatus 机制。
|
||||
|
||||
这个机制的主要思路就是,在 iOS 系统上弹出尽可能多的内存供当前应用使用。把这个机制落到优先级上,就是先强杀后台应用;如果内存还不够多就强杀掉当前应用。而在macOS 系统里,MemoryStatus 只会强杀掉标记为空闲退出的进程。
|
||||
|
||||
在实现上,MemoryStatus 机制会开启一个memorystatus_jetsam_thread 的线程。这个线程,和内存压力监测线程 vm_pressure_monitor 没有联系,只负责强杀应用和记录日志,不会发送消息,所以内存压力检测线程无法获取到强杀应用的消息。
|
||||
|
||||
除内存过大被系统强杀这种内存问题以外,还有以下三种内存问题:
|
||||
|
||||
- 访问未分配的内存: XNU 会报 EXC_BAD_ACCESS错误,信号为 SIGSEGV Signal #11 。
|
||||
- 访问已分配但未提交的内存:XNU 会拦截分配物理内存,出现问题的线程分配内存页时会被冻结。
|
||||
- 没有遵守权限访问内存:内存页面的权限标准类似 UNIX 文件权限。如果去写只读权限的内存页面就会出现错误,XNU 会发出 SIGBUS Signal #7 信号。
|
||||
|
||||
第一种和第三种问题都可以通过崩溃信息获取到,在收集崩溃信息时如果发现是这两类,我们就可以把内存分配的记录同时传过来进行分析,对于不合理的内存分配进行优化和修改。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
我今天提到了定位内存问题需要获取更多的信息,比如内存分配。那么,请你来根据我们今天所讲的 hook malloc_logger 的方法,来实现一个记录内存分配的小工具吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
246
极客时间专栏/geek/iOS开发高手课/基础篇/15 | 日志监控:怎样获取 App 中的全量日志?.md
Normal file
246
极客时间专栏/geek/iOS开发高手课/基础篇/15 | 日志监控:怎样获取 App 中的全量日志?.md
Normal file
@@ -0,0 +1,246 @@
|
||||
<audio id="audio" title="15 | 日志监控:怎样获取 App 中的全量日志?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/f2/60426f4ce3ed216e4ef1daffcd386cf2.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
我在前面的第12、13和14三篇文章中,和你分享了崩溃、卡顿、内存问题的监控。一旦监控到问题,我们还需要记录下问题的详细信息,形成日志告知开发者,这样开发者才能够从这些日志中定位问题。
|
||||
|
||||
但是,很多问题的定位仅靠问题发生的那一刹那记录的信息是不够的,我们还需要依赖更多的日志信息。
|
||||
|
||||
在以前公司还没有全量日志的时候,我发现线上有一个上报到服务器的由数据解析出错而引起崩溃的问题。由于数据解析是在生成数据后在另一个线程延迟执行的,所以很难定位到是谁生成的数据造成了崩溃。
|
||||
|
||||
如果这个时候,我能够查看到崩溃前的所有日志,包括手动打的日志和无侵入自动埋点的日志,就能够快速定位到是由谁生成的数据造成了崩溃。这些在 App 里记录的所有日志,比如用于记录用户行为和关键操作的日志,就是全量日志了。
|
||||
|
||||
有了更多的信息,才更利于开发者去快速、精准地定位各种复杂问题,并提高解决问题的效率。那么,**怎样才能够获取到 App 里更多的日志呢**?
|
||||
|
||||
你可能会觉得获取到全量的日志很容易啊,只要所有数据都通过相同的打日志库,不就可以收集到所有日志了吗?但,现实情况并没有这么简单。
|
||||
|
||||
一个 App 很有可能是由多个团队共同开发维护的,不同团队使用的日志库由于历史原因可能都不一样,要么是自己开发的,要么就是使用了不同第三方日志库。如果我们只是为了统一获取日志,而去推动其他团队将以前的日志库代码全部替换掉,明显是不现实的。因为,我们谁也无法确定,这种替换日志库的工作,以后是不是还会再来一次。
|
||||
|
||||
那么,我们还有什么好办法来解决这个问题吗?在我看来,要解决这个问题,我们就需要先逐个地分析各团队使用的日志库,使用不侵入的方式去获取所有日志。
|
||||
|
||||
接下来,我就先和你说说怎样获取系统自带NSLog的日志。
|
||||
|
||||
## 获取 NSLog 的日志
|
||||
|
||||
我们都知道,NSLog其实就是一个C函数,函数声明是:
|
||||
|
||||
```
|
||||
void NSLog(NSString *format, ...);
|
||||
|
||||
```
|
||||
|
||||
它的作用是,输出信息到标准的Error控制台和系统日志(syslog)中。在内部实现上,它其实使用的是ASL(Apple System Logger,是苹果公司自己实现的一套输出日志的接口)的API,将日志消息直接存储在磁盘上。
|
||||
|
||||
那么,**我们如何才能获取到通过ASL存放在系统日志中的日志呢?**
|
||||
|
||||
ASL 会提供接口去查找所有的日志,通过 [CocoaLumberjack](https://github.com/CocoaLumberjack/CocoaLumberjack) 这个第三方日志库里的 DDASLLogCapture 这个类,我们可以找到实时捕获 NSLog 的方法。DDASLLogCapture会在 start 方法里开启一个异步全局队列去捕获 ASL 存储的日志。start 方法的代码如下:
|
||||
|
||||
```
|
||||
+ (void)start {
|
||||
...
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
|
||||
[self captureAslLogs];
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,捕获ASL存储日志的主要处理都在 captureAslLogs 方法里。在日志被保存到 ASL 的数据库时,syslogd(系统里用于接收分发日志消息的日志守护进程) 会发出一条通知。因为发过来的这一条通知可能会有多条日志,所以还需要先做些合并的工作,将多条日志进行合并。具体的实现,你可以查看 captureAslLogs 方法的实现,关键代码如下:
|
||||
|
||||
```
|
||||
+ (void)captureAslLogs {
|
||||
@autoreleasepool {
|
||||
...
|
||||
notify_register_dispatch(kNotifyASLDBUpdate, &notifyToken, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),^(int token) {
|
||||
@autoreleasepool {
|
||||
...
|
||||
// 利用进程标识兼容在模拟器情况时其他进程日志无效通知
|
||||
[self configureAslQuery:query];
|
||||
|
||||
// 迭代处理所有新日志
|
||||
aslmsg msg;
|
||||
aslresponse response = asl_search(NULL, query);
|
||||
|
||||
while ((msg = asl_next(response))) {
|
||||
// 记录日志
|
||||
[self aslMessageReceived:msg];
|
||||
|
||||
lastSeenID = (unsigned long long)atoll(asl_get(msg, ASL_KEY_MSG_ID));
|
||||
}
|
||||
asl_release(response);
|
||||
asl_free(query);
|
||||
|
||||
if (_cancel) {
|
||||
notify_cancel(token);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,notify_register_dispatch的作用是用来注册进程间的系统通知。其中,kNotifyASLDBUpdate 宏表示的就是,日志被保存到 ASL 数据库时发出的跨进程通知,其键值是 com.apple.system.logger.message。
|
||||
|
||||
既然是跨进程通知,那么多个 App 之间也是可以进行通知的。不过对于 iPhone 来说,多个 App 同时保活的机会太少,所以一般都是接收系统功能发出的通知。
|
||||
|
||||
在iOS系统中,类似地把日志保存到 ASL 数据库时发出的通知还有很多种,比如键值是 com.apple.system.lowdiskspace 的 kNotifyVFSLowDiskSpace 宏,该通知是在系统磁盘空间不足时发出的。当捕获到这个通知时,你可以去清理缓存空间,避免发生缓存写入磁盘失败的情况。
|
||||
|
||||
更多的跨进程通知宏,你可以在 notify_keys.h 里看到,终端查看命令如下:
|
||||
|
||||
```
|
||||
cat /usr/include/notify_keys.h
|
||||
|
||||
```
|
||||
|
||||
接下来,**我继续和你说说 captureAslLogs方法,看看 captureAslLogs 是怎么处理 ASL 日志的。**
|
||||
|
||||
在captureAslLogs方法里,处理日志的方法是 aslMessageReceived,入参是 aslmsg 类型,由于 aslmsg 类型不是字符串类型,无法直接查看。所以在 aslMessageReceived方法的开始阶段,会使用 asl_get 方法将其转换为 char 字符串类型。类型转换代码如下:
|
||||
|
||||
```
|
||||
const char* messageCString = asl_get( msg, ASL_KEY_MSG );
|
||||
|
||||
```
|
||||
|
||||
接下来,char 字符串会被转换成 NSString类型,NSString 是 Objective-C 里字符串类型,转成 NSString 更容易在 Objective-C 里使用。
|
||||
|
||||
```
|
||||
NSString *message = @(messageCString);
|
||||
|
||||
```
|
||||
|
||||
因为CocoaLumberjack 的日志最后都是通过 DDLog:log:message: 方法进行记录的,其中 message 参数的类型是 DDLogMessage,所以 NSString类型还需要转换成 DDLogMessage 类型。
|
||||
|
||||
因为 DDLogMessage 类型包含了日志级别,所以转换类型后还需要设置日志的级别。CocoaLumberjack 这个第三方日志库,将捕获到的 NSLog 日志的级别设置为了 Verbose。那为什么要这么设置呢?
|
||||
|
||||
CocoaLumberjack 的日志级别,包括两类:
|
||||
|
||||
- 第一类是Verbose 和 Debug ,属于调试级;
|
||||
- 第二类是Info、Warn、Error ,属于正式级,适用于记录更重要的信息,是需要持久化存储的。特别是,Error可以理解为严重级别最高。
|
||||
|
||||
将日志级别定义为 Verbose,也只是基于CocoaLumberjack 对 NSLog日志的理解。其实,NSLog是被苹果公司专门定义为记录错误信息的:
|
||||
|
||||
>
|
||||
Logs an error message to the Apple System Log facility.
|
||||
|
||||
|
||||
据我观察,现在有很多开发者都用 NSLog 来调试。**但是我觉得,一般的程序调试,用断点就好了,我不推荐你把 NSLog 作为一种调试手段。**因为,使用NSLog调试,会发生 IO 磁盘操作,当频繁使用 NSLog 时,性能就会变得不好。另外,各团队都使用 NSLog 来调试的话很容易就会刷屏,这样你也没有办法在控制台上快速、准确地找到你自己的调试信息。
|
||||
|
||||
而如果你需要汇总一段时间的调试日志的话,自己把这些日志写到一个文件里就好了。这样的话,你随便想要怎么看都行,也不会参杂其他人打的日志。
|
||||
|
||||
所以说 ,CocoaLumberjack 将 NSLog 设置为 Verbose ,在我看来 CocoaLumberjack 对 NSLog 的理解也不够准确。说完如何创建一个 DDLogMessage,接下来我们再看看**如何通过 DDLog 使用 DDLogMessage 作为参数添加一条 ASL 日志**。下面是 DDLog 记录 ASL 日志相关的代码:
|
||||
|
||||
```
|
||||
DDLogMessage *logMessage = [[DDLogMessage alloc] initWithMessage:message level:_captureLevel flag:flag context:0 file:@"DDASLLogCapture" function:nil line:0 tag:nil option:0 timestamp:timeStamp];
|
||||
|
||||
[DDLog log:async message:logMessage]
|
||||
|
||||
```
|
||||
|
||||
到这里,通过ASL获取 NSLog 日志的过程你就应该很清楚了。你可以直接使用 CocoaLumberjack 这个库通过 [DDASLLogCapture start] 捕获所有 NSLog 的日志。
|
||||
|
||||
你现在已经清楚了CocoaLumberjack 的捕获原理和方法,如果不想引入这个第三方库的话,也可以按照它的思路写个简化版的工具出来,只要这个工具能够把日志记录下来,并且能够在出现问题的时候,把日志上传到服务器,方便我们进行问题的追踪和定位即可。
|
||||
|
||||
为了使日志更高效,更有组织,在 iOS 10 之后,使用了新的统一日志系统(Unified Logging System)来记录日志,全面取代 ASL的方式。以下是官方原话:
|
||||
|
||||
>
|
||||
Unified logging is available in iOS 10.0 and later, macOS 10.12 and later, tvOS 10.0 and later, and watchOS 3.0 and later, and supersedes ASL (Apple System Logger) and the Syslog APIs. Historically, log messages were written to specific locations on disk, such as /etc/system.log. The unified logging system stores messages in memory and in a data store, rather than writing to text-based log files.
|
||||
|
||||
|
||||
接下来,我们就看看iOS 10之后,如何来获取NSLog日志。
|
||||
|
||||
统一日志系统的方式,是把日志集中存放在内存和数据库里,并提供单一、高效和高性能的接口去获取系统所有级别的消息传递。
|
||||
|
||||
macOS 10.12 开始使用了统一日志系统,我们通过控制台应用程序或日志命令行工具,就可以查看到日志消息。
|
||||
|
||||
但是,新的统一日志系统没有 ASL 那样的接口可以让我们取出全部日志,所以**为了兼容新的统一日志系统,你就需要对 NSLog 日志的输出进行重定向。**
|
||||
|
||||
对NSLog进行重定向,我们首先想到的就是采用 Hook 的方式。因为NSLog本身就是一个C函数,而不是 Objective-C方法,所以我们就可以使用 fishhook 来完成重定向的工作。具体的实现代码如下所示:
|
||||
|
||||
```
|
||||
static void (&orig_nslog)(NSString *format, ...);
|
||||
|
||||
void redirect_nslog(NSString *format, ...) {
|
||||
// 可以在这里先进行自己的处理
|
||||
|
||||
// 继续执行原 NSLog
|
||||
va_list va;
|
||||
va_start(va, format);
|
||||
NSLogv(format, va);
|
||||
va_end(va);
|
||||
}
|
||||
|
||||
int main(int argc, const char * argv[]) {
|
||||
@autoreleasepool {
|
||||
struct rebinding nslog_rebinding = {"NSLog",redirect_nslog,(void*)&orig_nslog};
|
||||
|
||||
NSLog(@"try redirect nslog %@,%d",@"is that ok?");
|
||||
}
|
||||
return
|
||||
|
||||
```
|
||||
|
||||
可以看到,我在上面这段代码中,利用了 fishhook 对方法的符号地址进行了重新绑定,从而只要是 NSLog 的调用就都会转向 redirect_nslog 方法调用。
|
||||
|
||||
在 redirect_nslog 方法中,你可以先进行自己的处理,比如将日志的输出重新输出到自己的持久化存储系统里,接着调用 NSLog 也会调用的 NSLogv 方法进行原 NSLog 方法的调用。当然了,你也可以使用 fishhook 提供的原方法调用方式 orig_nslog,进行原 NSLog 方法的调用。上面代码里也已经声明了类 orig_nslog,直接调用即可。
|
||||
|
||||
NSLog 最后写文件时的句柄是 STDERR,我先前跟你说了苹果对于 NSLog 的定义是记录错误的信息,STDERR 的全称是 standard error,系统错误日志都会通过 STDERR 句柄来记录,所以 NSLog 最终将错误日志进行写操作的时候也会使用 STDERR 句柄,而 dup2 函数是专门进行文件重定向的,那么也就有了另一个不使用 fishhook 还可以捕获 NSLog 日志的方法。你可以使用 dup2 重定向 STDERR 句柄,使得重定向的位置可以由你来控制,关键代码如下:
|
||||
|
||||
```
|
||||
int fd = open(path, (O_RDWR | O_CREAT), 0644);
|
||||
dup2(fd, STDERR_FILENO);
|
||||
|
||||
```
|
||||
|
||||
其中,path 就是你自定义的重定向输出的文件地址。
|
||||
|
||||
这样,我们就能够获取到各个系统版本的 NSLog了。那么,通过其他方式打的日志,我们怎么才能获取到呢?
|
||||
|
||||
现在与日志相关的第三方库里面,使用最多的就是 CocoaLumberjack。而且,其他的很多第三库的思路也和CocoaLumberjack类似,都是直接在 CocoaLumberjack 的基础上包装了一层,增加了统一管控力和易用性而已。
|
||||
|
||||
接下来,我们就先看看 CocoaLumberjack 的整体架构是怎样的,进而找到获取 CocoaLumberjack 所有日志的方法。
|
||||
|
||||
## 获取 CocoaLumberjack 日志
|
||||
|
||||
CocoaLumberjack主要由DDLog、DDLoger、DDLogFormatter和DDLogMessage四部分组成,其整体架构如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/fb/ff12f684d74be1b901e2dede5b5ab5fb.png" alt="">
|
||||
|
||||
在这其中,DDLog 是个全局的单例类,会保存 DDLogger 协议的 logger;DDLogFormatter 用来格式化日志的格式;DDLogMessage 是对日志消息的一个封装;DDLogger 协议是由 DDAbstractLogger 实现的。logger 都是继承于 DDAbstractLogger:
|
||||
|
||||
- 日志输出到控制台是通过 DDTTYLogger实现的;
|
||||
- DDASLLogger 就是用来捕获 NSLog 记录到 ASL 数据库的日志;
|
||||
- DDAbstractDatabaseLogger是数据库操作的抽象接口;
|
||||
- DDFileLogger 是用来保存日志到文件的,还提供了返回 CocoaLumberjack 日志保存文件路径的方法,使用方法如下:
|
||||
|
||||
```
|
||||
DDFileLogger *fileLogger = [[DDFileLogger alloc] init];
|
||||
NSString *logDirectory = [fileLogger.logFileManager logsDirectory];
|
||||
|
||||
```
|
||||
|
||||
其中,logDirectory 方法可以获取日志文件的目录路径。有了目录以后,我们就可以获取到目录下所有的 CocoaLumberjack 的日志了,也就达到了我们要获取CocoaLumberjack 所有日志的目的。
|
||||
|
||||
## 小结
|
||||
|
||||
在今天这篇文章中,我和你介绍了 NSLog 和 CocoaLumberjack 日志的获取方法。这两种打日志的方式基本覆盖了大部分场景,你在使用其他日志库时,只要找到日志存储的目录,就可以进行日志的收集合并工作了。
|
||||
|
||||
收集全量日志,可以提高分析和解决问题的效率,节省下来的时间我们可以去做更有意义的事情。
|
||||
|
||||
在今天讲获取 NSLog 日志的过程中,你会发现为了达到获取 NSLog 日志的目的,方法有三个:
|
||||
|
||||
- 第一个是使用官方提供的接口 ASL 来获取;
|
||||
- 第二个是通过一招吃遍天下的 fishhook 来 hook 的方法;
|
||||
- 第三个方法,需要用到 dup2 函数和 STDERR 句柄。我们只有了解了这些知识点后,才会想到这个方法。
|
||||
|
||||
在第2篇文章“[App 启动速度怎么做优化与监控?](https://time.geekbang.org/column/article/85331)”里,我也提到过两个方案来实现启动监控。其中,第二个使用 hook objc_msgSend 方法的方案,看起来连汇编语言都用到了,应该没有更好的方案了吧,其实不然,我这里卖个关子,后面有机会我还会和你介绍另一个方案。
|
||||
|
||||
所以,我们接触的知识面越多,遇到问题时能想到的办法也就会越多。当出现意外时,就像是 ASL 在 iOS 10.0 之后不能用了这种情况下,你依然还能够有其他方法来解决问题。
|
||||
|
||||
## 课后作业
|
||||
|
||||
今天课程中提到了跨进程通知,那么接下来就请你写一个监听用户设备磁盘空间不够时,清理App 缓存文件的功能吧。
|
||||
|
||||
我今天还会再多布置一个作业。我在和你分析捕获 NSLog 日志时,提到了一个用dup2替换掉默认句柄的方法。所以,我希望你可以动手实践一下,按照这个思路将其完整实现出来吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
270
极客时间专栏/geek/iOS开发高手课/基础篇/16 | 性能监控:衡量 App 质量的那把尺.md
Normal file
270
极客时间专栏/geek/iOS开发高手课/基础篇/16 | 性能监控:衡量 App 质量的那把尺.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="16 | 性能监控:衡量 App 质量的那把尺" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/07/4e743b0f3e2ee55157e4a2d6b1eb6a07.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
通常情况下,App 的性能问题虽然不会导致 App不可用,但依然会影响到用户体验。如果这个性能问题不断累积,达到临界点以后,问题就会爆发出来。这时,影响到的就不仅仅是用户了,还有负责App开发的你。
|
||||
|
||||
为了能够主动、高效地发现性能问题,避免App质量进入无人监管的失控状态,我们就需要对App的性能进行监控。目前,对App的性能监控,主要是从线下和线上两个维度展开。
|
||||
|
||||
今天这篇文章,我就从这两个方面来和你聊聊如何做性能监控这个话题。接下来,我们就先看看苹果官方的线下性能监控王牌 Instruments。
|
||||
|
||||
## Instruments
|
||||
|
||||
关于线下性能监控,苹果公司官方就有一个性能监控工具Instruments。它是一款被集成在 Xcode 里,专门用来在线下进行性能分析的工具。
|
||||
|
||||
Instruments的功能非常强大,比如说Energy Log就是用来监控耗电量的,Leaks就是专门用来监控内存泄露问题的,Network就是用来专门检查网络情况的,Time Profiler就是通过时间采样来分析页面卡顿问题的。
|
||||
|
||||
如下图所示,就是Instruments的各种性能检测工具。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/e9/087ddcf91e5c222804f753389edf2de9.png" alt="">
|
||||
|
||||
除了对各种性能问题进行监控外,**最新版本的Instruments 10还有以下两大优势**:
|
||||
|
||||
<li>
|
||||
Instruments基于os_signpost 架构,可以支持所有平台。
|
||||
</li>
|
||||
<li>
|
||||
Instruments由于标准界面(Standard UI)和分析核心(Analysis Core)技术,使得我们可以非常方便地进行自定义性能监测工具的开发。当你想要给Instruments内置的工具换个交互界面,或者新创建一个工具的时候,都可以通过自定义工具这个功能来实现。
|
||||
</li>
|
||||
|
||||
其实,Instruments的这些优势也不是与生俱来的,都是伴随着移动开发技术的发展而演进来的。就比如说自定义工具的功能吧,这是因为App的规模越来越大,往往还涉及到多个团队合作开发、集成多个公司SDK的情况,所以我们就需要以黑盒的方式来进行性能监控。这样的需求,也就迫使苹果公司要不断地增强Instruments的功能。
|
||||
|
||||
从整体架构来看,Instruments 包括Standard UI 和 Analysis Core 两个组件,它的所有工具都是基于这两个组件开发的。而且,你如果要开发自定义的性能分析工具的话,完全基于这两个组件就可以实现。
|
||||
|
||||
**开发一款自定义Instruments工具**,主要包括以下这几个步骤:
|
||||
|
||||
<li>
|
||||
在Xcode中,点击File > New > Project;
|
||||
</li>
|
||||
<li>
|
||||
在弹出的Project模板选择界面,将其设置为macOS;
|
||||
</li>
|
||||
<li>
|
||||
选择 Instruments Package,点击后即可开始自定义工具的开发了。如下图所示。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/be/e51d838fda0c79ff0a48566ec87305be.png" alt="">
|
||||
|
||||
经过上面的三步之后,会在新创建的工程里面生成一个.instrpkg 文件,接下来的开发过程主要就是对这个文件的配置工作了。这些配置工作中最主要的是要完成Standard UI 和 Analysis Core 的配置。
|
||||
|
||||
上面这些内容,就是你在开发一个自定义Instruments工具时,需要完成的编码工作了。可以看到,Instruments 10版本的自定义工具开发还是比较简单的。与此同时,苹果公司还提供了大量的代码片段,帮助你进行个性化的配置。你可以[点击这个链接](https://help.apple.com/instruments/developer/mac/current/),查看官方指南中的详细教程。
|
||||
|
||||
如果你想要更好地进行个性化定制,就还需要再了解Instruments收集和处理数据的机制,也就是**分析核心(Analysis Core )的工作原理**。
|
||||
|
||||
Analysis Core收集和处理数据的过程,可以大致分为以下这三步:
|
||||
|
||||
<li>
|
||||
处理我们配置好的各种数据表,并申请存储空间 store;
|
||||
</li>
|
||||
<li>
|
||||
store去找数据提供者,如果不能直接找到,就会通过 Modeler 接收其他store 的输入信号进行合成;
|
||||
</li>
|
||||
<li>
|
||||
store 获得数据源后,会进行 Binding Solution 工作来优化数据处理过程。
|
||||
</li>
|
||||
|
||||
这里需要强调的是,在我们通过store找到的这些数据提供者中,对开发者来说最重要的就是 os_signpost。os_signpost 的主要作用,是让你可以在程序中通过编写代码来获取数据。你可以在工程中的任何地方通过 os_signpost API ,将需要的数据提供给 Analysis Core。
|
||||
|
||||
苹果公司在 WWDC 2018 Session 410 [Creating Custom Instruments](https://developer.apple.com/videos/play/wwdc2018/410) 里提供了一个范例:通过 os_signpost API 将图片下载的数据提供给 Analysis Core 进行监控观察。这个示例在 App 的代码如下所示:
|
||||
|
||||
```
|
||||
os_signpost(.begin, log: parsinglog, name:"Parsing", "Parsing started SIZE:%ld", data.count)
|
||||
// Decode the JSON we just downloaded
|
||||
let result = try jsonDecoder.decode(Trail.self, from: data)
|
||||
os_signpost(.end, log: parsingLog, name:"Parsing", "Parsing finished")
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,上面代码中,os_signpost 的 begin 和 end 需要成对出现。
|
||||
|
||||
上面这段代码就是使用 os_signpost 的 API 获取了程序里的数据。接下来,我们再看看 Instruments 是如何通过配置数据表来使用这些数据的。配置的数据表的 XML 设计如下所示:
|
||||
|
||||
```
|
||||
<os-signpost-interval-schema>
|
||||
<id>json-parse</id>
|
||||
<title>Image Download</title>
|
||||
<subsystem>"com.apple.trailblazer</subsystem>
|
||||
<category>"Networking</category>
|
||||
<name>"Parsing"</name>
|
||||
<start-pattern>
|
||||
<message>"Parsing started SIZE:" ?data-size</message>
|
||||
</start-pattern>
|
||||
<column>
|
||||
<mnemonic>data-size</mnemonic>
|
||||
<title>JSON Data Size</title>
|
||||
<type>size-in-bytes</type>
|
||||
<expression>?data-size</expression>
|
||||
</column>
|
||||
</os-signpost-interval-schema>
|
||||
|
||||
```
|
||||
|
||||
这里,我们配置数据表是要对数据输出进行可视化配置,从而可以将代码中的数据展示出来。如下图所示,就是对下载图片大小监控的效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/b2/cdf512c900bac905b21d28202386b8b2.png" alt="">
|
||||
|
||||
通过上面的分析我们可以看到,Instruments 10通过提供 os_signpost API 的方式使得开发者监控自定义的性能指标时更方便,从而解决了在此之前只能通过重新建设工具来完成的问题。并且,Instruments通过 XML 标准数据接口解耦展示和数据分析的思路,也非常值得我们借鉴和学习。
|
||||
|
||||
在线下性能监控中,Instruments可以说是王者,但却对线上监控无能为力。那么,对于线上的性能监控,我们应该怎么实现呢?
|
||||
|
||||
## 线上性能监控
|
||||
|
||||
对于线上性能监控,我们需要先明白两个原则:
|
||||
|
||||
<li>
|
||||
监控代码不要侵入到业务代码中;
|
||||
</li>
|
||||
<li>
|
||||
采用性能消耗最小的监控方案。
|
||||
</li>
|
||||
|
||||
线上性能监控,主要集中在CPU使用率、FPS的帧率和内存这三个方面。接下来,我们就分别从这三个方面展开讨论吧。
|
||||
|
||||
### CPU使用率的线上监控方法
|
||||
|
||||
App作为进程运行起来后会有多个线程,每个线程对CPU 的使用率不同。各个线程对CPU使用率的总和,就是当前App对CPU 的使用率。明白了这一点以后,我们也就摸清楚了对CPU使用率进行线上监控的思路。
|
||||
|
||||
在iOS系统中,你可以在 usr/include/mach/thread_info.h 里看到线程基本信息的结构体,其中的cpu_usage 就是 CPU使用率。结构体的完整代码如下所示:
|
||||
|
||||
```
|
||||
struct thread_basic_info {
|
||||
time_value_t user_time; // 用户运行时长
|
||||
time_value_t system_time; // 系统运行时长
|
||||
integer_t cpu_usage; // CPU 使用率
|
||||
policy_t policy; // 调度策略
|
||||
integer_t run_state; // 运行状态
|
||||
integer_t flags; // 各种标记
|
||||
integer_t suspend_count; // 暂停线程的计数
|
||||
integer_t sleep_time; // 休眠的时间
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
因为每个线程都会有这个 thread_basic_info 结构体,所以接下来的事情就好办了,你只需要定时(比如,将定时间隔设置为2s)去遍历每个线程,累加每个线程的 cpu_usage 字段的值,就能够得到当前App所在进程的 CPU 使用率了。实现代码如下:
|
||||
|
||||
```
|
||||
+ (integer_t)cpuUsage {
|
||||
thread_act_array_t threads; //int 组成的数组比如 thread[1] = 5635
|
||||
mach_msg_type_number_t threadCount = 0; //mach_msg_type_number_t 是 int 类型
|
||||
const task_t thisTask = mach_task_self();
|
||||
//根据当前 task 获取所有线程
|
||||
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
|
||||
|
||||
if (kr != KERN_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
integer_t cpuUsage = 0;
|
||||
// 遍历所有线程
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
|
||||
thread_info_data_t threadInfo;
|
||||
thread_basic_info_t threadBaseInfo;
|
||||
mach_msg_type_number_t threadInfoCount = THREAD_INFO_MAX;
|
||||
|
||||
if (thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount) == KERN_SUCCESS) {
|
||||
// 获取 CPU 使用率
|
||||
threadBaseInfo = (thread_basic_info_t)threadInfo;
|
||||
if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
|
||||
cpuUsage += threadBaseInfo->cpu_usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(vm_deallocate(mach_task_self(), (vm_address_t)threads, threadCount * sizeof(thread_t)) == KERN_SUCCESS);
|
||||
return cpuUsage;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这段代码中,task_threads 方法能够取到当前进程中的线程总数 threadCount 和所有线程的数组 threads。
|
||||
|
||||
接下来,我们就可以通过遍历这个数组来获取单个线程的基本信息。其中,线程基本信息的结构体是 thread_basic_info_t,这个结构体里就包含了我们需要的 CPU 使用率的字段 cpu_usage。然后,我们累加这个字段就能够获取到当前的整体 CPU 使用率。
|
||||
|
||||
到此,我们就实现了对CPU使用率的线上监控。接下来,我们再看看对FPS的线上监控方法吧。
|
||||
|
||||
### FPS 线上监控方法
|
||||
|
||||
FPS 是指图像连续在显示设备上出现的频率。FPS低,表示App不够流畅,还需要进行优化。
|
||||
|
||||
但是,和前面对CPU使用率和内存使用量的监控不同,iOS系统中没有一个专门的结构体,用来记录与FPS相关的数据。但是,对FPS的监控也可以比较简单的实现:通过注册 CADisplayLink 得到屏幕的同步刷新率,记录每次刷新时间,然后就可以得到 FPS。具体的实现代码如下:
|
||||
|
||||
```
|
||||
- (void)start {
|
||||
self.dLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsCount:)];
|
||||
[self.dLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
||||
}
|
||||
|
||||
// 方法执行帧率和屏幕刷新率保持一致
|
||||
- (void)fpsCount:(CADisplayLink *)displayLink {
|
||||
if (lastTimeStamp == 0) {
|
||||
lastTimeStamp = self.dLink.timestamp;
|
||||
} else {
|
||||
total++;
|
||||
// 开始渲染时间与上次渲染时间差值
|
||||
NSTimeInterval useTime = self.dLink.timestamp - lastTimeStamp;
|
||||
if (useTime < 1) return;
|
||||
lastTimeStamp = self.dLink.timestamp;
|
||||
// fps 计算
|
||||
fps = total / useTime;
|
||||
total = 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 内存使用量的线上监控方法
|
||||
|
||||
通常情况下,我们在获取 iOS 应用内存使用量时,都是使用task_basic_info 里的 resident_size 字段信息。但是,我们发现这样获得的内存使用量和 Instruments 里看到的相差很大。后来,在 2018 WWDC Session 416 [iOS Memory Deep Dive](https://developer.apple.com/videos/play/wwdc2018/416/)中,苹果公司介绍说 phys_footprint 才是实际使用的物理内存。
|
||||
|
||||
内存信息存在 task_info.h (完整路径 usr/include/mach/task.info.h)文件的 task_vm_info 结构体中,其中phys_footprint 就是物理内存的使用,而不是驻留内存 resident_size。结构体里和内存相关的代码如下:
|
||||
|
||||
```
|
||||
struct task_vm_info {
|
||||
mach_vm_size_t virtual_size; // 虚拟内存大小
|
||||
integer_t region_count; // 内存区域的数量
|
||||
integer_t page_size;
|
||||
mach_vm_size_t resident_size; // 驻留内存大小
|
||||
mach_vm_size_t resident_size_peak; // 驻留内存峰值
|
||||
|
||||
...
|
||||
|
||||
/* added for rev1 */
|
||||
mach_vm_size_t phys_footprint; // 物理内存
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
OK,类似于对CPU使用率的监控,我们只要从这个结构体里取出phys_footprint 字段的值,就能够监控到实际物理内存的使用情况了。具体实现代码如下:
|
||||
|
||||
```
|
||||
uint64_t memoryUsage() {
|
||||
task_vm_info_data_t vmInfo;
|
||||
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||
kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
|
||||
if (result != KERN_SUCCESS)
|
||||
return 0;
|
||||
return vmInfo.phys_footprint;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从以上三个线上性能监控方案可以看出,它们的代码和业务逻辑是完全解耦的,监控时基本都是直接获取系统本身提供的数据,没有额外的计算量,因此对 App 本身的性能影响也非常小,满足了我们要考虑的两个原则。
|
||||
|
||||
## 小结
|
||||
|
||||
在今天这篇文章中,我和你分享了如何通过线下和线上监控,去掌控App的性能。
|
||||
|
||||
关于线下的性能监控,我们可以使用苹果官方的Instruments 去解决性能监控的问题。同时,我还和你分享了如何使用 Instruments 的 os_signpost API 来完成自定义的性能数据监控工具开发。
|
||||
|
||||
关于线上的性能监控,我们需要在不影响性能的前提下,去监控线上的性能问题。在这一部分内容中,我主要和你介绍了对CPU使用率、内存使用量和FPS的线上监控方案。
|
||||
|
||||
最后,我还要再和你提一个建议。作为一名 iOS 开发者,与其一起开始到处去寻找各种解决方案,不如先摸透苹果公司自己的库和工具,这里面的设计思想和演进包含有大量可以吸取和学习的知识。掌握好了这些知识,你也就能够开发出适合自己团队的工具了。这,也正是我没有在这篇文章中和你介绍第三方线上性能监控工具的原因。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
Instruments 可以自定义性能数据的监控,那么接下来就请你看下,你现在工程中有哪些数据是需要监控的,然后新建一个自定义 Instruments 工具将其监控起来吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
184
极客时间专栏/geek/iOS开发高手课/基础篇/17 | 远超你想象的多线程的那些坑.md
Normal file
184
极客时间专栏/geek/iOS开发高手课/基础篇/17 | 远超你想象的多线程的那些坑.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<audio id="audio" title="17 | 远超你想象的多线程的那些坑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/28/47416cfb13173aa933ce888a16aabe28.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天,我们一起来聊聊iOS开发中,使用多线程技术会带来的那些问题。
|
||||
|
||||
我们可以先来试想这么一个场景,如果没有多线程技术,那么我们要对一张照片进行滤镜处理时,就只能由主线程来完成这个处理。可想而知,这时候主线程阻塞了,其他的任何操作都无法继续。
|
||||
|
||||
解决这个问题的方法,就是再多创建一个线程来进行滤镜处理的操作,这样主线程就可以继续执行其他操作。这,也就是我们今天要说的多线程技术了。
|
||||
|
||||
目前,在iOS开发中,我们经常会用到系统提供的方法来使用多线程技术开发App,期望可以充分利用硬件资源来提高 App 的运行效率。
|
||||
|
||||
但是,我们不禁会想到,像UIKit这样的前端框架并没有使用多线程技术。而 AFNetworking 2.0(网络框架)、FMDB(第三方数据库框架)这些用得最多的基础库,使用多线程技术时也非常谨慎。
|
||||
|
||||
那么,你有没有想过为什么 UIKit 不是线程安全的,UI 都要在主线程上操作。
|
||||
|
||||
在 AFNetworking 2.0 中,把每个请求都封装成了单独的NSOperationQueue,再由NSOperationQueue根据当前的CPU数量和系统负载来控制并发。那么,为什么 AFNetworking 2.0 没有为每个请求创建一个线程,而只是创建了一个线程,用来接收NSOperationQueue的回调呢?
|
||||
|
||||
FMDB只通过FMDatabaseQueue开启了一个线程队列,来串行地操作数据库。这,又是为什么呢?
|
||||
|
||||
让我说,这就是因为多线程技术有坑。特别是 UIKit 干脆就做成了线程不安全,只能在主线程上操作。
|
||||
|
||||
当你学了多线程的相关知识后,一定会忍不住去使用多线程,但在使用时一定要小心多线程的那些陷阱。只有这样,我们在使用多线程技术时才能够预见到可能会出现的问题,做到心中有数。
|
||||
|
||||
而写 UIKit、AFNetworking、FMDB 这些库的“大神”们,并不是解决不了多线程技术可能会带来的问题,而相反正是因为他们非常清楚这些可能存在的问题,所以为避免使用者滥用多线程,亦或是出于性能考虑,而选择了使用单一线程来保证这些基础库的稳定可用。
|
||||
|
||||
那这么说的话,为了稳定我就不能使用多线程技术了吗?
|
||||
|
||||
当然不是,多线程技术还是有很多适用场景的。就比如说,在需要快速进行多个任务计算的场景里,多线程技术确实能够明显提高单位时间内的计算效率。
|
||||
|
||||
还是以照片处理为例,当选择一张照片后,你希望能够看到不同滤镜处理后的效果。如果这些效果图都是在一个队列里串行处理的话,那么你就得等着这些滤镜一个一个地来处理。这么做的话,不仅会影响用户体验,也没能充分利用硬件资源,可以说是把高端手机当作低端机来用了。换句话说就是,用户花大价钱升级了手机硬件,操作App的体验却没有得到提升。
|
||||
|
||||
所以,我们不能因为多线程技术有坑就不去用,正确的方法应该是更多地去了解多线程会有哪些问题,如果我们能够事先预见到那些问题的话,那么避免这些问题的发生也就不在话下了。
|
||||
|
||||
接下来,我们就一起来看看多线程技术常见的两个大坑,常驻线程和并发问题,分别是从何而来,以及如何避免吧。
|
||||
|
||||
# 常驻线程
|
||||
|
||||
我们先说说多线程技术的第一个坑:常驻线程。
|
||||
|
||||
常驻线程,指的就是那些不会停止,一直存在于内存中的线程。我们在文章开始部分,说到的AFNetworking 2.0 专门创建了一个线程来接收 NSOperationQueue 的回调,这个线程其实就是一个常驻线程。接下来,我们就看看常驻线程这个问题是如何引起的,以及是否有对应的解决方案。
|
||||
|
||||
我们先通过 AFNetworking 2.0 创建常驻线程的代码,来看一下这个线程是怎么创建的。
|
||||
|
||||
```
|
||||
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
|
||||
@autoreleasepool {
|
||||
// 先用 NSThread 创建了一个线程
|
||||
[[NSThread currentThread] setName:@"AFNetworking"];
|
||||
// 使用 run 方法添加 runloop
|
||||
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
|
||||
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
|
||||
[runLoop run];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如代码所示,AFNetworking 2.0 先用 NSThread 创建了一个线程,并使用 NSRunLoop 的 run 方法给这个新线程添加了一个 runloop。
|
||||
|
||||
这里我需要先和你说明白一个问题,通过NSRunLoop添加runloop的方法有三个:
|
||||
|
||||
- run方法。通过 run 方法添加的 runloop ,会不断地重复调用runMode:beforeDate: 方法,来保证自己不会停止。
|
||||
- runUntilDate: 和 runMode:beforeDate 方法。这两个方法添加的runloop,可以通过指定时间来停止 runloop。
|
||||
|
||||
看到这里,你一定在想,原来创建一个常驻线程这么容易,那么我每写一个库就创建一个常驻线程来专门处理当前库自己的事情,该多好啊。你看,大名鼎鼎的 AFNetworking 2.0 库就是这么干的。
|
||||
|
||||
但是,你再想想,如果你有30个库,每个库都常驻一个线程。那这样做,不但不能提高CPU的利用率,反而会降低程序的执行效率。也就是说,这样做的话,就不是充分利用而是浪费CPU 资源了。如果你的库非常多的话,按照这个思路创建的常驻线程也会更多,结果就只会带来更多的坑。
|
||||
|
||||
说到这里,**既然常线程是个坑,那为什么 AFNetworking 2.0 库还要这么做呢?**
|
||||
|
||||
其实,这个问题的根源在于 AFNetworking 2.0 使用的是 NSURLConnection,而NSURLConnection的设计上存在些缺陷。接下来,我和你说说它的设计上有哪些缺陷,了解了这些缺陷后你也就能够理解当时 AFNetworking 2.0 为什么明知常驻线程有坑,还是使用了常驻线程。这样,你以后再碰到类似的情况时,也可以跟 AFNetworking 2.0 一样使用常线程去解决问题,只要不滥用常驻线程就可以了。
|
||||
|
||||
NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收 NSURLConnectionDelegate回调方法。但是,网络返回的时间不确定,所以这个线程就需要一直常驻在内存中。既然这样,AFNetworking 2.0为什么没有在主线程上完成这个工作,而一定要新创建一个线程来做呢?
|
||||
|
||||
这是因为主线程还要处理大量的UI 和交互工作,为了减少对主线程的影响,所以AFNetworking 2.0 就新建了一个常驻线程,用来处理所有的请求和回调。AFNetworking 2.0的线程设计如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/48/02c4b5f2f0a9a2d0cef55b9e5420e148.png" alt="">
|
||||
|
||||
通过上面的分析我们可以知道,如果不是因为NSURLConnection 的请求必须要有一个一直存活的线程来接收回调,那么AFNetworking 2.0 就不用创建一个常驻线程出来了。虽然说,在一个 App 里网络请求这个动作的占比很高,但也有很多不需要网络的场景,所以线程一直常驻在内存中,也是不合理的。
|
||||
|
||||
但是,AFNetworking 在3.0版本时,使用苹果公司新推出的 NSURLSession 替换了 NSURLConnection,从而避免了常驻线程这个坑。NSURLSession 可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。实现代码如下:
|
||||
|
||||
```
|
||||
self.operationQueue = [[NSOperationQueue alloc] init];
|
||||
self.operationQueue.maxConcurrentOperationCount = 1;
|
||||
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
|
||||
|
||||
```
|
||||
|
||||
从上面的代码可以看出,NSURLSession发起的请求,可以指定回调的delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。
|
||||
|
||||
可见,AFNetworking 2.0 使用常驻线程也是无奈之举,一旦有方案能够替代常驻线程,它就会毫不犹豫地废弃常驻线程。那么,你还有什么理由要使用常驻线程呢?
|
||||
|
||||
如果**你需要确实需要保活线程一段时间**的话,可以选择使用 NSRunLoop 的另外两个方法 runUntilDate: 和 runMode:beforeDate,来指定线程的保活时长。让线程存活时间可预期,总比让线程常驻,至少在硬件资源利用率这点上要更加合理。
|
||||
|
||||
或者,你还可以使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。
|
||||
|
||||
## 并发
|
||||
|
||||
并发是多线程技术的第二个大坑。
|
||||
|
||||
在iOS 并发编程技术中,GCD的使用率是最高的。所以,在这篇文章中,我就以GCD为例和你说说多线程的并发问题。
|
||||
|
||||
GCD(Grand Central Dispatch)是由苹果公司开发的一个多核编程解决方案。它提供的一套简单易用的接口,极大地方便了并发编程。同时,它还可以完成对复杂的线程创建、释放时机的管理。但是,GCD带来这些便利的同时,也带来了资源使用上的风险。
|
||||
|
||||
例如,在进行数据读写操作时,总是需要一段时间来等待磁盘响应的,如果在这个时候通过 GCD 发起了一个任务,那么GCD就会本着最大化利用 CPU的原则,会在等待磁盘响应的这个空档,再创建一个新线程来保证能够充分利用 CPU。
|
||||
|
||||
而如果GCD发起的这些新任务,都是类似于数据存储这样需要等待磁盘响应的任务的话,那么随着任务数量的增加,GCD 创建的新线程就会越来越多,从而导致内存资源越来越紧张,等到磁盘开始响应后,再读取数据又会占用更多的内存。结果就是,失控的内存占用会引起更多的内存问题。
|
||||
|
||||
这种情况最典型的场景就是数据库读写操作。[FMDB](https://github.com/ccgus/fmdb)是一个开源的第三方数据库框架,通过FMDatabaseQueue 这个核心类,将与读写数据库相关的磁盘操作都放到一个串行队列里执行,从而避免了线程创建过多导致系统资源紧张的情况。
|
||||
|
||||
FMDatabaseQueue 使用起来也很简单,[我的开源项目“已阅](https://github.com/ming1016/GCDFetchFeed)”就是使用FMDB 进行数据存储的。但,我使用的是 FMDatabase 而不是 FMDatabaseQueue。为什么要这么做呢?因为这个项目里的并发量并不大,是可控的,所以即使不使用 FMDatabaseQueue 也可以快速完成数据的存储工作。
|
||||
|
||||
但,为了能够支持以后可能更大的并发量,下面我将其中“已读”功能的数据库操作改成 FMDatabaseQueue。这样,我就可以将并行队列转化为串行队列来执行,避免大并发读写磁盘操作造成内存问题,改写代码如下:
|
||||
|
||||
```
|
||||
// 标记文章已读
|
||||
- (RACSignal *)markFeedItemAsRead:(NSUInteger)iid fid:(NSUInteger)fid{
|
||||
@weakify(self);
|
||||
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
|
||||
@strongify(self);
|
||||
// 改写成 FMDatabaseQueue 串行队列进行数据库操作
|
||||
FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:self.feedDBPath];
|
||||
[queue inDatabase:^(FMDatabase *db) {
|
||||
FMResultSet *rs = [FMResultSet new];
|
||||
// 读取文章数据
|
||||
if (fid == 0) {
|
||||
rs = [db executeQuery:@"select * from feeditem where isread = ? and iid >= ? order by iid desc", @(0), @(iid)];
|
||||
} else {
|
||||
rs = [db executeQuery:@"select * from feeditem where isread = ? and iid >= ? and fid = ? order by iid desc", @(0), @(iid), @(fid)];
|
||||
}
|
||||
NSUInteger count = 0;
|
||||
while ([rs next]) {
|
||||
count++;
|
||||
}
|
||||
// 更新文章状态为已读
|
||||
if (fid == 0) {
|
||||
[db executeUpdate:@"update feeditem set isread = ? where iid >= ?", @(1), @(iid)];
|
||||
} else {
|
||||
[db executeUpdate:@"update feeditem set isread = ? where iid >= ? and fid = ?", @(1), @(iid), @(fid)];
|
||||
}
|
||||
|
||||
[subscriber sendNext:@(count)];
|
||||
[subscriber sendCompleted];
|
||||
[db close];
|
||||
}];
|
||||
return nil;
|
||||
}];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如代码所示,你只需要将数据库的操作放到 FMDatabaseQueue 的 inDatabase 方法入参 block 中,就可以在 FMDatabaseQueue 维护的串行队列里排队等待执行了。原 FMDatabase 的写法,你可以直接到我的“已阅”项目里查看。
|
||||
|
||||
总结来讲,类似数据库这种需要频繁读写磁盘操作的任务,尽量使用串行队列来管理,避免因为多线程并发而出现内存问题。
|
||||
|
||||
## 内存问题
|
||||
|
||||
在并发这部分,我一直在和你说线程开多了会有内存问题,那到底是什么内存问题呢?为什么会有内存问题呢?
|
||||
|
||||
我们知道,创建线程的过程,需要用到物理内存,CPU 也会消耗时间。而且,新建一个线程,系统还需要为这个进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在iOS 开发中,主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。
|
||||
|
||||
除了内存开销外,线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程还会有较大的 CPU 消耗。
|
||||
|
||||
所以,线程过多时内存和 CPU 都会有大量的消耗,从而导致App 整体性能降低,使得用户体验变成差。CPU 和内存的使用超出系统限制时,甚至会造成系统强杀。这种情况对用户和App的伤害就更大了。
|
||||
|
||||
## 小结
|
||||
|
||||
在今天的这篇文章中,我与你分享了多线程技术会带来的一些问题。
|
||||
|
||||
一提到多线程技术,我们往往都会联想到死锁等锁的问题,但其实锁的问题是最容易查出来的,反而是那些藏在背后,会慢慢吃尽你系统资源的问题,才是你在使用多线程技术时需要时刻注意的。
|
||||
|
||||
其实,线程是个非常大的这个话题,涉及的知识也非常多,而我今天只是选取了常驻线程和并发和你详细展开。因为,这两个技术非常容易使用不当,造成不堪设想的后果。所以,我给你的建议是:常驻线程一定不要滥用,最好不用。对于多线程并发也是一样,除非是并发数量少且可控,或者必须要在短时间内快速处理数据的情况,否则我们在一般情况下为避免数量不可控的并发处理,都需要把并行队列改成串行队列来处理。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
今天的课后小作业,我准备了两个,你可以选择其中一个,当然也可以全部完成。
|
||||
|
||||
第一个小作业是一道动手题:请你先fork 我的“[已阅](https://github.com/ming1016/GCDFetchFeed)”项目,将里面的 FMDatabase 替换成 FMDatabaseQueue;然后,再使用性能工具查看前后的内存消耗情况。
|
||||
|
||||
第二个小作业相对轻松些,请你在留言中说说曾经遇到过的多线程问题,你又是怎么解决的。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
200
极客时间专栏/geek/iOS开发高手课/基础篇/18 | 怎么减少 App 电量消耗?.md
Normal file
200
极客时间专栏/geek/iOS开发高手课/基础篇/18 | 怎么减少 App 电量消耗?.md
Normal file
@@ -0,0 +1,200 @@
|
||||
<audio id="audio" title="18 | 怎么减少 App 电量消耗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/27/23effcfb8188dc4425932ae36a574227.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
手机设备电量有限,App 开发时如不注意电量的的消耗,当用户发现你的 App 是耗电大户时,就会毫不犹豫地将其抛弃。所以,每次开发完,我们都需要去检查自己的App有没有耗电的问题。
|
||||
|
||||
耗电的原因有千万种,如果每次遇到耗电过多的问题,我们都从头查找一番的话,那必然会效率低下。
|
||||
|
||||
就比如说,测试同学过来跟你说“某个页面的前一个版本还好好的,这个版本的耗电怎么多了那么多”,那么你首先想到可能就是这个页面有没有开启定位,网络请求是不是频繁,亦或是定时任务时间是不是间隔过小。接下来,你会去查找耗电问题到底是怎么引起的。你去翻代码的时候却发现,这个页面的相关功能在好几个版本中都没改过了。
|
||||
|
||||
那么,到底是什么原因使得这一个版本的耗电量突然增加呢?不如就使用排除法吧,你把功能一个个都注释掉,却发现耗电量还是没有减少。这时,你应该怎么办呢?接下来,我就在今天的文章里面和你详细分享一下这个问题的解法吧。
|
||||
|
||||
我们首先需要明确的是,只有获取到电量,才能够发现电量问题。所以,我就先从如何获取电量和你讲起。
|
||||
|
||||
## 如何获取电量?
|
||||
|
||||
在iOS中,IOKit framework 是专门用于跟硬件或内核服务通信的。所以,我们可以通过IOKit framework 来获取硬件信息,进而获取到电量消耗信息。在使用IOKit framework时,你需要:
|
||||
|
||||
- 首先,把IOPowerSources.h、IOPSKeys.h和IOKit 这三个文件导入到工程中;
|
||||
- 然后,把batteryMonitoringEnabled置为true;
|
||||
- 最后,通过如下代码获取1%精确度的电量信息。
|
||||
|
||||
```
|
||||
#import "IOPSKeys.h"
|
||||
#import "IOPowerSources.h"
|
||||
|
||||
-(double) getBatteryLevel{
|
||||
// 返回电量信息
|
||||
CFTypeRef blob = IOPSCopyPowerSourcesInfo();
|
||||
// 返回电量句柄列表数据
|
||||
CFArrayRef sources = IOPSCopyPowerSourcesList(blob);
|
||||
CFDictionaryRef pSource = NULL;
|
||||
const void *psValue;
|
||||
// 返回数组大小
|
||||
int numOfSources = CFArrayGetCount(sources);
|
||||
// 计算大小出错处理
|
||||
if (numOfSources == 0) {
|
||||
NSLog(@"Error in CFArrayGetCount");
|
||||
return -1.0f;
|
||||
}
|
||||
|
||||
// 计算所剩电量
|
||||
for (int i=0; i<numOfSources; i++) {
|
||||
// 返回电源可读信息的字典
|
||||
pSource = IOPSGetPowerSourceDescription(blob, CFArrayGetValueAtIndex(sources, i));
|
||||
if (!pSource) {
|
||||
NSLog(@"Error in IOPSGetPowerSourceDescription");
|
||||
return -1.0f;
|
||||
}
|
||||
psValue = (CFStringRef) CFDictionaryGetValue(pSource, CFSTR(kIOPSNameKey));
|
||||
|
||||
int curCapacity = 0;
|
||||
int maxCapacity = 0;
|
||||
double percentage;
|
||||
|
||||
psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSCurrentCapacityKey));
|
||||
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &curCapacity);
|
||||
|
||||
psValue = CFDictionaryGetValue(pSource, CFSTR(kIOPSMaxCapacityKey));
|
||||
CFNumberGetValue((CFNumberRef)psValue, kCFNumberSInt32Type, &maxCapacity);
|
||||
|
||||
percentage = ((double) curCapacity / (double) maxCapacity * 100.0f);
|
||||
NSLog(@"curCapacity : %d / maxCapacity: %d , percentage: %.1f ", curCapacity, maxCapacity, percentage);
|
||||
return percentage;
|
||||
}
|
||||
return -1.
|
||||
|
||||
```
|
||||
|
||||
说完耗电量的获取方法,我们再继续看如何解决电量问题。
|
||||
|
||||
## 如何诊断电量问题?
|
||||
|
||||
回到最开始的问题,当你用排除法将所有功能注释掉后,如果还有问题,那么这个耗电一定是由其他线程引起的。创建这个耗电线程的地方可能是在其他地方,比如是由第三方库引起,或者是公司其他团队开发的二方库。
|
||||
|
||||
所以,你需要逆向地去思考这个问题。这里,你不妨回顾一下,我们在第12篇文章“[iOS崩溃千奇百怪,如何全面监控](https://time.geekbang.org/column/article/88600)”中是怎么定位问题的。
|
||||
|
||||
也就是说,我们还是先反过来看看出现电量问题的期间,哪个线程是有问题的。通过下面的这段代码,你就可以获取到所有线程的信息:
|
||||
|
||||
```
|
||||
thread_act_array_t threads;
|
||||
mach_msg_type_number_t threadCount = 0;
|
||||
const task_t thisTask = mach_task_self();
|
||||
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
|
||||
|
||||
```
|
||||
|
||||
从上面代码可以看出,通过 task_threads 函数,我们就能够得到所有的线程信息数组 threads,以及线程总数 threadCount。threads 数组里的线程信息结构体 thread_basic_info 里有一个记录 CPU 使用百分比的字段 cpu_usage。thread_basic_info结构体的代码如下:
|
||||
|
||||
```
|
||||
struct thread_basic_info {
|
||||
time_value_t user_time; /* user 运行的时间 */
|
||||
time_value_t system_time; /* system 运行的时间 */
|
||||
integer_t cpu_usage; /* CPU 使用百分比 */
|
||||
policy_t policy; /* 有效的计划策略 */
|
||||
integer_t run_state; /* run state (see below) */
|
||||
integer_t flags; /* various flags (see below) */
|
||||
integer_t suspend_count; /* suspend count for thread */
|
||||
integer_t sleep_time; /* 休眠时间 */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
有了这个 cpu_usage 字段,你就可以通过遍历所有线程,去查看是哪个线程的 CPU 使用百分比过高了。如果某个线程的CPU使用率长时间都比较高的话,比如超过了90%,就能够推断出它是有问题的。这时,将其方法堆栈记录下来,你就可以知道到底是哪段代码让你 App 的电量消耗多了。
|
||||
|
||||
通过这种方法,你就可以快速定位到问题,有针对性地进行代码优化。多线程 CPU 使用率检查的完整代码如下:
|
||||
|
||||
```
|
||||
// 轮询检查多个线程 CPU 情况
|
||||
+ (void)updateCPU {
|
||||
thread_act_array_t threads;
|
||||
mach_msg_type_number_t threadCount = 0;
|
||||
const task_t thisTask = mach_task_self();
|
||||
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
thread_info_data_t threadInfo;
|
||||
thread_basic_info_t threadBaseInfo;
|
||||
mach_msg_type_number_t threadInfoCount = THREAD_INFO_MAX;
|
||||
if (thread_info((thread_act_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount) == KERN_SUCCESS) {
|
||||
threadBaseInfo = (thread_basic_info_t)threadInfo;
|
||||
if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
|
||||
integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
|
||||
if (cpuUsage > 90) {
|
||||
//cup 消耗大于 90 时打印和记录堆栈
|
||||
NSString *reStr = smStackOfThread(threads[i]);
|
||||
//记录数据库中
|
||||
[[[SMLagDB shareInstance] increaseWithStackString:reStr] subscribeNext:^(id x) {}];
|
||||
NSLog(@"CPU useage overload thread stack:\n%@",reStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 优化电量
|
||||
|
||||
现在我们已经知道了在线上碰到电量问题时,应该如何解决,但是电量的不合理消耗也可能来自其他方面。CPU 是耗电的大头,引起 CPU 耗电的单点问题可以通过监控来解决,但点滴汇聚终成大海,每一个不合理的小的电量消耗,最终都可能会造成大的电量浪费。所以,我们在平时的开发工作中,时刻关注对耗电量的优化也非常重要。
|
||||
|
||||
对 CPU 的使用要精打细算,要避免让 CPU 做多余的事情。对于大量数据的复杂计算,应该把数据传到服务器去处理,如果必须要在 App 内处理复杂数据计算,可以通过 GCD 的 dispatch_block_create_with_qos_class 方法指定队列的 Qos 为 QOS_CLASS_UTILITY,将计算工作放到这个队列的 block 里。在 QOS_CLASS_UTILITY 这种 Qos 模式下,系统针对大量数据的计算,以及复杂数据处理专门做了电量优化。
|
||||
|
||||
接下来,我们再看看**除了 CPU 会影响耗电,对电量影响较大的因素还有哪些呢?**
|
||||
|
||||
除了 CPU,I/O操作也是耗电大户。任何的 I/O 操作,都会破坏掉低功耗状态。那么,针对 I/O 操作要怎么优化呢?
|
||||
|
||||
业内的普遍做法是,将碎片化的数据磁盘存储操作延后,先在内存中聚合,然后再进行磁盘存储。碎片化的数据进行聚合,在内存中进行存储的机制,可以使用系统自带的 NSCache 来完成。
|
||||
|
||||
NSCache 是线程安全的,NSCache 会在到达预设缓存空间值时清理缓存,这时会触发 cache:willEvictObject: 方法的回调,在这个回调里就可以对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 操作的次数减少了,对电量的消耗也就减少了。
|
||||
|
||||
SDWebImage 图片加载框架,在图片的读取缓存处理时没有直接使用 I/O,而是使用了NSCache。使用 NSCache 的相关代码如下:
|
||||
|
||||
```
|
||||
- (UIImage *)imageFromMemoryCacheForKey:(NSString *)key {
|
||||
return [self.memCache objectForKey:key];
|
||||
}
|
||||
|
||||
- (UIImage *)imageFromDiskCacheForKey:(NSString *)key {
|
||||
// 检查 NSCache 里是否有
|
||||
UIImage *image = [self imageFromMemoryCacheForKey:key];
|
||||
if (image) {
|
||||
return image;
|
||||
}
|
||||
// 从磁盘里读
|
||||
UIImage *diskImage = [self diskImageForKey:key];
|
||||
if (diskImage && self.shouldCacheImagesInMemory) {
|
||||
NSUInteger cost = SDCacheCostForImage(diskImage);
|
||||
[self.memCache setObject:diskImage forKey:key cost:cost];
|
||||
}
|
||||
return diskImage;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,SDWebImage 将获取的图片数据都放到了 NSCache 里,利用 NSCache 缓存策略进行图片缓存内存的管理。每次读取图片时,会检查 NSCache 是否已经存在图片数据:如果有,就直接从 NSCache 里读取;如果没有,才会通过 I/O 读取磁盘缓存图片。
|
||||
|
||||
使用了 NSCache 内存缓存能够有效减少 I/O 操作,你在写类似功能时也可以采用这样的思路,让你的 App 更省电。
|
||||
|
||||
**CPU 和 I/O 这两大耗电问题都解决后,还有什么要注意的呢?**这里还有两份关于App电量消耗的资料,你可以对照你的App来查看。
|
||||
|
||||
苹果公司专门维护了一个电量优化指南“[Energy Efficiency Guide for iOS Apps](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/)”,分别从 CPU、设备唤醒、网络、图形、动画、视频、定位、加速度计、陀螺仪、磁力计、蓝牙等多方面因素提出了电量优化方面的建议。所以,当使用了苹果公司的电量优化指南里提到的功能时,严格按照指南里的最佳实践去做就能够保证这些功能不会引起不合理的电量消耗。
|
||||
|
||||
同时,苹果公司在2017年 WWDC 的 Session 238 也分享了一个关于如何编写节能 App 的主题“[Writing Energy Efficient Apps](https://developer.apple.com/videos/play/wwdc2017/238/)”。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我跟你分享了如何通过获取线程信息里的cpu_usage 字段来判断耗电线程,进而得到当前线程执行方法堆栈,从而精准、快速地定位到引起耗电的具体方法。我曾经用这个方法解决了几起难以定位的耗电问题,这些问题都出在二方库上。通过获取到的方法堆栈,我就有了充足的证据去推动其他团队进行电量优化。
|
||||
|
||||
除此之外,我还跟你介绍了如何在平时开发中关注电量的问题。在我看来,减少 App 耗电也是开发者的天职,不然如何向我们可爱的用户交代呢。
|
||||
|
||||
## 课后小作业
|
||||
|
||||
请你使用我今天分享的耗电检查方法,检查一下你的 App,看看哪个方法最耗电。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
374
极客时间专栏/geek/iOS开发高手课/基础篇/19 | 热点问题答疑(二):基础模块问题答疑.md
Normal file
374
极客时间专栏/geek/iOS开发高手课/基础篇/19 | 热点问题答疑(二):基础模块问题答疑.md
Normal file
@@ -0,0 +1,374 @@
|
||||
<audio id="audio" title="19 | 热点问题答疑(二):基础模块问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/73/c70cd2b2b0448f676b67693b3a1a3373.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
这是我们《iOS开发高手课》专栏的第二期答疑文章,我将继续和你分享大家在学习前面文章时遇到的最普遍的问题。
|
||||
|
||||
首先呢,我要感谢你这段时间对专栏的关注,让我感觉写专栏这件事儿格外有意义。通过这段时间对大家留言问题的观察,我也发现还有很多同学对 RunLoop 原理的一些基础概念不是很了解。这就导致在出现了比如卡顿或者线程问题时找不到好的解决方案,所以我今天就先和你分享一下学习RunLoop的方法和参考资料。
|
||||
|
||||
其实,**目前关于RunLoop 原理的高质量资料非常多,那我们究竟应该怎么利用这些资料,来高效地掌握RunLoop的原理呢?**
|
||||
|
||||
我建议你按照下面的顺序来学习RunLoop 原理,坚持下来你就会对RunLoop的基础概念掌握得八九不离十了。
|
||||
|
||||
- 首先,你可以看一下孙源的一个线下分享《[RunLoop](https://v.youku.com/v_show/id_XODgxODkzODI0.html)》,对 RunLoop 的整体有个了解。
|
||||
- 然后,你可以再看[官方文档](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html),全面详细地了解苹果公司设计的 RunLoop 机制,以及如何运用 RunLoop来解决问题。
|
||||
- 最后,了解了RunLoop的机制和运用后,你需要深入了解 RunLoop 的实现,掌握 RunLoop 原理中的基础概念。ibireme 的一篇文章 《[深入理解RunLoop](https://blog.ibireme.com/2015/05/18/runloop/)》,结合着底层 CFRunLoop 的源码,对RunLoop 机制进行了深入分析。
|
||||
|
||||
好了,关于RunLoop原理学习的内容,我就先说到这里。接下来,**我再跟你说说最近被问到的,我认为比较重要的两个问题:**
|
||||
|
||||
- 一个是,使用 dlopen() ,App能不能审核通过?
|
||||
- 另一个是, matrix-iOS 里的卡顿监控系统,与我在[第13篇文章](https://time.geekbang.org/column/article/89494)里提到的卡顿监控系统有什么区别?
|
||||
|
||||
其实,我知道大家还都比较关注课后作业的解题思路,但是考虑到有很多同学还没有静下心来去思考、去完成,所以我准备过一段时间再和你分享这部分内容。这里,我还是想再和你分享一下我在开篇词中提出的观点:
|
||||
|
||||
>
|
||||
对于咱们手艺人来说,不动手都是空谈,就像绘画教程,光看不练,是不会有进步的。这就如同九阴真经的口诀,铭记于心后还需要常年累月的修炼才能精进。动手就会碰到问题,就会思考,这个主动过程会加深你的记忆,这样后面再碰到问题时,你会更容易将相关知识串联起来,形成创新式的思考。
|
||||
|
||||
|
||||
这些作业确实有难度,也确实需要你投入很多精力,如果你在动手解决这些问题的过程中,具体有哪里卡住了,欢迎给我留言。我可以针对你遇到的问题给出有针对性的解答,或许这样对你的帮助会更大。
|
||||
|
||||
现在,我们就从第一个问题说起吧。
|
||||
|
||||
## 使用 dlopen() 能不能审核通过?
|
||||
|
||||
@Ant同学在第6篇文章“[App 如何通过注入动态库的方式实现极速编译调试?](https://time.geekbang.org/column/article/87188)”中留言问到:
|
||||
|
||||
>
|
||||
Injection 使用了 dlopen() 方法,审核能通过吗? 是不是调试的时候用,提交App Store时候移除呢?
|
||||
|
||||
|
||||
苹果公司关于App审核的规定,你可以点击[这个链接](https://developer.apple.com/cn/app-store/review/guidelines/)查看。其中2.5.2规定如下:
|
||||
|
||||
>
|
||||
App 应自包含在自己的套装中,不得在指定容器范围外读取或写入数据,也不得下载、安装或执行会引入或更改 App 特性或功能的代码,包括其他 App。仅在特殊情况下,用于教授、开发或允许学生测试可执行代码的教育类 App 可以下载所提供的代码,但这类代码不得用于其他用途。这类 App 必须开放 App 提供的源代码,让客户可以完全查看和编辑这些源代码。
|
||||
|
||||
|
||||
2018年11月,苹果公司集中下线了718个 App,主要原因就是它们违反了 2.5.2 这个条款,下面是苹果公司对于违反了 2.5.2条款的回复:
|
||||
|
||||
>
|
||||
Your app, extension, and/or linked framework appears to contain code designed explicitly with the capability to change your app’s behavior or functionality after App Review approval, which is not in compliance with App Store Review Guideline 2.5.2 and section 3.3.2 of the Apple Developer Program License Agreement.
|
||||
|
||||
|
||||
>
|
||||
This code, combined with a remote resource, can facilitate significant changes to your app’s behavior compared to when it was initially reviewed for the App Store. While you may not be using this functionality currently, it has the potential to load private frameworks, private methods, and enable future feature changes. This includes any code which passes arbitrary parameters to dynamic methods such as dlopen(), dlsym(), respondsToSelector:, performSelector:, method_exchangeImplementations(), and running remote scripts in order to change app behavior and/or call SPI, based on the contents of the downloaded script. Even if the remote resource is not intentionally malicious, it could easily be hijacked via a Man In The Middle (MiTM) attack, which can pose a serious security vulnerability to users of your app.
|
||||
|
||||
|
||||
苹果公司在这段回复中,提到了使用 dlopen()、dlsym()、respondsToSelector:、performSelector:、 method_exchangeImplementations() 这些方法去执行远程脚本,是不被允许的。因为这些方法和远程资源相结合,可能加载私有框架和私有方法,可能使 App 的行为发生重大变化。这就会和审核时的情况不一样,即使使用的远程资源本身不是恶意的,但是它们也很容易被劫持,给用户带来不可预计的伤害,使得应用程序有安全漏洞。
|
||||
|
||||
其实,我在[第11篇答疑文章](https://time.geekbang.org/column/article/88799)里就提到,**苹果公司不允许通过运行时加载远程动态库的 App 上线 App Store。**
|
||||
|
||||
那么现在,我们回到 Ant同学提的问题本身,App 带着 Injection 上线后,如果使用 dlopen() 去读取远程动态库,就会被拒绝。另外,在我看来,Injection 本来就是用于线下调试的,为什么还要带着它上 App Store 呢。
|
||||
|
||||
下面我来说下第二个问题,matrix-iOS 里卡顿监控系统,与我在[第13篇文章](https://time.geekbang.org/column/article/89494)里提到的卡顿监控系统有什么区别?
|
||||
|
||||
## matrix-iOS
|
||||
|
||||
[第13篇文章](https://time.geekbang.org/column/article/89494)上线后,有很多朋友和我反馈说,微信最近开源了一个卡顿监控系统 [matrix-iOS](https://github.com/tencent/matrix/tree/master/matrix/matrix-iOS),并询问我它和我在这篇文章里提到的卡顿监控系统,有什么区别。
|
||||
|
||||
因为matrix-iOS 对性能的优化考虑得非常全面,这些优化不仅能够应用在卡顿监控上,对于其他监控都有很好的借鉴作用,所以非常值得我们深入了解一下。接下来,我就这个话题和你展开一下。
|
||||
|
||||
记得在2015年8月的时候,微信团队的一位同学做了一次关于iOS卡顿监控方案的分享。这次分享让我受益匪浅,而且这也是我第一次听说 iOS 卡顿监控方案。次月,微信团队就放出了一篇文章专门介绍卡顿监控方案“[微信iOS卡顿监控系统](https://mp.weixin.qq.com/s?__biz=MzAwNDY1ODY2OQ==&mid=207890859&idx=1&sn=e98dd604cdb854e7a5808d2072c29162&scene=4)”。之后,很多团队参照这篇文章开发了自己的卡顿监控系统。我在第13篇文章中设计的卡顿监控系统,也是按照这个思路写的。
|
||||
|
||||
在今年的4月3号,微信团队将他们的卡顿监控系统[matrix](https://github.com/Tencent/matrix)开源出来了,包括[Matrix for iOS/macOS](https://github.com/Tencent/matrix/tree/master/matrix/matrix-iOS)和[Android](https://github.com/Tencent/matrix/tree/master/matrix/matrix-android)系统的监控方案。关于matrix-iOS的卡顿监控原理,你可以点击[这个链接](https://github.com/Tencent/matrix/wiki/Matrix-for-iOS-macOS-%E5%8D%A1%E9%A1%BF%E7%9B%91%E6%8E%A7%E5%8E%9F%E7%90%86)查看。
|
||||
|
||||
如果你的 App 现在还没有卡顿监控系统,可以考虑直接集成 matrix-iOS,直接在 Podfile 里添加 pod ‘matrix-wechat’ 就可以了。如果已经有了卡顿监控系统,我建议你阅读下 matrix-iOS 的代码,里面有很多细节值得我们学习。比如:
|
||||
|
||||
- 子线程监控检测时间间隔:matrix-iOS 监控卡顿的子线程是通过 NSThread 创建的,检测时间间隔正常情况是1秒,在出现卡顿情况下,间隔时间会受检测线程退火算法影响,按照[斐波那契数列](https://en.wikipedia.org/wiki/Fibonacci_number)递增,直到没有卡顿时恢复为1秒。
|
||||
- 子线程监控退火算法:避免一个卡顿会写入多个文件的情况。
|
||||
- RunLoop 卡顿时间阈值设置:对于 RunLoop 超时阈值的设置,我在第13篇文章里建议设置为3秒,微信设置的是2秒。
|
||||
- CPU 使用率阈值设置:当单核 CPU 使用率超过 80%,就判定 CPU 占用过高。CPU 使用率过高,可能导致 App 卡顿。
|
||||
|
||||
在我看来,这四点是能够让卡顿监控系统在对 App 性能损耗很小的情况下,更好地监控到线上 App 卡顿情况的四个细节,也是和我们[第13篇文章](https://time.geekbang.org/column/article/89494)中的卡顿方案有所不同的地方。
|
||||
|
||||
那接下来,我就跟你说下 matrix-iOS 的这四处细节具体是如何实现的吧。matrix-iOS 卡顿监控系统的主要代码在 [WCBlockMonitorMgr.mm](https://github.com/Tencent/matrix/blob/master/matrix/matrix-iOS/Matrix/WCCrashBlockMonitor/CrashBlockPlugin/Main/BlockMonitor/WCBlockMonitorMgr.mm)文件中。
|
||||
|
||||
### 子线程监控检测时间间隔
|
||||
|
||||
matrix-iOS 是在 addMonitorThread 方法里,通过 NSThread 添加一个子线程来进行监控的。addMonitorThread 方法代码如下:
|
||||
|
||||
```
|
||||
- (void)addMonitorThread
|
||||
{
|
||||
m_bStop = NO;
|
||||
m_monitorThread = [[NSThread alloc] initWithTarget:self selector:@selector(threadProc) object:nil];
|
||||
[m_monitorThread start];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码中创建的 NSThread 子线程,会去执行 threadProc 方法。这个方法包括了子线程监控卡顿的所有逻辑。它的代码比较多,我先和你分析与检测时间间隔相关的代码,然后再和你分析其他的主要代码:
|
||||
|
||||
```
|
||||
while (YES) {
|
||||
@autoreleasepool {
|
||||
if (g_bMonitor) {
|
||||
// 检查是否卡顿,以及卡顿原因
|
||||
...
|
||||
// 针对不同卡顿原因进行不同的处理
|
||||
...
|
||||
}
|
||||
|
||||
// 时间间隔处理,检测时间间隔正常情况是1秒,间隔时间会受检测线程退火算法影响,按照斐波那契数列递增,直到没有卡顿时恢复为1秒。
|
||||
for (int nCnt = 0; nCnt < m_nIntervalTime && !m_bStop; nCnt++) {
|
||||
if (g_MainThreadHandle && g_bMonitor) {
|
||||
int intervalCount = g_CheckPeriodTime / g_PerStackInterval;
|
||||
if (intervalCount <= 0) {
|
||||
usleep(g_CheckPeriodTime);
|
||||
} else {
|
||||
...
|
||||
}
|
||||
} else {
|
||||
usleep(g_CheckPeriodTime);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_bStop) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,创建的子线程通过 while 使其成为常驻线程,直到主动执行 stop 方法才会被销毁。其中,使用 usleep 方法进行时间间隔操作, g_CheckPeriodTime就是正常情况的时间间隔的值,退火算法影响的是 m_nIntervalTime,递增后检测卡顿的时间间隔就会不断变长。直到判定卡顿已结束,m_nIntervalTime 的值会恢复成1。
|
||||
|
||||
接下来,跟踪 g_CheckPeriodTime 的定义就能够找到正常情况下子线程卡顿监控的时间间隔。 g_CheckPeriodTime 的定义如下:
|
||||
|
||||
```
|
||||
static useconds_t g_CheckPeriodTime = g_defaultCheckPeriodTime;
|
||||
|
||||
```
|
||||
|
||||
其中 g_defaultCheckPeriodTime 的定义是:
|
||||
|
||||
```
|
||||
#define BM_MicroFormat_Second 1000000
|
||||
const static useconds_t g_defaultCheckPeriodTime = 1 * BM_MicroFormat_Second;
|
||||
|
||||
```
|
||||
|
||||
可以看出,子线程监控检测时间间隔g_CheckPeriodTime,被设置的值就是1秒。
|
||||
|
||||
### 子线程监控退火算法
|
||||
|
||||
子线程监控检测时间间隔设置为1秒,在没有卡顿问题,不需要获取主线程堆栈信息的情况下性能消耗几乎可以忽略不计。但是,当遇到卡顿问题时,而且一个卡顿持续好几秒的话,就会持续获取主线程堆栈信息,增加性能损耗。更重要的是,持续获取的这些堆栈信息都是重复的,完全没有必要。
|
||||
|
||||
所以,matrix-iOS 采用了退火算法递增时间间隔,来避免因为同一个卡顿问题,不断去获取主线程堆栈信息的情况,从而提升了算法性能。
|
||||
|
||||
同时,一个卡顿问题只获取一个主线程堆栈信息,也就是一个卡顿问题 matrix-iOS 只会进行一次磁盘存储,减少了存储 I/O 也就减少了性能消耗。
|
||||
|
||||
所以,这种策略能够有效减少由于获取主线程堆栈信息带来的性能消耗。
|
||||
|
||||
那么,**matrix-iOS 是如何实现退火算法的呢?**
|
||||
|
||||
因为触发退火算法的条件是卡顿,所以我们先回头来看看子线程监控卡顿主方法 threadProc 里和发现卡顿后处理相关的代码:
|
||||
|
||||
```
|
||||
while (YES) {
|
||||
@autoreleasepool {
|
||||
if (g_bMonitor) {
|
||||
// 检查是否卡顿,以及卡顿原因
|
||||
EDumpType dumpType = [self check];
|
||||
if (m_bStop) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 针对不同卡顿原因进行不同的处理
|
||||
...
|
||||
if (dumpType != EDumpType_Unlag) {
|
||||
if (EDumpType_BackgroundMainThreadBlock == dumpType ||
|
||||
EDumpType_MainThreadBlock == dumpType) {
|
||||
if (g_CurrentThreadCount > 64) {
|
||||
// 线程数超过64个,认为线程过多造成卡顿,不用记录主线程堆栈
|
||||
dumpType = EDumpType_BlockThreadTooMuch;
|
||||
[self dumpFileWithType:dumpType];
|
||||
} else {
|
||||
EFilterType filterType = [self needFilter];
|
||||
if (filterType == EFilterType_None) {
|
||||
if (g_MainThreadHandle) {
|
||||
if (g_PointMainThreadArray != NULL) {
|
||||
free(g_PointMainThreadArray);
|
||||
g_PointMainThreadArray = NULL;
|
||||
}
|
||||
g_PointMainThreadArray = [m_pointMainThreadHandler getPointStackCursor];
|
||||
// 函数主线程堆栈写文件记录
|
||||
m_potenHandledLagFile = [self dumpFileWithType:dumpType];
|
||||
// 回调处理主线程堆栈文件
|
||||
...
|
||||
|
||||
} else {
|
||||
// 主线程堆栈写文件记录
|
||||
m_potenHandledLagFile = [self dumpFileWithType:dumpType];
|
||||
...
|
||||
}
|
||||
} else {
|
||||
// 对于 filterType 满足退火算法、主线程堆栈数太少、一天内记录主线程堆栈过多这些情况不用进行写文件操作
|
||||
...
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m_potenHandledLagFile = [self dumpFileWithType:dumpType];
|
||||
}
|
||||
} else {
|
||||
[self resetStatus];
|
||||
}
|
||||
}
|
||||
|
||||
// 时间间隔处理,检测时间间隔正常情况是1秒,间隔时间会受检测线程退火算法影响,按照斐波那契数列递增,直到没有卡顿时恢复为1秒。
|
||||
...
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,当检测出主线程卡顿后,matrix-iOS 会先看线程数是否过多。为什么会先检查线程数呢?
|
||||
|
||||
我在17篇文章“[远超你想象的多线程的那些坑](https://time.geekbang.org/column/article/90870)”里提到线程过多时 CPU 在切换线程上下文时,还会更新寄存器,更新寄存器时需要寻址,而寻址的过程还会有较大的 CPU 消耗。你可以借此机会再回顾下这篇文章的相关内容。
|
||||
|
||||
按照微信团队的经验,线程数超出64个时会导致主线程卡顿,如果卡顿是由于线程多造成的,那么就没必要通过获取主线程堆栈去找卡顿原因了。根据 matrix-iOS 的实测,每隔 50 毫秒获取主线程堆栈会增加 3% 的 CPU 占用,所以当检测到主线程卡顿以后,我们需要先判断是否是因为线程数过多导致的,而不是一有卡顿问题就去获取主线程堆栈。
|
||||
|
||||
如果不是线程过多造成的卡顿问题,matrix-iOS 会通过 needFilter 方法去对比前后两次获取的主线程堆栈,如果两次堆栈是一样的,那就表示卡顿还没结束,满足退火算法条件,needFilter 方法会返回 EFilterType。EFilterType 为 EFilterType_Annealing,表示类型为退火算法。满足退火算法后,主线程堆栈就不会立刻进行写文件操作。
|
||||
|
||||
在 needFilter 方法里,needFilter 通过 [m_pointMainThreadHandler getLastMainThreadStack] 获取当前主线程堆栈,然后记录在 m_vecLastMainThreadCallStack 里。下次卡顿时,再获取主线程堆栈,新获取的堆栈和上次记录的 m_vecLastMainThreadCallStack 堆栈进行对比:
|
||||
|
||||
- 如果两个堆栈不同,表示这是一个新的卡顿,就会退出退火算法;
|
||||
- 如果两个堆栈相同,就用斐波那契数列递增子线程检查时间间隔。
|
||||
|
||||
递增时间的代码如下:
|
||||
|
||||
```
|
||||
if (bIsSame) {
|
||||
NSUInteger lastTimeInterval = m_nIntervalTime;
|
||||
// 递增 m_nIntervalTime
|
||||
m_nIntervalTime = m_nLastTimeInterval + m_nIntervalTime;
|
||||
m_nLastTimeInterval = lastTimeInterval;
|
||||
MatrixInfo(@"call stack same timeinterval = %lu", (unsigned long) m_nIntervalTime);
|
||||
return EFilterType_Annealing;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,将子线程检查主线程时间间隔增加后,needFilter 就直接返回 EFilterType_Annealing 类型表示当前情况满足退火算法。使用退火算法,可以有效降低没有必要地获取主线程堆栈的频率。这样的话,我们就能够在准确获取卡顿的前提下,还能保障 App 性能不会受卡顿监控系统的影响。
|
||||
|
||||
### RunLoop 卡顿时间阈值设置
|
||||
|
||||
RunLoop 超时检查的相关逻辑代码都在 check 方法里。check 方法和 RunLoop 超时相关代码如下:
|
||||
|
||||
```
|
||||
- (EDumpType)check
|
||||
{
|
||||
// 1. RunLoop 超时判断
|
||||
// RunLoop 是不是处在执行方法状态中
|
||||
BOOL tmp_g_bRun = g_bRun;
|
||||
// 执行了多长时间
|
||||
struct timeval tmp_g_tvRun = g_tvRun;
|
||||
|
||||
struct timeval tvCur;
|
||||
gettimeofday(&tvCur, NULL);
|
||||
unsigned long long diff = [WCBlockMonitorMgr diffTime:&tmp_g_tvRun endTime:&tvCur];
|
||||
|
||||
...
|
||||
|
||||
m_blockDiffTime = 0;
|
||||
// 判断执行时长是否超时
|
||||
if (tmp_g_bRun && tmp_g_tvRun.tv_sec && tmp_g_tvRun.tv_usec && __timercmp(&tmp_g_tvRun, &tvCur, <) && diff > g_RunLoopTimeOut) {
|
||||
m_blockDiffTime = tvCur.tv_sec - tmp_g_tvRun.tv_sec;
|
||||
|
||||
...
|
||||
|
||||
return EDumpType_MainThreadBlock;
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
// 2. CPU 使用率
|
||||
|
||||
...
|
||||
|
||||
// 3. 没问题
|
||||
return EDumpType
|
||||
|
||||
```
|
||||
|
||||
可以看出,在判断执行时长是否超时代码中的 g_RunLoopTimeOut 就是超时的阈值。通过这个阈值,我们就可以知道 matrix-iOS 设置的 RunLoop 卡顿时间阈值是多少了。g_RunLoopTimeOut 的定义如下:
|
||||
|
||||
```
|
||||
static useconds_t g_RunLoopTimeOut = g_defaultRunLoopTimeOut;
|
||||
const static useconds_t g_defaultRunLoopTimeOut = 2 * BM_MicroFormat_Second;
|
||||
|
||||
```
|
||||
|
||||
可以看出,matrix-iOS 设置的 RunLoop 卡顿时间阈值是2秒。我在[第13篇文章](https://time.geekbang.org/column/article/89494)里设置的卡顿时间阈值是3秒,@80后空巢老肥狗在评论区留言到:
|
||||
|
||||
>
|
||||
这个3秒是不是太长了,1秒60帧,每帧16.67ms。RunLoop 会在每次sleep之前去刷新UI,这样的话如果掉了30帧,就是500ms左右,用户的体验就已经下去了,能感觉到卡顿了。
|
||||
|
||||
|
||||
关于卡顿时间阈值设置的这个问题,其实我和 matrix-iOS 的想法是一致的。你在实际使用时,如果把这个阈值设置为2秒后发现的线上卡顿问题比较多,短期内无法全部修复的话,可以选择把这个值设置为3秒。
|
||||
|
||||
还有一点我需要再说明一下,**我们所说的卡顿监控方案,主要是针对那些在一段时间内用户无法点击,通过日志也很难复现问题的情况而做的。这样的卡顿问题属于头部问题,对用户的伤害是最大的,是需要优先解决的。**这种方案,是不适合短时间掉帧的情况的。短时间掉帧问题对用户体验也有影响,但是属于优化问题。
|
||||
|
||||
除了 RunLoop 超时会造成卡顿问题外,在 check 方法里还有对于 CPU 使用率的判断处理,那么我再带你来看看 matrix-iOS 是如何通过 CPU 使用率来判断卡顿的。
|
||||
|
||||
### CPU 使用率阈值设置
|
||||
|
||||
我在第18篇文章“[怎么减少 App 电量消耗?](https://time.geekbang.org/column/article/90874)”中,设置的 CPU 使用率阈值是 90%。那么,matrix-iOS 是如何设置这个 CPU 使用率阈值的呢?check 方法里的相关代码如下:
|
||||
|
||||
```
|
||||
if (m_bTrackCPU) {
|
||||
unsigned long long checkPeriod = [WCBlockMonitorMgr diffTime:&g_lastCheckTime endTime:&tvCur];
|
||||
gettimeofday(&g_lastCheckTime, NULL);
|
||||
// 检查是否超过 CPU 使用率阈值限制,报 CPU 使用率一段时间过高
|
||||
if ([m_cpuHandler cultivateCpuUsage:cpuUsage periodTime:(float)checkPeriod / 1000000]) {
|
||||
MatrixInfo(@"exceed cpu average usage");
|
||||
BM_SAFE_CALL_SELECTOR_NO_RETURN(_delegate, @selector(onBlockMonitorIntervalCPUTooHigh:), onBlockMonitorIntervalCPUTooHigh:self)
|
||||
if ([_monitorConfigHandler getShouldGetCPUIntervalHighLog]) {
|
||||
return EDumpType_CPUIntervalHigh;
|
||||
}
|
||||
}
|
||||
// 针对 CPU 满负荷情况,直接报 CPU 使用率过高引起卡顿
|
||||
if (cpuUsage > g_CPUUsagePercent) {
|
||||
MatrixInfo(@"check cpu over usage dump %f", cpuUsage);
|
||||
BM_SAFE_CALL_SELECTOR_NO_RETURN(_delegate, @selector(onBlockMonitorCurrentCPUTooHigh:), onBlockMonitorCurrentCPUTooHigh:self)
|
||||
if ([_monitorConfigHandler getShouldGetCPUHighLog]) {
|
||||
return EDumpType_CPUBlock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面代码,你会发现 matrix-iOS 使用了两个阈值,分别返回两种类型的问题,对应两种导致卡顿的情况:
|
||||
|
||||
- 一个是, CPU 已经满负荷,直接返回 CPU 使用率过高引起卡顿;
|
||||
- 另一个是,持续时间内 CPU 使用率一直超过某个阈值,就返回 CPU 使用率造成了卡顿。
|
||||
|
||||
如上面代码所示,CPU 使用率阈值就在 cultivateCpuUsage:cpuUsage periodTime:periodSec 方法里。阈值相关逻辑代码如下:
|
||||
|
||||
```
|
||||
if (cpuUsage > 80. && m_tickTok == 0 && m_bLastOverEighty == NO) {
|
||||
MatrixInfo(@"start track cpu usage");
|
||||
m_foregroundOverEightyTotalSec = 0;
|
||||
m_backgroundOverEightyTotalSec = 0;
|
||||
m_bLastOverEighty = YES;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,matrix-iOS 设置的 CPU 使用率阈值是80%。
|
||||
|
||||
到这里,我就已经把 matrix-iOS 的卡顿监控系统4个非常值得我们学习的细节说完了。而matrix-iOS 如何利用 RunLoop 原理去获取卡顿时长的原理,我已经在[第13篇文章](https://time.geekbang.org/column/article/89494)里跟你说过,这里就不再赘述了。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天这篇文章中,我和你分享了下最近这段时间大家对专栏文章的一些问题。
|
||||
|
||||
首先,是关于对RunLoop原理的学习。我发现有很多同学在这方面的基础比较薄弱,所以特意梳理了这方面的学习方法和资料,希望可以帮到你。
|
||||
|
||||
然后,我针对大家比较关注的苹果公司审核动态化的相关规定,通过Injection里面带dlopen()方法能否审核通过和你做了说明,希望可以帮助你了解类似 dlopen()这样的技术应该怎样使用。
|
||||
|
||||
最后,我针对第13篇文章的监控系统,分析了最近微信团队新开源的matrix-iOS监控系统,为你详细分析了其中与卡顿监控相关的实现细节,也希望对你完善自己的监控系统有所帮助。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
112
极客时间专栏/geek/iOS开发高手课/基础篇/20 | iOS开发的最佳学习路径是什么?.md
Normal file
112
极客时间专栏/geek/iOS开发高手课/基础篇/20 | iOS开发的最佳学习路径是什么?.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<audio id="audio" title="20 | iOS开发的最佳学习路径是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/78/6e9e64caed520a17eb6db24cb72def78.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
我在专栏的第一篇文章中,就和你分享了我的 iOS 知识体系。通过前面“基础篇”内容的学习,你有没有总结出一套高效地构建自己的知识体系的路径呢?
|
||||
|
||||
今天这篇文章,我就重点和你分享一下,从一个新人到专家的学习路径,希望帮你少走一些弯路,快速成长。这条路径里面不仅有我的亲身经历,更有我观察到的、身边的那些优秀开发者的经历。所以,你大可放心地按照这条路径走下去,达到领域专家的水平一定是没有问题的。
|
||||
|
||||
在我看来,**iOS开发者,可以分为这么三类:刚跨入 iOS 领域的开发者、有一定 iOS 实际开发经验的开发者,以及 iOS 开发老手**。接下来,我就和你聊聊这三类人分别应该如何精进自己的iOS开发能力,成为这个领域的专家。
|
||||
|
||||
在我开始讲述这三类人的成长路径之前,我先和你分享一下完全0基础想要学习iOS开发的群体,可以参考什么样的资料来入门。
|
||||
|
||||
如果你喜欢通过书籍来学习的话,我推荐你去看看《iOS编程》(**iOS Programming**)这本书。这本书的内容,包括了开发语言、Cocoa 设计模式和控件使用、Xcode 技巧等,涉及 iOS 开发基础的方方面面。因此,它非常适合 iOS 编程0基础的人阅读,在 Quora 上被评为 iOS 最佳入门书。而且每次 iOS 系统和开发语言增加了新特性,这本书都会进行同步的版本更新。
|
||||
|
||||
如果你习惯于通过手把手的实例来学习的话,我推荐你看一下[APPCODA](https://www.appcoda.com/)网站。这里面每篇教程都非常简单易懂,而且详细,可以带着你一步一步地动手编写程序,非常适合初学者。同时,这个网站的更新也很频繁。
|
||||
|
||||
当然了,不排除以后还会有小学生朋友阅读这个专栏,那么如果你还在读小学或者初中,希望能够自己开发 App的话,你可以看一下知乎里的这个问答“[12 岁如何入门 iOS 编程?](https://www.zhihu.com/question/20919784)”。这个问题下的被赞的最多的回答里,列出了很多孩子编程成功的事例,相信也可以让你信心满满。
|
||||
|
||||
接下来,我们就具体看一下刚跨入 iOS 领域的开发者、有一定 iOS 实际开发经验的开发者,以及 iOS 开发老手分别应该选择什么样的进阶路径吧。
|
||||
|
||||
## 不贪基础知识
|
||||
|
||||
是不是总是有人和你说,不论做什么,都要先打好基础。但是,基础怎样才算打好了呢,却没有人可以替你做主。因为,基础知识涉及面实在是太广了,而且多偏理论,如不经过实践的检验,要想掌握好也不是件简单的事情。
|
||||
|
||||
但我这么说,并不是说基础就不重要了,必要的基础一定要掌握,而且还要掌握好。那么,对于 iOS 开发者来说,哪些基础是在开始就需要好好学的呢?
|
||||
|
||||
我先将**新入 iOS 开发的人群进行一个细分,包括在校生、刚参加工作的应届生、从其他领域转向 iOS 开发的老手。**
|
||||
|
||||
**对于在校生来说**,我推荐斯坦福大学在 iTunes U 上的 [App 开发课程](http://web.stanford.edu/class/cs193p/cgi-bin/drupal/),网上还有[同步的中文字幕项目](https://github.com/ApolloZhu/Developing-iOS-11-Apps-with-Swift)。这个课程中包含了开发一个 App 需要的、最基础的知识,同时还会现场一步一步带领你来开发一些简单的 App。
|
||||
|
||||
这个课程中会涉及MVC 架构、iOS 开发语言基础知识、多点触摸、动画、ViewController 的生命周期、Scroll View、多线程、Auto Layout、拖拽、TableView、Collection View、Text Field、持续化存储、弹窗、通知、整个 App 生命周期、Storyboards、Core Motion、摄像等内容。
|
||||
|
||||
跟着斯坦福的这个课程学完,动手开发几个简单 App 上线是没什么问题的。但是,你不要就此满足,因为真实工作中光靠这些知识是远远不够的,甚至光靠这些知识连面试都过不了。基于类似这样简单应用层面的知识,掌握斯坦福的这个课程就可以了,不要一味地贪多,后面还有更多值得深入学习的知识在等着你。而且,应用层面的基础知识根据实际工作需要再去学习也不迟。
|
||||
|
||||
**对于刚参加工作的应届生**,我就不推荐斯坦福的这个课程了。不是说不适用,而是对于应届生来说,可能一上来手头就会有开发任务,怎么能够快速地上手完成任务才是最重要的。因为,公司是不会等到你掌握好了各种开发知识以后,才让你开始工作的;而且,不同公司的产品使用的技术侧重点也会有很大差异,也等不到你掌握好各种开发知识后才让你开始工作。所以,对于这个阶段的iOS开发者来说,如何快速上手完成任务才是最重要的。
|
||||
|
||||
针对应届生,我推荐苹果[官方 iOS 开发指南](https://developer.apple.com/library/archive/referencelibrary/GettingStarted/DevelopiOSAppsSwift/),虽然内容不多,但却能够帮你快速掌握开发思路。实际工作中碰到所需要的知识,首先翻看[官方开发手册](https://developer.apple.com/documentation/),先在这里面找解决方法。官方代码示例都很规范,分类也很清晰,内容也更全。从大块上,可以分为 App Frameworks、图形、App 服务、媒体、开发工具、系统等。
|
||||
|
||||
- App Frameworks 里面主要是 Fundation 框架、UIKit、AppKit 这类文档资料。
|
||||
- 图形分类里包含了 UIkit 更底层的 Core Graphics、Core Image、Core Animation,还有 ARKit、Metal、GameKit、SpriteKit 等也在这里面。
|
||||
- App 服务里是苹果公司为开发者提供的服务套件,比如推送套件 PushKit、富文本套件 Core Text、方便集成机器学习模型到你 App 的 Core ML、车载应用的 CarPlay 等。JavaScript 引擎 JavaScriptCore 在 iOS 中应用接口的资料,你也可以在这个分类里找到。
|
||||
- 媒体里主要包含了 AVFundation、Core Audio Kit、Core Media 这些音视频的内容。
|
||||
- 开发工具里有 Swift Playgrounds、XcodeKit、XCTest。
|
||||
- 系统这个分类里的内容非常多而且实用,有网络基础 CFNetwork 和 Network、多核队列相关的 Dispatch、内核框架 Kernel、运行时库 Objective-C Runtime、安全 Security。
|
||||
|
||||
这份开发手册内容大而全,没必要在开始时就什么都学,不然耗费大量精力学到的东西却一时难以用上,会导致你的职业道路走得过慢。我觉得用时再看即可,只要你记着,工作中碰到 iOS 不清的知识,先到这里来找就对了。
|
||||
|
||||
**对于已经有多年其他领域开发经验的开发者来说**,通过几个示例代码,或者看看已有项目中的代码就能够快速上手了。其实, iOS 开发中的 Objective-C 语言实际上就是 C 语言的一个超集,有 C 和 C++ 基础和经验的开发者转行过来是很容易的,在开发思想上也有很多可以互相借鉴的地方。而Swift 语言也融入了很多新语言的特性,比如闭包、多返回值、泛型、扩展、协议的结构体、函数编程模式等,你再学起来时也会有似曾相识的感觉。
|
||||
|
||||
对这个阶段的开发者,我推荐到 [RayWenderlich](https://www.raywenderlich.com/library)网站里翻翻它们的教程。这里的每一个教程都会详细指导你如何一步一步去实际完成一个项目,来达到掌握一个知识点的目的。RayWenderlich 里也有很详细的分类,你可以根据实际工作需要来学习。我的建议同样是,这里知识虽好,但不要贪多。
|
||||
|
||||
**关于掌握了一定的基础知识后,如何继续学习来提升自己的iOS 开发技能**,在5年前唐巧写了篇博客“[iOS开发如何提高](https://blog.devtang.com/2014/07/27/ios-levelup-tips/)”、limboy 写了篇“[自学 iOS 开发的一些经验](https://limboy.me/tech/2014/12/31/learning-ios.html)”,里面提到的这些提高自己开发能力的方法,拿到今天依然适用。不过,学习终究是需要实践来验证的。
|
||||
|
||||
在进入实战阶段之前,为了避免少走弯路,你需要一份 iOS 最佳实践。这里有一份一直在维护的[最佳实践指导](https://github.com/futurice/ios-good-practices),里面包含了完整的 App 开发生命周期,从 IDE 搭建工程最佳使用方式、基础库选择、架构选择、存储方式、资源管理、代码规范、安全、静态分析、崩溃分析、构建,到部署,甚至是 IAP(In-App Purchases 应用内支付)都考虑到了。
|
||||
|
||||
实战过程中手册是最常用的,特别是 Swift 语言特性非常多,在还没能达到熟练的情况下,手册往往能够起到查漏补缺的效果。Swift 语言的手册就在它的官网,你可以点击[这个链接](https://github.com/futurice/ios-good-practices)查看;[中文版](https://swiftgg.gitbook.io/swift/huan-ying-shi-yong-swift)[手册](https://swiftgg.gitbook.io/swift/),现在是由 SwiftGG 在维护,目前已经同步更新到了 Swift5。如果你想及时获得Swift的最新消息,可以订阅 [Swift 官网的博客](https://swift.org/blog/)。
|
||||
|
||||
## 在实践中积累
|
||||
|
||||
**基础知识不要贪多,但对于工作实践中涉及到的领域相关知识,我的建议是一定要贪多,而且越多越好。**在实践中多多积累工作中涉及的相关知识,这种学习方法特别适合有了几年工作经验的开发者。此外,你还要时刻关注和你工作内容相关的领域知识的发展动向,并全面掌握,从而达到由量变到质变的效果,最终达到领域专家的水平。
|
||||
|
||||
举个例子吧。有一段时间,我的工作是和 App 性能相关的。这段时间,我会首先在网上收集一些其他公司在性能上做的事情,然后针对那些事情再去学习相关知识,平时还会通过订阅一些博客和技术团队的输出,持续关注性能这个领域。
|
||||
|
||||
等工作上做出了些成绩以后,我就会及时进行整理和总结。在这个过程中,再进行一些思考,多问问自己为什么这么做,还有没有更好的做法。最后再输出,看看其他人和团队的意见和建议。通过交流相互成长,独乐乐不如众乐乐,何乐而不为呢。
|
||||
|
||||
**对于学习和积累什么样的知识,我的建议是,你一定要怀着一颗饥渴的心,查找资料时永远不要觉得够了。**查到了很多资料后要多总结、多思考,这样才会有新的思路、新的方案出来。如果你细心观察 iOS 技术这几年的发展,就会发现很多方案刚开始都很简单,但是随着对底层的研究和思考后会出现更多更优解。
|
||||
|
||||
以内存监控方案为例。这个方案一开始是到 JetsamEvent 日志里找到那些由内存引起的系统强杀日志来做监控的,但是很难精确定位到问题。
|
||||
|
||||
后来,随着 fishhook 这种可以 Hook 系统 c 方法的库浮出水面,最终将其引入到了获取内存上限值的方案里。引入fishhook后的原理是,先找到分配内存都会执行的 c 函数 malloc_logger,然后使用 fishhook 去截获,并记录到日志里,从而可以精确分析出内存过大的那个对象是在哪里分配的内存。
|
||||
|
||||
所以,我也会在专栏里面,分享一些我在实际工作中积累的领域知识,帮你解决这部分的痛点。我相信,这些领域知识也正是你所需要的。
|
||||
|
||||
## 殊途同归,深挖知识
|
||||
|
||||
在上面的内容中,我提到说很多方案,都是在不断地研究底层原理的基础上日趋完善的。由此看出,在基础知识掌握到一定程度、领域知识积累到一定程度后,我们需要再继续深挖知识,也就是众多计算机细分领域中的通用底层知识。
|
||||
|
||||
在我看来,底层知识是最值得深挖的,不管哪个领域,殊途同归,底层都是需要持续学习的。这里我推荐 Michael Ash 的“[**The Complete Friday Q&A**](https://www.mikeash.com/book.html)”。这本书里面涉及的内容,都是对一些知识点更深一层的探究,会让你了解更多内存、性能和系统内部的原理,帮你理解那些万变不离其宗的核心知识。
|
||||
|
||||
同样,我也会在专栏里面,通过5篇文章的篇幅,和你分享那些通用的底层知识,也就是系统内核XNU、AOP、内存管理和编译这些主题。
|
||||
|
||||
**当你 iOS 基础打牢了,也积累了很多实践经验,工作上也取得了一定成绩,那你也就成长为一名 iOS 开发老手了。**这个时候,你可以选择一个方向持续地深入学习。在我看来,最好的深入学习方式就是从头开始实现一个技术雏型。
|
||||
|
||||
如果你想对 LLVM 编译器能够有更加深刻的理解,那就去实现一个简单的编译器或解释器。比如,这个最小的 C 编译器 [OTCC](https://bellard.org/otcc/)(Obfuscated Tiny C Compiler)就是一个很好的实践。
|
||||
|
||||
如果你想更多地了解数据库的原理,除了看已有数据库的源码外,还可以自己动手开发一个简单的数据库项目。这里有个教程“[Let’s Build a Simple Database](https://cstack.github.io/db_tutorial/)”,可以一步步地教你如何开发一个简单的数据库。你可以照着这个教程,动手做起来。
|
||||
|
||||
甚至是,如果你想更多地了解操作系统,也可以学着从头创建一个。这样做,可以帮助你更深刻地理解分页、信号量、内存管理等知识。这里有个非常好的教程叫作“[os-tutorial](https://github.com/cfenollosa/os-tutorial)”,你可以跟着动手做起来,去学习怎么开发一个麻雀虽小五脏俱全的操作系统,包括系统启动、中断处理、屏幕输出键盘输入、basic libc、内存管理、文件存储系统、简单的 shell、用户模式、文本编辑器、多进程和调度等。
|
||||
|
||||
对于 iOS 老手来说,你能够使用已经掌握的技术栈,触类旁通地去接触其他领域来拓宽自己的技术视野。以服务端开发为例,使用 [Perfect](https://github.com/PerfectlySoft)就能够用 Swift 语言来做服务器的开发。
|
||||
|
||||
Perfect是由加拿大一个团队开发并维护的,这个团队的成员对技术都很有热情,热衷于用优秀的技术去做优秀的事情。所以,Perfect不仅功能丰富,而且紧跟最新技术的发展,对TensorFlow、NIO、MySQL、MongoDB、Ubuntu、Redis的支持做的都很到位。
|
||||
|
||||
这也是我们作为iOS开发老手,需要借鉴并学习的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我和你总结了一下 iOS 的学习路径。
|
||||
|
||||
随着工作时间的增加,为了将工作做得更好,我们会去学习更多的知识。但是,学得越多就会发现缺失的知识越多,那么接下来的学习之路该何去何从呢?有没有办法少走弯路呢?如何才能够系统化学习呢?
|
||||
|
||||
我觉得如果你到了这一步,有了这些困惑,就应该好好去梳理自己的知识体系了,查漏补缺让自己的知识更体系化。
|
||||
|
||||
我参与过日活千万和日活过亿的 App 项目,团队规模和代码规模都很大,攻坚过很多难解问题,对于性能质量和开发效率也有过很多思考和实践,我都会在这个专栏中与你一一分享,希望能够对你有帮助。
|
||||
|
||||
现在,我们已经掌握了构建自己iOS开发知识体系的高效路径,接下来需要做的就是不断自我完善的过程了。
|
||||
|
||||
今天我留给你的课后思考题,就是请你来总结一下自己学习iOS开发的方法,并和我分享一下吧。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user