28-iOS开发高手课

开篇词 | 锚定一个点，然后在这个点上深耕

你好，我是戴铭，欢迎你加入我的 iOS 开发专栏。

说起 iOS 开发，自然是绕不开 iPhone 和 App Store 这两个词。多少年过去了，我依然记得 2007 年乔布斯发布第一代 iPhone 时的场景，可以说，那款 iPhone 重新定义了很多人对于手机的认知。那一天，也是移动互联网时代的开端。紧接着，在 2008 年 7 月的 WWDC 苹果全球开发者大会上，苹果宣布 App Store 正式对外开放，这也意味着属于开发者的移动互联网时代真正开始了。

一转眼，十多年过去了，移动开发早已飞入寻常百姓家，移动 App 基本成为了大众互联网产品的标配。从技术角度看，这些年来移动开发领域相继诞生了组件化、热修复、动态化、大前端、Flutter、小程序等热门技术，给人一种“乱花渐欲迷人眼”的感觉，它们争先恐后地成为行业焦点，然后又在不知不觉中被后来者替代，最后再逐步淡出开发者的视野。

在我看来，这些技术看似过眼云烟，实则是递进式推动着移动技术的演进。

这些技术看似“纷繁杂乱”，实则是殊途同归，它们背后都是一些通用的底层技术和创新的设计思想。比如，热修复、动态化和大前端的底层技术，都是 JavaScriptCore 这样的 JavaScript 虚拟机技术；再比如，大前端和 Flutter 的渲染，使用的都是 WebCore 中 Skia 这样的渲染引擎。

所以，每当我串起来整体看移动开发领域这些年的这些“新”技术时，总是会感慨说“万变不离其宗”。我就觉得如果我们能深入进去把某一门技术研究透彻，那再拿下其他的技术时就会变得轻车熟路。

以组件化为例，它是顺应着 App 从单一业务到多业务汇聚的演进而出现的一门技术。比如微信刚发布时业务单一，就只有聊天的功能，后来又加上了支付、朋友圈、游戏，再等到小程序功能上线后更是打车、电影票、购物等只要你能想到的需求它都有，俨然成为了一个超级平台。

从本质上讲，组件化是将上层业务隔离开，下层提供通用能力的一种架构模式，这样上层业务团队可以分开从而减少团队沟通成本，下层能力的通用性又反过来提高了各个业务团队的开发效率。为了达到不同业务隔离的结果，解耦手段不断被引入到 iOS 开发中，比如使用协议或者中间者模式在运行时统调等方式。

听到这里，你还会觉得组件化技术陌生吗？并不陌生，它的核心思想就是解耦。只要你把这块研究透彻了，那再理解与解耦相关的其他技术架构也就水到渠成了。

我们再以热修复为例，如果你看过相关的热修复技术源代码或者架构图的话，应该不难发现热修复技术的核心引擎主要就是 JavaScriptCore，它要求原生开发者使用 JavaScript 来编写代码。而为了方便原生开发者，热修复引擎最大卖点就是将原生 Objective-C 代码转成 JavaScript 代码然后让 JavaScriptCore 去解释。这样一个代码转换过程其实就是通过编译技术来实现的。

同时，在目前正流行的这波大前端和小程序浪潮中，各种大前端技术都对 Web 标准做了大量限制，定义了自己的规范模板。这些自定义的模板最终都会使用 Babel 这样的前端编译技术将其编译成 JavaScript 代码，然后再交给 JavaScriptCore 解释调用原生渲染。

所以，你看，只要掌握了热修复中的核心技术，就不难理解小程序的实现原理。一切看起来就是那么自然。

最近圈子里又开始流行 Flutter 了，在 Flutter 这波浪潮还没有全面落地铺开前，很多人就开始关心：下一个热点会是什么？其实我觉得大可不必在乎，你只要静下心来好好消化掉这几年浪潮留下的关键技术，在这个基础上再去理解各种“新技术”，必然会驾轻就熟。

最后，再说个你最能切身体会的企业招聘对于 iOS 开发者的要求。以前对工作经历只要求有过完整独立完成的 App 上架就够了，而现在如果你缺少大型项目经验，团队规模小，没有好的提质提效开发经验，在应聘时的竞争力会大打折扣。

但，这并不是 iOS 领域的个体问题，任何一个领域其实都和移动领域一样，从小型到大型，从个人到团队，从低效到高效，从凑合够用到高要求。

比如说，后端开发领域伴随着互联网的发展，也有过同样的经历。你会发现，在一些公司从后端晋升到更高级别的开发者会更多些。这就是因为后端开发领域很早就从小规模开发转变成了大规模开发，在这个过程中已经整体经过了大量的演进，对于开发人员的要求也在逐步提高，特别是对系统架构的稳定和灵活设计能力的要求，还有对工程质量和规范效率方面的高要求。

我有幸深度经历了移动技术和后端技术的演进过程，并在工作中进行了深度的调研和研究，最终将成果落地到各个项目中。在 iOS 技术发展的过程中，我的知识也得到了递进式提升，也最终被运用到了实际工作中，比如组件化方案落地、大前端建设、应用开发阶段效率的提升、上线后各种难点问题的解决等。

我热爱分享，喜欢将平时学习和工作中的经验分享到我的博客和微博上，也会将一些技术总结通过代码发到 我的 GitHub上。

在这个专栏里，我会针对移动开发这些年演进过程中沉淀下来的那些技术，那些支撑着 iOS 迈向更稳健、更成熟的技术进行详细而系统的输出。同时，我也会提出自己的一些思考，包括对于各种技术后面发展的方向和可能性的想法。

接下来，我跟你说下专栏大致内容构成和写作思路。

移动开发面对的也是计算设备，和后端一样也要监控和解决设备的内存和线程等性能问题，编程的本质就是要解决问题，无论是需求、开发、调试、线上问题都需要编程来解决，而代码是开发者的唯一武器。

所以，在第一部分的基础篇，我会围绕着如何解决 iOS 开发各个阶段的问题展开。这是编程的基础，没有这个基础其他都免谈。同时，解决问题的扎实程度，也决定了你在面试中的竞争力。

iOS 开发者更多的是面向用户界面和交互的开发，而在界面、交互以及数据通信处理过程中存在大量的重复工作，因此我会在第二部分的应用开发篇里，给你推荐一些经典好用的第三方库。

用好这些库，能够帮助你大幅提高应用开发的效率。同时，我也会带你去探究这些优秀库的背后原理和实现思路，当你面对千奇百怪的需求时，也能够开发出适合特定需求的库。说不定下一个经典的第三方库就是由你开发的呢？

如果你希望自己能在技术能力和职级上得到晋升，在碰到问题时不再被动地见招拆招，而是按照自己的套路主动出击化险为夷，那么对于底层原理的深入学习就非常必要了。在这个过程中，你还能学到前辈解决问题的思路，这将让你收获颇丰。

所以，在第三部分的原理篇，我会专门针对一些底层原理，比如 XNU、内存管理、编译等进行分享，期待能够激发起你的学习兴趣，让你内力大增。

所谓良药苦口，底层知识的最初学习过程一定是辛苦的，只有目标和意志非常坚定的那群人才能坚持下来。但是，当你利用这些知识造出更好的轮子时，那种成就感是你在舒适区获得的愉悦感无法比拟的。

iOS 开发技术的演进和前端是齐头并进的，前端从开始的 H5 Hybrid 容器“陪跑姿态”转变为以 React Native 这样的技术为支撑的“助跑角色”，还有 Flutter 这种新原生技术期待能够主导 iOS 的开发，最后小程序这种产品形态主打生态牌，而技术上返璞归真采用 Hybrid 技术又将前端技术重新拉回舞台。

面对现在这种原生与前端共舞的情景，我会在第四部分帮你拆解各种技术细节，以及它们之间的内在联系，以便帮助你站在更高的视角去判断未来的技术走向和趋势。晋升到更高位置的你，对未来技术走向的判断将尤为重要。

最后，我希望你能认真动手完成每篇文章后面的课后作业。

对于咱们手艺人来说，不动手都是空谈，就像绘画教程，光看不练，是不会有进步的。这就如同九阴真经的口诀，铭记于心后还需要常年累月的修炼才能精进。动手就会碰到问题，就会思考，这个主动过程会加深你的记忆，这样后面再碰到问题时，你会更容易将相关知识串联起来，形成创新式的思考。

好了，今天的内容就到这里，如果可以的话，还请你在留言区中做个自我介绍，和我聊聊你目前的工作、学习情况，以及你在学习 iOS 开发时的痛点，这样我们可以彼此了解，也方便我在后面针对性地给你讲解。

加油，让我们一起静下心，沉到具体的技术里，潜心研究。

01 | 建立你自己的iOS开发知识体系

你好，我是戴铭。

这是我们专栏的第一篇文章。所谓知己知彼，今天我们先来聊聊 iOS 开发需要掌握哪些知识，以及如何把这些知识融会贯通，进而形成一套成熟的知识体系。

我们现在所处的互联网时代，学习资料真的是非常完备。比如，GitHub 上各领域的 Awesome 系列，就是专门用来搜集各类资料的，其中 iOS 的 Awesome 里面，就涉及了 iOS 开发的各个方面。

但知识掌握的牢固、精细程度，是根据学习资料收集的多少来衡量的吗？当然不是了。

相比于以前的资料匮乏，现在的情况往往是大多数人手里资料一大堆，但真正消化吸收的却是少之又少，用到相关知识时总有种“书到用时方恨少”的无奈。毕竟，人的精力是有限的，根本无法完全掌握这些被轻松收集来的资料。

再看看我们身边那些“厉害”角色，他们并不是样样精通，而是有擅长的领域。从我接触的这些“大神”们的成长经历来看，都是先深挖某一领域，经过大量的学习和实践后理解了编程的本质，从而可以灵活调配和运用自己已经积累的知识。在这之后，他们再探索其他领域时，就做到了既快又深，成了我们眼中的“大神”。

所以，学习 iOS 开发这件事儿，不要一开始就求多，而要求精、求深。因为，条条大路通罗马，计算机的细分领域虽然多，但学到底层都是一样的。

就比如说，很多 iOS 开发者，刚学会通过网络请求个数据、摆摆界面弄出 App 后，看到人工智能火了就去学一下，区块链火了又去学一下，前端火了又开始蠢蠢欲动。但结果呢？每一门技术都学不深不说，学起来还都非常费劲。

因此，我的建议是不要被新技术牵着鼻子走，而是努力提升自己的内功，这样才能得心应手地应对层出不穷的各种新技术。

接下来再回到专栏上，我希望这个专栏能够结合自己的成长经历，与你分享核心且重要的 iOS 开发知识。这些知识，不仅有助于你平时的开发工作，能够提高你开发 App 的质量和效率，还能够引导你将各类知识贯穿起来，进而形成一套自己的核心且有深度的知识体系。

形成了这套知识体系后，当你再学习新知识时，就会更加快速和容易，达到所谓的融会贯通。

举个例子，iOS 动态化和静态分析技术里有大量与编译相关的知识。编译作为计算机学科的基础知识，除了 iOS 开发，在跨端技术 Weex、React Native，甚至前端领域（比如 Babel）都被广泛使用。

但是，这些知识的学习也要有所取舍，毕竟精力有限，而且我们也确实需要一些“立竿见影”的效果来激励自己。那么，我们应该先学习哪些知识，才能快速提高日后学习和工作的效率呢？接下来，我就和你分享一下我脑海中的 iOS 知识体系，帮你梳理出要重点掌握的核心知识脉络。

iOS 的知识体系，包括了基础、原理、应用开发、原生与前端四大模块。我认为好的知识体系首先需要能起到指导 iOS 应用的开发和发现并解决开发问题的作用。所以，这四大模块的设置初衷是：

基础模块的作用，就是让你具有基本的发现并解决开发问题的能力；

应用开发模块，就是用来指导应用开发的；

好的知识体系还要能够应对未来变革，也就是需要打好底子掌握原理、理清规律，看清方向。所以，原理模块的作用就是帮你掌握原理和理清规律，而原生与前端模块会助你看清方向。

接下来，我就为你一一细说这四个模块。

基础模块

我把 iOS 开发者需要掌握的整个基础知识，按照 App 的开发流程（开发、调试测试、发布、上线）进行了划分，如下图所示。

我们在开发阶段需要掌握的基础知识，主要包括：启动流程、页面布局和架构设计。

启动的快慢，可谓 App 的门面，同时关乎日常的使用体验，其重要性不言而喻。而只有了解了 App 的启动流程，才能合理安排启动阶段对功能初始化的节奏，将启动速度优化到极致。在专栏的基础篇，我会和你一起剖析 App 启动时都做了哪些事儿，让你可以掌控 App 的启动。

界面是开发 App 的必经之路，是一定绕不开的，如何提高界面开发的质量和效率，一直是各大团队研究的重要课题。那么，我在专栏中和你介绍、分析的目前界面开发的趋势，就可以帮你夯实界面这块内容的基础。

架构怎么设计才是合理的，也是这个阶段需要探索的一个重要课题。毕竟每个团队的情况不一样，什么样的架构能够适应团队的长期发展，就是我在这个专栏里要和你好好说道说道的。

在调试测试阶段，我们需要掌握的主要就是提速调试和静态分析的内容。

iOS 开发的代码都是需要编译的。那么，程序体量大了后，编译调试过程必然会变长。而有啥法子能够将这个过程的速度提高到极限，就是我要在这个专栏里面和你分享的了。

对 App 质量的检查，分为人工检查和自动检查。而质量检查的自动化肯定是趋势，所以自动化静态分析代码也是这个专栏会讲的。

在发布阶段，我们需要做些和业务需求开发无关、涉及基础建设的事情。这部分工作中，最主要的就是无侵入埋点和包大小优化。

iOS 安装包过大会导致 4G 环境下无法下载的情况，所以对包大小的控制已经成为各大公司最头疼的事情之一。希望我在包大小瘦身上的经验能够帮到你。

发布前需要加上各种埋点，这样才能让你充分地掌握 App 运行状态是否健康，同时埋点也是分析上线后遇到的各种问题的重要手段。但是，如果埋点的代码写的到处都是，修改和维护时就会举步维艰。所以我在这个专栏里，也会和你分享一些好的将埋点和业务代码解耦的无侵入埋点方案。

在上线阶段，开发质量的高低会影响上线后问题的数量和严重程度，比如有崩溃和卡顿这样导致 App 不可用的问题，也有性能和电量这样影响用户体验的问题。对于这些问题你一定不会袖手旁观，那怎么才能监控到这些问题呢？怎样才能够更准确、更全面地发现问题，同时能够收集到更多有助于分析问题的有效信息呢？

在这个专栏中，我会从崩溃、卡顿、内存、日志、性能、线程、电量等方面和你一一细说。

应用开发

应用开发部分，我们需要关注的就是一些经典库，因为这些经典库往往出自技术大拿之手，代码结构和设计思想都非常优秀，同时经过了大规模的实践，不断打磨完善，具有很高的质量保障。比如：动画库 Pop，响应式框架 RAC、RxSwift，JSON 处理库 JSONModel、Mantle 等。

在专栏里，我会和你好好介绍下布局框架新贵 Cartography 和富文本霸王 YYText 、DTCoreText ，分享我的使用经验，让你也能够快速上手并应用到你的 App 中。

应用开发中和视觉表现相关的 GUI 框架、动画、布局框架、富文本等部分知识掌握好了，直接能够让用户感知到你 App 的优秀表现。响应式框架、TDD/BDD、编码规范等知识能够让你的开发更规范化、更有效率。我会从实践应用和实现原理的方面，带你全方位地去了解如何更好地进行应用开发。

有道是选择大于努力，可能你使用一个不恰当的库所做的大量努力，也不及别人用对了一个好的库轻轻松松、高质量完成的任务。

原理模块

说到 iOS 开发原理，主要就是系统内核 XNU、AOP、内存管理和编译的知识。这些知识具有很强的通用性，其他任何语言、系统和领域都会涉及到。

在接下来的专栏里，我会为你剖析这些知识，希望能够帮助你更好地理解它们的原理。掌握这些通用知识，可以提升你的内功，助你打通任督二脉；深挖知识，可以帮你理清楚开发知识的规律，达到融会贯通的效果；掌握通用知识，也能够让你对未来技术见招拆招。所以，你花在这部分知识上时间，绝对是超值的。

原生与前端

随着 Flutter 和 React Native 越来越完善，关注的人也越来越多。原生还是前端，才是移动应用的未来，谁都没法说得清楚。有句话怎么说来着，无法选择时就都选择。

在原生与前端这个部分，我会着重和你分析隐藏在这些时髦技术背后的解释器和渲染技术，也正是这些技术的演进造就了目前跨端方案的繁荣。

值得一说的是，从 H5 到 Flutter，渲染底层图形库都使用的是 Skia。也就是说，这么多年来渲染底层技术就基本没有变过。而且，向 Flutter 的演进也只是去掉了 H5 对低版本标准的支持。但，仅仅是去掉这些兼容代码，就使性能提升了数倍。

所以说，对于新的技术如何去看，很重要，先不要急着深入到开发细节中了，那样你会迷失在技术海洋中。你需要先建立好自己的知识体系，打好基础，努力提升自己的内功，然后找好指明灯，这样才能追着目标航行。

最后，我来把整个专栏中涉及到的基础、原理、应用开发和原生与前端的知识，梳理到一起，就形成了如下图所示的 iOS 知识体系。

小结

今天我跟你说了 iOS 开发中哪些知识是需要着重学习的，以及怎样将这些知识体系化。在学习知识的道路上，我的建议是求精、求深，基础打牢，以不变应万变。在工作上，则要注重开发效率，避免不必要地重复造轮子，理解原理和细节，同时开阔眼界，紧跟技术前沿。

说到底，不要急着看到啥就去学啥，有目的、有体系地去学习，效果才会更好。即使工作再忙，你也要找时间成体系地提升自己的内功，完善自己，然后反哺到工作上，让工作效率和质量达到质的提升，进而从容应对技术的更新迭代。

按照知识体系高效学习是很棒的，会让你成长得很快。不过，有时找个咖啡小店，随便拿起一本书翻翻，或者随便挑几篇平时收集的文章读读，再拿出小本子记记笔记，也不失为一种很佛系的学习方式，毕竟生活中总是需要点儿惊喜不是吗。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

02 | App 启动速度怎么做优化与监控？

你好，我是戴铭。

我已经在专栏的第一篇文章中，和你梳理了一份 iOS 开发的知识体系框架图。今天，我们就正式从基础出发，开始自己的 iOS 开发知识体系构建之路吧。接下来，我就先和你聊聊与 App 启动速度密切相关的那些事儿。希望你听我说完启动速度的事儿之后，在专栏里的学习状态也能够快速地启动起来。

在文章开始前，我们先设想这么一个场景：假设你在排队结账时，掏出手机打开 App 甲准备扫码支付，结果半天进不去，后面排队的人给你压力够大吧。然后，你又打开 App 乙，秒进，支付完成。试想一下，以后再支付时你会选择哪个 App 呢。

不难想象，在提供的功能和服务相似的情况下，一款 App 的启动速度，不单单是用户体验的事情，往往还决定了它能否获取更多的用户。这就好像陌生人第一次碰面，第一感觉往往决定了他们接下来是否会继续交往。

由此可见，启动速度的优化必然就是 App 开发过程中，不可或缺的一个环节。接下来，我就先和你一起分析下 App 在启动时都做了哪些事儿。

App 启动时都干了些什么事儿？

一般情况下，App 的启动分为冷启动和热启动。

冷启动是指， App 点击启动前，它的进程不在系统里，需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。

热启动是指 ，App 在冷启动后用户将 App 退后台，在 App 的进程还在系统里的情况下，用户重新启动进入 App 的过程，这个过程做的事情非常少。

所以，今天这篇文章，我们就只展开讲 App 冷启动的优化。

用户能感知到的启动慢，其实都发生在主线程上。而主线程慢的原因有很多，比如在主线程上执行了大文件读写操作、在渲染周期中执行了大量计算等。但是，有时你会发现即使你把首屏显示之前的这些主线程的耗时问题都解决了，还是比竞品启动得慢。

那么，究竟如何才能把启动时的所有耗时都找出来呢？解决这个问题，你首先需要弄清楚 App 在启动时都干了哪些事儿。

一般而言，App 的启动时间，指的是从用户点击 App 开始，到用户看到第一个界面之间的时间。总结来说，App 的启动主要包括三个阶段：

main() 函数执行前；

main() 函数执行后；

首屏渲染完成后。

整个启动过程的示意图，如下所示：

图 1 App 的整个启动过程

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() 函数开始执行后到首屏渲染完成前只处理首屏相关的业务，其他非首屏业务的初始化、监听注册、配置文件读取等都放到首屏渲染完成后去做。如下图所示：

图 2 功能级别的启动优化示意图

方法级别的启动优化

经过功能级别的启动优化，也就是将非首屏业务所需的功能滞后以后，从用户点击 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 的运行时代码。你可以在苹果公司的开源网站，找到 objc_msgSend 的源码。

图 3 objc_msgSend 全架构实现源代码文件列表

上图列出的是所有架构的实现，包括 x86_64 等。objc_msgSend 是 iOS 方式执行最核心的部分，编程领域的宝藏，值得你深入探究和细细品味。

objc_msgSend 方法执行的逻辑是：先获取对象对应类的信息，再获取方法的缓存，根据方法的 selector 查找函数指针，经过异常错误处理后，最后跳到对应函数的实现。

按照这个逻辑去看源码会更加清晰，更容易注意到实现细节。阅读 objc_msgSend 源码是编写方法级耗时工具的一个必要的环节，后面还需要编写一些对应的汇编代码。

接下来，我们再看看怎么 hook objc_msgSend 方法？

Facebook 开源了一个库，可以在 iOS 上运行的 Mach-O 二进制文件中动态地重新绑定符号，这个库叫 fishhook。你可以在 GitHub 上，查看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 表示。主要的实现思路是：

入栈参数，参数寄存器是 x0~ x7。对于 objc_msgSend 方法来说，x0 第一个参数是传入对象，x1 第二个参数是选择器 _cmd。syscall 的 number 会放到 x8 里。

交换寄存器中保存的参数，将用于返回的寄存器 lr 中的数据移到 x1 里。

使用 bl label 语法调用 pushCallRecord 函数。

执行原始的 objc_msgSend，保存返回值。

使用 bl label 语法调用 popCallRecord 函数。

具体的汇编代码，如下所示：

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 方法的耗时了。接下来，我们再看看怎样才能够做到像下图那样记录和展示方法调用的层级关系和顺序呢？

图 4 方法调用层级和顺序

不要着急，我来一步一步地跟你说。

第一步，设计两个结构体：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--]; // 减少深度

}

耗时检查的完整代码，你可以在我的开源项目里查看。在需要检测耗时时间的地方调用 [SMCallTrace start]，结束时调用 stop 和 save 就可以打印出方法的调用层级和耗时了。你还可以设置最大深度和最小耗时检测，来过滤不需要看到的信息。

有了这样一个检查方法耗时的工具，你就可以在每个版本开发结束后执行一次检查，统计总耗时以及启动阶段每个方法的耗时，有针对性地观察启动速度慢的问题。如果你在线上做个灰度开关，还可以监控线上启动慢的一些特殊情况。

小结

启动速度优化和监控的重要性不言而喻，加快 App 的启动速度对用户的体验提升是最大的。

启动速度的优化也有粗有细：粗上来讲，这需要对启动阶段功能进行分类整理，合理地将和首屏无关的功能滞后，放到首屏渲染完成之后，保证大头儿没有问题；细的来讲，这就需要些匠人精神，使用合适的工具，针对每个方法进行逐个分析、优化，每个阶段都做到极致。

课后作业

按照今天文中提到的 Time Profiler 工具检查方法耗时的原理，你来动手实现一个方法耗时检查工具吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

最近，我收到一些同学的反馈，说这门课的一些内容比较深，一时难以琢磨透。如果你也有这样的感受，推荐你学习极客时间刚刚上新的另一门视频课程：由腾讯高级工程师朱德权，主讲的《从 0 开发一款 iOS App》。

朱德权老师将会基于最新技术，从实践出发，手把手带你构建类今日头条的 App。要知道，那些很牛的 iOS 开发者，往往都具备独立开发一款 App 的能力。

这门课正在上新优惠，欢迎点击这里试看。

03 | Auto Layout 是怎么进行自动布局的，性能如何？

你好，我是戴铭。今天我来跟你聊下 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”Session 中介绍说： iOS 12 将大幅提高 Auto Layout 性能，使滑动达到满帧，这又是如何做到的呢？你是应该选择继续手动布局还是选择 Auto Layout 呢？

就着这三个问题，我们就来详细聊聊 Auto Layout 吧。

Auto Layout 的来历

图 1 Auto Layout 来历

上图记录了两个时间点：

一个是 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”论文中提出的，为了能方便开发者更好地理解 这个算法，并将其运用到更多的开发语言中，作者还将代码发布到了他们搭建的 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，完成后进入监听约束变化的状态。当再次监听到约束变化，即进入下一轮循环中。整个过程如下图所示：

图 2 Layout Engine 界面布局过程

图中， 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 的性能是否有问题，我们先看看苹果公司自己是怎么说的吧。

图 3 Auto Layout 在 iOS 12 中优化后的表现

上图是 WWDC 2018 中 202 Session 里讲到的 Auto Layout 在 iOS 12 中优化后的表现。可以看到，优化后的性能，已经基本和手写布局一样可以达到性能随着视图嵌套的数量呈线性增长了。而在此之前的 Auto Layout，视图嵌套的数量对性能的影响是呈指数级增长的。

所以，你说 Auto Layout 对性能影响能大不大呢。但是，这个锅应该由 Cassowary 算法来背吗？

在 1997 年时，Cassowary 是以高效的界面线性方程求解算法被提出来的。它解决的是界面的线性规划问题，而线性规划问题的解法是 Simplex 算法。单从 Simplex 算法的复杂度来看，多数情况下是没有指数时间复杂度的。而 Cassowary 算法又是在 Simplex 算法基础上对界面关系方程进行了高效的添加、修改更新操作，不会带来时间复杂度呈指数级增长的问题。

那么，如果 Cassowary 算法本身没有问题的话，问题就只可能是苹果公司在 iOS 12 之前在某些情况下没有用好这个算法。

接下来，我们再看一下 WWDC 2018 中 202 Session 的 Auto Layout 在兄弟视图独立开布局的情况。

图 4 Auto Layout 在兄弟视图独立开布局的情况

可以看到，兄弟视图之间没有关系时，是不会出现性能呈指数增加问题的。这就表示 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，而不用在每个子视图中都设置自己和兄弟视图的关系，如下图所示：

图 5 UIStackView 在父视图里设置子视图的排列方式

这样做，可以极大地减少你在约束关系设置上所做的重复工作，提升页面布局的体验。

我曾开发过一个 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)

    ]

}

这段代码对应的界面效果如下：

图 6 页面布局效果示例

可以看到，嵌套视图通过简单的 DSL 也能很好地表现出来。详细的使用说明和代码实现，你可以在这里查看：https://github.com/ming1016/STMAssembleView

小结

今天这篇文章，我和你说了 Auto Layout 背后使用的 Cassowary 算法。同时，我也和你说了苹果公司经过一番努力，终于在 iOS 12 上用到了 Cassowary 算法的界面更新策略，使得 Auto Layout 的性能得到了大幅提升。

至此，Auto Layout 性能的提升可以让你放心地使用。

记得上次我和一个苹果公司的技术支持人员聊到，到底应该使用苹果自己的布局还是第三方工具比如 Texture 时，他的观点是：使用苹果公司的技术得到的技术升级是持续的，而第三方不再维护的可能性是很高的。

其实细细想来，这非常有道理。这次 Auto Layout 的升级就是一个很好的例子，你的代码一行不变就能享受到耗时从指数级下降到线性的性能提升。而很多第三方库，会随着 iOS 系统升级失去原有的优势。

课后小作业

请你参考VFL 的手册，编写一个基于 UIStackView 的 DSL。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

04 | 项目大了人员多了，架构怎么设计更合理？

你好，我是戴铭。今天，我要跟你说说怎么设计一个能够支持大型 iOS 工程的架构。

记得以前所在的团队，规模大了以后，客户端团队也被按照不同业务拆分到了不同的地方。当时，所有的代码都集中在一个仓库，团队里面一百多号人，只要有一个人提交错了，那么所有要更新代码的人都得等到修复后提交。这样一天下来，整个团队的沟通和互相等待都浪费了大量时间。同时，开发完成要进行测试时，由于代码相互耦合、归属不清，也影响到了问题排查的效率，并增加了沟通时间。

后来，我们痛定思痛，花了很大力气去进行架构整治，将业务完全解耦，将通用功能下沉，每个业务都是一个独立的 Git 仓库，每个业务都能够生成一个 Pod 库，最后再集成到一起。这样经过架构整治以后，也就没再出现过先前的窘境，开发效率也得到了极大的提升。由此可见，合理的架构是多么得重要。

其实，这并不是个例。当业务需求量和团队规模达到一定程度后，任何一款 App 都需要考虑架构设计的合理性。

而谈到架构治理，就需要将老业务、老代码按照新的架构设计模式进行重构。所以，架构重构考虑得越晚，重构起来就越困难，快速迭代的需求开发和漫长的重构之间的矛盾，如同在飞行的飞机上换引擎。及早考虑架构设计就显得尤为重要。

那么，如何设计一个能支持大规模 App 的架构呢？接下来，我就和你说说这个话题。

苹果官方推荐的 App 开发模式是 MVC，随之衍生出其他很多类 MVC 的设计模式 MVP、MVVM、MVCS ，它们在不同程度上增强了视图、数据的通信方式，使得逻辑、视图、数据之间的通信更灵活、规整、易于扩展。在 App 浪潮初期，几乎所有 App 采用的都是这种类 MVC 的结构。原因在于，MVC 是很好的面向对象编程范式，非常适合个人开发或者小团队开发。

但是，项目大了，人员多了以后，这种架构就扛不住了。因为，这时候功能的量级不一样了。一个大功能，会由多个功能合并而成，每个功能都成了一个独立的业务，团队成员也会按照业务分成不同的团队。此时，简单的逻辑、视图、数据划分再也无法满足 App 大规模工程化的需求。

所以，接下来我们就不得不考虑模块粒度如何划分、如何分层，以及多团队如何协作这三个问题了。解决了这三个问题以后，我们就可以对模块内部做进一步优化了。模块久经考验后，就能成为通用功能对外部输出，方便更多的团队。

总的来说，架构是需要演进的。如果项目规模大了还不演进，必然就会拖累业务的发展速度。

简单架构向大型项目架构演进中，就需要解决三个问题，即：模块粒度应该如何划分？如何分层？多团队如何协作？而在这其中，模块粒度的划分是架构设计中非常关键的一步。同时，这也是一个细活，我们最好可以在不同阶段采用不同的粒度划分模块。现在，我们就带着这三个问题继续往下看吧。

大项目、多人、多团队架构思考

接下来，我先和你说下模块粒度应该怎么划分的问题。

首先，项目规模变大后，模块划分必须遵循一定的原则。如果模块划分规则不规范、不清晰，就会导致代码耦合严重的问题，并加大架构重构的难度。这些问题主要表现在：

业务需求不断，业务开发不能停。重新划分模块的工作量越大，成本越高，重构技改需求排上日程的难度也就越大。

老业务代码年久失修，没有注释，修改起来需要重新梳理逻辑和关系，耗时长。

其次，我们需要搞清楚模块的粒度采用什么标准进行划分，也就是要遵循的原则是什么。

对于 iOS 这种面向对象编程的开发模式来说，我们应该遵循以下五个原则，即 SOLID 原则。

单一功能原则：对象功能要单一，不要在一个对象里添加很多功能。

开闭原则：扩展是开放的，修改是封闭的。

里氏替换原则：子类对象是可以替代基类对象的。

接口隔离原则：接口的用途要单一，不要在一个接口上根据不同入参实现多个功能。

依赖反转原则：方法应该依赖抽象，不要依赖实例。iOS 开发就是高层业务方法依赖于协议。

同时，遵守这五个原则是开发出容易维护和扩展的架构的基础。

最后，我们需要选择合适的粒度。切记，大型项目的模块粒度过大或者过小都不合适。

其中，组件可以认为是可组装的、独立的业务单元，具有高内聚，低耦合的特性，是一种比较适中的粒度。就像用乐高拼房子一样，每个对象就是一块小积木。一个组件就是由一块一块的小积木组成的有单一功能的组合，比如门、柱子、烟囱。

在我看来，iOS 开发中的组件，不是 UI 的控件，也不是 ViewController 这种大 UI 和功能的集合。因为，UI 控件的粒度太小，而页面的粒度又太大。iOS 组件，应该是包含 UI 控件、相关多个小功能的合集，是一种粒度适中的模块。

并且，采用组件的话，对于代码逻辑和模块间的通信方式的改动都不大，完成老代码切换也就相对容易些。我们可以先按照物理划分，也就是将多个相同功能的类移动到同一个文件夹下，然后做成 CocoaPods 的包进行管理。

但是，仅做到这一步还不够，因为功能模块之间的耦合还是没有被解除。如果没有解除耦合关系的话，不同功能的开发还是没法独立开来，勉强开发完成后的影响范围评估也难以确定。

所以接下来，我们就需要重新梳理组件之间的逻辑关系，进行改造。

但是，组件解耦并不是说要求每个组件间都没有耦合，组件间也需要有上下层依赖的关系。组件间的上下层关系划分清楚了，就会容易维护和管理。而对于组件间如何分层这个问题，我认为层级最多不要超过三个，你可以这么设置：

底层可以是与业务无关的基础组件，比如网络和存储等；

中间层一般是通用的业务组件，比如账号、埋点、支付、购物车等；

最上层是迭代业务组件，更新频率最高。

这样的三层结构，尤其有利于多个团队分别开发维护。比如，一开始有两个业务团队 A 和 B，他们在开发时既有通用的功能、账号、埋点、个人页等，也有专有的业务功能模块，每个功能都是一个组件。

这样，新创建的业务团队 C，就能非常轻松地使用团队 A 和 B 开发出的通用组件。而且，如果两个业务团队有相同功能时，对相应的功能组件进行简单改造后，也能同时适用于两个业务团队。

但是，我认为不用把所有的功能都做成组件，只有那些会被多个业务或者团队使用的功能模块才需要做成组件。因为，改造成组件也是需要时间成本的，很少有公司愿意完全停下业务去进行重构，而一旦决定某业务功能模块要改成组件，就要抓住机会，严格按照 SOLID 原则去改造组件，因为返工和再优化的机会可能不会再有。

多团队之间如何分工？

在代码层面，我们通过组件化解决了大项目、多人、多团队架构的问题，但是架构问题还涉及到团队人员结构上的架构。当公司或者集团的 App 多了后，相应的团队也就多了，为了能够让产品快速迭代和稳定发展，也需要一个合理的团队结构。在我看来，这个合理的团队结构应该是这样的：

首先，需要一个专门的基建团队，负责业务无关的基础功能组件和业务相关通用业务组件的开发。

然后，每个业务都由一个专门的团队来负责开发。业务可以按照功能耦合度来划分，耦合度高的业务可以划分成单独的业务团队。

基建团队人员应该是流动的，从业务团队里来，再回到业务团队中去。这么设计是因为业务团队和基建团队的边界不应该非常明显，否则就会出现基建团队埋头苦干，结果可能是做得过多、做得不够，或着功能不好用的问题，造成严重的资源浪费。

总结来讲，我想说的是团队分工要灵活，不要把人员隔离固化了，否则各干各的，做的东西相互都不用。核心上，团队分工还是要围绕着具体业务进行功能模块提炼，去解决重复建设的问题，在这个基础上把提炼出的模块做精做扎实。否则，拉一帮子人臆想出来的东西，无人问津，那就是把自己架空了。

我心目中好的架构是什么样的？

现在，我们已经可以从代码内外来分析 App 开发的架构设计了，但也只是会分析了而已，脑海中并没有明确好的架构是什么样的，也不知道具体应该怎么设计。接下来，我们就带着这两个问题继续看下面的内容。

组件化是解决项目大、人员多的一种很好的手段，这在任何公司或团队都是没有歧义的。组件间关系协调却没有固定的标准，协调的优劣，成为了衡量架构优劣的一个基本标准。所以在实践中，一般分为了协议式和中间者两种架构设计方案。

协议式架构设计主要采用的是协议式编程的思路：在编译层面使用协议定义规范，实现可在不同地方，从而达到分布管理和维护组件的目的。这种方式也遵循了依赖反转原则，是一种很好的面向对象编程的实践。

但是，这个方案的缺点也很明显，主要体现在以下两个方面：

由于协议式编程缺少统一调度层，导致难于集中管理，特别是项目规模变大、团队变多的情况下，架构管控就会显得越来越重要。

协议式编程接口定义模式过于规范，从而使得架构的灵活性不够高。当需要引入一个新的设计模式来开发时，我们就会发现很难融入到当前架构中，缺乏架构的统一性。

虽然协议式架构有这两方面的局限性，但由于其简单易用的特点依然被很多公司采用。

另一种常用的架构形式是中间者架构。它采用中间者统一管理的方式，来控制 App 的整个生命周期中组件间的调用关系。同时，iOS 对于组件接口的设计也需要保持一致性，方便中间者统一调用。

中间者架构如下图所示：

图 1 中间者架构示意图

可以看到，拆分的组件都会依赖于中间者，但是组间之间就不存在相互依赖的关系了。由于其他组件都会依赖于这个中间者，相互间的通信都会通过中间者统一调度，所以组件间的通信也就更容易管理了。在中间者上也能够轻松添加新的设计模式，从而使得架构更容易扩展。

在我看来，好的架构一定是健壮的、灵活的。中间者架构的易管控带来的架构更稳固，易扩展带来的灵活性，所以我认为中间者这种架构设计模式是非常值得推荐的。casatwy 以前设计了一个 CTMediator 就是按照中间者架构思路设计的。你可以在GitHub上看到它的内容。

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];

可以看出，指定了对象名和调用方法名，把参数封装成字典传进去就能够直接调用该方法了。

但是，这种运行时直接硬编码的调用方式也有些缺点，主要表现在两个方面：

直接硬编码的调用方式，参数是以 string 的方法保存在内存里，虽然和将参数保存在 Text 字段里占用的内存差不多，同时还可以避免.h 文件的耦合，但是其对代码编写效率的降低也比较明显。

由于是在运行时才确定的调用方法，调用方式由 [obj method] 变成 [obj performSelector:@""]。这样的话，在调用时就缺少类型检查，是个很大的缺憾。因为，如果方法和参数比较多的时候，代码编写效率就会比较低。

这篇文章发出后 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 链接 。

这个范例的主要组件类名和方法名，如下图所示：

图 2 主要的组件类名和方法名

可以看出，这个范例在中间者架构的基础上增加了对中间件、状态机、观察者、工厂模式的支持。同时，这个案例也在使用上做了些优化，支持了链式调用，代码如下：

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]];

}

可以看出，采用了中间者这种架构设计思想后，架构就具有了很高的扩展性和可管控性。所以，我推崇这种架构设计思路。

小结

架构的设计绝对不是要等到工程到了燃眉之急之时，再去环顾其他公司或团队在用什么架构，然后拍脑袋拿一个过来，来次大重构。好的架构，需要在业务开发过程中及早发现开发的痛点，进行有针对性的改良，不然就会和实际开发越走越远。

比如，某个业务模块的逻辑非常复杂、状态有很多，这时我们就需要在架构层面考虑如何处理会更方便，改动最小的支持状态机模式，又或者在开始架构设计时就多考虑如何将架构设计的具有更高的易用性和可扩展性。

好的架构是能够在一定的规范内同时支持高灵活度，这种度的把握是需要架构师长期跟随团队开发，随着实际业务需求的演进进行分析和把控的。

在项目大了，人员多了的情况下，好的架构一定是不简单的，不通用的，但一定是接地气的，这样才能更适合自己的团队，才能够用得上。那些大而全，炫技，脱离业务开发需求的架构是没法落地的。

最后，我提点建议。我在面试应聘者的时候，通常都会问他所负责项目的整体架构是怎样的。结果呢，很多人都只对自己负责的那摊子事儿说的溜，而回答所在项目整体情况时却支支吾吾，也因此没能面试成功。

所以，作为一名普通的开发者，除了日常需求开发和技术方案调研、设计外，你还需要了解自己所在项目的整体架构是怎样的，想想架构上哪些地方是不够好需要改进的，业界有哪些好的架构思想是可以落地到自己项目中的。有了从项目整体上去思考的意识，你才能够站在更高的视角上去思考问题。这，也是对架构师的基本要求。

课后作业

架构如何设计众说纷纭，请你来说下你们项目目前架构是怎样的，并画出你心中理想的架构图。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

05 | 链接器：符号是怎么绑定到地址上的？

你好，我是戴铭。

你是不是经常会好奇自己参与的这么些项目，为什么有的编译起来很快，有的却很慢；编译完成后，有的启动得很快，有的却很慢。其实，在理解了编译和启动时链接器所做的事儿之后，你就可以从根儿上找到这些问题的答案了。

所以，在今天这篇文章中，我就重点和你讲解一下链接器相关的知识。简单地说，链接器最主要的作用，就是将符号绑定到地址上。理解了这其中的原理后，你就可以有针对性地去调整和优化项目了。

同时，掌握了链接器的作用，也将有助于你理解后面文章中，关于符号表、加载相关的内容。

现在，我们就从 iOS 开发的起点，也就是编写代码和编译代码开始说起，看看链接器在这其中到底发挥了什么作用。

iOS 开发为什么使用的是编译器？

我们都知道，iOS 编写的代码是先使用编译器把代码编译成机器码，然后直接在 CPU 上执行机器码的。之所以不使用解释器来运行代码，是因为苹果公司希望 iPhone 的执行效率更高、运行速度能达到最快。

那为什么说用解释器运行代码的速度不够快呢？这是因为解释器会在运行时解释执行代码，获取一段代码后就会将其翻译成目标代码（就是字节码（Bytecode）），然后一句一句地执行目标代码。

也就是说，解释器，是在运行时才去解析代码，这样就比在运行之前通过编译器生成一份完整的机器码再去执行的效率要低。

这时你一定会纳闷了，既然编译器效率这么高，那为什么还有人用解释器呢？所谓事有利弊，解释器可以在运行时去执行代码，说明它具有动态性，程序运行后能够随时通过增加和更新代码来改变程序的逻辑。

也就是说，你写的程序跑起来后不用重新启动，就可以看到代码修改后的效果，这样就缩短了调试周期。程序发布后，你还可以随时修复问题或者增加新功能，用户也不用一定要等到发布新版本后才可以升级使用。所以说，使用解释器可以帮我们缩短整个程序的开发周期和功能更新周期。

那么，使用编译器和解释器执行代码的特点，我们就可以概括如下：

采用编译器生成机器码执行的好处是效率高，缺点是调试周期长。

解释器执行的好处是编写调试方便，缺点是执行效率低。

编译器和解释器的比较图示如下：

图 1 编译器和执行器的对比

明确了 iOS 开发使用编译器的原因以后，你还需要了解 iOS 开发使用的到底是什么编译器。

现在苹果公司使用的编译器是 LLVM，相比于 Xcode 5 版本前使用的 GCC，编译速度提高了 3 倍。同时，苹果公司也反过来主导了 LLVM 的发展，让 LLVM 可以针对苹果公司的硬件进行更多的优化。

总结来说，LLVM 是编译器工具链技术的一个集合。而其中的 lld 项目，就是内置链接器。编译器会对每个文件进行编译，生成 Mach-O（可执行文件）；链接器会将项目中的多个 Mach-O 文件合并成一个。

LLVM 的编译过程非常复杂。如果你有兴趣的话，可以通过官方手册查看完整的编译过程。

这里，我先简单为你总结下编译的几个主要过程：

首先，你写好代码后，LLVM 会预处理你的代码，比如把宏嵌入到对应的位置。

预处理完后，LLVM 会对代码进行词法分析和语法分析，生成 AST 。AST 是抽象语法树，结构上比代码更精简，遍历起来更快，所以使用 AST 能够更快速地进行静态检查，同时还能更快地生成 IR（中间表示）。

最后 AST 会生成 IR，IR 是一种更接近机器码的语言，区别在于和平台无关，通过 IR 可以生成多份适合不同平台的机器码。对于 iOS 系统，IR 生成的可执行文件就是 Mach-O。

下图展示了编译的主要过程。

图 2 编译的主要过程

编译时链接器做了什么？

Mach-O 文件里面的内容，主要就是代码和数据：代码是函数的定义；数据是全局变量的定义，包括全局变量的初始值。不管是代码还是数据，它们的实例都需要由符号将其关联起来。

为什么呢？因为 Mach-O 文件里的那些代码，比如 if、for、while 生成的机器指令序列，要操作的数据会存储在某个地方，变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码，引用的函数符号也需要绑定到该函数的地址上。

而链接器的作用，就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号，就可以理解为变量名和函数名。

那为什么要让链接器做符号和地址绑定这样一件事儿呢？不绑定的话，又会有什么问题？

如果地址和符号不做绑定的话，要让机器知道你在操作什么内存地址，你就需要在写代码时给每个指令设好内存地址。写这样的代码的过程，就像你直接在和不同平台的机器沟通，连编译生成 AST 和 IR 的步骤都省掉了，甚至优化平台相关的代码都需要你自己编写。

这件事儿看起来挺酷，但可读性和可维护性都会很差，比如修改代码后对地址的维护就会让你崩溃。而这种“崩溃”的罪魁祸首就是代码和内存地址绑定得太早。

另外，绑定得太早除了可读性和可维护性差之外，还会有更多的重复工作。因为，你需要针对不同的平台写多份代码，而这些代码本可以通过高级语言一次编译成多份。既然这样，那我们应该怎么办呢？

我们首先想到的就是，用汇编语言来让这种绑定滞后。随着编程语言的进化，我们很快就发现，采用任何一种高级编程语言，都可以解决代码和内存绑定过早产生的问题，同时还能扫掉使用汇编写程序的烦恼。

现在，我们已经通过反证法，理解了在一个文件里把符号和地址绑定在一起的必要性。接下来，我们再看看链接器为什么还要把项目中的多个 Mach-O 文件合并成一个。

其实，这个问题也好回答。

你肯定不希望一个项目是在一个文件里从头写到尾的吧。项目中文件之间的变量和接口函数都是相互依赖的，所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。

没有这个绑定过程的话，单个文件生成的 Mach-O 文件是无法正常运行起来的。因为，如果运行时碰到调用在其他文件中实现的函数的情况时，就会找不到这个调用函数的地址，从而无法继续执行。

链接器在链接多个目标文件的过程中，会创建一个符号表，用于记录所有已定义的和所有未定义的符号。链接时如果出现相同符号的情况，就会出现“ld: dumplicate symbols”的错误信息；如果在其他目标文件里没有找到符号，就会提示“Undefined symbols”的错误信息。

说完了链接器解决的问题，我们再一起来看看链接器对代码主要做了哪几件事儿。

去项目文件里查找目标代码文件里没有定义的变量。

扫描项目中的不同文件，将所有符号定义和引用地址收集起来，并放到全局符号表中。

计算合并后长度及位置，生成同类型的段进行合并，建立绑定。

对项目中不同文件里的变量进行地址重定位。

你在项目里为某项需求写了一些功能函数，但随着业务的发展，一些功能被下掉了或者被其他负责的同事在另一个文件里用其他函数更新了功能。那么这时，你以前写的那些函数就没有用武之地了。日长月久，无用的函数越来越多，生成的 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

_OBJC_CLASS_$_Boy ，表示 Boy 的 OC 符号。

(undefined) external ，表示未实现非私有。如果是私有的话，就是 non-external。

external _main ，表示 main() 函数，处理 0 地址，记录在 __TEXT,__text 区域里。

接下来，我们再看看 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 符号表示的是该文件类未定义，所以在目标文件和 Fundation 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

…

数一下，被加载的库还挺多的。

因为 Fundation 还会依赖一些其他动态库，这些依赖的其他库还会再依赖更多的库，所以相互依赖的符号会很多，需要处理的时间也会比较长。

这里系统上的动态链接器会使用共享缓存，共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时，动态链接器会先检查是否有共享缓存。每个进程都会在自己的地址空间映射这些共享缓存，这样做可以起到优化 App 启动速度的作用。

而关于动态链接器的作用顺序是怎样的，你可以先看看 Mike Ash 写的这篇关于 dyld 的博客： Dynamic Linking On OS X。这篇博客里面，很详细地讲解了 dyld 所做的事情。

简单来说， dyld 做了这么几件事儿：

先执行 Mach-O 文件，根据 Mach-O 文件里 undefined 的符号加载对应的动态库，系统会设置一个共享缓存来解决加载的递归依赖问题；

加载后，将 undefined 的符号绑定到动态库里对应的地址上；

最后再处理 +load 方法，main 函数返回后运行 static terminator。

调用 +load 方法是通过 runtime 库处理的。你可以通过一个可编译的开源 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 的 Fundation 库的话，可以通过 GNUStep 源码来学习。比如 ，NSNotificationCenter 发送通知是按什么顺序发送的，你可以查看 NSNotificationCenter.m 里的 addObserver 方法和 postNotification 方法，看看观察者是怎么添加的，以及是怎么被遍历通知到的。

最后说一句，dyld 是开源的，地址是：https://github.com/opensource-apple/dyld

小结

今天这篇文章，我与你介绍了链接器是什么，为什么需要链接器，以及链接器在编译时和程序启动时会做的事情。总体来看，从编译、链接、执行、动态库加载到 main 函数开始执行的过程如下图所示。

编译阶段由于有了链接器，你的代码可以写在不同的文件里，每个文件都能够独立编成 Mach-O 文件进行标记。编译器可以根据你修改的文件范围来减少编译，通过这种方式提高每次编译的速度。

了解了这种链接机制，你也能够明白，文件越多，链接器链接 Mach-O 文件所需绑定的遍历操作就会越多，编译速度也会越慢。

了解程序运行阶段的动态库链接原理，会让你更多地了解程序在启动时做的事情，同时还能够对你有一些启发。

比如，在开发调试阶段，是不是代码改完以后可以先不去链接项目里的所有文件，只编译当前修改的文件动态库，通过运行时加载动态库及时更新，看到修改的结果。这样调试的速度，不就能够得到质的提升了么。而具体怎么做，我会在第 6 篇文章“App 如何通过注入动态库的方式实现极速编译调试？”中和你详细说明。

再比如，你可以逆向找出其他 App 里你感兴趣功能的使用方法，然后在自己的程序里直接调用它，最后将那个 App 作为动态库加载到自己的 App 里。这样，你感兴趣的这个功能，就能够在你自己的程序里起作用了。

其实，使用链接器不仅能提高开发效率，还可以让你发挥想象力再去做些其他有趣的事情。

课后小作业

请你写一段代码，在 App 运行时通过 dlopen 和 dlsym 链接加载 bundle 里的动态库。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

最近，我收到一些同学的反馈，说这门课的一些内容比较深，一时难以琢磨透。如果你也有这样的感受，推荐你学习极客时间刚刚上新的另一门视频课程：由腾讯高级工程师朱德权，主讲的《从 0 开发一款 iOS App》。

朱德权老师将会基于最新技术，从实践出发，手把手带你构建类今日头条的 App。要知道，那些很牛的 iOS 开发者，往往都具备独立开发一款 App 的能力。

这门课正在上新优惠，欢迎点击这里试看。

06 | App 如何通过注入动态库的方式实现极速编译调试？

你好，我是戴铭。

在上一篇文章中，我和你分享了链接器的基础知识。今天我们再继续聊聊，动态库链接器的实际应用，也就是编译调试的提速问题。

iOS 原生代码的编译调试，都是通过一遍又一遍地编译重启 App 来进行的。所以，项目代码量越大，编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里，来避免每次都全量编译来加快编译速度，但即使这样，每次编译都还是需要重启 App，需要再走一遍调试流程。

对于开发者来说，提高编译调试的速度就是提高生产效率。试想一下，如果上线前一天突然发现了一个严重的 bug，每次编译调试都要耗费几十分钟，结果这一天的黄金时间，一晃就过去了。到最后，可能就是上线时间被延误。这个责任可不轻啊。

那么问题来了，原生代码怎样才能够实现动态极速调试，以此来大幅提高编译调试速度呢？在回答这个问题之前，我们先看看有哪些工具是这么玩儿的。了解了它们的玩法，我们也就自然清楚这个问题的答案了。

Swift Playground

说到 iOS 代码动态极速调试的工具，你首先能想到的估计就是 Playground。它是 Xcode 里集成的一个能够快速、实时调试程序的工具，可以实现所见即所得的效果，如下图所示：

图 1 Playground 工具实时调试示例

可以看到，任何的代码修改都能够实时地在右侧反馈出来。

Flutter Hot Reload

Flutter 是 Google 开发的一个跨平台开发框架，调试也是快速实时的。官方的效果动画如下：

图 2 Flutter 使用示例

可以看到，在 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 也提供了动画演示效果，如下：

图 3 Injection 使用示例

作者已经开源了这个工具，地址是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) 方法的具体实现代码，你可以点击这个链接查看。

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 的工作原理用一张图表示了出来，如下所示：

图 4 Injection 的工作原理示意图

小结

今天这篇文章，我和你详细分享了动态库链接器的一个非常实用的应用场景：如何使用动态库加载方式进行极速调试。由此我们可以看出，类似链接器这样的底层知识是非常重要的。

当然了，这只是一个场景，还有更多的场景等待着我们去发掘。比如把 Injection 技术扩展开想，每当你修改了另一个人负责的代码就给那个人发条消息，同时将修改的代码编译、打包成动态库直接让对方看到修改的情况，这样不仅是提高了自己的效率，还提高了整个团队的沟通效率。怎么样？是不是有种想立刻尝试的感觉，心动不如行动，动手写起来吧。

所以，打好了底层知识的基础以后，我们才可以利用它们去提高开发效率，为用户提供更稳定、性能更好的 App 。

今天这篇文章最后，我留给你的一个小作业是，思考一下底层知识还有哪些运用场景，并在评论区分享出来吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

07 | Clang、Infer 和 OCLint ，我们应该使用谁来做静态分析？

你好，我是戴铭。

随着业务开发迭代速度越来越快，完全依赖人工保证工程质量也变得越来越不牢靠。所以，静态分析，这种可以帮助我们在编写代码的阶段就能及时发现代码错误，从而在根儿上保证工程质量的技术，就成为了 iOS 开发者最常用到的一种代码调试技术。

Xcode 自带的静态分析工具 Analyze，通过静态语法分析能够找出在代码层面就能发现的内存泄露问题，还可以通过上下文分析出是否存在变量无用等问题。但是，Analyze 的功能还是有限，还是无法帮助我们在编写代码的阶段发现更多的问题。所以，这才诞生出了功能更全、定制化高、效率高的第三方静态检查工具。比如，OCLint、Infer、Clang 静态分析器等。

一款优秀的静态分析器，能够帮助我们更加全面的发现人工测试中的盲点，提高检查问题的效率，寻找潜在的可用性问题，比如空指针访问、资源和内存泄露等等。

同时，静态分析器还可以检查代码规范和代码可维护性的问题，根据一些指标就能够找出哪些代码需要优化和重构。这里有三个常用的复杂度指标，可以帮助我们度量是否需要优化和重构代码。

圈复杂度高。圈复杂度，指的是遍历一个模块时的复杂度，这个复杂度是由分支语句比如 if、case、while、for，还有运算符比如 &&、||，以及决策点，共同确定的。一般来说，圈复杂度在以 4 以内是低复杂度，5 到 7 是中复杂度，8 到 10 是高复杂度，11 以上时复杂度就非常高了，这时需要考虑重构，不然就会因为测试用例的数量过高而难以维护。
而这个圈复杂度的值，是很难通过人工分析出来的。而静态分析器就可以根据圈复杂度规则，来监控圈复杂度，及时发现代码是否过于复杂，发现问题后及早解决，以免造成代码过于复杂难以维护。

NPath 复杂度高。NPath 度量是指一个方法所有可能执行的路径数量。一般高于 200 就需要考虑降低复杂度了。

NCSS 度量高。NCSS 度量是指不包含注释的源码行数，方法和类过大会导致代码维护时阅读困难，大的 NCSS 值表示方法或类做的事情太多，应该拆分或重构。一般方法行数不过百，类的行数不过千。

但是，使用静态分析技术来保证工程质量，也并不尽如人意，还有如下两大缺陷：

需要耗费更长的时间。相比于编译过程，使用静态分析技术发现深层次程序错误时，会对当前分析的方法、参数、变量去和整个工程关联代码一起做分析。所以，随着工程代码量的增加，每一步分析所依赖的影响面都会增大，所需耗时就更长。
虽然我们在设计静态分析器时，就已经对其速度做了很多优化，但还是达不到程序编译的速度。因为静态分析本身就包含了编译最耗时的 IO 和语法分析阶段，而且静态分析的内容多于编译，所以再怎么优化，即使是最好的情况也会比编译过程来得要慢。

静态分析器只能检查出那些专门设计好的、可查找的错误。对于特定类型的错误分析，还需要开发者靠自己的能力写一些插件并添加进去。

好了，现在我们已经了解了静态分析器的优缺点，那么面对繁多的 iOS 的静态代码检查工具，我们到底应该选择哪一个呢？

接下来，我选择了 3 款主流的静态分析工具 OCLint、Clang 静态分析器、Infer，和你说说如何选择的问题。

OCLint

OCLint 是基于 Clang Tooling 开发的静态分析工具，主要用来发现编译器检查不到的那些潜在的关键技术问题。2017 年 9 月份新发布的 OCLint 0.13 版本中，包含了 71 条规则。

这些规则已经基本覆盖了具有通用性的规则，主要包括语法上的基础规则、Cocoa 库相关规则、一些约定俗成的规则、各种空语句检查、是否按新语法改写的检查、命名上长变量名短变量名检查、无用的语句变量和参数的检查。

除此之外，还包括了和代码量大小是否合理相关的一些规则，比如过大的类、类里方法是否太多、参数是否过多、Block 嵌套是否太深、方法里代码是否过多、圈复杂度的检查等。

你可以在官方规则索引中，查看完整的规则说明。

这些规则可以在运行时被动态地加载到系统中，规则配置灵活、可扩展性好、方便自定义。

说到 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 静态分析器专门为速度做过优化，可以在保证查出错误的前提下，使用更聪明的算法减少检查的工作量。

你可以点击这里下载Clang 静态分析器，然后解压就可以了，不需要放到特定目录下。而卸载它的话，删除这个解压后的目录即可。

在 Clang 静态分析器中，常用的就是 scan-build 和 scan-view 这两个工具。

scan-build 和 scan-view 所在的目录路径，如下图所示。

图 1 scan-build 和 scan-view 所在的目录路径

scan-build 是用来运行分析器的命令行工具；scan-view 包含了 scan-build 工具，会在 scan-build 执行完后将结果可视化。

scan-build 的原理是，将编译器构建改成另一个“假的”编译器来构建，这个“假的”编译器会执行 Clang 来编译，然后执行静态分析器分析你的代码。

scan-build 的使用方法，也很简单，你只需要到项目目录下，使用如下命令即可：

\yourpath\scan-build -k -V make

关于 scan-build 的更多参数和使用说明，你可以点击这个链接查看。

Clang 静态分析器是由分析引擎 (analyzer core) 和 checkers 组成的。所有的 checker 都是基于底层分析引擎之上的。通过分析引擎提供的功能，我们可以编写新的 checker。

checker 架构能够方便用户扩展代码检查的规则，或者通过自定义来扩展 bug 类型。如果你想编写自己的 checker，可以在 Clang 项目的 lib/StaticAnalyzer/Checkers 目录下找到示例参考，比如 ObjCUnusedIVarsChecker.cpp 就是用来检查是否有定义了，但是从未使用过的变量。

当然，如果为了编写自定义的 checker 一开始就埋头进去看那些示例代码是很难看懂的，你甚至都不能知道编写 checker 时有哪些方法可以为我所用。所以，你需要先了解 Clang 静态分析器提供了哪些功能接口，然后再参考官方的大量实例，去了解怎么使用这些功能接口，在这之后再动手开发才会事半功倍。

接下来，我就跟你聊聊开发 checker 时需要了解的 Clang 静态分析器提供的一些功能接口。

checker 的官方示例代码里有一个非常实用的，也就是内存泄露检查示例 MallocChecker，你可以点击这个链接查看代码。

在这段代码开头，我们可以看到引入了 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 符号管理里维护着变量的生命周期分析。想要了解具体实现的话，你可以点击这个链接参看源码实现。

这个内存泄露检查示例 MallocChecker 里，运用了 Clang 静态分析器提供的语法树层级节点检查、变量值路径追踪以及内存管理分析功能接口，对我们编写自定义的 checker 是一个很全面、典型的示例。

追其根本，编写自己的 checker ，其核心还是要更多地掌握 Clang 静态分析器的内在原理。很早之前，苹果公司就在 LLVM Developers Meeting 上，和我们分享过怎样通过 Clang 静态分析器去找 bug。你可以点击这个链接，查看相应的 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;

}

@en

在终端输入：

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 工作的流程：

第一个阶段是转化阶段，将源代码转成 Infer 内部的中间语言。类 C 语言使用 Clang 进行编译，Java 语言使用 javac 进行编译，编译的同时转成中间语言，输出到 infer-out 目录。

第二个阶段是分析阶段，分析 infer-out 目录下的文件。分析每个方法，如果出现错误的话会继续分析下一个方法，不会被中断，但是会记录下出错的位置，最后将所有出错的地方进行汇总输出。
默认情况下，每次运行 infer 命令都会删除之前的 infer-out 文件夹。你可以通过 --incremental 参数使用增量模式。增量模式下，运行 infer 命令不会删除 infer-out 文件夹，但是会利用这个文件夹进行 diff，减少分析量。
一般进行全新一轮分析时直接使用默认的非增量模式，而对于只想分析修改部分情况时，就使用增量模式。

Infer 检查的结果，在 infer-out 目录下，是 JSON 格式的，名字叫做 report.json 。生成 JSON 格式的结果，通用性会更强，集成到其他系统时会更方便。

Infer 的工作流程图如下：

图 2 Infer 的工作流程图

小结

在今天这篇文章中，我和你一一分析了 Clang 静态分析器、Infer 和 OCLint 这三个 iOS 静态分析工具。对于 iOS 的静态分析，这三个工具都是基于 Clang 库开发的。

其中 Clang 静态分析器和 Xcode 的集成度高，也支持命令行。不过，它们检查的规则少，基本都是只能检查出较大的问题，比如类型转换问题，而对内存泄露问题检查的侧重点则在于可用性。

OCLint 检查规则多、定制性强，能够发现很多潜在问题。但缺点也是检查规则太多，反而容易找不到重点；可定制度过高，导致易用性变差。

Infer 的效率高，支持增量分析，可小范围分析。可定制性不算最强，属于中等。

综合来看，Infer 在准确性、性能效率、规则、扩展性、易用性整体度上的把握是做得最好的，我认为这些是决定静态分析器好不好最重要的几点。所以，我比较推荐的是使用 Infer 来进行代码静态分析。

课后作业

我们今天提到的三款静态分析工具都是基于 Clang 库来开发的。那么请你来说下，Clang 给这三款工具提供了什么能力呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

08 | 如何利用 Clang 为 App 提质？

你好，我是戴铭。

以前在工作中，有段时间连续发生了多次线上事故，在复盘时大家都提出是因为代码不规范、代码规范执行不到位，从而导致代码质量过差，无法监管，我们才被动处理线上事故。会上牢骚发完，会后应该怎么执行呢？

我们都知道，监管手段是需要自己动手建设的，第三方工具无法满足所有的业务技术规范监控。在上篇文章“Clang、Infer 和 OCLint ，我们应该使用谁来做静态分析？”中，我们提到通过 Clang 提供的丰富接口功能就可以开发出静态分析工具，进而管控代码质量。

除此之外，基于 Clang 还可以开发出用于代码增量分析、代码可视化、代码质量报告来保障 App 质量的系统平台，比如CodeChecker。

比如，当周末发现线上问题时，你会发现很多时候分析问题的人都不在电脑边，无法及时处理问题。这时，我们就需要一款在线网页代码导航工具，比如 Mozilla 开发的 DXR，方便在便携设备上去操作、分析问题，这样的工具都是基于 Clang 开发的。

Clang 的功能如此强大，那么它到底是什么呢？Clang 做了哪些事情？Clang 还提供了什么能力可以为 App 提质呢？今天，我们就一起来看看这几个问题吧。

什么是 Clang？

关于 Clang 是什么，你可以先看一下如下所示的 iOS 开发的完整编译流程图。

图 1 iOS 开发完整的编译流程图

图中，左侧黑块部分就是 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 以外，还可以给我们提供一个学习优秀代码、提升编程思维能力的机会。特别是在编写自定义插件或者工具时，如果你对用到的接口了解得不是很清楚，或者好奇接口的实现，这时候去看源码，对于你的帮助是非常大的。

你可以点击这里的链接，在线查看 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 的类型、值，以及位置。你可以在这个链接中，看到 Clang 定义的所有 Token 类型。我们可以把这些 Token 类型，分为下面这 4 类。

关键字：语法中的关键字，比如 if、else、while、for 等；

标识符：变量名；

字面量：值、数字、字符串；

特殊符号：加减乘除等符号。

接下来，词法分析完后就会进行语法分析，将输出的 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 库可供使用。

Clang Plugins

Clang Plugins 可以让你在 AST 上做些操作，这些操作能够集成到编译中，成为编译的一部分。插件是在运行时由编译器加载的动态库，方便集成到构建系统中。

使用 Clang Plugins 一般都是希望能够完全控制 Clang AST，同时能够集成在编译流程中，可以影响编译的过程，进行中断或者提示。关于 Clang Plugins 开发的更多内容，我会在第 37 篇文章“如何编写 Clang 插件？”中和你详细说明。

LibTooling

LibTooling 是一个 C++ 接口，通过 LibTooling 能够编写独立运行的语法检查和代码重构工具。LibTooling 的优势如下：

所写的工具不依赖于构建系统，可以作为一个命令单独使用，比如 clang-check、clang-fixit、clang-format；

可以完全控制 Clang AST；

能够和 Clang Plugins 共用一份代码。

与 Clang Plugins 相比，LibTooling 无法影响编译过程；与 LibClang 相比，LibTooling 的接口没有那么稳定，也无法开箱即用，当 AST 的 API 升级后需要更新接口的调用。

但是，LibTooling 基于能够完全控制 Clang AST 和可独立运行的特点，可以做的事情就非常多了。

改变代码：可以改变 Clang 生成代码的方式。基于现有代码可以做出大量的修改。还可以进行语言的转换，比如把 OC 语言转成 JavaScript 或者 Swift。

做检查：检查命名规范，增加更强的类型检查，还可以按照自己的定义进行代码的检查分析。

做分析：对源码做任意类型分析，甚至重写程序。给 Clang 添加一些自定义的分析，创建自己的重构器，还可以基于工程生成相关图形或文档进行分析。

在 LibTooling 的基础之上有个开发人员工具合集 Clang tools，Clang tools 作为 Clang 项目的一部分，已经提供了一些工具，主要包括：

语法检查工具 clang-check；

自动修复编译错误工具 clang-fixit；

自动代码格式工具 clang-format；

新语言和新功能的迁移工具；

重构工具。

如果你打算基于 LibTooling 来开发工具，Clang tools 将会是很好的范例。

官方有一个教程叫作 Tutorial for building tools using LibTooling and LibASTMatchers，可以一步步地告诉你怎样使用 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”这篇论文而来。在 Clang 里的具体实现代码，你可以查看 MemRegion.h 和 RegionStore.cpp 这两个文件。对于 Clang 静态分析器的原理描述，你可以参看官方说明。

手中握有好兵器，你对 App 代码质量的掌控也就有了底气。程序员开发软件的目的，就是要提高开发效率，同时也不要忽略检查代码质量时的效率。所以，对于开发者来说，我们要避免人工繁琐的 Review 代码，并减少由人工带来的低效和高差错率。我们的原则就是，能够让程序自动解决的，绝对不要人工手动完成。

课后作业

请你搭建 Clang 的开发环境，然后基于 LibTooling 编写一个简单语法转换工具，比如把 C 语言的方法调用转 Lisp 方法调用。

C 的方法调用代码：

multiply(add(1.4, 3))

Lisp 的方法调用代码：

(multiply (add 1.4 3))

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

09 | 无侵入的埋点方案如何实现？

你好，我是戴铭。

在 iOS 开发中，埋点可以解决两大类问题：一是了解用户使用 App 的行为，二是降低分析线上问题的难度。目前，iOS 开发中常见的埋点方式，主要包括代码埋点、可视化埋点和无埋点这三种。

代码埋点主要就是通过手写代码的方式来埋点，能很精确的在需要埋点的代码处加上埋点的代码，可以很方便地记录当前环境的变量值，方便调试，并跟踪埋点内容，但存在开发工作量大，并且埋点代码到处都是，后期难以维护等问题。

可视化埋点，就是将埋点增加和修改的工作可视化了，提升了增加和维护埋点的体验。

无埋点，并不是不需要埋点，而更确切地说是“全埋点”，而且埋点代码不会出现在业务代码中，容易管理和维护。它的缺点在于，埋点成本高，后期的解析也比较复杂，再加上 view_path 的不确定性。所以，这种方案并不能解决所有的埋点需求，但对于大量通用的埋点需求来说，能够节省大量的开发和维护成本。

在这其中，可视化埋点和无埋点，都属于是无侵入的埋点方案，因为它们都不需要在工程代码中写入埋点代码。所以，采用这样的无侵入埋点方案，既可以做到埋点被统一维护，又可以实现和工程代码的解耦。

接下来，我们就通过今天这篇文章，一起来分析一下无侵入埋点方案的实现问题吧。

运行时方法替换方式进行埋点

我们都知道，在 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，每个视图都在树的子节点上。如下图所示：

一个视图下的子节点可能是同一个视图的不同实例，比如上图中 UIView 视图节点下的两个 UIButton 是同一个类的不同实例，所以光靠视图树的路径还是没法唯一确定出视图的标识。那么，这种情况下，我们又应该如何区别不同的视图呢？

这时，我们想到了索引：每个子视图在父视图中都会有自己的索引，所以如果我们再加上这个索引的话，每个视图的标识就是唯一的了。

接下来的一个问题是，视图层级路径加上在父视图中的索引来进行唯一标识，是不是就能够涵盖所有情况了呢？

当然不是。我们还需要考虑类似 UITableViewCell 这种具有可复用机制的视图，Cell 会在页面滚动时不断复用，所以加索引的方式还是没法用。

但这个问题也并不是无解的。UITableViewCell 需要使用 indexPath，这个值里包含了 section 和 row 的值。所以，我们可以通过 indexPath 来确定每个 Cell 的唯一性。

除了 UITableViewCell 这种情况之外， UIAlertController 也比较特殊。它的特殊性在于视图层级的不固定，因为它可能出现在任何页面中。但是，我们都知道它的功能区分往往通过弹窗内容来决定，所以可以通过内容来确定它的唯一标识。

除此之外，还有更多需要特殊处理的情况，但我们总是可以通过一些办法去确定它们的唯一性，所以我在这里也就不再一一列举了。思路上来说就是，想办法找出元素间不相同的因素然后进行组合，最后形成一个能够区别于其他元素的标识来。

除了上面提到的这些特殊情况外，还有一种情况使得我们也难以得到准确的唯一标识。如果视图层级在运行时会被更改，比如执行 insertSubView:atIndex:、removeFromSuperView 等方法时，我们也无法得到唯一标识，即使只截取部分路径也无法保证后期代码更新时不会动到这个部分。就算是运行时视图层级不会修改，以后需求迭代页面更新频繁的话，视图唯一标识也需要同步的更新维护。

这种问题就不好解决了，事件唯一标识的准确性难以保障，这也是通过运行时方法替换进行无侵入埋点很难在各个公司全面铺开的原因。虽然无侵入埋点无法覆盖到所有情况，全面铺开面临挑战，但是无侵入埋点还是解决了大部分的埋点需求，也节省了大量的人力成本。

小结

今天这篇文章，我与你分享了运行时替换方法进行无侵入埋点的方案。这套方案由于唯一标识难以维护和准确性难以保障的原因，很难被全面采用，一般都只是用于一些功能和视图稳定的地方，手动侵入式埋点方式依然占据大部分场景。

无侵入埋点也是业界一大难题，目前还只是初级阶段，还有很长的路要走。我认为，运行时替换方法的方式也只是一种尝试，但是现实中业务代码太过复杂。同时，为了使无侵入的埋点能够覆盖得更全、准确度更高，代价往往是对埋点所需的标识维护成本不断增大。

所以说，我觉得这种方案并不一定是未来的方向。我倒是觉得使用 Clang AST 的接口，在构建时遍历 AST，通过定义的规则将所需要的埋点代码直接加进去，可能会更加合适。这时，我们可以使用前一篇文章“如何利用 Clang 为 App 提质？”中提到的 LibTooling 来开发一个独立的工具，专门以静态方式插入埋点代码。这样做，既可以享受到手动埋点的精确性，还能够享受到无侵入埋点方式的统一维护、开发解耦、易维护的优势。

课后作业

今天我和你具体说了下 UIViewController 生命周期和 UIButton 点击事件的无侵入埋点方式，并给了具体的实现代码。那么，对于 UITableViewCell 点击事件的无侵入埋点，应该怎么来实现的代码，就当做一个课后小作业留给你来完成吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

10 | 包大小：如何从资源和代码层面实现全方位瘦身？

你好，我是戴铭。今天我来跟你说下如何对 App 包大小做优化。

对 App 包大小做优化的目的，就是节省用户流量，提高用户下载速度。当初，我在主持滴滴客户端的瘦身时，就是奔着对包大小进行最大化优化的目标，3 个月内将包大小从 106MB 降到了最低 64MB，半年内稳定在了 70MB。当时业务还没有停，从 106MB 降到 64MB 的这 3 个月里如履薄冰，不同团队各显神威，几乎用到了所有手段，也做了很多创新，最终达成了目标。

图 1 瘦身背景

上图就是当时主流 App 的大小，可以看到最大的百度和淘宝，分别是 131MB 和 115MB，滴滴是 106MB，最小的是微信 87MB。

图 2 主流 App 安装包半年内的大小变化

可以看到，经过半年的时间，除了滴滴外每个 App 的安装包都增大了不少，先前最小的微信也从 87MB 增加到了 116MB。

相信你的团队也曾遇到过或正在经历着对包大小进行优化的任务，特别是 App Store 规定了安装包大小超过 150MB 的 App 不能使用 OTA（over-the-air）环境下载，也就是只能在 WiFi 环境下下载。所以，150MB 就成了 App 的生死线，一旦超越了这条线就很有可能会失去大量用户。

如果你的 App 要再兼容 iOS7 和 iOS8 的话，苹果官方还规定主二进制 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，列出来的是历来各个 iOS 设备的指令集详细矩阵分布。从中，我们可以一窥所有设备的芯片指令集以及支持的最高和最低 iOS 版本。

图 3 各个 iOS 设备的指令集详细矩阵分布

使用 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 模板，如下图所示：

图 4 选择使用 Asset Catalog 模板

然后，按照 Asset Catalog 的模板添加图片资源即可，添加的 2x 分辨率的图片和 3x 分辨率的图片，会在上传到 App Store 后被创建成不同的变体以减小 App 安装包的大小。而芯片指令集架构文件只需要按照默认的设置， App Store 就会根据设备创建不同的变体，每个变体里只有当前设备需要的那个芯片指令集架构文件。

使用 App Thining 后，你可以将 2x 图和 3x 图区分开，从而达到减小 App 安装包体积的目的。如果我们要进一步减小 App 包体积的话，还需要在图片和代码上继续做优化。接下来，我就跟你说说，为了减小 App 安装包的体积，我们还能在图片上做些什么？

无用图片资源

图片资源的优化空间，主要体现在删除无用图片和图片资源压缩这两方面。而删除无用图片，又是其中最容易、最应该先做的。像代码瘦身这样难啃的骨头，我们就留在后面吧。那么，我们是如何找到并删除这些无用图片资源的呢？

删除无用图片的过程，可以概括为下面这 6 大步。

通过 find 命令获取 App 安装包中的所有资源文件，比如 find /Users/daiming/Project/ -name。

设置用到的资源的类型，比如 jpg、gif、png、webp。

使用正则匹配在源码中找出使用到的资源名，比如 pattern = @"@"(.+?)""。

使用 find 命令找到的所有资源文件，再去掉代码中使用到的资源文件，剩下的就是无用资源了。

对于按照规则设置的资源名，我们需要在匹配使用资源的正则表达式里添加相应的规则，比如 @“image_%d”。

确认无用资源后，就可以对这些无用资源执行删除操作了。这个删除操作，你可以使用 NSFileManger 系统类提供的功能来完成。

整个过程如下图：

图 5 删除无用图片资源的过程

如果你不想自己重新写一个工具的话，可以选择开源的工具直接使用。我觉得目前最好用的是 LSUnusedResources，特别是对于使用编号规则的图片来说，可以通过直接添加规则来处理。使用方式也很简单，你可以参看下面的动画演示：

图 6 LSUnusedResources 使用示例

图片资源压缩

无用图片资源处理完了，那么有用的图片还有瘦身的空间吗？

答案是有的。

对于 App 来说，图片资源总会在安装包里占个大头儿。对它们最好的处理，就是在不损失图片质量的前提下尽可能地作压缩。目前比较好的压缩方案是，将图片转成 WebP。WebP 是 Google 公司的一个开源项目。

首先，我们一起看看选择 WebP 的理由：

WebP 压缩率高，而且肉眼看不出差异，同时支持有损和无损两种压缩模式。比如，将 Gif 图转为 Animated WebP ，有损压缩模式下可减少 64% 大小，无损压缩模式下可减少 19% 大小。

WebP 支持 Alpha 透明和 24-bit 颜色数，不会像 PNG8 那样因为色彩不够而出现毛边。

接下来，我们再看看怎么把图片转成 WebP？

Google 公司在开源 WebP 的同时，还提供了一个图片压缩工具 cwebp来将其他图片转成 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。iSpart 是一个 GUI 工具，操作方便快捷，可以实现 PNG 格式转 WebP，同时提供批量处理和记录操作配置的功能。如果是其他格式的图片要转成 WebP 格式的话，需要先将其转成 PNG 格式，再转成 WebP 格式。它的 GUI 界面如下图：

图 7 iSparta 将 PNG 转 WebP 使用示例

图片压缩完了并不是结束，我们还需要在显示图片时使用 libwebp 进行解析。这里有一个 iOS 工程使用 libwebp 的范例，你可以点击这个链接查看。

不过，WebP 在 CPU 消耗和解码时间上会比 PNG 高两倍。所以，我们有时候还需要在性能和体积上做取舍。

我的建议是，如果图片大小超过了 100KB，你可以考虑使用 WebP；而小于 100KB 时，你可以使用网页工具 TinyPng或者 GUI 工具ImageOptim进行图片压缩。这两个工具的压缩率没有 WebP 那么高，不会改变图片压缩方式，所以解析时对性能损耗也不会增加。

代码瘦身

App 的安装包主要是由资源和可执行文件组成的，所以我们在掌握了对图片资源的处理方式后，需要再一起来看看对可执行文件的瘦身方法。

可执行文件就是 Mach-O 文件，其大小是由代码量决定的。通常情况下，对可执行文件进行瘦身，就是找到并删除无用代码的过程。而查找无用代码时，我们可以按照找无用图片的思路，即：

首先，找出方法和类的全集；

然后，找到使用过的方法和类；

接下来，取二者的差集得到无用代码；

最后，由人工确认无用代码可删除后，进行删除即可。

接下来，我们就看看具体的代码瘦身方法吧。

LinkMap 结合 Mach-O 找无用代码

我先和你说下怎么快速找到方法和类的全集。

我们可以通过分析 LinkMap 来获得所有的代码类和方法的信息。获取 LinkMap 可以通过将 Build Setting 里的 Write Link Map File 设置为 Yes，然后指定 Path to Link Map File 的路径就可以得到每次编译后的 LinkMap 文件了。设置选项如下图所示：

图 8 LinkMap 文件获取方法

LinkMap 文件分为三部分：Object File、Section 和 Symbols。如下图所示：

图 9 LinkMap 文件主要组成

其中：

Object File 包含了代码工程的所有文件；

Section 描述了代码段在生成的 Mach-O 里的偏移位置和大小；

Symbols 会列出每个方法、类、block，以及它们的大小。

通过 LinkMap ，你不光可以统计出所有的方法和类，还能够清晰地看到代码所占包大小的具体分布，进而有针对性地进行代码优化。

得到了代码的全集信息以后，我们还需要找到已使用的方法和类，这样才能获取到差集，找出无用代码。所以接下来，我就先和你说说怎么通过 Mach-O 取到使用过的方法和类。

我在第 2 篇文章“APP 启动速度怎么做优化与监控？”中，和你提到过 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 这个软件来查看 Mach-O 文件里的信息。MachOView 同时也是一款开源软件，如果你对源码感兴趣的话，可以点击这个地址查看。

具体的查看方法，我将通过一个案例和你展开。

首先，我们需要编译一个 App。在这里，我 clone 了一个 GitHub 上的示例 下来编译。

然后，将生成的 GCDFetchFeed.app 包解开，取出 GCDFetchFeed。

最后，我们就可以使用 MachOView 来查看 Mach-O 里的信息了。

图 10 使用 MachOView 查看 GCDFetchFeed

如图上所示，我们可以看到 __objc_selrefs、__objc_classrefs 和、__objc_superrefs 这三个 section。

但是，这种查看方法并不是完美的，还会有些问题。原因在于， Objective-C 是门动态语言，方法调用可以写成在运行时动态调用，这样就无法收集全所有调用的方法和类。所以，我们通过这种方法找出的无用方法和类就只能作为参考，还需要二次确认。

通过 AppCode 找出无用代码

那么，有什么好的工具能够找出无用的代码吗？

我用过不少工具，但效果其实都不是很好，都卡在了各种运用运行时调用方法的写法上。即使是大名鼎鼎的 AppCode 在这方面也做得不是很好，当代码量过百万行时 AppCode 的静态分析会“歇菜”。

但是，如果工程量不是很大的话，我还是建议你直接使用 AppCode 来做分析。毕竟代码量达到百万行的工程并不多。而，那些代码量达到百万行的团队，则会自己通过 Clang 静态分析来开发工具，去检查无用的方法和类。

用 AppCode 做分析的方法很简单，直接在 AppCode 里选择 Code->Inspect Code 就可以进行静态分析。

图 11 使用 AppCode 来做静态分析

静态分析完以后，我们可以在 Unused code 里看到所有的无用代码，如下图所示：

图 12 Unused code 里看到所有无用代码

接下来，我和你说一下这些无用代码的主要类型。

无用类：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 里有哪些类实际上是没有被初始化用到的吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

11 | 热点问题答疑（一）：基础模块问题答疑

你好，我是戴铭。

专栏上线以来，我通过评论区收到了很多同学提出的问题、建议、心得和经验，当然提的问题居多。虽然我未在评论区对每条留言做出回复，但是我对大家提出的问题却都一一记录了下来，等待这次答疑文章的到来。其实，不光是在留言区，也有一些朋友通过私信跟我反馈了学习专栏遇到的问题。

所以，今天我就借此机会，汇总并整理了几个典型并重要的问题，和你详细说一说，希望可以解答你在学习前面 10 篇文章时的一些困惑。

动态库加载方式的相关问题

@五子棋在看完第 5 篇文章“链接器：符号是怎么绑定到地址上的？”后，关于动态库是否参与链接的问题，通过私信和我反馈了他的观点。他指出：动态库也是要参与链接的，不然就没法知道函数的标记在哪儿。

为了帮助大家理解这个问题，我把与这个问题相关的内容，再和你展开一下。

我在文章中，是这么阐述这部分内容的：

Mach-O 文件是编译后的产物，而动态库在运行时才会被链接，并没参与 Mach-O 文件的编译和链接，所以 Mach-O 文件中并没有包含动态库里的符号定义。也就是说，这些符号会显示为“未定义”，但它们的名字和对应的库的路径会被记录下来。运行时通过 dlopen 和 dlsym 导入动态库时，先根据记录的库路径找到对应的库，再通过记录的名字符号找到绑定的地址。

细细想来，这种说法并不严谨。关于这个问题，更严谨的说法应该是，加载动态库的方式有两种：

一种是，在程序开始运行时通过 dyld 动态加载。通过 dyld 加载的动态库需要在编译时进行链接，链接时会做标记，绑定的地址在加载后再决定。

第二种是，显式运行时链接（Explicit Runtime Linking），即在运行时通过动态链接器提供的 API dlopen 和 dlsym 来加载。这种方式，在编译时是不需要参与链接的。﻿
﻿不过，通过这种运行时加载远程动态库的 App，苹果公司是不允许上线 App Store 的，所以只能用于线下调试环节。关于这种方式的适用场景，我也已经在文章第 6 篇文章“App 如何通过注入动态库的方式实现极速编译调试？”中和你举例说明过，你可以再回顾下相关内容。

在第 5 篇文章中，我将动态库的这两种加载方式混在一起说了，让你感到些许困惑，所以在这里我特地做个补充说明。

接下来，我们就再看看第 6 篇文章后的留言。这篇文章留言区的问题集中在：项目中使用了 CocoaPods 来开发组件，在使用 InjectionIII 调试时，遇到了修改源码无法进行注入的问题。

在这里，我首先要感谢 @manajay 同学在 InjectionIII 的 issue 里找到了相关的解答，并分享到了留言区。

其实，关于 InjectionIII 的这部分内容，我更希望你能够了解 InjectionIII 的工作原理，从而加深对运行时动态库加载原理的理解。然后，根据自己的工程情况动手改造或者直接造个新轮子，我相信会极大地提升你的技术水平，至少比直接使用已有轮子的效果要好得多。

所以，还是回到我在开篇词中和你提到的观点：动手就会碰到问题，就会思考，这个主动过程会加深你的记忆，这样后面再碰到问题时，你会更容易将相关知识串联起来，形成创新式的思考。但如果你在碰到困难时，就选择放弃那必定会抱有遗憾。

在第 8 篇文章“如何利用 Clang 为 App 提质？”中，@鹏哥同学在评论区问了我这样一个问题：

在第 1 篇文章“建立你自己的 iOS 开发知识体系”中，你提到对某一领域的知识要做到精通的程度，而不能只是了解。那么，你在这个专栏中提到了这么多内容，我应该选择哪些内容去深入研究呢？还是说所有的内容，我都需要去深入研究？

我给出的回答是，根据工作需要来选择。比如说，如果调试速度的问题，确实是你目前工作中面临的最大挑战，那我觉得你就应该在这个点上深挖，并勇敢地克服其中遇到的困难，就像我上次通过“极客时间”的平台直播时，和你分享的我自己学画画的经历一样，挑战素描的过程确实很痛苦，但挺过来了之后我会很受益并享受自己的进步。对我们这种手艺人来说，不断挑战才能不断进步。

最近我在看一个豆瓣评分非常高的日剧《北国之恋》，在第 3 集“决心”里，一位老爷爷在北海道送别朋友时说了一番话，我觉得特别有力量。所以，我把这段话放在这里和你共勉：

不可思议啊，虽然是流行歌曲，不过呢。听到这首歌，这歌流行起来，让人回想起那个时代的往事。那年发生了很严重的冻灾，再加上农业机械的引进，农场的经营方式慢慢不一样了。一起来开荒的伙伴们，收拾行李，一个一个地从麓乡离开了。那是 11 月啊，亲密的伙伴们，四家一起放弃了农场，那个时候，我，当然要来送行，稀稀落落地下起了雪，那时流行北岛三郎，有四家要离开，来送行的只有我和老婆两个人，大家都一句话也不说。不过，那个时候，我真想把心里想的说出来。你们，这么做行吗？这是输了之后逃跑啊，二十多年来一直在一起努力，你们的心酸、悲哀、悔恨，一切的一切，我自以为都了解。因此，我没有对别人说三道四，没有对别人自以为是地指指点点。可是，说这句话的权利我还是有的，你们失败了逃跑了，背叛了我们。逃跑了，这一点，你们给我好好记住。

好了，我们现在继续回到专栏文章上吧。

App 启动时通过 dyld 加载动态库，就是运行时动态库加载在 App 启动速度优化上的一个应用场景。在专栏的第 2 篇文章“App 启动速度怎么做优化与监控？”中，我和你分享了动态库加载后的监控和优化，文后的评论区就有很多同学提到了，想要多了解些动态库加载方面的优化。

关于 App 开始启动到 main 函数之间的 dyld 内部细节，我推荐你去看苹果公司的 WWDC 2016 Session 406 Optimizing App Startup Time视频。这个视频里面，不仅详细剖析了 dyld，还提供了构建代码的最佳实践。

除此之外，“How we cut our iOS app’s launch time in half (with this one cool trick)”这篇博客，也是个不错的阅读资料。光看名字就很吸引人了，对吧。

关于 App 启动速度的话题，很多同学还提出了其他问题，包括很多关于课后作业的问题。所以接下来，我就针对这个话题再专门做个答疑吧。

App 启动速度的相关问题

专栏的第 2 篇文章“App 启动速度怎么做优化与监控？”中的大部分问题，我都直接在评论区回复了。下面的答疑内容，我主要是针对课后作业和汇编部分，统一做下回复。

关于课后作业

在这篇文章最后，我留下的课后作业是：

按照今天文中提到的 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”这篇博客，详细讲述了 objc_msgSend 的 ARM64 汇编代码。等你看完这篇博客以后，再来看我们这篇文章中的汇编代码就一定会觉得轻松很多。

关于 Clang 的相关问题

专栏已经更新的第 7~ 第 10 这 4 篇文章中，都涉及到了 Clang 的知识以及应用，所以我在这里单独列出了一个问题，和你一起解决关于 Clang 的相关问题。

其实，我在第 7 篇文章“Clang、Infer 和 OCLint ，我们应该使用谁来做静态分析？”中，介绍的 3 款静态分析工具都用到了 Clang，而且 Clang 本身也提供了 LibTooling 这种强大的 C++ 接口来方便定制独立的工具。

当然了，Clang 的知识也是需要投入大量精力才能掌握好。那么，你可能会问，我掌握这些偏底层的知识有什么用呢，好像也解决不了我在现实开发工作中遇到的问题啊？

在我看来，你只有掌握了某个方面的知识，在工作中碰到问题时才能够想到用这个知识去解决问题。如果你都不知道有这么一种方法，又怎么会用它去解决自己的问题呢？

就比如说，你掌握了 Clang 的知识，那在研究无侵入的埋点方案应该如何实现时，你才能可能会想到用 Clang 的 LibTooling 来开发一个独立的工具，专门以静态方式插入埋点的代码；只有掌握了 Clang 的知识，当你在面对代码量达到百万行的App 包瘦身需求时，才会想到通过 Clang 静态分析来开发工具，去检查无用的方法和类。

当你掌握了 Clang 的相关知识后，编译前端的技术也就掌握得差不多了；在理解了编译前端的词法分析和语法分析的套路后，脱离 Clang 的接口完成第 8 篇文章“如何利用 Clang 为 App 提质？”的课后作业，也就没什么难度了。

在完成这个课后作业之前，你也可以先看看王垠在 2012 年的一篇博客“怎样写一个解释器”。看完后这篇博客后，你一定会有撸起袖子加油干的冲劲儿。

关于第 8 篇文章的课后作业，如果你还有其他不明白的地方，欢迎继续给我留言。

小结

专栏更新至今，已经发布了 10 篇文章，大家在评论区留下很多高质量的留言，让我非常感动，在这里我也要感谢你的支持与鼓励。

这 10 篇文章学习下来，你可能会觉得这些文章 so easy，也可能会觉得这些文章确实帮你解决了工作中遇到的困惑，还可能会觉得这些文章太难啃了但依旧在努力学习中，我想要和你说的就是：有的知识学起来很难，但是再坚持一下，并不断重复，只要能比昨天的自己进步一点点，终究可以掌握你想要的知识。

所以，在今天这篇答疑文章，也是我们专栏的第一篇答疑文章中，我不打算大而全地去回复太多的问题，只是甄选了其中其中非常重要、核心的几个问题，和你再一起巩固下我们所学的知识，并和你分享一些我的学习方法。

希望通过今天这篇文章，可以帮你搞明白那些让你困惑的知识点，逐步地建立起自己的知识体系。如果你还有其他的问题，欢迎你给我留言。

最后，虽然这是篇答疑文章，还是要留给你一个小小的思考题。

王垠的博客文章中，除了我在前面提到的“怎样写一个解释器”外，其他文章也都可以帮助你开阔眼界，非常值得一看。在看完他的博客后，你会发现他对编程语言本质的理解非常透彻，而你自己也能从中受益良多。

我在看完他所有的博客文章之后，对很多知识有了更深的理解，但同时知识量也非常大，无法一时都消化掉，感觉需要学习的地方还有很多。所以，我当时的感觉就是酸甜苦辣咸五味俱全。不知道你看完他的文章后，会有什么感觉呢？我们就把这个话题作为今天文章的思考题，请你在评论区分享一下你的阅后感吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

12 | iOS 崩溃千奇百怪，如何全面监控？

你好，我是戴铭。今天我要跟你说的是崩溃监控。

App 上线后，我们最怕出现的情况就是应用崩溃了。但是，我们线下测试好好的 App，为什么上线后就发生崩溃了呢？这些崩溃日志信息是怎么采集的？能够采集的全吗？采集后又要怎么分析、解决呢？

接下来，通过今天这篇文章，你就可以了解到造成崩溃的情况有哪些，以及这些崩溃的日志都是如何捕获收集到的。

App 上线后，是很脆弱的，导致其崩溃的问题，不仅包括编写代码时的各种小马虎，还包括那些被系统强杀的疑难杂症。

下面，我们就先看看几个常见的编写代码时的小马虎，是如何让应用崩溃的。

数组越界：在取数据索引时越界，App 会发生崩溃。还有一种情况，就是给数组添加了 nil 会崩溃。

多线程问题：在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作，因为处理时机不一致，比如有一个线程在置空数据的同时另一个线程在读取这个数据，可能会出现崩溃情况。

主线程无响应：如果主线程超过系统规定的时间无响应，就会被 Watchdog 杀掉。这时，崩溃问题对应的异常编码是 0x8badf00d。关于这个异常编码，我还会在后文和你说明。

野指针：指针指向一个已删除的对象访问内存区域时，会出现野指针崩溃。野指针问题是需要我们重点关注的，因为它是导致 App 崩溃的最常见，也是最难定位的一种情况。关于野指针等内存相关问题，我会在第 14 篇文章“临近 OOM，如何获取详细内存分配信息，分析内存问题？”里和你详细说明。

程序崩溃了，你的 App 就不可用了，对用户的伤害也是最大的。因此，每家公司都会非常重视自家产品的崩溃率，并且会将崩溃率（也就是一段时间内崩溃次数与启动次数之比）作为优先级最高的技术指标，比如千分位是生死线，万分位是达标线等，去衡量一个 App 的高可用性。

而崩溃率等技术指标，一般都是由崩溃监控系统来搜集。同时，崩溃监控系统收集到的堆栈信息，也为解决崩溃问题提供了最重要的信息。

但是，崩溃信息的收集却并没有那么简单。因为，有些崩溃日志是可以通过信号捕获到的，而很多崩溃日志却是通过信号捕获不到的。

你可以看一下下面这幅图，我列出了常见的部分崩溃情况：

图 1 常见的部分崩溃情况分类

通过这张图片，我们可以看到， KVO 问题、NSNotification 线程问题、数组越界、野指针等崩溃信息，是可以通过信号捕获的。但是，像后台任务超时、内存被打爆、主线程卡顿超阈值等信息，是无法通过信号捕捉到的。

但是，只有捕获到所有崩溃的情况，我们才能实现崩溃的全面监控。也就是说，只有先发现了问题，然后才能够分析问题，最后解决问题。接下来，我就一起分析下如何捕获到这两类崩溃信息。

我们先来看看信号可捕获的崩溃日志收集

收集崩溃日志最简单的方法，就是打开 Xcode 的菜单选择 Product -> Archive。如下图所示：

图 2 收集崩溃日志最简单的方法

然后，在提交时选上“Upload your app’s symbols to receive symbolicated reports from Apple”，以后你就可以直接在 Xcode 的 Archive 里看到符号化后的崩溃日志了。

但是这种查看日志的方式，每次都是纯手工的操作，而且时效性较差。所以，目前很多公司的崩溃日志监控系统，都是通过PLCrashReporter 这样的第三方开源库捕获崩溃日志，然后上传到自己服务器上进行整体监控的。

而没有服务端开发能力，或者对数据不敏感的公司，则会直接使用 Fabric或者Bugly来监控崩溃。

你可能纳闷了：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 版本；

异常信息：异常类型、异常编码、异常的线程；

线程回溯：崩溃时的方法调用栈。

通常情况下，我们分析崩溃日志时最先看的是异常信息，分析出问题的是哪个线程，在线程回溯里找到那个线程；然后，分析方法调用栈，符号化后的方法调用栈可以完整地看到方法调用的过程，从而知道问题发生在哪个方法的调用上。

其中，方法调用栈如下图所示：

图 3 方法调用栈展示图片

方法调用栈顶，就是最后导致崩溃的方法调用。完整的崩溃日志里，除了线程方法调用栈还有异常编码。异常编码，就在异常信息里。

一些被系统杀掉的情况，我们可以通过异常编码来分析。你可以在维基百科上，查看完整的异常编码。这里列出了 44 种异常编码，但常见的就是如下三种：

0x8badf00d，表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。

0xdeadfa11，表示 App 被用户强制退出。

0xc00010ff，表示 App 因为运行造成设备温度太高而被杀掉。

0x8badf00d 这种情况是出现最多的。当出现被 watchdog 杀掉的情况时，我们就可以把范围控制在主线程被卡的情况。我会在第 13 篇文章“如何利用 RunLoop 原理去监控卡顿？”中，和你详细说明如何去监控这种情况来防范和快速定位到问题。

0xdeadfa11 的情况，是用户的主动行为，我们不用太关注。

0xc00010ff 这种情况，就要对每个线程 CPU 进行针对性的检查和优化。我会在第 18 篇文章“怎么减少 App 的电量消耗？”中，和你详细说明。

除了崩溃日志外，崩溃监控平台还需要对所有采集上来的日志进行统计。我以腾讯的 Bugly 平台为例，和你一起看一下崩溃监控平台一般都会记录哪些信息，来辅助开发者追溯崩溃问题。

图 4 Bugly 的崩溃趋势展示

上图展示的就是整体崩溃情况的趋势图，你可以选择 App 的不同版本查看不同时间段的趋势。这个相当于总控台，能够全局观察 App 的崩溃大盘。

除了崩溃率，你还可以在这个平台上能查看次数、用户数等趋势。下图展示的是某一个 App 的崩溃在不同 iOS 系统、不同 iPhone 设备、App 版本的占比情况。这也是全局大盘观察，从不同维度来分析。

图 5 App 崩溃在不同的系统版本、设备、版本版本的占比

有了全局大盘信息，一旦出现大量崩溃，你就需要明白是哪些方法调用出现了问题，需要根据影响的用户数量按照从大到小的顺序排列出来，优先解决影响面大的问题。如下图所示：

图 6 App 崩溃问题列表

同时，每个崩溃也都有自己的崩溃趋势图、iOS 系统分布图等信息，来辅助开发者跟踪崩溃修复效果。

有了崩溃的方法调用堆栈后，大部分问题都能够通过方法调用堆栈，来快速地定位到具体是哪个方法调用出现了问题。有些问题仅仅通过这些堆栈还无法分析出来，这时就需要借助崩溃前用户相关行为和系统环境状况的日志来进行进一步分析。

关于日志如何收集协助分析问题，我会在第 15 篇文章“日志监控：怎样获取 App 中的全量日志？”中，和你详细说明。

小结

学习完今天的这篇文章，我相信你就不再是只能依赖现有工具来解决线上崩溃问题的 iOS 开发者了。在遇到那些工具无法提供信息的崩溃场景时，你也有了自己动手去收集崩溃信息的能力。

现有的崩溃监控系统，不管是开源的崩溃日志收集库还是类似 Bugly 的崩溃监控系统，离最优解都还有一定的距离。

这个“非最优”，我们需要分两个维度来看：一个维度是，怎样才能够让崩溃信息的收集效率更高，丢失率更低；另一个维度是，如何能够收集到更多的崩溃信息，特别是系统强杀带来的崩溃。

随着 iOS 系统的迭代更新，强杀阈值和强杀种类都在不断变化，因此崩溃监控系统也需要跟上系统迭代更新的节奏，同时还要做好向下兼容。

课后小作业

请你写一段代码，在 App 退后台以后执行一段超过 3 分钟的任务，在临近 3 分钟时打印出线程堆栈。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

最近，我收到一些同学的反馈，说这门课的一些内容比较深，一时难以琢磨透。如果你也有这样的感受，推荐你学习极客时间刚刚上新的另一门视频课程：由腾讯高级工程师朱德权，主讲的《从 0 开发一款 iOS App》。

朱德权老师将会基于最新技术，从实践出发，手把手带你构建类今日头条的 App。要知道，那些很牛的 iOS 开发者，往往都具备独立开发一款 App 的能力。

这门课正在上新优惠，欢迎点击这里试看。

13 | 如何利用 RunLoop 原理去监控卡顿？

你好，我是戴铭。今天，我来和你说说如何监控卡顿。

卡顿问题，就是在主线程上无法响应用户交互的问题。如果一个 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 过程，我们可以总结为如下所示的一张图片。

这里只列出了 CFRunLoop 的关键代码，你可以点击这个链接查看完整代码。

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这个开源的第三方库来获取堆栈信息。这种方法的特点是，能够定位到问题代码的具体位置，而且性能消耗也不大。所以，也是我推荐的获取堆栈信息的方法。

具体如何使用 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);

搜集到卡顿的方法堆栈信息以后，就是由开发者来分析并解决卡顿问题了。

在今天这篇文章中，我们用到的从监控卡顿到收集卡顿问题信息的完整代码，你都可以点击这个链接查看。

小结

今天我给你介绍了使用 RunLoop 监控卡顿的方案，我还跟你说了下 RunLoop 的原理，希望能够帮助你更好地理解 RunLoop 监控卡顿的方案。

读到这里你可能会想，为什么要将卡顿监控放到线上做呢？其实这样做主要是为了能够更大范围的收集问题，如果仅仅通过线下收集卡顿的话，场景无法被全面覆盖。因为，总有一些卡顿问题，是由于少数用户的数据异常导致的。

而用户反馈的卡顿问题往往都是说在哪个页面卡住了，而具体是执行哪个方法时卡主了，我们是无从得知的。在碰到这样问题时，你一定会感觉手足无措，心中反问一百遍：“我怎么在这个页面不卡，测试也不卡，就你卡”。而且，通过日志我们也很难查出个端倪。这时候，线上监控卡顿的重要性就凸显出来了。

有时，某个问题看似对 App 的影响不大，但如果这个问题在某个版本中爆发出来了就会变得难以收场。所以，你需要对这样的问题进行有预见性的监控，一方面可以早发现、早解决，另一方面在遇到问题时能够快速定位原因，不至于过于被动。要知道，面对问题的响应速度往往是评判基础建设优劣的一个重要的标准。

以上就是我们今天的内容了。接下来，我想请你回顾一下你都碰到过哪些卡顿问题，又是如何解决的呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

14 | 临近 OOM，如何获取详细内存分配信息，分析内存问题？

你好，我是戴铭。今天我们来聊聊，临近 OOM，如何获取详细的内存分配信息，分析内存问题的话题。

OOM，是 Out of Memory 的缩写，指的是 App 占用的内存达到了 iOS 系统对单个 App 占用内存上限后，而被系统强杀掉的现象。这么说的话，OOM 其实也属于我们在第 12 篇文章“iOS 崩溃千奇百怪，如何全面监控？”中提到的应用“崩溃”中的一种，是由 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 的值。如下图红框部分所示：

可以看到，内存页大小 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 的方法，来实现一个记录内存分配的小工具吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

15 | 日志监控：怎样获取 App 中的全量日志？

你好，我是戴铭。

我在前面的第 12、13 和 14 三篇文章中，和你分享了崩溃、卡顿、内存问题的监控。一旦监控到问题，我们还需要记录下问题的详细信息，形成日志告知开发者，这样开发者才能够从这些日志中定位问题。

但是，很多问题的定位仅靠问题发生的那一刹那记录的信息是不够的，我们还需要依赖更多的日志信息。

在以前公司还没有全量日志的时候，我发现线上有一个上报到服务器的由数据解析出错而引起崩溃的问题。由于数据解析是在生成数据后在另一个线程延迟执行的，所以很难定位到是谁生成的数据造成了崩溃。

如果这个时候，我能够查看到崩溃前的所有日志，包括手动打的日志和无侵入自动埋点的日志，就能够快速定位到是由谁生成的数据造成了崩溃。这些在 App 里记录的所有日志，比如用于记录用户行为和关键操作的日志，就是全量日志了。

有了更多的信息，才更利于开发者去快速、精准地定位各种复杂问题，并提高解决问题的效率。那么，怎样才能够获取到 App 里更多的日志呢？

你可能会觉得获取到全量的日志很容易啊，只要所有数据都通过相同的打日志库，不就可以收集到所有日志了吗？但，现实情况并没有这么简单。

一个 App 很有可能是由多个团队共同开发维护的，不同团队使用的日志库由于历史原因可能都不一样，要么是自己开发的，要么就是使用了不同第三方日志库。如果我们只是为了统一获取日志，而去推动其他团队将以前的日志库代码全部替换掉，明显是不现实的。因为，我们谁也无法确定，这种替换日志库的工作，以后是不是还会再来一次。

那么，我们还有什么好办法来解决这个问题吗？在我看来，要解决这个问题，我们就需要先逐个地分析各团队使用的日志库，使用不侵入的方式去获取所有日志。

接下来，我就先和你说说怎样获取系统自带 NSLog 的日志。

获取 NSLog 的日志

我们都知道，NSLog 其实就是一个 C 函数，函数声明是：

void NSLog(NSString *format, ...);

它的作用是，输出信息到标准的 Error 控制台和系统日志（syslog）中。在内部实现上，它其实使用的是 ASL（Apple System Logger，是苹果公司自己实现的一套输出日志的接口）的 API，将日志消息直接存储在磁盘上。

那么，我们如何才能获取到通过 ASL 存放在系统日志中的日志呢？

ASL 会提供接口去查找所有的日志，通过 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 四部分组成，其整体架构如下图所示：

在这其中，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 启动速度怎么做优化与监控？”里，我也提到过两个方案来实现启动监控。其中，第二个使用 hook objc_msgSend 方法的方案，看起来连汇编语言都用到了，应该没有更好的方案了吧，其实不然，我这里卖个关子，后面有机会我还会和你介绍另一个方案。

所以，我们接触的知识面越多，遇到问题时能想到的办法也就会越多。当出现意外时，就像是 ASL 在 iOS 10.0 之后不能用了这种情况下，你依然还能够有其他方法来解决问题。

课后作业

今天课程中提到了跨进程通知，那么接下来就请你写一个监听用户设备磁盘空间不够时，清理 App 缓存文件的功能吧。

我今天还会再多布置一个作业。我在和你分析捕获 NSLog 日志时，提到了一个用 dup2 替换掉默认句柄的方法。所以，我希望你可以动手实践一下，按照这个思路将其完整实现出来吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

16 | 性能监控：衡量 App 质量的那把尺

你好，我是戴铭。

通常情况下，App 的性能问题虽然不会导致 App 不可用，但依然会影响到用户体验。如果这个性能问题不断累积，达到临界点以后，问题就会爆发出来。这时，影响到的就不仅仅是用户了，还有负责 App 开发的你。

为了能够主动、高效地发现性能问题，避免 App 质量进入无人监管的失控状态，我们就需要对 App 的性能进行监控。目前，对 App 的性能监控，主要是从线下和线上两个维度展开。

今天这篇文章，我就从这两个方面来和你聊聊如何做性能监控这个话题。接下来，我们就先看看苹果官方的线下性能监控王牌 Instruments。

Instruments

关于线下性能监控，苹果公司官方就有一个性能监控工具 Instruments。它是一款被集成在 Xcode 里，专门用来在线下进行性能分析的工具。

Instruments 的功能非常强大，比如说 Energy Log 就是用来监控耗电量的，Leaks 就是专门用来监控内存泄露问题的，Network 就是用来专门检查网络情况的，Time Profiler 就是通过时间采样来分析页面卡顿问题的。

如下图所示，就是 Instruments 的各种性能检测工具。

图 1 Instruments 提供的各种性能检测工具

除了对各种性能问题进行监控外，最新版本的 Instruments 10 还有以下两大优势：

Instruments 基于 os_signpost 架构，可以支持所有平台。

Instruments 由于标准界面（Standard UI）和分析核心（Analysis Core）技术，使得我们可以非常方便地进行自定义性能监测工具的开发。当你想要给 Instruments 内置的工具换个交互界面，或者新创建一个工具的时候，都可以通过自定义工具这个功能来实现。

其实，Instruments 的这些优势也不是与生俱来的，都是伴随着移动开发技术的发展而演进来的。就比如说自定义工具的功能吧，这是因为 App 的规模越来越大，往往还涉及到多个团队合作开发、集成多个公司 SDK 的情况，所以我们就需要以黑盒的方式来进行性能监控。这样的需求，也就迫使苹果公司要不断地增强 Instruments 的功能。

从整体架构来看，Instruments 包括 Standard UI 和 Analysis Core 两个组件，它的所有工具都是基于这两个组件开发的。而且，你如果要开发自定义的性能分析工具的话，完全基于这两个组件就可以实现。

开发一款自定义 Instruments 工具，主要包括以下这几个步骤：

在 Xcode 中，点击 File > New > Project；

在弹出的 Project 模板选择界面，将其设置为 macOS；

选择 Instruments Package，点击后即可开始自定义工具的开发了。如下图所示。

图 2 开发自定义 Instrument 工具

经过上面的三步之后，会在新创建的工程里面生成一个.instrpkg 文件，接下来的开发过程主要就是对这个文件的配置工作了。这些配置工作中最主要的是要完成 Standard UI 和 Analysis Core 的配置。

上面这些内容，就是你在开发一个自定义 Instruments 工具时，需要完成的编码工作了。可以看到，Instruments 10 版本的自定义工具开发还是比较简单的。与此同时，苹果公司还提供了大量的代码片段，帮助你进行个性化的配置。你可以点击这个链接，查看官方指南中的详细教程。

如果你想要更好地进行个性化定制，就还需要再了解 Instruments 收集和处理数据的机制，也就是分析核心（Analysis Core ）的工作原理。

Analysis Core 收集和处理数据的过程，可以大致分为以下这三步：

处理我们配置好的各种数据表，并申请存储空间 store；

store 去找数据提供者，如果不能直接找到，就会通过 Modeler 接收其他 store 的输入信号进行合成；

store 获得数据源后，会进行 Binding Solution 工作来优化数据处理过程。

这里需要强调的是，在我们通过 store 找到的这些数据提供者中，对开发者来说最重要的就是 os_signpost。os_signpost 的主要作用，是让你可以在程序中通过编写代码来获取数据。你可以在工程中的任何地方通过 os_signpost API ，将需要的数据提供给 Analysis Core。

苹果公司在 WWDC 2018 Session 410 Creating Custom Instruments 里提供了一个范例：通过 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>

这里，我们配置数据表是要对数据输出进行可视化配置，从而可以将代码中的数据展示出来。如下图所示，就是对下载图片大小监控的效果。

图 3 对下载图片大小的监控

通过上面的分析我们可以看到，Instruments 10 通过提供 os_signpost API 的方式使得开发者监控自定义的性能指标时更方便，从而解决了在此之前只能通过重新建设工具来完成的问题。并且，Instruments 通过 XML 标准数据接口解耦展示和数据分析的思路，也非常值得我们借鉴和学习。

在线下性能监控中，Instruments 可以说是王者，但却对线上监控无能为力。那么，对于线上的性能监控，我们应该怎么实现呢？

线上性能监控

对于线上性能监控，我们需要先明白两个原则：

监控代码不要侵入到业务代码中；

采用性能消耗最小的监控方案。

线上性能监控，主要集中在 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中，苹果公司介绍说 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 工具将其监控起来吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

17 | 远超你想象的多线程的那些坑

你好，我是戴铭。今天，我们一起来聊聊 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 的线程设计如下图所示：

图 1 AFNetworking 2.0 的线程设计示意图

通过上面的分析我们可以知道，如果不是因为 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是一个开源的第三方数据库框架，通过 FMDatabaseQueue 这个核心类，将与读写数据库相关的磁盘操作都放到一个串行队列里执行，从而避免了线程创建过多导致系统资源紧张的情况。

FMDatabaseQueue 使用起来也很简单，我的开源项目“已阅”就是使用 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 我的“已阅”项目，将里面的 FMDatabase 替换成 FMDatabaseQueue；然后，再使用性能工具查看前后的内存消耗情况。

第二个小作业相对轻松些，请你在留言中说说曾经遇到过的多线程问题，你又是怎么解决的。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

18 | 怎么减少 App 电量消耗？

你好，我是戴铭。

手机设备电量有限，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 崩溃千奇百怪，如何全面监控”中是怎么定位问题的。

也就是说，我们还是先反过来看看出现电量问题的期间，哪个线程是有问题的。通过下面的这段代码，你就可以获取到所有线程的信息：

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”，分别从 CPU、设备唤醒、网络、图形、动画、视频、定位、加速度计、陀螺仪、磁力计、蓝牙等多方面因素提出了电量优化方面的建议。所以，当使用了苹果公司的电量优化指南里提到的功能时，严格按照指南里的最佳实践去做就能够保证这些功能不会引起不合理的电量消耗。

同时，苹果公司在 2017 年 WWDC 的 Session 238 也分享了一个关于如何编写节能 App 的主题“Writing Energy Efficient Apps”。

小结

今天我跟你分享了如何通过获取线程信息里的 cpu_usage 字段来判断耗电线程，进而得到当前线程执行方法堆栈，从而精准、快速地定位到引起耗电的具体方法。我曾经用这个方法解决了几起难以定位的耗电问题，这些问题都出在二方库上。通过获取到的方法堆栈，我就有了充足的证据去推动其他团队进行电量优化。

除此之外，我还跟你介绍了如何在平时开发中关注电量的问题。在我看来，减少 App 耗电也是开发者的天职，不然如何向我们可爱的用户交代呢。

课后小作业

请你使用我今天分享的耗电检查方法，检查一下你的 App，看看哪个方法最耗电。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

19 | 热点问题答疑（二）：基础模块问题答疑

你好，我是戴铭。

这是我们《iOS 开发高手课》专栏的第二期答疑文章，我将继续和你分享大家在学习前面文章时遇到的最普遍的问题。

首先呢，我要感谢你这段时间对专栏的关注，让我感觉写专栏这件事儿格外有意义。通过这段时间对大家留言问题的观察，我也发现还有很多同学对 RunLoop 原理的一些基础概念不是很了解。这就导致在出现了比如卡顿或者线程问题时找不到好的解决方案，所以我今天就先和你分享一下学习 RunLoop 的方法和参考资料。

其实，目前关于 RunLoop 原理的高质量资料非常多，那我们究竟应该怎么利用这些资料，来高效地掌握 RunLoop 的原理呢？

我建议你按照下面的顺序来学习 RunLoop 原理，坚持下来你就会对 RunLoop 的基础概念掌握得八九不离十了。

首先，你可以看一下孙源的一个线下分享《RunLoop》，对 RunLoop 的整体有个了解。

然后，你可以再看官方文档，全面详细地了解苹果公司设计的 RunLoop 机制，以及如何运用 RunLoop 来解决问题。

最后，了解了 RunLoop 的机制和运用后，你需要深入了解 RunLoop 的实现，掌握 RunLoop 原理中的基础概念。ibireme 的一篇文章 《深入理解 RunLoop》，结合着底层 CFRunLoop 的源码，对 RunLoop 机制进行了深入分析。

好了，关于 RunLoop 原理学习的内容，我就先说到这里。接下来，我再跟你说说最近被问到的，我认为比较重要的两个问题：

一个是，使用 dlopen() ，App 能不能审核通过？

另一个是， matrix-iOS 里的卡顿监控系统，与我在第 13 篇文章里提到的卡顿监控系统有什么区别？

其实，我知道大家还都比较关注课后作业的解题思路，但是考虑到有很多同学还没有静下心来去思考、去完成，所以我准备过一段时间再和你分享这部分内容。这里，我还是想再和你分享一下我在开篇词中提出的观点：

对于咱们手艺人来说，不动手都是空谈，就像绘画教程，光看不练，是不会有进步的。这就如同九阴真经的口诀，铭记于心后还需要常年累月的修炼才能精进。动手就会碰到问题，就会思考，这个主动过程会加深你的记忆，这样后面再碰到问题时，你会更容易将相关知识串联起来，形成创新式的思考。

这些作业确实有难度，也确实需要你投入很多精力，如果你在动手解决这些问题的过程中，具体有哪里卡住了，欢迎给我留言。我可以针对你遇到的问题给出有针对性的解答，或许这样对你的帮助会更大。

现在，我们就从第一个问题说起吧。

使用 dlopen() 能不能审核通过？

@Ant 同学在第 6 篇文章“App 如何通过注入动态库的方式实现极速编译调试？”中留言问到：

Injection 使用了 dlopen() 方法，审核能通过吗？ 是不是调试的时候用，提交 App Store 时候移除呢？

苹果公司关于 App 审核的规定，你可以点击这个链接查看。其中 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 篇答疑文章里就提到，苹果公司不允许通过运行时加载远程动态库的 App 上线 App Store。

那么现在，我们回到 Ant 同学提的问题本身，App 带着 Injection 上线后，如果使用 dlopen() 去读取远程动态库，就会被拒绝。另外，在我看来，Injection 本来就是用于线下调试的，为什么还要带着它上 App Store 呢。

下面我来说下第二个问题，matrix-iOS 里卡顿监控系统，与我在第 13 篇文章里提到的卡顿监控系统有什么区别？

matrix-iOS

第 13 篇文章上线后，有很多朋友和我反馈说，微信最近开源了一个卡顿监控系统 matrix-iOS，并询问我它和我在这篇文章里提到的卡顿监控系统，有什么区别。

因为 matrix-iOS 对性能的优化考虑得非常全面，这些优化不仅能够应用在卡顿监控上，对于其他监控都有很好的借鉴作用，所以非常值得我们深入了解一下。接下来，我就这个话题和你展开一下。

记得在 2015 年 8 月的时候，微信团队的一位同学做了一次关于 iOS 卡顿监控方案的分享。这次分享让我受益匪浅，而且这也是我第一次听说 iOS 卡顿监控方案。次月，微信团队就放出了一篇文章专门介绍卡顿监控方案“微信 iOS 卡顿监控系统”。之后，很多团队参照这篇文章开发了自己的卡顿监控系统。我在第 13 篇文章中设计的卡顿监控系统，也是按照这个思路写的。

在今年的 4 月 3 号，微信团队将他们的卡顿监控系统matrix开源出来了，包括Matrix for iOS/macOS和Android系统的监控方案。关于 matrix-iOS 的卡顿监控原理，你可以点击这个链接查看。

如果你的 App 现在还没有卡顿监控系统，可以考虑直接集成 matrix-iOS，直接在 Podfile 里添加 pod ‘matrix-wechat’ 就可以了。如果已经有了卡顿监控系统，我建议你阅读下 matrix-iOS 的代码，里面有很多细节值得我们学习。比如：

子线程监控检测时间间隔：matrix-iOS 监控卡顿的子线程是通过 NSThread 创建的，检测时间间隔正常情况是 1 秒，在出现卡顿情况下，间隔时间会受检测线程退火算法影响，按照斐波那契数列递增，直到没有卡顿时恢复为 1 秒。

子线程监控退火算法：避免一个卡顿会写入多个文件的情况。

RunLoop 卡顿时间阈值设置：对于 RunLoop 超时阈值的设置，我在第 13 篇文章里建议设置为 3 秒，微信设置的是 2 秒。

CPU 使用率阈值设置：当单核 CPU 使用率超过 80%，就判定 CPU 占用过高。CPU 使用率过高，可能导致 App 卡顿。

在我看来，这四点是能够让卡顿监控系统在对 App 性能损耗很小的情况下，更好地监控到线上 App 卡顿情况的四个细节，也是和我们第 13 篇文章中的卡顿方案有所不同的地方。

那接下来，我就跟你说下 matrix-iOS 的这四处细节具体是如何实现的吧。matrix-iOS 卡顿监控系统的主要代码在 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 篇文章“远超你想象的多线程的那些坑”里提到线程过多时 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 篇文章里设置的卡顿时间阈值是 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 电量消耗？”中，设置的 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 篇文章里跟你说过，这里就不再赘述了。

总结

在今天这篇文章中，我和你分享了下最近这段时间大家对专栏文章的一些问题。

首先，是关于对 RunLoop 原理的学习。我发现有很多同学在这方面的基础比较薄弱，所以特意梳理了这方面的学习方法和资料，希望可以帮到你。

然后，我针对大家比较关注的苹果公司审核动态化的相关规定，通过 Injection 里面带 dlopen() 方法能否审核通过和你做了说明，希望可以帮助你了解类似 dlopen() 这样的技术应该怎样使用。

最后，我针对第 13 篇文章的监控系统，分析了最近微信团队新开源的 matrix-iOS 监控系统，为你详细分析了其中与卡顿监控相关的实现细节，也希望对你完善自己的监控系统有所帮助。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

20 | iOS开发的最佳学习路径是什么？

你好，我是戴铭。

我在专栏的第一篇文章中，就和你分享了我的 iOS 知识体系。通过前面“基础篇”内容的学习，你有没有总结出一套高效地构建自己的知识体系的路径呢？

今天这篇文章，我就重点和你分享一下，从一个新人到专家的学习路径，希望帮你少走一些弯路，快速成长。这条路径里面不仅有我的亲身经历，更有我观察到的、身边的那些优秀开发者的经历。所以，你大可放心地按照这条路径走下去，达到领域专家的水平一定是没有问题的。

在我看来，iOS 开发者，可以分为这么三类：刚跨入 iOS 领域的开发者、有一定 iOS 实际开发经验的开发者，以及 iOS 开发老手。接下来，我就和你聊聊这三类人分别应该如何精进自己的 iOS 开发能力，成为这个领域的专家。

在我开始讲述这三类人的成长路径之前，我先和你分享一下完全 0 基础想要学习 iOS 开发的群体，可以参考什么样的资料来入门。

如果你喜欢通过书籍来学习的话，我推荐你去看看《iOS 编程》（iOS Programming）这本书。这本书的内容，包括了开发语言、Cocoa 设计模式和控件使用、Xcode 技巧等，涉及 iOS 开发基础的方方面面。因此，它非常适合 iOS 编程 0 基础的人阅读，在 Quora 上被评为 iOS 最佳入门书。而且每次 iOS 系统和开发语言增加了新特性，这本书都会进行同步的版本更新。

如果你习惯于通过手把手的实例来学习的话，我推荐你看一下APPCODA网站。这里面每篇教程都非常简单易懂，而且详细，可以带着你一步一步地动手编写程序，非常适合初学者。同时，这个网站的更新也很频繁。

当然了，不排除以后还会有小学生朋友阅读这个专栏，那么如果你还在读小学或者初中，希望能够自己开发 App 的话，你可以看一下知乎里的这个问答“12 岁如何入门 iOS 编程？”。这个问题下的被赞的最多的回答里，列出了很多孩子编程成功的事例，相信也可以让你信心满满。

接下来，我们就具体看一下刚跨入 iOS 领域的开发者、有一定 iOS 实际开发经验的开发者，以及 iOS 开发老手分别应该选择什么样的进阶路径吧。

不贪基础知识

是不是总是有人和你说，不论做什么，都要先打好基础。但是，基础怎样才算打好了呢，却没有人可以替你做主。因为，基础知识涉及面实在是太广了，而且多偏理论，如不经过实践的检验，要想掌握好也不是件简单的事情。

但我这么说，并不是说基础就不重要了，必要的基础一定要掌握，而且还要掌握好。那么，对于 iOS 开发者来说，哪些基础是在开始就需要好好学的呢？

我先将新入 iOS 开发的人群进行一个细分，包括在校生、刚参加工作的应届生、从其他领域转向 iOS 开发的老手。

对于在校生来说，我推荐斯坦福大学在 iTunes U 上的 App 开发课程，网上还有同步的中文字幕项目。这个课程中包含了开发一个 App 需要的、最基础的知识，同时还会现场一步一步带领你来开发一些简单的 App。

这个课程中会涉及 MVC 架构、iOS 开发语言基础知识、多点触摸、动画、ViewController 的生命周期、Scroll View、多线程、Auto Layout、拖拽、TableView、Collection View、Text Field、持续化存储、弹窗、通知、整个 App 生命周期、Storyboards、Core Motion、摄像等内容。

跟着斯坦福的这个课程学完，动手开发几个简单 App 上线是没什么问题的。但是，你不要就此满足，因为真实工作中光靠这些知识是远远不够的，甚至光靠这些知识连面试都过不了。基于类似这样简单应用层面的知识，掌握斯坦福的这个课程就可以了，不要一味地贪多，后面还有更多值得深入学习的知识在等着你。而且，应用层面的基础知识根据实际工作需要再去学习也不迟。

对于刚参加工作的应届生，我就不推荐斯坦福的这个课程了。不是说不适用，而是对于应届生来说，可能一上来手头就会有开发任务，怎么能够快速地上手完成任务才是最重要的。因为，公司是不会等到你掌握好了各种开发知识以后，才让你开始工作的；而且，不同公司的产品使用的技术侧重点也会有很大差异，也等不到你掌握好各种开发知识后才让你开始工作。所以，对于这个阶段的 iOS 开发者来说，如何快速上手完成任务才是最重要的。

针对应届生，我推荐苹果官方 iOS 开发指南，虽然内容不多，但却能够帮你快速掌握开发思路。实际工作中碰到所需要的知识，首先翻看官方开发手册，先在这里面找解决方法。官方代码示例都很规范，分类也很清晰，内容也更全。从大块上，可以分为 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网站里翻翻它们的教程。这里的每一个教程都会详细指导你如何一步一步去实际完成一个项目，来达到掌握一个知识点的目的。RayWenderlich 里也有很详细的分类，你可以根据实际工作需要来学习。我的建议同样是，这里知识虽好，但不要贪多。

关于掌握了一定的基础知识后，如何继续学习来提升自己的 iOS 开发技能，在 5 年前唐巧写了篇博客“iOS 开发如何提高”、limboy 写了篇“自学 iOS 开发的一些经验”，里面提到的这些提高自己开发能力的方法，拿到今天依然适用。不过，学习终究是需要实践来验证的。

在进入实战阶段之前，为了避免少走弯路，你需要一份 iOS 最佳实践。这里有一份一直在维护的最佳实践指导，里面包含了完整的 App 开发生命周期，从 IDE 搭建工程最佳使用方式、基础库选择、架构选择、存储方式、资源管理、代码规范、安全、静态分析、崩溃分析、构建，到部署，甚至是 IAP(In-App Purchases 应用内支付) 都考虑到了。

实战过程中手册是最常用的，特别是 Swift 语言特性非常多，在还没能达到熟练的情况下，手册往往能够起到查漏补缺的效果。Swift 语言的手册就在它的官网，你可以点击这个链接查看；中文版手册，现在是由 SwiftGG 在维护，目前已经同步更新到了 Swift5。如果你想及时获得 Swift 的最新消息，可以订阅 Swift 官网的博客。

在实践中积累

基础知识不要贪多，但对于工作实践中涉及到的领域相关知识，我的建议是一定要贪多，而且越多越好。在实践中多多积累工作中涉及的相关知识，这种学习方法特别适合有了几年工作经验的开发者。此外，你还要时刻关注和你工作内容相关的领域知识的发展动向，并全面掌握，从而达到由量变到质变的效果，最终达到领域专家的水平。

举个例子吧。有一段时间，我的工作是和 App 性能相关的。这段时间，我会首先在网上收集一些其他公司在性能上做的事情，然后针对那些事情再去学习相关知识，平时还会通过订阅一些博客和技术团队的输出，持续关注性能这个领域。

等工作上做出了些成绩以后，我就会及时进行整理和总结。在这个过程中，再进行一些思考，多问问自己为什么这么做，还有没有更好的做法。最后再输出，看看其他人和团队的意见和建议。通过交流相互成长，独乐乐不如众乐乐，何乐而不为呢。

对于学习和积累什么样的知识，我的建议是，你一定要怀着一颗饥渴的心，查找资料时永远不要觉得够了。查到了很多资料后要多总结、多思考，这样才会有新的思路、新的方案出来。如果你细心观察 iOS 技术这几年的发展，就会发现很多方案刚开始都很简单，但是随着对底层的研究和思考后会出现更多更优解。

以内存监控方案为例。这个方案一开始是到 JetsamEvent 日志里找到那些由内存引起的系统强杀日志来做监控的，但是很难精确定位到问题。

后来，随着 fishhook 这种可以 Hook 系统 c 方法的库浮出水面，最终将其引入到了获取内存上限值的方案里。引入 fishhook 后的原理是，先找到分配内存都会执行的 c 函数 malloc_logger，然后使用 fishhook 去截获，并记录到日志里，从而可以精确分析出内存过大的那个对象是在哪里分配的内存。

所以，我也会在专栏里面，分享一些我在实际工作中积累的领域知识，帮你解决这部分的痛点。我相信，这些领域知识也正是你所需要的。

殊途同归，深挖知识

在上面的内容中，我提到说很多方案，都是在不断地研究底层原理的基础上日趋完善的。由此看出，在基础知识掌握到一定程度、领域知识积累到一定程度后，我们需要再继续深挖知识，也就是众多计算机细分领域中的通用底层知识。

在我看来，底层知识是最值得深挖的，不管哪个领域，殊途同归，底层都是需要持续学习的。这里我推荐 Michael Ash 的“The Complete Friday Q&A”。这本书里面涉及的内容，都是对一些知识点更深一层的探究，会让你了解更多内存、性能和系统内部的原理，帮你理解那些万变不离其宗的核心知识。

同样，我也会在专栏里面，通过 5 篇文章的篇幅，和你分享那些通用的底层知识，也就是系统内核 XNU、AOP、内存管理和编译这些主题。

当你 iOS 基础打牢了，也积累了很多实践经验，工作上也取得了一定成绩，那你也就成长为一名 iOS 开发老手了。这个时候，你可以选择一个方向持续地深入学习。在我看来，最好的深入学习方式就是从头开始实现一个技术雏型。

如果你想对 LLVM 编译器能够有更加深刻的理解，那就去实现一个简单的编译器或解释器。比如，这个最小的 C 编译器 OTCC（Obfuscated Tiny C Compiler）就是一个很好的实践。

如果你想更多地了解数据库的原理，除了看已有数据库的源码外，还可以自己动手开发一个简单的数据库项目。这里有个教程“Let’s Build a Simple Database”，可以一步步地教你如何开发一个简单的数据库。你可以照着这个教程，动手做起来。

甚至是，如果你想更多地了解操作系统，也可以学着从头创建一个。这样做，可以帮助你更深刻地理解分页、信号量、内存管理等知识。这里有个非常好的教程叫作“os-tutorial”，你可以跟着动手做起来，去学习怎么开发一个麻雀虽小五脏俱全的操作系统，包括系统启动、中断处理、屏幕输出键盘输入、basic libc、内存管理、文件存储系统、简单的 shell、用户模式、文本编辑器、多进程和调度等。

对于 iOS 老手来说，你能够使用已经掌握的技术栈，触类旁通地去接触其他领域来拓宽自己的技术视野。以服务端开发为例，使用 Perfect就能够用 Swift 语言来做服务器的开发。

Perfect 是由加拿大一个团队开发并维护的，这个团队的成员对技术都很有热情，热衷于用优秀的技术去做优秀的事情。所以，Perfect 不仅功能丰富，而且紧跟最新技术的发展，对 TensorFlow、NIO、MySQL、MongoDB、Ubuntu、Redis 的支持做的都很到位。

这也是我们作为 iOS 开发老手，需要借鉴并学习的。

小结

今天这篇文章，我和你总结了一下 iOS 的学习路径。

随着工作时间的增加，为了将工作做得更好，我们会去学习更多的知识。但是，学得越多就会发现缺失的知识越多，那么接下来的学习之路该何去何从呢？有没有办法少走弯路呢？如何才能够系统化学习呢？

我觉得如果你到了这一步，有了这些困惑，就应该好好去梳理自己的知识体系了，查漏补缺让自己的知识更体系化。

我参与过日活千万和日活过亿的 App 项目，团队规模和代码规模都很大，攻坚过很多难解问题，对于性能质量和开发效率也有过很多思考和实践，我都会在这个专栏中与你一一分享，希望能够对你有帮助。

现在，我们已经掌握了构建自己 iOS 开发知识体系的高效路径，接下来需要做的就是不断自我完善的过程了。

今天我留给你的课后思考题，就是请你来总结一下自己学习 iOS 开发的方法，并和我分享一下吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

21 | 除了 Cocoa，iOS还可以用哪些 GUI 框架开发？

你好，我是戴铭。

在专栏的第 2 篇文章“App 启动速度怎么做优化与监控？”中，我和你分享了如何实现方法级别的启动优化，从而实现整个 App 启动速度的优化。

通过这篇文章的内容你会发现，把可以优化方法的工作都处理完之后，比如主线程上的大量计算、IO 操作、懒加载（也叫作延时加载，即当对象需要用到的时候再去加载），就只剩下了 GUI（Graphical User Interface 图形用户界面） 相关的方法。

在 iOS 开发时，默认使用的都是系统自带的 Cocoa Touch 框架，所以如果你还想进一步提高界面响应速度，赶超其他使用 Cocoa Touch 框架的 App 用户体验时，就要考虑使用其他的 GUI 框架来优化 App 界面的响应速度了。

接下来，我们就一起聊聊除了 Cocoa Touch 框架外，还有哪些 GUI 框架可以用来做 iOS 开发。

目前流行的 GUI 框架

现在流行的 GUI 框架除了 Cocoa Touch 外，还有 WebKit、Flutter、Texture（原名 AsyncDisplayKit）、Blink、Android GUI 等。其中，WebKit、Flutter、Texture 可以用于 iOS 开发。接下来，我就和你说说这三款 GUI 框架。

WebKit 框架包含了 WebCore 和 JavaScriptCore，使用 HTML 和 CSS 进行布局，使用 JavaScript 编写程序。WebKit 还提供了 Objective-C 应用程序编程接口，方便基于 Cocoa API 的应用使用。在 iOS 开发中，我们最常使用的 UIWebView 和 WKWebView 控件都是基于 WebKit 框架。

关于 WebKit 框架，我以前写过一篇博客“深入剖析 WebKit”，详细分析了它的原理。感兴趣的话，你可以去看一下。

Flutter 是 Google 公司于 2017 年推出的一个移动应用开发的 GUI 框架，使用 Dart 语言编写程序，一套代码可以同时运行在 iOS 和 Android 平台。对 Flutter 的相关介绍，我会在专栏后面的文章“React Native、Flutter 等跨端方案，应该怎么选？”和“iOS 原生、大前端和 Flutter 分别是怎么渲染的？”里，和你详细说明。

Texture 框架的基本单元，是基于 UIView 抽象的节点 ASDisplayNode。和 UIView 不同的是 ，ASDisplayNode 是线程安全的，可以在后台线程上并行实例化和配置整个层级结构。Texture 框架的开发语言，使用的是苹果公司自家的 Objective-C 和 Swift。

WebKit、Flutter、Texture 这三个 GUI 框架，与 Cocoa Touch 的对比，如下图所示：

图 1 WebKit、Flutter、Texture 框架，与 Cocoa Touch 的对比

通过这个对比，我们可以发现，Texture 框架和 Cocoa Touch 框架，在使用的编程语言、渲染这两个方面，是完全一样的。其实，Texture 框架，正是建立在 Cocoa Touch 框架之上的。

我们再从这些框架使用的布局来看一下，Texture 和其他 GUI 框架一样都是使用的应用更加广泛的 FlexBox 布局。使用 FlexBox 布局的好处是，可以让 iOS 开发者用到前端先进的 W3C 标准响应式布局。目前， FlexBox 已经是布局的趋势，连 iOS 新推出的 UIStackView 布局方式，也是按照 FlexBox 布局思路来设计的。

另外，Texture 是这些框架中唯一使用异步节点计算的框架。使用异步节点计算，可以提高主线程的响应速度。所以，Texture 在节点计算上的效率要比其他框架高。

基于以上三个方面的原因，如果要从 Cocoa Touch 框架前移到其他的 GUI 框架，从学习成本、收益等角度考虑的话，转到 Texture 会是个不错的选择。

因此，我会和你重点分析一下 Texture 框架。因为现在的 GUI 技术已经非常成熟了，各种 GUI 框架的底层也大同小异，所以接下来我会先和你介绍 GUI 框架中的通用性内容，然后再与你讲述 Texture 的独特之处。

GUI 框架里都有什么？

GUI 框架的基本单元是控件，你熟悉的按钮、图片、文本框等等就是控件。

控件主要负责界面元素数据的存储和更新，这些原始数据都存储在控件的属性上，直接更新控件的属性就能够完成界面元素更新操作，控件的属性设置成不同的值会让界面元素呈现不同的外观。

控件之间的关系是由渲染树（Render Tree）这种抽象的树结构来记录的。渲染树关注的是界面的布局，控件在界面中的位置和大小都是由渲染树来确定。

基于渲染树，GUI 框架还会创建一个渲染层树（RenderLayer Tree），渲染层树由渲染层对象组成，根据 GUI 框架的优化条件来确定创建哪些渲染层对象，每次新创建一个渲染层对象就会去设置它的父对象和兄弟对象。渲染层对象创建完毕，接下来就需要将各渲染层对象里的控件按照渲染树布局生成 Bitmap，最后 GPU 就可以渲染 Bitmap 来让你看到界面了。

控件、渲染树、渲染层树之间的关系，如下图所示：

图 2 控件、渲染树、渲染层树之间的关系

WebKit 和 Flutter 都是开源项目，我们可以通过它们的代码看到 GUI 框架具体是怎么实现控件、渲染树、渲染层树和生成 Bitmap 的。

WebKit 在 GUI 框架层面的效率并不低，单就渲染来说，它的性能一点也不弱于 Cocoa Touch 和 Flutter 框架。

使用 WebKit 的网页显示慢，主要是由于 CSS（Cascading Style Sheet） 和 JavaScript 资源加载方式导致的。

同时，解析时 HTML、CSS、JavaScript 需要兼容老版本，JavaScript 类型推断失败会重来，列表缺少重用机制等原因，导致 WebKit 框架的整体性能没有其他框架好。

开始的时候，Flutter 也是基于 Chrome 浏览器引擎的。后来，谷歌公司考虑到 Flutter 的性能，所以去掉了 HTML、CSS、JavaScript 的支持，而改用自家的 Dart 语言以甩掉历史包袱。关于这方面的细节，你可以查看Flutter 创始人 Eric 的采访视频来了解 。

这些年来，虽然 GUI 框架百家争鸣，但其渲染技术却一直很稳定。接下来，我就和你详细说说 GUI 框架中的渲染过程。

渲染流程

GUI 框架中的渲染，一般都会经过布局、渲染、合成这三个阶段。

布局阶段要完成的主要工作，是依据渲染树计算出控件的大小和位置。WebKit 用 CSS 来布局，CSS 会提供 Frame 布局和 FlexBox 布局；Flutter 也支持 Frame 布局和 FlexBox 布局；Cocoa Touch 框架本身不支持 FlexBox 布局，但是通过 Facebook 的 Yoga 库也能够使用 FlexBox 布局。

由于 Frame 布局需要精确描述每个界面元素的具体位置和大小，无论从代码编写，还是从代码可读性上看，成本都会高很多。所以说，FlexBox 对于 iOS 开发来说是另外一种很好的选择。

渲染阶段的主要工作，是利用图形函数计算出界面的内容。一般情况下，对于 2D 平面的渲染都是使用 CPU 计算，对 3D 空间的渲染会使用 GPU 计算。

Cocoa Touch 和 Texture 框架使用的是 Core Animation，3D 使用的是 Metal 引擎。Flutter 使用的是 Skia，3D 使用的是 OpenGL(ES)。

在渲染这方面，我觉得 WebKit 做得更出色，考虑到多平台支持，WebKit 将渲染接口抽象了出来，实现层根据平台进行区分，比如在 iOS 上就用 CoreGraphics 来渲染，在 Android 就用 Skia 渲染。

合成阶段的主要工作，是合并图层。这样做的目的，主要是节省显示内存，只显示一屏需要的像素，也只进行这些像素数据的计算。这个过程完成后，再将所得数据进行光栅化处理，最后交给 GPU 渲染成你可以看到的 Bitmap。

关于 WebKit、Cocoa Touch、Flutter 框架渲染相关的内容，我会在后面“iOS 原生、大前端和 Flutter 分别是怎么渲染的？”的文章里和你详细说明。

通过上面的内容，我们可以看到，主流 GUI 框架的内容和渲染流程，区别并不是很大。

但 Texture 对于那些希望能够在用户交互体验上进行大幅提升的 iOS 开发者来说，很小的切换成本，同时性能能大幅提升的收益，其实是很有诱惑力的。

通过对 GUI 框架都包括什么和渲染流程的分析，再次印证了我们的观点：Texture 是个值得推荐的框架，不仅在于它与 Cocoa Touch 框架的相似使得学习成本较低，还得益于它本身在性能上的先进性。

那么，接下来我就再跟你说说 Texture 最大的优势是什么？以及它是怎么做到的？

Texture 里 Node 的异步绘制

Texture 最大的优势就是开发了线程安全的 ASDisplayNode，而且还能够很好的和 UIView 共生。这样的话，我们就可以在原有使用 UIView 开发的程序基础之上使用 Texture，而不用完全重构所有界面。

随着 CPU 多核技术的发展，界面渲染计算都在主线程完成，对于多核 CPU 来说确实是有些浪费。ASDisplayNode 是 UIView 和 CALayer 的抽象，能实现不在主线程执行视图的布局绘制和层级计算，充分发挥多核 CPU 的优势。

首先，我们来看看 Texture 最核心的线程安全节点 ASDisplayNode 是做什么的？

在 Cocoa Touch 框架里，当 CALayer 内容更新时会去找 CALayer 的 delegate，也就是 displayLayer: 方法。UIView 会实现 displayLayer: 这个 delegate 方法。UIView 里实现 drawRect: 这个 delegate 方法能够自定义 CALayer。

在 Texture 中，ASDisplayNode 替代了这个 delegate，解耦了 UIView 和 CALayer，并将 UIView 包装成 ASDisplayView，将 CALayer 包装成 ASDisplayLayer 供外部使用。

然后，我们再来看看ASDisplayNode 是如何进行异步绘制的？

ASDisplayLayer 是整个绘制的起点，绘制事件先在 displayBlock 设置好，然后 ASDisplayNode 调用 displayBlock 来进行异步绘制。整个过程分为三步。

第一步，得到 displayBlock。这个 Block 里有需要绘制的内容，对应的代码如下：

asyncdisplaykit_async_transaction_operation_block_t displayBlock = [self _displayBlockWithAsynchronous:asynchronously isCancelledBlock:isCancelledBlock rasterizing:NO];

其中，displayBlock 就是需要绘制的内容。

第二步，定义一个回调 completionBlock ，绘制完成后执行。代码如下：

asyncdisplaykit_async_transaction_operation_completion_block_t completionBlock = ^(id<NSObject> value, BOOL canceled){

  ASDisplayNodeCAssertMainThread();

  if (!canceled && !isCancelledBlock()) {

    // displayBlock 执行的是绘图操作，返回的类型是 UIImage

    UIImage *image = (UIImage *)value;

    BOOL stretchable = (NO == UIEdgeInsetsEqualToEdgeInsets(image.capInsets, UIEdgeInsetsZero));

    if (stretchable) {

      ASDisplayNodeSetResizableContents(layer, image);

    } else {

      layer.contentsScale = self.contentsScale;

      // 设置为 CALayer 的寄宿图

      layer.contents = (id)image.CGImage;

    }

    [self didDisplayAsyncLayer:self.asyncLayer];

    if (rasterizesSubtree) {

      ASDisplayNodePerformBlockOnEverySubnode(self, NO, ^(ASDisplayNode * _Nonnull node) {

        [node didDisplayAsyncLayer:node.asyncLayer];

      });

    }

  }

};

上面代码中，completionBlock 里就是绘制完成后需要去执行的事情，内容是完成 CALayer 寄宿图的设置。 value 是 displayBlock 返回的值，类型是 UIImage。displayBlock 用的是线程安全的 Core Graphics，所以你可以安心地把 displayBlock 放到后台线程去异步执行。

第三步，如果设置为异步展示，就先向上找到属性 asyncdisplaykit_parentTransactionContainer 为 YES 的 CALayer，获取 containerLayer 的 ASAsyncTransaction，然后将 displayBlock 添加到 ASAsyncTransaction 的调度队列里，根据 drawingPriority 优先级执行 displayBlock。具体代码如下：

if (asynchronously) {

  // 向上找到属性 asyncdisplaykit_parentTransactionContainer 为 YES 的 CALayer

  CALayer *containerLayer = layer.asyncdisplaykit_parentTransactionContainer ? : layer;

  // 获取 containerLayer 的 ASAsyncTransaction

  _ASAsyncTransaction *transaction = containerLayer.asyncdisplaykit_asyncTransaction;

  // 将 displayBlock 添加到 ASAsyncTransaction 的调度队列里

  [transaction addOperationWithBlock:displayBlock priority:self.drawingPriority queue:[_ASDisplayLayer displayQueue] completion:completionBlock];

} else {

  // 设置为不是异步就直接调用 displayBlock 进行绘制

  UIImage *contents = (UIImage *)displayBlock();

  completionBlock(contents, NO);

}

可以看到，displayBlock 被添加到 ASAsyncTransaction 里进行调度。这里，ASAsyncTransactionQueue 是 Texture 的调度队列。

通过上面这三步，Texture 就完成了 ASDisplayNode 的异步绘制。

小结

在今天这篇文章中，我和你介绍了目前可以用于 iOS 开发的主流 GUI 框架有哪些，这些框架里都有什么，以及它们的渲染流程是怎样的。

通过今天的介绍，你会发现在选择 GUI 框架时，渲染方面的区别并不大，而且渲染技术相对比较成熟。所以，我们需要在框架的易用性，以及与现有工程的兼容上做更多的考虑。

如果你想提高 App 的使用体验，让界面操作更流畅的话，我推荐你使用 Texture。Texture 易用性和兼容性都很好，同时 Texture 的学习成本与收益比也是最高的。而且，Texture 代码本身的质量很高，有很多值得学习的地方。

课后作业

今天我跟你介绍了 ASDisplayNode 异步绘制的三个步骤，你能说清楚 ASAsyncTransaction 是如何调度 displayBlock 的吗？请你把答案留言留言给我吧（提示：你可以去翻看一下 Texture 的源码）。

当然了，我还为你准备了一个动手题，来帮助你巩固今天所学的内容。请你使用 Texture 来写一个列表，完成后上传到 GitHub 上吧。同时，记得将 GitHub 的地址贴到评论区，我们一起学习，共同进步。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

22 | 细说 iOS 响应式框架变迁，哪些思想可以为我所用？

你好，我是戴铭。

说到 iOS 响应式框架，最开始被大家知道的是 ReactiveCocoa（简称 RAC），后来比较流行的是 RxSwift。但据我了解，iOS 原生开发使用 ReactiveCocoa 框架的团队并不多，而前端在推出 React.js 后，响应式思路遍地开花。

那么，响应式框架到底是什么，为什么在 iOS 原生开发中没被广泛采用，却能在前端领域得到推广呢？

我们先来看看响应式框架，它指的是能够支持响应式编程范式的框架。使用了响应式框架，你在编程时就可以使用数据流传播数据的变化，响应这个数据流的计算模型会自动计算出新的值，将新的值通过数据流传给下一个响应的计算模型，如此反复下去，直到没有响应者为止。

React.js 框架的底层有个 Virtual DOM（虚拟文档对象模型），页面组件状态会和 Virtual DOM 绑定，用来和 DOM（文档对象模型）做映射与转换。当组件状态更新时，Virtual DOM 就会进行 Diff 计算，最终只将需要渲染的节点进行实际 DOM 的渲染。

JavaScript 每次操作 DOM 都会全部重新渲染，而 Virtual DOM 相当于 JavaScript 和 DOM 之间的一个缓存，JavaScript 每次都是操作这个缓存，对其进行 Diff 和变更，最后才将整体变化对应到 DOM 进行最后的渲染，从而减少没必要的渲染。

React.js 的 Virtual DOM 映射和转换 DOM 的原理，如下图所示。我们一起通过原理，来分析一下它的性能提升。

可以看出，操作 Virtual DOM 时并不会直接进行 DOM 渲染，而是在完成了 Diff 计算得到所有实际变化的节点后才会进行一次 DOM 操作，然后整体渲染。而 DOM 只要有操作就会进行整体渲染。

直接在 DOM 上进行操作是非常昂贵的，所以视图组件会和 Virtual DOM 绑定，状态的改变直接更改 Virtual DOM。Virtual DOM 会检查两个状态之间的差异，进行最小的修改，所以 React.js 具有很好的性能。也正是因为性能良好，React.js 才能够在前端圈流行起来。

而反观 iOS，ReactiveCocoa 框架的思路，其实与 React.js 中页面组件状态和 Virtual DOM 绑定、同步更新的思路是一致的。那为什么 ReactiveCocoa 在 iOS 原生开发中就没流行起来呢？

我觉得，主要原因是前端 DOM 树的结构非常复杂，进行一次完整的 DOM 树变更，会带来严重的性能问题，而有了 Virtual DOM 之后，不直接操作 DOM 可以避免对整个 DOM 树进行变更，使得我们不用再担忧应用的性能问题。

但是，这种性能问题并不存在于 iOS 原生开发。这，主要是得易于 Cocoa Touch 框架的界面节点树结构要比 DOM 树简单得多，没有前端那样的历史包袱。

与前端 DOM 渲染机制不同，Cocoa Touch 每次更新视图时不会立刻进行整个视图节点树的重新渲染，而是会通过 setNeedsLayout 方法先标记该视图需要重新布局，直到绘图循环到这个视图节点时才开始调用 layoutSubviews 方法进行重新布局，最后再渲染。

所以说，ReactiveCocoa 框架并没有为 iOS 的 App 带来更好的性能。当一个框架可有可无，而且没有明显收益时，一般团队是没有理由去使用的。那么，像 ReactiveCocoa 这种响应式思想的框架在 iOS 里就没有可取之处了吗？

我觉得并不是。今天，我就来跟你分享下，ReactiveCocoa 里有哪些思想可以为我所用，帮我们提高开发效率？

ReactiveCocoa 是将函数式编程和响应式编程结合起来的库，通过函数式编程思想建立了数据流的通道，数据流动时会经过各种函数的处理最终到达和数据绑定的界面，由此实现了数据变化响应界面变化的效果。

Monad

ReactiveCocoa 是采用号称纯函数式编程语言里的 Monad 设计模式搭建起来的，核心类是 RACStream。我们使用最多的 RACSignal（信号类，建立数据流通道的基本单元） ，就是继承自 RACStream。RACStream 的定义如下：

typedef RACStream * (^RACStreamBindBlock)(id value, BOOL *stop);

/// An abstract class representing any stream of values.

///

/// This class represents a monad, upon which many stream-based operations can

/// be built.

///

/// When subclassing RACStream, only the methods in the main @interface body need

/// to be overridden.

@interface RACStream : NSObject

+ (instancetype)empty;

+ (instancetype)return:(id)value;

- (instancetype)bind:(RACStreamBindBlock (^)(void))block;

- (instancetype)concat:(RACStream *)stream;

- (instancetype)zipWith:(RACStream *)stream;

@end

通过定义的注释可以看出，RACStream 的作者也很明确地写出了 RACStream 类表示的是一个 Monad，所以我们在 RACStream 上可以构建许多基于数据流的操作；RACStreamBindBlock，就是用来处理 RACStream 接收到数据的函数。那么，Monad 就一定是好的设计模式吗？

从代码视觉上看，Monad 为了避免赋值语句做了很多数据传递的管道工作。这样的话，我们在分析问题时，就很容易从代码层面清晰地看出数据流向和变化。而如果是赋值语句，在分析数据时就需要考虑数据状态和生命周期，会增加调试定位的成本，强依赖调试工具去观察变量。

从语言发展来看，Monad 虽然可以让上层接口看起来很简洁，但底层的实现却犹如一团乱麻。为了达到“纯”函数效果，Monad 底层将各种函数的参数和返回值封装在了类型里，将本来可以通过简单数据赋值给变量记录的方式复杂化了。

不过无论是赋值方式还是 Monad 方式，编译后生成的代码都是一样的。王垠在他的博文“函数式语言的宗教”里详细分析了 Monad，并且写了两段分别采用赋值和函数式的代码，编译后的机器码实际上是一样的。如果你感兴趣的话，可以看一下这篇文章。

所以，如果你不想引入 ReactiveCocoa 库，还想使用函数响应式编程思想来开发程序的话，完全不用去重新实现一个采用 Monad 模式的 RACStream，只要在上层按照函数式编程的思想来搭建数据流管道，在下层使用赋值方式来管理数据就可以了。并且，采用这种方式，可能会比 Monad 这种“纯”函数来得更加容易。

函数响应式编程例子

接下来，我通过一个具体的案例来和你说明下，如何搭建一个不采用 Monad 模式的函数响应式编程框架。

这个案例要完成的功能是：添加学生基本信息，添加完学生信息后，通过按钮点击累加学生分数，每次点击按钮分数加 5；所得分数在 30 分内，颜色显示为灰色；分数在 30 到 70 分之间，颜色显示为紫色；分数在 70 分内，状态文本显示不合格；超过 70 分，分数颜色显示为红色，状态文本显示合格。初始态分数为 0，状态文本显示未设置。

这个功能虽然不难完成，但是如果我们将这些逻辑都写在一起，那必然是条件里套条件，当要修改功能时，还需要从头到尾再捋一遍。

如果把逻辑拆分成小逻辑放到不同的方法里，当要修改功能时，查找起来也会跳来跳去，加上为了描述方法内逻辑，函数名和参数名也需要非常清晰。这，无疑加重了开发和维护成本，特别是函数里面的逻辑被修改了后，我们还要对应着修改方法名。否则，错误的方法名，将会误导后来的维护者。

那么，使用函数响应式编程方式会不会好一些呢？

这里，我给出了使用函数响应式编程方式的代码，你可以对比看看是不是比条件里套条件和方法里套方法的写法要好。

首先，创建一个学生的记录，在创建记录的链式调用里添加一个处理状态文本显示的逻辑。代码如下：

// 添加学生基本信息

self.student = [[[[[SMStudent create]

                   name:@"ming"]

                  gender:SMStudentGenderMale]

                 studentNumber:345]

                filterIsASatisfyCredit:^BOOL(NSUInteger credit){

                    if (credit >= 70) {

                        // 分数大于等于 70 显示合格

                        self.isSatisfyLabel.text = @" 合格 ";

                        self.isSatisfyLabel.textColor = [UIColor redColor];

                        return YES;

                    } else {

                        // 分数小于 70 不合格

                        self.isSatisfyLabel.text = @" 不合格 ";

                        return NO;

                    }

                }];

可以看出，当分数小于 70 时，状态文本会显示为“不合格”，大于等于 70 时会显示为“合格”。

接下来，针对分数，我再创建一个信号，当分数有变化时，信号会将分数传递给这个分数信号的两个订阅者。代码如下：

// 第一个订阅的 credit 处理

[self.student.creditSubject subscribeNext:^(NSUInteger credit) {

    NSLog(@" 第一个订阅的 credit 处理积分 %lu",credit);

    self.currentCreditLabel.text = [NSString stringWithFormat:@"%lu",credit];

    if (credit < 30) {

        self.currentCreditLabel.textColor = [UIColor lightGrayColor];

    } else if(credit < 70) {

        self.currentCreditLabel.textColor = [UIColor purpleColor];

    } else {

        self.currentCreditLabel.textColor = [UIColor redColor];

    }

}];

// 第二个订阅的 credit 处理

[self.student.creditSubject subscribeNext:^(NSUInteger credit) {

    NSLog(@" 第二个订阅的 credit 处理积分 %lu",credit);

    if (!(credit > 0)) {

        self.currentCreditLabel.text = @"0";

        self.isSatisfyLabel.text = @" 未设置 ";

    }

}];

可以看出，这两个分数信号的订阅者分别处理了两个功能逻辑：

第一个处理的是分数颜色；

第二个处理的是初始状态下状态文本的显示逻辑。

整体看起来，所有的逻辑都围绕着分数这个数据的更新自动流动起来，也能够很灵活地通过信号订阅的方式进行归类处理。

采用这种编程方式，上层实现方式看起来类似于 ReactiveCocoa，而底层实现却非常简单，将信号订阅者直接使用赋值的方式赋值给一个集合进行维护，而没有使用 Monad 方式。底层对信号和订阅者的实现代码如下所示：

@interface SMCreditSubject : NSObject

typedef void(^SubscribeNextActionBlock)(NSUInteger credit);

+ (SMCreditSubject *)create;

// 发送信号

- (SMCreditSubject *)sendNext:(NSUInteger)credit;

// 接收信号

- (SMCreditSubject *)subscribeNext:(SubscribeNextActionBlock)block;

@end

@interface SMCreditSubject()

@property (nonatomic, assign) NSUInteger credit; // 积分

@property (nonatomic, strong) SubscribeNextActionBlock subscribeNextBlock; // 订阅信号事件

@property (nonatomic, strong) NSMutableArray *blockArray; // 订阅信号事件队列

@end

@implementation SMCreditSubject

// 创建信号

+ (SMCreditSubject *)create {

    SMCreditSubject *subject = [[self alloc] init];

    return subject;

}

// 发送信号

- (SMCreditSubject *)sendNext:(NSUInteger)credit {

    self.credit = credit;

    if (self.blockArray.count > 0) {

        for (SubscribeNextActionBlock block in self.blockArray) {

            block(self.credit);

        }

    }

    return self;

}

// 订阅信号

- (SMCreditSubject *)subscribeNext:(SubscribeNextActionBlock)block {

    if (block) {

        block(self.credit);

    }

    [self.blockArray addObject:block];

    return self;

}

#pragma mark - Getter

- (NSMutableArray *)blockArray {

    if (!_blockArray) {

        _blockArray = [NSMutableArray array];

    }

    return _blockArray;

}

如上面代码所示，订阅者都会记录到 blockArray 里，block 的类型是 SubscribeNextActionBlock。

最终，我们使用函数式编程的思想，简单、高效地实现了这个功能。这个例子完整代码，你可以点击这个链接查看。

小结

今天这篇文章，我和你分享了 ReactiveCocoa 这种响应式编程框架难以在 iOS 原生开发中流行开的原因。

从本质上看，响应式编程没能提高 App 的性能，是其没能流行起来的主要原因。

在调试上，由于 ReactiveCocoa 框架采用了 Monad 模式，导致其底层实现过于复杂，从而在方法调用堆栈里很难去定位到问题。这，也是 ReactiveCocoa 没能流行起来的一个原因。

但， ReactiveCocoa 的上层接口设计思想，可以用来提高代码维护的效率，还是可以引入到 iOS 开发中的。

ReactiveCocoa 里面还有很多值得我们学习的地方，比如说宏的运用。对此感兴趣的话，你可以看看 sunnyxx 的那篇《Reactive Cocoa Tutorial [1] = 神奇的 Macros》。

对于 iOS 开发来说，响应式编程还有一个很重要的技术是 KVO，使用 KVO 来实现响应式开发的范例可以参考我以前的一个 demo。如果你有关于 KVO 的问题，也欢迎在评论区给我留言。

课后作业

在今天这篇文章里面，我和你聊了 Monad 的很多缺点，不知道你是如何看待 Monad 的，在评论区给我留言分享下你的观点吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

23 | 如何构造酷炫的物理效果和过场动画效果？

你好，我是戴铭。今天，我要和你分享的是如何为你 App 添加酷炫的动画效果。

不论是 iOS 开发，还是 Android 开发，现在的动画库差不多都需要手动去编写动画代码。这样的话，iOS 和 Android 开发者就需要分别去编写适合自己系统的代码。而且，手动编写动画的代码也非常复杂，不容易维护，很多动画细节的调整还需要和动画设计师不断沟通打磨，尤其是千行以上的动画代码编写、维护、沟通的成本巨大。

手动编写动画代码，除了会影响到开发者外，动画设计师也难以幸免。一款产品适配的平台越多，动画设计师设计走查的周期就越长，相应的动画成本就越高。同时，动画设计师很兴奋地设计出一套炫酷地动画效果后，在要通过开发者实现出来时，却因为工时评估过长而一再被简化，甚至被直接取消。试想一下，以后他还会动力十足地去设计酷炫的动画效果吗？

所以，你会发现现在有酷炫的动画效果的 App 非常少，而且多是出自个人开发者之手。那么，这就提高了对个人开发者的要求，不但要求他代码写得好，还要能够设计出好的动画效果。但是，这样的人才也是不可多得。

那，到底有没有什么办法能够把动画制作和 App 开发隔离开，专人做专事，而且还能使得多个平台的动画效果保持一致呢？

办法总比困难多。接下来，我们就一起看看如何实现的问题吧。

Lottie

Lottie 框架就很好地解决了动画制作与开发隔离，以及多平台统一的问题。

Lottie 是 Airbnb 开源的一个动画框架。Lottie 这个名字来自于一名德国导演洛特·赖尼格尔（Lotte Reiniger），她最著名的电影叫作“阿赫迈德王子历险记（The Adventures of Prince Achmed）”。这个框架和其他的动画框架不太一样，动画的编写和维护将由动画设计师完成，完全无需开发者操心。

动画设计师做好动画以后，可以使用After Effects将动画导出成 JSON 文件，然后由 Lottie 加载和渲染这个 JSON 文件，并转换成对应的动画代码。由于是 JSON 格式，文件也会很小，可以减少 App 包大小。运行时还可以通过代码控制更改动画，比如更改颜色、位置以及任何关键值。另外，Lottie 还支持页面切换的过场动画（UIViewController Transitions）。

下面的两张动画，就是使用 Lottie 做出来的效果。

上面这些动画，就是由动画设计师使用 After Effects 创作，然后使用 Bodymovin进行导出的，开发者完全不用做什么额外的代码工作，就能够使用原生方式将其渲染出来。

Bodymovin 是 Hernan Torrisi 做的一个 After Effects 的插件，起初导出的 JSON 文件只是通过 JavaScript 在网页中进行动画的播放，后来才将 JSON 文件的解析渲染应用到了其他平台上。

那么，如何使用 Bodymovin 呢？

Bodymovin

你需要先到Adobe 官网下载 Bodymovin 插件，并在 After Effects 中安装。使用 After Effects 制作完动画后，选择 Windows 菜单，找到 Extensions 的 Bodymovin 项，在菜单中选择 Render 按钮就可以输出 JSON 文件了。

LottieFiles 网站还是一个动画设计师分享作品的平台，每个动画效果的 JSON 文件都可下载使用。所以，如果你现在没有动画设计师配合的话，可以到这个网站去查找并下载一个 Bodymovin 生成的 JSON 文件，然后运用到工程中去试试效果。

在 iOS 中使用 Lottie

在 iOS 开发中使用 Lottie 也很简单，只要集成 Lottie 框架，然后在程序中通过 Lottie 的接口控制 After Effects 生成的动画 JSON 就行了。

首先，你可以通过 CocoaPods 集成 Lottie 框架到你工程中。Lottie iOS 框架的 GitHub 地址是https://github.com/airbnb/lottie-ios/，官方也提供了可供学习的示例。

然后，快速读取一个由 Bodymovin 生成的 JSON 文件进行播放。具体代码如下所示：

LOTAnimationView *animation = [LOTAnimationView animationNamed:@"Lottie"];

[self.view addSubview:animation];

[animation playWithCompletion:^(BOOL animationFinished) {

  // 动画完成后需要处理的事情

}];

利用 Lottie 的动画进度控制能力，还可以完成手势与动效同步的问题。动画进度控制是 LOTAnimationView 的 animationProgress 属性，设置属性的示例代码如下：

CGPoint translation = [gesture getTranslationInView:self.view];

CGFloat progress = translation.y / self.view.bounds.size.height;

animationView.animationProgress = progress;

Lottie 还带有一个 UIViewController animation-controller，可以自定义页面切换的过场动画，示例代码如下：

#pragma mark -- 定制转场动画

// 代理返回推出控制器的动画

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {

  LOTAnimationTransitionController *animationController = [[LOTAnimationTransitionController alloc] initWithAnimationNamed:@"vcTransition1" fromLayerNamed:@"outLayer" toLayerNamed:@"inLayer" applyAnimationTransform:NO];

  return animationController;

}

// 代理返回退出控制器的动画

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {

  LOTAnimationTransitionController *animationController = [[LOTAnimationTransitionController alloc] initWithAnimationNamed:@"vcTransition2" fromLayerNamed:@"outLayer" toLayerNamed:@"inLayer" applyAnimationTransform:NO];

  return animationController;

}

Lottie 在运行期间提供接口和协议来更改动画，有动画数据搜索接口 LOTKeyPath，以及设置动画数据的协议 LOTValueDelegate。详细的说明和使用示例代码，你可以参看官方 iOS 教程。

多平台支持

Lottie 支持多平台，除了 支持iOS，还支持 Android 、React Native和Flutter。除了官方维护的这些平台外，Lottie 还支持Windows、Qt、Skia 。陈卿还实现了 React、Vue和Angular对 Lottie 的支持，并已将代码放到了 GitHub 上。

有了这么多平台的支持，对于动画设计师来说，可以安心做动画，只要简单地转换就可以完美展现动画效果，再也不用担心到开发者那里动画效果被大打折扣了。而对于开发者来说，再也不用写那些难以维护的大量动效代码了，而且 App 安装包的体积还变小了。

那么，这么神奇的框架，在 iOS 里到底是怎么实现的呢？接下来，我们就看下 Lottie 的实现原理吧。

通过原理的学习，你会掌握通过 JSON 来控制代码逻辑的能力。比如，你可以把运营活动流程的代码逻辑设计为一种规范，再设计一个拖拽工具用来创建运营活动流程，最后生成一份表示运营活动逻辑的 JSON，下发到 App 内来开启新的运营活动。

Lottie 实现原理

实际上，Lottie iOS在 iOS 内做的事情就是将 After Effects 编辑的动画内容，通过 JSON 文件这个中间媒介，一一映射到 iOS 的 LayerModel、Keyframe、ShapeItem、DashElement、Marker、Mask、Transform 这些类的属性中并保存了下来，接下来再通过 CoreAnimation 进行渲染。这就和你手动写动画代码的实现是一样的，只不过这个过程的精准描述，全部由动画设计师通过 JSON 文件输入进来了。

Lottie iOS 使用系统自带的 Codable 协议来解析 JSON 文件，这样就可以享受系统升级带来性能提升的便利，比如 ShapeItem 这个类设计如下：

// Shape Layer

class ShapeItem: Codable {

  /// shape 的名字

  let name: String

  /// shape 的类型

  let type: ShapeType

  // 和 json 中字符映射

  private enum CodingKeys : String, CodingKey {

    case name = "nm"

    case type = "ty"

  }

  // 初始化

  required init(from decoder: Decoder) throws {

    let container = try decoder.container(keyedBy: ShapeItem.CodingKeys.self)

    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Layer"

    self.type = try container.decode(ShapeType.self, forKey: .type)

  }

}

通过上面代码可以看出，ShapeItem 有两个属性，映射到 JSON 的字符键值是 nm 和 ty，分别代表 shape 的名字和类型。下面，我们再一起看一段 Bodymovin 生成的 JSON 代码：

{"ty":"st","fillEnabled":true,"c":{"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":22,"s":[0,0.65,0.6,1],"e":[0.76,0.76,0.76,1]},{"t":36}]},"o":{"k":100},"w":{"k":3},"lc":2,"lj":2,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"}

在这段 JSON 代码中，nm 键对应的值是 Stroke 1，ty 键对应的值是 st。那我们再来看看，st 是什么类型。

我们知道，ShapeType 是个枚举类型，它的定义如下：

enum ShapeType: String, Codable {

  case ellipse = "el"

  case fill = "fl"

  case gradientFill = "gf"

  case group = "gr"

  case gradientStroke = "gs"

  case merge = "mm"

  case rectangle = "rc"

  case repeater = "rp"

  case round = "rd"

  case shape = "sh"

  case star = "sr"

  case stroke = "st"

  case trim = "tm"

  case transform = "tr"

}

通过上面的枚举定义，可以看到 st 对应的是 stroke 类型。

Lottie 就是通过这种方式，定义了一系列的类结构，可以将 JSON 数据全部映射过来。所有映射用的类都放在 Lottie 的 Model 目录下。使用 CoreAnimation 渲染的相关代码都在 NodeRenderSystem 目录下，比如前面举例的 Stoke。

在渲染前会生成一个节点，实现在 StrokeNode.swift 里，然后对 StokeNode 这个节点渲染的逻辑在 StrokeRenderer.swift 里。核心代码如下：

// 设置 Context

func setupForStroke(_ inContext: CGContext) {

  inContext.setLineWidth(width) // 行宽

  inContext.setMiterLimit(miterLimit)

  inContext.setLineCap(lineCap.cgLineCap) // 行间隔

  inContext.setLineJoin(lineJoin.cgLineJoin)

	// 设置线条样式

  if let dashPhase = dashPhase, let lengths = dashLengths {

    inContext.setLineDash(phase: dashPhase, lengths: lengths)

  } else {

    inContext.setLineDash(phase: 0, lengths: [])

  }

}

// 渲染

func render(_ inContext: CGContext) {

  guard inContext.path != nil && inContext.path!.isEmpty == false else {

    return

  }

  guard let color = color else { return }

  hasUpdate = false

  setupForStroke(inContext)

  inContext.setAlpha(opacity) // 设置透明度

  inContext.setStrokeColor(color) // 设置颜色

  inContext.strokePath()

}

这段代码看起来是不是就很熟悉了？

如果是手写动画，这些代码就需要不断重复地写。使用第三方库去写动画的话，也无非就是多封装了一层，而属性的设置、动画时间的设置等，还是需要手动添加很多代码来完成。

但是，使用 Lottie 后，你就完全不用去管这些代码了，只需要在 After Effects 那设置属性、控制动画时间就好了。

小结

今天这篇文章，我分享了一个制作动画的利器 Lottie，并和你说了如何在 iOS 中使用，以及它的实现原理。听到这，你一定感到奇怪， iOS 开发中还有很多优秀的动画框架，比如 Pop，但是为什么我只跟你说了 Lottie 呢？

因为在我看来， Lottie 这样的工作流程或许就是未来的趋势，就像 iOS 现在的发展趋势一样，越来越多的业务逻辑不再需要全部使用 Objective-C 或 Swift 来实现了，而是使用 JavaScript 语言或者 DSL 甚至是工具来描述业务，然后将描述业务的代码转换成一种中间代码，比如 JSON，不同平台再对相同的中间代码进行解析处理，以执行中间代码描述的业务逻辑。

这样做不仅可以减轻 App 包的大小，实现多端逻辑的统一处理，还可以让团队分工更加明确，一部分人专门开发业务代码，另一部分人负责端内稳定性、质量把控、性能提升工作的建设。

课后作业

相信你看到这，一定已经忍不住想小试身手了，那么就请你到 LottieFiles网站下载一个 JSON 文件，做一个 Lottie Demo 感受下吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

24 | A/B 测试：验证决策效果的利器

你好，我是戴铭。今天，我来跟你聊聊验证决策的利器 A/B 测试。

现在 App Store 中的应用，就像商场中的商品一样琳琅满目，可以解决用户各个方面的需求。这时，你要想创新，或者做出比竞品更优秀的功能，是越来越不容易。所以，很多公司都必须去做一些实验，看看有哪些功能可以增强自己 App 的竞争力，又有哪些功能可以废弃掉。而进行这样的实验的主要方法，就是 A/B 测试。

A/B 测试，也叫桶测试或分流测试，指的是针对一个变量的两个版本 A 和 B，来测试用户的不同反应，从而判断出哪个版本更有效，类似统计学领域使用的双样本假设测试。

简单地说，A/B 测试就是检查 App 的不同用户在使用不同版本的功能时，哪个版本的用户反馈最好。

比如，引导用户加入会员的按钮，要设置为什么颜色更能吸引他们加入，这时候我们就需要进行 A/B 测试。产品接触的多了，我们自然清楚一个按钮的颜色，会影响到用户点击它，并进入会员介绍页面的概率。

这里我再和你分享一件有意思的事儿。记得我毕业后去新西兰的那段时间里，认识了一个住在海边的油画家，她在海边还有一间画廊，出售自己的作品还有美院学生的作品。

有一天她要给画廊门面重涂油漆，叫我过去帮忙。涂漆之前问我用什么颜色好，我环顾下了旁边的店面，大多是黑色、灰色和深蓝色，而我觉得卖橄榄球衣服那家的黑底红字，看起来很帅气，于是就说黑色可能不错。

她想了想摇头说：我觉得橙色好，因为这附近都是暗色调，如果用了明亮的橙色可能更容易吸引游客。结果呢，后来一段时间进店的人确实多了，而且画也卖得多了。

当然了，我举这个例子的目的不是说用了橙色就一定能够提高用户进店率。试想一下，如果这个画廊周围都是花花绿绿的店面，你还能够保证橙色会吸引用户吗。

实际情况往往要比选择门面颜色更复杂，也只有有专业经验的人才可以做出正确的决策，但并不是每个人都是有相关领域经验的专家。所以，就有了 A/B 测试这一利器，来辅助我们进行决策。

知乎上有个关于A/B 测试的问答，里面列举了很多关于实际案例，有兴趣的话你可以去看看。接下来，我和你说说 iOS 中的 A/B 测试。

App 开发中的 A/B 测试

从 App 开发层面看，新版本发布频繁，基本上是每月或者每半月会发布一个版本。那么，新版本发布后，我们还需要观察界面调整后情况如何，性能问题修复后线上情况如何，新加功能使用情况如何等。这时，我们就需要进行 A/B 测试来帮助我们分析这些情况，通过度量每个版本的测试数据，来确定下一个版本应该如何迭代。

对于 App 版本迭代的情况简单说就是，新版本总会在旧版本的基础上做修改。这里，我们可以把旧版本理解为 A/B 测试里的 A 版本，把新版本理解为 B 版本。在 A/B 测试中 A 版本和 B 版本会同时存在，B 版本一开始是将小部分用户放到 B 测试桶里，逐步扩大用户范围，通过分析 A 版本和 B 版本的数据，看哪个版本更接近期望的目标，最终确定用哪个版本。

总的来说，A/B 测试就是以数据驱动的可回退的灰度方案，客观、安全、风险小，是一种成熟的试错机制。

A/B 测试全景设计

一个 A/B 测试框架主要包括三部分：

策略服务，为策略制定者提供策略；

A/B 测试 SDK，集成在客户端内，用来处理上层业务去走不同的策略；

日志系统，负责反馈策略结果供分析人员分析不同策略执行的结果。

其中，策略服务包含了决策流程、策略维度。A/B 测试 SDK 将用户放在不同测试桶里，测试桶可以按照系统信息、地址位置、发布渠道等来划分。日志系统和策略服务，主要是用作服务端处理的，这里我就不再展开了。

下图是 A/B 测试方案的结构图：

今天我主要跟你说下客户端内的 A/B 测试 SDK。从 iOS 开发者的角度看 A/B 测试，如何设计或选择一个好用的 A/B 测试 SDK 框架才是我们最关心的。

A/B 测试 SDK

谈到 A/B 测试 SDK 框架，我们需要首先要考虑的是生效机制。生效机制主要分为冷启动生效和热启动生效，相对于冷启动，热启动落实策略要及时些。但是，考虑到一个策略可能关联到多个页面或者多个功能，冷启动可以保持策略整体一致性。

所以我的结论是，如果一个策略只在一个地方生效的话，可以使用热启动生效机制；而如果一个策略在多个地方生效的话，最好使用冷启动生效机制。

除了生效机制，A/B 测试 SDK 框架对于业务方调用接口的设计也很重要。你所熟悉的著名 AFNetworking 网络库和 Alamofire 网络库的作者 Mattt ，曾编写过一个叫作SkyLab的 A/B 测试库。

SkyLab 使用的是 NSUserDefault 保存策略，使得每个用户在使用过程中，不管是在哪个测试桶里，都能够保持相同的策略。 SkyLab 对外的调用接口，和 AFNetworking 一样使用的是 Block ，来接收版本 A 和 B 的区别处理。这样设计的接口易用性非常高。

通过 SkeyLab 原理的学习，你能够体会到如何设计一个优秀易用的接口。这，对你开发公用库的帮助会非常大。

接下来，我们先看看 SkeyLab 接口使用代码，示例如下：

// A/B Test

[SkyLab abTestWithName:@"Title" A:^{

    self.titleLabel.text = NSLocalizedString(@"Hello, World!", nil);

} B:^{

    self.titleLabel.text = NSLocalizedString(@"Greetings, Planet!", nil);

}];

可以看出，Mattt 这个人的接口设计功底有多强了。你一看这两个 block 参数名称，就知道是用来做 A/B 测试的，简单明了。接下来，我们再进入接口看看 Mattt 是具体怎么实现的。

+ (void)abTestWithName:(NSString *)name

                     A:(void (^)())A

                     B:(void (^)())B

{

    [self splitTestWithName:name conditions:[NSArray arrayWithObjects:@"A", @"B", nil] block:^(NSString *choice) {

        if ([choice isEqualToString:@"A"] && A) {

            // 执行版本 A

            A();

        } else if ([choice isEqualToString:@"B"] && B) {

            // 执行版本 B

            B();

        }

    }];

}

你会发现 SkyLab:abTestWithName:A:B: 方法只是一个包装层，里面真正的实现是 SkyLab:splitTestWithName:conditions:block 方法，其定义如下：

+ (void)splitTestWithName:(NSString *)name

               conditions:(id <NSFastEnumeration>)conditions

                    block:(void (^)(id condition))block;

通过定义你会发现，conditions 参数是个 id 类型，通过类型约束，即使用 NSFastEnumeration 协议进行了类型限制。Mattt 是希望这个参数能够接收字典和数组，而字典和数组都遵循 NSFastEnumeration 协议的限制，两者定义如下：

@interface NSDictionary<__covariant KeyType, __covariant ObjectType> : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>

@interface NSArray<__covariant ObjectType> : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>

在这里，我和你介绍这个接口的设计方式，是因为这个设计非常赞，非常值得我们学习。类型约束，是苹果公司首先在 Swift 泛型引入的一个特性，后来引入到了 Objective-C 中。

而之所以设计 conditions 这个支持数组和字典的参数，本来是为了扩展这个 SkyLab 框架，使其不仅能够支持 A/B 测试，还能够支持更为复杂的 Multivariate testing或 Multinomial testing。Multivariate testing 和 Multinomial testing 的区别在于，支持更多版本变体来进行测试验证。

接下来，我们再看看 SkyLab 是如何做人群测试桶划分的。

SkyLab 使用的是随机分配方式，会将分配结果通过 NSUserDefaults 进行持续化存储，以确保测试桶的一致性。其实测试桶分配最好由服务端来控制，这样服务端能够随时根据用户群的维度分布分配测试桶。

如果你所在项目缺少服务端支持的话，SkyLab 对测试桶的分配方式还是非常值得借鉴的。SkyLab 对 A/B 测试的测试桶分配代码如下：

static id SLRandomValueFromArray(NSArray *array) {

    if ([array count] == 0) {

        return nil;

    }

    // 使用 arc4random_uniform 方法随机返回传入数组中某个值

    return [array objectAtIndex:(NSUInteger)arc4random_uniform([array count])];

}

代码中的 array 参数就是包含 A 和 B 两个版本的数组，随机返回 A 版本或 B 版本，然后保存返回版本。实现代码如下：

condition = SLRandomValueFromArray(mutableCandidates);

// 判断是否需要立刻进行同步保存

BOOL needsSynchronization = ![condition isEqual:[[NSUserDefaults standardUserDefaults] objectForKey:SLUserDefaultsKeyForTestName(name)]];

// 通过 NSUserDefaults 进行保存

[[NSUserDefaults standardUserDefaults] setObject:condition forKey:SLUserDefaultsKeyForTestName(name)];

if (needsSynchronization) {

    [[NSUserDefaults standardUserDefaults] synchronize];

}

持久化存储后，当前用户就命中了 A 和 B 版本中的一个，后续的使用会一直按照某个版本来，操作的关键数据会通过日志记录，并反馈到统计后台。至此，你就可以通过 A、B 版本的数据比较，来决策哪个版本更优了。

小结

今天我跟你说了 A/B 测试在产品中的重要性，特别是在 App 版本迭代时，A/B 测试可以帮助我们判断新版本的功能更新是否能够更好地服务用户。然后，我为你展示了 A/B 测试方案的全景设计，并针对其中 iOS 开发者最关注的 A/B 测试 SDK 的设计做了详细分享。

通过 Mattt 设计的 SkyLab 这个 A/B 测试 SDK 框架，你会发现好的接口设计不是凭空想出来的，而是需要一定的知识积累。比如，将泛型的类型约束引入到 Objective-C 中以提高接口易用性，这需要了解 Swift 才能够做到的。

今天我在看评论区的留言时，有同学问我现在应该学习 Objective-C 还是 Swift，为什么？我想，我们今天对 SkyLab 接口的分析应该就是最好的回答了。知识的学习最好结合工作需求来，无论是 Objective-C 还是 Swift，最重要的还是代码设计能力。

课后作业

今天我留给你一个作业，前面我提到 Swift 是值得学习的，那么今天的作业就是参照 SkyLab，使用 Swift 来写一个 A/B 测试 SDK。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

25 | 怎样构建底层的发布和订阅事件总线？

你好，我是戴铭。今天，我来跟你聊聊怎么构建事件总线。

事件总线是对发布和订阅设计模式的一种实现，通过发布、订阅可以将组件间一对一和一对多的耦合关系解开。这种设计模式，特别适合数据层通过异步发布数据的方式告知 UI 层订阅者，使得 UI 层和数据层可以不用耦合在一起，在重构数据层或者 UI 层时不影响业务层。

现在，我们先一起来捋一下 iOS 系统里有没有现成可用的技术，当数据层异步发布数据后，可以通过 Delegate 回调给 UI 层来进行展示，但是这个只适合一对一的模式。如果异步处理完后，还需要将数据发布给其他 UI 进行处理和展示的话，就需要继续发布给其他 Delegate，从而造成 Delegate 套 Delegate 的情况。

使用 Block 和使用 Delegate 的情况类似。如果需要不断异步发布给下一个数据订阅者的话，也会出现 Block 回调嵌套其他 Block 回调的情况。

iOS 系统里也有一对多模式的技术，比如 KVO 和 NSNotificationCenter。

使用 KVO 是强依赖属性的，只要更新了属性就会发布给所有的观察者，对应关系过于灵活，难以管控和维护。NSNotificationCenter 也有类似的问题，通过字符串来维护发布者和订阅者之间的关系，不仅可读性差，而且和 KVO 一样面临着难以管控和维护的情况。

总的来说，由于 Delegate 和 Block 只适合做一对一数据传递，KVO 和 NSNotificationCenter 虽然可以支持一对多的数据传递，但存在过于灵活而无法管控和维护的问题，而事件总线需要通过发布和订阅这种可管控方式实现一对一和一对多数据传递。由此可以看出，iOS 现有的 Delegate、Block、KVO、NSNotificationCenter 等技术并不适合来做事件总线。

既然 iOS 系统提供的技术没有适合做事件总线的，那么有没有好的第三方库可以处理事件总线呢？

其实，响应式第三方库 ReactiveCocoa 和 RxSwift 对事件总线的支持是没有问题的，但这两个库更侧重的是响应式编程，事件总线只是其中很小的一部分。所以，使用它们的话，就有种杀鸡焉用牛刀的感觉。

那么，事件总线有没有小而美的第三方库可用呢？

Promise

现在前端领域有一种模式叫作 Promise，这是一种专门针对异步数据操作编写的一套统一规则的模式。

本质上，这种模式本质是通过 Promise 对象保存异步数据操作，同时 Promise 对象提供统一的异步数据操作事件处理的接口。这样，事件总线的数据订阅和数据发布事件，就可以通过 Promise 对象提供的接口实现出来，比以前通过 Delegate 回调处理异步事件来说更加合理。

接下来，我们再一起看看，Promise 模式中的 Promise 对象是怎么运作的。

Promise 的概念最早是在 E 语言中被提出的。C++ 11 以 std :: promise 模板形式加入到标准库中，随后出现了 CommonJS Promises/A 规范，jQuery 将这个规范实现后引入到 jQuery 1.5 版本中。

Promise 模式大受欢迎后， ECMAScript 6 将其写入了语言标准，统一了用法，并提供了原生 的 Promise 对象。 Promise 对象里保存有异步事件，Promise 的统一接口，使得其他异步操作都能够用相同的接口来处理事件。

Promise 对象会有三种状态，分别是 pending、fulfilled、rejected：

pending 表示 Promise 对象当前正在等待异步事件处理中；

fulfilled 指的是 Promise 对象当前处理的异步事件已经成功完成；

rejected 表示 Promise 对象当前处理的异步事件没有成功。

Promise 对象还有两个重要的方法，分别是 then 和 catch。Promise 对象每次执行完 then 和 catch 方法后，这两个方法会返回先前的 Promise 对象，同时根据异步操作结果改变 Promise 对象的状态。

then 和 catch 方法与 Promise 对象状态更改关系，如下图所示：

如上图所示，执行 then 方法后返回的 Promise 对象是 rejected 状态的话，程序会直接执行 catch 方法。then 方法执行的就是订阅操作，Promise 对象触发 then 方法就是事件总线中的发布操作，then 方法执行完返回 Promise 对象能够继续同步执行多个 then 方法，由此，实现了一个发布操作对应多个订阅事件。

有了 Promise 对象后，整个异步发布和订阅操作都以同步操作的方式表现出来了。Promise 对象不仅能够避免回调层层嵌套，而且通过 Promise 的统一接口，使得事件总线的发布和订阅操作更加规范和易用。

PromiseKit

ECMAScript 6 已经内置了 Promise 对象，使得前端开发者无需引入其他库就能够直接使用 Promise 来进行日常开发。随后，Homebrew 的作者 Max Howell 开发了 PromiseKit，将 Promise 标准带到了 iOS 中。所以，现在 iOS 上也有了小而美的事件总线技术。

接下来，我就跟你介绍下如何使用 PromiseKit 吧，相信你一定会有种相见恨晚的感觉。

我们先来看看如何使用 Promise 对象的 then 和 catch 方法。

假设有这么一个需求：

首先，通过一个异步请求获取当前用户信息；

然后，根据获取到的用户信息里的用户编号再去异步请求获取用户的时间轴列表；

最后，将用户的时间轴列表数据，赋值给当前类的时间轴列表属性。

这里，我先给出使用 PromiseKit 实现的具体代码，然后我再和你分析其中的关键步骤。

使用 PromiseKit 实现的代码如下：

firstly {

    // 异步获取当前用户信息

    fetchUserInfo()

}.then { userInfo in

    // 使用异步获取到的用户信息中的 uid 再去异步获取用户的 timeline

    fetchUserTimeline(uid: userInfo.uid)

}.then { timeline in

    // 记录 timeline

    self.timeline = timeline

}.catch {

    // 整个方法链的错误都会在这处理

}

可以看出，多次异步请求通过 Promise 的方法调用，看起来就像进行同步操作一样，顺序和逻辑也更加清晰了。使用 then 方法可以让异步操作一个接着一个地按顺序进行。如果异步操作 fetchUserInfo 失败，会返回一个状态是 rejected 的 Promise 对象，返回的这个 Promise 对象会跳过后面所有的 then 方法直接执行 catch 方法。这就和事件总线中发布事件触发后，订阅事件会一个接一个执行是一样的。

除了 then 和 catch 方法以外，PromiseKit 还有一些好用的方法。

比如 always 方法。使用了 always 方法以后， Promise 对象每次在执行方法时，都会执行一次 always 方法。

再比如 when 方法。这个方法的使用场景就是，指定多个异步操作，等这些操作都执行完成后就会执行 when 方法。when 方法类似 GCD 里面的 Dispatch Group，虽然实现的功能一样，但是代码简单了很多，使用起来也更加方便。

PromiseKit 还为苹果的 API 提供了扩展。这些扩展需要单独集成，你可以在PromiseKit 组织页面获取。目前大部分常用的 API 都有扩展，比如 UIKit、Foundation、CoreLocation、QuartzCore、CloudKit 等等，甚至还支持了第三方的框架 Alamofire。

如果你觉得 PromiseKit 提供的扩展还不够，还想让你使用的第三方库也支持 Promises 的话，可以通过 PromiseKit 提供的扩展文档，或者直接查看已支持的第三方库（比如 Alamofire ）的扩展实现，去学习如何让其他库也支持 Promises。

小结

在今天这篇文章中，我和你分享了事件总线是什么，以及事件总线解决了什么样的问题。

当工程业务逻辑越来越复杂时，你会发现如果数据层和 UI 层不做解耦，日后想进行重构或者优化就会非常困难。这，也是很多工程前期没有使用事件总线，到了后期会留下大量无法修改的代码的原因所在。

如果使用类似 Promise 这样的技术规范实现事件总线，通过简单、清晰、规范的 Promise 接口将异步的数据获取、业务逻辑、界面串起来，对于日后的维护或重构都会容易很多。

课后小作业

PromiseKit 不仅支持 Swift 语言，还支持 Objective-C。所以，今天的课后作业是，将 PromiseKit 集成到你的 Objective-C 工程中，并对其中一个模块进行改造。

很多优秀工具都是用过才知道好，心动不如行动，你也试试吧。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

26 | 如何提高 JSON 解析的性能？

你好，我是戴铭。

在 iOS 开发中，我们都会碰到这样的问题：不同团队开发的库需要进行数据通信，而通信数据规范通常很难确定。今天，我们就来聊聊如何高效地解决这个问题吧。

同一个编程语言之间的数据通信非常简单，因为数据的规范都是相同的，所以输入和输出不需要做任何转换。但是，在不同编程语言之间进行数据通信，就会比较麻烦了。比如，一种语言按照自身的标准规范输出了一份数据，另一门语言接收到时需要依据自身编程语言标准进行数据对齐。

对齐一门语言的数据或许你还能够接受，但是如果对接的语言多了，你就需要写很多份能够与之对应的数据对齐转换代码。编写和维护的成本可想而知，那么目前有没有一种通用，而且各个编程语言都能支持的数据格式呢？

答案是有的。这个数据格式，就是我今天要跟你聊的 JSON。

接下来，在今天这篇文章中，我会先和你聊聊什么是 JSON；然后，再和你说说 JSON 的使用场景，以及 iOS 里是如何解析 JSON 的；最后，再和你分析如何提高 JSON 的解析性能。

什么是 JSON？

JSON ，是 JavaScript Object Notation 的缩写。其实，JSON 最初是被设计为 JavaScript 语言的一个子集，但最终因为和编程语言无关，所以成为了一种开放标准的常见数据格式。

虽然 JSON 源于 JavaScript，但到目前很多编程语言都有了 JSON 解析的库，包括 C、C++、Java、Perl、Python 等等。除此之外，还有很多编程语言内置了 JSON 生成和解析的方法，比如 PHP 在 5.2 版本开始内置了 json_encode() 方法，可以将 PHP 里的 Array 直接转化成 JSON。转换代码如下：

$arr = array(array(7,11,21));

echo json_encode($arr)."<br>";

$dic = array('name1' => 'val1', 'name2' => 'val2');

echo json_encode($dic)

输出结果如下：

[[7,11,21]]

{"name1":"val1","name2":"val2"}

如上所示，生成了两个 JSON 对象，第一个解析完后就是一个二维数组，第二个解析完后就是一个字典。有了编程语言内置方法解析和生成 JSON 的支持，JSON 成为了理想的数据交换格式。

通过上面生成的 JSON 可以看出，JSON 这种文本数据交换格式易读，且结构简单。

JSON 基于两种结构：

名字 / 值对集合：这种结构在其他编程语言里被实现为对象、字典、Hash 表、结构体或者关联数组。

有序值列表：这种结构在其他编程语言里被实现为数组、向量、列表或序列。

各种编程语言都以某种形式支持着这两种结构。比如，PHP 的 Array 既支持名字 / 值对集合又支持有序值列表；在 Swift 里键值集合就是字典，有序值列表就是数组。名字 / 值对集合在 JSON 和 JavaScript 里都被称为对象。JSON 语法图以及说明，你可以在 JSON 官网查看。在这里，我只列出了几个用的比较多的语法图。

如上面语法图所示，对象是以左大括号开头和右大括号结尾，名字后面跟冒号，名字 / 值对用逗号分隔。比如：

{"name1":"val1","name2":"val2"}

有序值列表在 JSON 和 JavaScript 里都叫数组，其语法图如下：

可以看出数组是以左中括号开头，以右中括号结尾，值以逗号分隔。数组代码如下所示：

[[7,11,21]]

语法图中值的语法图如下：

可以看出，值可以是字符串、数字、对象、数组、布尔值 ture、布尔值 false、空值。根据这个语法，JSON 可以通过实现对象和数组的嵌套来描述更为复杂的数据结构。

JSON 是没有注释的，水平制表符、换行符、回车符都会被当做空格。字符串由双引号括起来，里面可以使零到多个 Unicode 字符序列，使用反斜杠来进行转义。

JSON 的使用场景

JSON 的数据结构和任何一门编程语言的语法结构比起来都要简单得多，但它能干的事情却一点儿也不少，甚至可以完整地描述出一门编程语言的代码逻辑。比如，下面的这段 JavaScript 代码：

if (hour < 18) {

  greeting = "Good day";

}

这段 JavaScript 代码的逻辑是，当 hour 变量小于 18 时，greeting 设置为 Good day 字符串，根据 JavaScript 的语法规则，完整逻辑的语法树结构可以通过 JSON 描述出来。对应的 JSON，如下：

{

  "type": "Program",

  "body": [

    {

      "type": "IfStatement",

      "test": {

        "type": "BinaryExpression",

        "left": {

          "type": "Identifier",

          "name": "hour"

        },

        "operator": "<",

        "right": {

          "type": "Literal",

          "value": 18,

          "raw": "18"

        }

      },

      "consequent": {

        "type": "BlockStatement",

        "body": [

          {

            "type": "ExpressionStatement",

            "expression": {

              "type": "AssignmentExpression",

              "operator": "=",

              "left": {

                "type": "Identifier",

                "name": "greeting"

              },

              "right": {

                "type": "Literal",

                "value": "Good day",

                "raw": "\"Good day\""

              }

            }

          }

        ]

      },

      "alternate": null

    }

  ],

  "sourceType": "module"

}

从上面的 JSON 代码可以看出，每个语法树节点都是一个 JSON 对象，同级节点使用的是 JSON 数组。JavaScript 语法规则标准可以在Ecma 网站上找到。

比如下面这段 JavaScript 代码：

button.onclick = function() {

    var name = realname('Tom');

    if(name.length >= 5) {

        show();

    }

}

上面这段 JavaScript 代码对应的语法树如下图所示：

JavaScript 编程语言的语法树能够使用 JSON 来描述，其他编程语言同样也可以，比如 Objective-C 或 Swift，都能够生成自己的语法树结构，转成 JSON 后能够在运行期被动态地识别。因此，App 的业务逻辑动态化就不仅限于使用 JavaScript 这一门语言来编写，而是可以选择使用其他你熟悉的语言。

JSON 不仅可以描述业务数据使得业务数据能够动态更新，还可以用来描述业务逻辑，以实现业务逻辑的动态化，除此之外还可以用来描述页面布局。比如，我以前就做过这么一件事儿：解析一个 H5 页面编辑器生成的 JSON，将 JSON 对应生成 iOS 原生界面布局代码。我当时是用 Swift 语言来编写这个项目的，完整代码在这里。

在这个项目中，对 JSON 的解析使用的是系统自带的 JSONDecoder 的 decode 方法，具体代码如下：

let jsonData = jsonString.data(using: .utf8)!

let decoder = JSONDecoder()

let jsonModel = try! decoder.decode(H5Editor.self, from: jsonData)

上面代码中的，H5Editor 是一个结构体，能够记录 JSON 解析后的字典和数组。H5Editor 结构体完整定义，请点击这里的链接。

那么， JSONDecoder 的 decode 方法到底是怎么解析 JSON 的呢？在我看来，了解这一过程的最好方式，就是直接看看它在 Swift 源码里是怎么实现的。

JSONDecoder 如何解析 JSON？

JSONDecoder 的代码，你可以在 Swift 的官方 GitHub 上查看。

接下来，我先跟你说下解析 JSON 的入口， JSONDecoder 的 decode 方法。下面是 decode 方法的定义代码：

open func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T {

    let topLevel: Any

    do {

       topLevel = try JSONSerialization.jsonObject(with: data)

    } catch {

        throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: error))

    }

    // JSONDecoder 的初始化

    let decoder = __JSONDecoder(referencing: topLevel, options: self.options)

    // 从顶层开始解析 JSON

    guard let value = try decoder.unbox(topLevel, as: type) else {

        throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: [], debugDescription: "The given data did not contain a top-level value."))

    }

    return value

}

接下来，我们通过上面的代码一起来看看 decode 方法是如何解析 JSON 的。

上面 decode 方法入参 T.type 的 T 是一个泛型，具体到解析 H5 页面编辑器生成的 JSON 的例子，就是 H5Editor 结构体；入参 data 就是 JSON 字符串转成的 Data 数据。

decode 方法在解析完后会将解析到的数据保存到传入的结构体中，然后返回。在 decode 方法里可以看到，对于传入的 Data 数据会首先通过 JSONSerialization 方法转化成 topLevel 原生对象，然后 topLevel 原生对象通过 JSONDecoder 初始化成一个 JSONDecoder 对象，最后使用 JSONDecoder 的 unbox 方法将数据和传入的结构体对应上，并保存在结构体里进行返回。

可以看出，目前 JSONSerialization 已经能够很好地解析 JSON，JSONDecoder 将其包装以后，通过 unbox 方法使得 JSON 解析后能很方便地匹配 JSON 数据结构和 Swift 原生结构体。

试想一下，如果要将 JSON 应用到更大的场景时，比如对编程语言的描述或者界面布局的描述，其生成的 JSON 文件可能会很大，并且对这种大 JSON 文件解析性能的要求也会更高。那么，有比 JSONSerialization 性能更好的解析 JSON 的方法吗？

提高 JSON 解析性能

2019 年 2 月，Geoff Langdale 和 Daniel Lemire 发布了 simdjson。 simdjson 是一款他们研究了很久的快速 JSON 解析器， 号称每秒可解析千兆字节 JSON 文件。simdjson 和其他 JSON 解析器对比如下图所示：

可以看出，只有 simdjson 能够达到每秒千兆字节级别，并且远远高于其他 JSON 解析器。那么 ，simdjson 是怎么做到的呢？接下来，我通过 simdjson 解析 JSON 的两个阶段来跟你说明下这个问题。

第一个阶段，使用 simdjson 去发现需要 JSON 里重要的字符集，比如大括号、中括号、逗号、冒号等，还有类似 true、false、null、数字这样的原子字符集。第一个阶段是没有分支处理的，这个阶段与词法分析非常类似。

第二个阶段，simdjson 也没有做分支处理，而是采用的堆栈结构，嵌套关系使用 goto 的方式进行导航。simdjson 通过索引可以处理所有输入的 JSON 内容而无需使用分支，这都归功于聪明的条件移动操作，使得遍历过程变得高效了很多。

通过 simdjson 解析 JSON 的两个阶段可以看出，simdjson 的主要思路是尽可能地以最高效的方式将 JSON 这种可读性高的数据格式转换为计算机能更快理解的数据格式。

为了达到快速解析的目的， simdjson 在第一个阶段一次性使用了 64 字节输入进行大规模的数据操作，检查字符和字符类时以及当获得掩码应用变换时以 64 位进行位操作。这种方式，对于大的 JSON 数据解析性能提升是非常明显的。

如果你想更详细地了解这两个阶段的解析思路，可以查看这篇论文“Parsing Gigabytes of JSON per Second”。其实，simdjson 就是对这篇论文的实现，你可以在GitHub上查看具体的实现代码。在我看来，一边看论文，一边看对应的代码实现，不失为一种高效的学习方式。

而如果你想要在工程中使用 simdjson 的话，直接使用它提供的一个简单接口即可。具体的使用代码如下：

#include "simdjson/jsonparser.h"

/...

const char * filename = ... // JSON 文件

std::string_view p = get_corpus(filename);

ParsedJson pj = build_parsed_json(p); // 解析方法

// you no longer need p at this point, can do aligned_free((void*)p.data())

if( ! pj.isValid() ) {

    // 出错处理

}

aligned_free((void*)p.data());

小结

在今天这篇文章中，我和你分享了什么是 JSON，JSON 的使用场景，以及 simdjson 这个开源 JSON 解析库。simdjson 能够极大地提高 JSON 解析性能，你也可以非常方便地把它用到自己的工程中。

当需要对现有方案进行优化时，有的人会利用自己扎实的计算机基础知识找出更优秀的解决方案，而有的人只能等待更优秀的解决方案的出现。simdjson 的作者明显就属于前者，而我们也要不断充实自己的基础知识，努力成为这其中的一员。

课后小作业

对于 JSON 的解析库，我今天只和你分析了系统自带的 JSONSerialization 和 simdjson。那么，我想请你说说你目前使用的 JSON 解析库是什么，以及它是如何解析 JSON 的，性能又如何呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

27 | 如何用 Flexbox 思路开发？跟自动布局比，Flexbox 好在哪？

你好，我是戴铭。今天，我要和你跟你聊聊 Flexbox。

你很有可能不知道 Flexbox 是啥，但一定不会对 React Native、Weex 和 Texture（AsyncDisplayKit） 感到陌生，而 Flexbox 就是这些知名布局库采用的布局思路。不可小觑的是，苹果公司官方的 UIStackView，也是采用 Flexbox 思路来实现布局的。

接下来，我们就一起来看看 Flexbox 布局思路有什么优势，以及如何用它来实现布局。

Flexbox 好在哪？

目前来看，iOS 系统提供的布局方式有两种：

一种是 Frame 这种原始方式，也就是通过设置横纵坐标和宽高来确定布局。

另一种是自动布局（Auto Layout），相比较于 Frame 需要指出每个视图的精确位置，自动布局对于视图位置的描述更加简洁和易读，只需要确定两个视图之间的关系就能够确定布局。

通过 Masonry和 SnapKit这些第三方库，自动布局的易用性也有了很大提升。而且 iOS 12 以后，苹果公司也已经解决了自动布局在性能方面的问题（这里，你可以再回顾下前面第 4 篇文章《Auto Layout 是怎么进行自动布局的，性能如何？》中的相关内容）。

那么在这种情况下，我们为什么还要关注其他布局思路呢？关于原因，我觉得主要包括以下两个方面。

其一，自动布局思路本身还可以再提高。Flexbox 比自动布局提供了更多、更规范的布局方法，布局方式考虑得更全面，使用起来也更加方便。同时，苹果公司基于 Flexbox 的布局思路，又在自动布局之上封装了一层 UIStackView。

其二，针对多个平台的库需要使用更加通用的布局思想。Flexbox 在 2009 年被 W3C 提出，可以很简单、完整地实现各种页面布局，而且还是响应式的，开始被应用于前端领域，目前所有浏览器都已支持。后来通过 React Native 和 Weex 等框架，它被带入到客户端开发中，同时支持了 iOS 和 Android。

与自动布局思路类似，Flexbox 使用的也是描述性的语言来布局。使用 Flexbox 布局的视图元素叫 Flex 容器（flex container），其子视图元素叫作 Flex 项目（flex item）。Flexbox 布局的主要思想是，通过 Flex 容器设定的属性来改变内部 Flex 项目的宽、高，并调整 flex 项目的位置来填充 flex 容器的可用空间。

下图来自 W3C 官网，表示了 flex 容器和 flex 项目间的关系，其中 Main-Axis 表示横轴方向，Cross-Axis 表示纵轴方向。

关于 Flexbox 的详细入门资料，你可以参看阮一峰老师的“Flex 布局教程：语法篇”一文。而 Flexbox 在 W3C 上完整的定义，你可以点击这个链接查看。

如果你的工程目前还没有迁移到 React Native 或 Weex，那我觉得你可以通过 Texture 来使用 Flexbox 思路开发界面布局。而关于 React Native 和 Weex 使用 Flexbox 布局的思路，我会在专栏后面的文章“原生布局转到前端布局，开发思路的转变有哪些？”里和你详细说明。

Texture 如何使用 Flexbox 思路进行布局？

基于 Flexbox 的布局思路，Texture 框架的布局方案考虑得十分长远，也已经十分成熟，虽然学习起来需要费些力气，但是性能远好于苹果的自动布局，而且写起来更简单。

Texture 框架的布局中，Texture 考虑到布局扩展性，提供了一个基类 ASLayoutSpec。这个基类 提供了布局的基本能力，使 Texture 可以通过它扩展实现多种布局思路，比如 Wrapper、Inset、Overlay、Ratio、Relative、Absolute 等布局思路，也可以继承 ASLayoutSpec 来自定义你的布局算法。

ASLayoutSpec 的子类，及其具体的功能如下：

ASAbsoluteLayoutSpec // 绝对布局

ASBackgroundLayoutSpec // 背景布局

ASInsetLayoutSpec // 边距布局

ASOverlayLayoutSpec // 覆盖布局

ASRatioLayoutSpec // 比例布局

ASRelativeLayoutSpec // 顶点布局

ASCenterLayoutSpec // 居中布局

ASStackLayoutSpec // 盒子布局

ASWrapperLayoutSpec // 填充布局

ASCornerLayoutSpec // 角标布局

ASLayoutSpec 子类实现了各种布局思路，ASLayoutSpec 会制定各种布局相通的协议方法，遵循这些协议后可以保证这些子类能够使用相同的规则去实现更丰富的布局。

通过 ASLayoutSpec 遵循的 ASLayoutElement 协议，可以知道 ASLayoutSpec 提供的基本能力有哪些。ASLayoutElement 协议定义如下：

@protocol ASLayoutElement <ASLayoutElementExtensibility, ASTraitEnvironment, ASLayoutElementAsciiArtProtocol>

#pragma mark - Getter

@property (nonatomic, readonly) ASLayoutElementType layoutElementType;

@property (nonatomic, readonly) ASLayoutElementStyle *style;

- (nullable NSArray<id<ASLayoutElement>> *)sublayoutElements;

#pragma mark - Calculate layout

// 要求节点根据给定的大小范围返回布局

- (ASLayout *)layoutThatFits:(ASSizeRange)constrainedSize;

// 在子 layoutElements 上调用它来计算它们在 calculateLayoutThatFits: 方法里实现的布局

- (ASLayout *)layoutThatFits:(ASSizeRange)constrainedSize parentSize:(CGSize)parentSize;

// 重写此方法以计算 layoutElement 的布局

- (ASLayout *)calculateLayoutThatFits:(ASSizeRange)constrainedSize;

// 重写此方法允许你接收 layoutElement 的大小。使用这些值可以计算最终的约束大小。但这个方法要尽量少用

- (ASLayout *)calculateLayoutThatFits:(ASSizeRange)constrainedSize

                     restrictedToSize:(ASLayoutElementSize)size

                 relativeToParentSize:(CGSize)parentSize;

- (BOOL)implementsLayoutMethod;

@end

通过上面代码可以看出，协议定义了 layoutThatFits 和 calculateLayoutThatFits 等回调方法。其中，layoutThatFits 回调方法用来要求节点根据给定的大小范围返回布局，重写 calculateLayoutThatFits 方法用以计算 layoutElement 的布局。定义了统一的协议方法，能让 ASLayoutSpec 统一透出布局计算能力，统一规范的协议方法，也有利于布局算法的扩展。

接下来，我们一起看看 ASLayoutSpec 的子类中，应用最广泛的 ASStackLayoutSpec。它和 iOS 中自带的 UIStackView 类似，布局思路参照了 Flexbox，比如 horizontalAlignment、alignItems、flexWrap 等属性很容易和 Flexbox 对应上。

下面示例是一段官方的 ASStackLayoutSpec 示例代码。ASStackLayoutSpec 布局思路和 Flexbox 是一样的，所以我们通过示例可以了解，如何通过 Texture 使用 Flexbox 布局思路开发界面：

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constraint

{

    // 创建一个纵轴方向的 ASStackLayoutSpec 视图容器 vStack

    ASStackLayoutSpec *vStack = [[ASStackLayoutSpec alloc] init];

    // 设置两个子节点，第一个节点是标题，第二个正文内容

    [vStack setChildren:@[titleNode, bodyNode];

    // 创建一个横轴方向的 ASStackLayoutSpec 视图容器 hstack

    ASStackLayoutSpec *hstack = [[ASStackLayoutSpec alloc] init];

    hStack.direction          = ASStackLayoutDirectionHorizontal;

    hStack.spacing            = 5.0; // 设置节点间距为 5

    // 在 hStack 里添加 imageNode 和 vStack 节点

    [hStack setChildren:@[imageNode, vStack]];

    // 创建一个 ASInsetLayoutSpec 容器，设置四周边距为 5，将 hStack 作为其子节点

    ASInsetLayoutSpec *insetSpec = [ASInsetLayoutSpec insetLayoutSpecWithInsets:UIEdgeInsetsMake(5,5,5,5) child:hStack];

    return insetSpec;

}

上面这段代码，会先创建一个纵轴方向的 ASStackLayoutSpec 视图容器 vStack；然后，为 vStack 设置两个子节点，第一个节点是标题，第二个节点是正文内容；接下来，创建一个横轴方向的 ASStackLayoutSpec 视图容器 hstack，在 hStack 里添加 imageNode 和 vStack 节点；最后，创建一个 ASInsetLayoutSpec 容器，设置四周边距为 5，将 hStack 作为其子节点。

上面示例代码对应的视图效果如下：

除了 Texture 用到了 Flexbox 的布局思路，React Native 和 Weex 也用到了这个布局思路。这两个框架对 Flexbox 算法的实现，是一个叫作Yoga 的 C++ 库。

除了 React Native 和 Weex 之外，Yoga 还为很多其他开源框架提供支持，比如 Litho、ComponentKit 等。

为了能够用于各个平台，Yoga 是由 C/C++ 语言编写的，依赖少，编译后的二进制文件也小，可以被方便地集成到 Android 和 iOS 上。

随着新硬件的不断推出，比如手表和折叠屏手机，你可能还需要掌握更多的布局算法，以不变应万变。比如说，除了 Flexbox 思路的布局 ASStackLayoutSpec 以外，Texture 中还有 Wrapper、Inset、Overlay、Ratio、Relative、Absolute 等针对不同场景的布局思路，同时还支持自定义布局算法。

那么，接下来我就跟你聊聊 Flexbox 的算法是怎样的。了解 Flexbox 的布局算法设计，一方面能够让你更好地理解 Flexbox 布局；另一方面，你也可以借此完整地了解一个布局算法是怎样设计的，使得你以后也能够设计出适合自己业务场景的布局算法。

Flexbox 算法

Flexbox 算法的主要思想是，让 flex 容器能够改变其 flex 项目的宽高和顺序，以填充可用空间，flex 容器可以通过扩大项目来填充可用空间，或者缩小项目以防止其超出其可用空间。

首先，创建一组匿名的 flex 项目，按照这组匿名 flex 项目设置的排列规则对其进行排列。

第一步，确定 flex 项目的 main space 和 cross space，如果 flex 容器定义了大小就直接使用定义的大小；否则， 从 flex 容器的可用空间里减去 margin、border、padding。

第二步，确定每个项目 的 flex base 大小和假设的大小，其中假设的大小是项目依据它最小和最大的大小属性来确定的。flex 容器的大小，由它的大小属性来确定。

这个计算过程中，flex 容器的最小内容大小，是由它所有项目的最小内容大小之和算出的；而 flex 容器的最大内容大小，则是由它所有项目的最大内容大小之和确定出。

接着，将 flex 项目收集到 flex lines 中。如果 flex 容器是单行，那么就把所有的 flex 项目都收集到单个 flex line 里。否则，就从第一个未收集的项目开始尽可能多地收集 flex 项目到 flex line 里，根据 flex 容器的 inner 大小判断是否当前 flex line 收集满。重复操作，直到将所有 flex 项目都被收集到了 flex lines 里。

处理完 flex lines 后，需要通过使用过的大小和可用大小来确定每个项目的 cross 大小，然后计算每个 flex line 的 cross 大小以及 flex line 里每个 flex 项目的 cross 大小。

最后，进行 Main-Axis 对齐和 Cross-Axis 对齐。

Main-Axis 对齐就是分配剩余空间。对于每个 flex line，如果有剩余空间， margin 设置为 auto 的话，就平均分配剩余空间。

Cross-Axis 对齐，先要解决自动 margin，然后沿 cross-axis 对齐所有 flex items；随后确定 flex container 使用的 cross 大小；最后对齐所有 flex lines。

结合视图的概念，简化后 Flexbox 布局算法如下图：

如图中所示，其中 View 类似 flex container，View 的 Subviews 类似 flex items，flexbox 的算法简而言之就是：首先依据 View 的 margin、padding、border 确定出横纵大小，接下来确定排列，根据 View 的大小确定 Subviews 的行内容，确定出行中每个 Subview 的大小，最终确定出 Subview 的位置。

小结

在今天这篇文中，我与你介绍了 Flexbox 比 iOS 系统自带的自动布局好在哪，还举例说明了 Texture 是如何利用 Flexbox 进行 iOS 开发的。

其实， iOS 系统自带的 UIStackView 也是依据 Flexbox 思路开发的。我们都知道，苹果公司一般不轻易使用第三方技术。这，也就表明了 Flexbox 的布局思路是非常优秀的。

所以，在最后我还跟你分享了 Flexbox 的布局算法。如果你想知道这个算法的具体实现，可以直接查看 Yoga 的代码。

我以前也做过一个将 HTML 代码转换成 Texture 代码的项目 HTN，HTML 使用 Flexbox 写的界面布局可以直接转成对应的 Texture 代码，使用示例代码如下：

public func htmlToTexture() {

    // inputLb.stringValue 是 html 代码

    let treeBuilder = HTMLTreeBuilder(inputLb.stringValue)

    _ = treeBuilder.parse()

    // 解析 CSS

    let cssStyle = CSSParser(treeBuilder.doc.allStyle()).parseSheet()

    // 生成 DOM 树

    let document = StyleResolver().resolver(treeBuilder.doc, styleSheet: cssStyle)

    document.des() // 打印包含样式信息的 DOM 树

    // 转 Textrue

    let layoutElement = LayoutElement().createRenderer(doc: document)

    _ = HTMLToTexture(nodeName:"Flexbox").converter(layoutElement);

    nativeCodeLb.string = ""

}

课后小作业

如果你还没有开始使用 Flexbox ，请你立刻集成 Yoga 对你业务中一个页面使用 Flexbox 布局重写一遍吧。如果你不想集成第三方库，使用 UIStackView 也行。

今天的作业是基于 ASLayoutElement 协议，实现一个 Texture 自定义布局类。这个布局算法可以很简单，主要是想要帮你验证下你学习 Flexbox 算法的成果。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

28 | 怎么应对各种富文本表现需求？

你好，我是戴铭。今天，我要和你分享的主题是，在 iOS 开发中，如何展示富文本的内容。

在 iOS 开发中，富文本的展示是一个非常常见的需求。为了帮助你更好地了解如何展示富文本，我在今天这篇文章中，会结合一个项目来跟你说说面对富文本展示需求时，要怎么考虑和实现。这样，你在自己的项目中，也可以借鉴今天这样的实现思路和方法。

简单来说，富文本就是一段有属性的字符串，可以包含不同字体、不同字号、不同背景、不同颜色、不同字间距的文字，还可以设置段落、图文混排等等属性。

我以前做过一个 RSS 阅读器，阅读器启动后，需要抓取最新的 RSS 内容进行展示。RSS 里面的文章内容属于富文本，是用 HTML 标签来描述的，包含了文字样式、链接和图片。

比如，RSS 阅读器中的某篇文章内容如下：

<item>

<title> 涉国资流失嫌疑 东方广益 6 亿元入股锤子科技被调查 </title>

<link>https://www.cnbeta.com/articles/tech/841851.htm</link>

<description>

<![CDATA[

<p><strong> 据虎嗅得到的独家消息，成都成华区监察委已立案调查“东方广益 6 亿元入股锤子科技（北京）股份有限公司”事宜，认为这个项目有国有资产流失嫌疑。</strong> 成华区监察委员会成立于 2018 年 2 月，按照管理权限对全区行使公权力的公职人员依法实行监察，履行监督、调查和处置职责。</p> <a href="https://www.cnbeta.com/articles/tech/841851.htm" target="_blank"><strong> 阅读全文 </strong></a>

]]>

</description>

<author>ugmbbc</author>

<source>cnBeta.COM</source>

<pubDate>Sat, 27 Apr 2019 09:46:45 GMT</pubDate>

<guid>https://www.cnbeta.com/articles/tech/841851.htm</guid>

</item>

文章的 HTML 代码就在上面 RSS 中的 description 标签里。解析出 RSS 中所有文章的 HTML 代码，并将它们保存到本地数据库中。

接下来，如何展示 HTML 内容呢？当时，我的第一反应就是使用 WebView 控件来展示。

WebView

使用 WebView 显示文章只需要创建一个 UIWebView 对象，进行一些基本滚动相关的设置，然后读取 HTML 字符串就可以了，具体实现代码如下：

self.wbView = [[UIWebView alloc] init];

self.wbView.delegate = self;

[self.view addSubview:self.wbView];

[self.wbView mas_makeConstraints:^(MASConstraintMaker *make) {

    make.top.left.right.bottom.equalTo(self.view);

}];

self.wbView.scalesPageToFit = YES; // 确保网页的显示尺寸和屏幕大小相同

self.wbView.scrollView.directionalLockEnabled = YES; // 只在一个方向滚动

self.wbView.scrollView.showsHorizontalScrollIndicator = NO; // 不显示左右滑动

[self.wbView setOpaque:NO]; // 默认是透明的

// 读取文章 html 字符串进行展示

[self.wbView loadHTMLString:articleString baseURL:nil];

和 UIWebView 的 loadRequest 相比，UIWebView 通过 loadHTMLString 直接读取 HTML 代码，省去了网络请求的时间，展示的速度非常快。不过，HTML 里的图片资源还是需要通过网络请求来获取。所以，如果能够在文章展示之前就缓存下图片，那么无需等待，就能够快速完整地展示丰富的文章内容了。

那么，我应该使用什么方案来缓存文章中的图片呢？

在 Cocoa 层使用 NSURLProtocol 可以拦截所有 HTTP 的请求，因此我可以利用 NSURLProtocol 来缓存文章中的图片。

接下来，我再来和你说说，如何用我写的一个 Web 页面预加载库 STMURLCache来预缓存 HTML 里的图片。这个库你也可以应用到自己项目中。

首先，我需要从数据库中取出所有未缓存图片的文章内容 HTML。实现代码如下：

[[[[[SMDB shareInstance] selectAllUnCachedFeedItems] subscribeOn:[RACScheduler schedulerWithPriority:RACSchedulerPriorityDefault]] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(NSMutableArray *x) {

    // 在数据库中获取所有未缓存的文章数据 x

    NSMutableArray *urls = [NSMutableArray array];

    if (x.count > 0) {

        self.needCacheCount = x.count;

        for (SMFeedItemModel *aModel in x) {

            // 将文章数据中的正文内容都存在 urls 数组中

            [urls addObject:aModel.des];

        }

    }

    ...

}];

如上面代码所示，在数据库中获取到所有未缓存文章的数据后，遍历所有数据，提取文章数据中的正文 HTML 内容保存到一个新的数组 urls 中。

然后，使用 STMURLCache 开始依次预下载文章中的图片进行缓存。实现代码如下：

[[STMURLCache create:^(STMURLCacheMk *mk) {

    mk.whiteUserAgent(@"gcdfetchfeed").diskCapacity(1000 * 1024 * 1024);

}] preloadByWebViewWithHtmls:[NSArray arrayWithArray:urls]].delegate = self;

STMURLCache 使用 preloadByWebViewWithHtmls 方法去预缓存所有图片，在 STMURLCache 初始化时，会设置 UserAgent 白名单，目的是避免额外缓存了其他不相关 UIWebView 的图片。

缓存图片的核心技术还是 NSURLProtocol，STMURLCache 最终也是使用 NSURLProtocol 来缓存图片的。NSURLProtocol 是一个抽象类，专门用来处理特定协议的 URL 数据加载。你可以使用自定义 URL 处理的方式，来重新定义系统 URL 加载。STMURLCache 缓存图片的具体实现代码，你可以在 STMURLProtocol这个类里查看。

STMURLProtocol 会在所有网络请求的入口 canInitWithRequest 方法中加上过滤条件，比如 STMURLCache 在初始化时设置 UserAgent 白名单，过滤代码如下：

// User-Agent 来过滤

if (sModel.whiteUserAgent.length > 0) {

    // 在 HTTP header 里取出 User Agent

    NSString *uAgent = [request.allHTTPHeaderFields objectForKey:@"User-Agent"];

    if (uAgent) {

        // 不在白名单中返回 NO，不会进行缓存

        if (![uAgent hasSuffix:sModel.whiteUserAgent]) {

            return NO;

        }

    } else {

        return NO;

    }

}

UserAgent 白名单过滤会通过 request 的 allHTTPHeaderFields 获取到当前网络请求的 UserAgent，然后和已经设置的 UserAgent 白名单做比较：如果在白名单中就进行缓存；否则，就不会缓存。

STMURLProtocol 还可以根据域名进行过滤，这样可以灵活、精确地控制缓存范围。如果你设置了域名白名单，那么只有在白名单里的域名下的网络请求才会执行缓存，过滤代码如下：

// 对于域名白名单的过滤

if (sModel.whiteListsHost.count > 0) {

    id isExist = [sModel.whiteListsHost objectForKey:request.URL.host];

    // 如果当前请求的域名不在白名单中也会返回 NO

    if (!isExist) {

        return NO;

    }

}

如代码所示，当前网络请求的域名可以通过 request.URL.host 属性获取到，获取到网络请求的域名后，再去看域名白名单里是否有，如果有就缓存，没有就返回 NO，不进行缓存操作。

在 canInitWithRequest 方法中满足缓存条件后，开始缓存的方法是 startLoading。startLoading 方法会判断已缓存和未缓存的情况，如果没有缓存会发起网络请求，将请求到的数据保存在本地。如果有缓存，则会直接从本地读取缓存，实现代码如下：

// 从缓存里读取数据

NSData *data = [NSData dataWithContentsOfFile:self.filePath];

NSURLResponse *response = [[NSURLResponse alloc] initWithURL:self.request.URL MIMEType:[otherInfo objectForKey:@"MIMEType"] expectedContentLength:data.length textEncodingName:[otherInfo objectForKey:@"textEncodingName"]];

[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];

// 使用 NSURLProtocolClient 的 URLProtocol:didLoadData 方法加载本地数据

[self.client URLProtocol:self didLoadData:data];

[self.client URLProtocolDidFinishLoading:self];

如代码所示，STMURLProtocol 先通过缓存的路径获取到缓存的数据，再使用 NSURLProtocolClient 的 URLProtocol:didLoadData 方法加载本地缓存数据，以减少网络请求。

显示文章内容时使用 NSURLProtocol，对于那些已经缓存过图片的文章就不用发起图片的网络请求，显示的速度跟本地加载显示速度一样快。

虽然通过 URLProtocol 重新定义系统 URL 加载的方式，来直接读取预缓存提升了加载速度，但在长列表的 Cell 上展示富文本，就需要性能更高、内存占用更小的方法。那么接下来，我们再看看除了 UIWebView 还有没有什么方法可以展示富文本呢？

当然还有了。

在长列表这种场景下，如果不用 HTML 来描述富文本的话，想要使用原生 iOS 代码来描述富文本的话，你还可以使用苹果官方的TextKit和 YYText来展示。

其中，YYText 不仅兼容 UILabel 和 UITextView，在异步文字布局和渲染上的性能也非常好。所以接下来，我们就一起看看 YYText 是如何展示富文本的吧。

YYText

集成 YYText 到你的 App 非常简单，只需要在 Podfile 中添加 pod ‘YYText’ 就可以了。下面代码展示了如何展示图文混排的富文本：

NSMutableAttributedString *text = [NSMutableAttributedString new];

UIFont *font = [UIFont systemFontOfSize:16];

NSMutableAttributedString *attachment = nil;

// 嵌入 UIImage

UIImage *image = [UIImage imageNamed:@"dribbble64_imageio"];

attachment = [NSMutableAttributedString yy_attachmentStringWithContent:image contentMode:UIViewContentModeCenter attachmentSize:image.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];

[text appendAttributedString: attachment];

// 嵌入 UIView

UISwitch *switcher = [UISwitch new];

[switcher sizeToFit];

attachment = [NSMutableAttributedString yy_attachmentStringWithContent:switcher contentMode:UIViewContentModeBottom attachmentSize:switcher.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];

[text appendAttributedString: attachment];

// 嵌入 CALayer

CASharpLayer *layer = [CASharpLayer layer];

layer.path = ...

attachment = [NSMutableAttributedString yy_attachmentStringWithContent:layer contentMode:UIViewContentModeBottom attachmentSize:switcher.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];

[text appendAttributedString: attachment];

如代码所示，YYText 对于富文本的图文混排使用的是自定义的 NSMutableAttributedString 分类，自定义分类不光简化了 NSMutableAttributedString，还增加了功能，除了图片外，可以嵌入 UIView 和 CALayer。

通过上面 YYText 描述富文本的代码，你会发现原生代码描述富文本跟 HTML 比，既复杂又啰嗦。HTML 代码更易读、更容易维护，所以除了长列表外，我建议你都使用 HTML 来描述富文本。

对于 UIWebView 内存占用高的问题，你可以考虑使用 HTML 代码转原生代码的思路解决。比如，你可以参考我以前做的将 HTML 代码转原生代码的示例项目 HTN里的解决思路。

小结

今天我跟你介绍了如何通过 UIWebView 和 YYText 来展示富文本。

UIWebView 展示的是使用 HTML 描述的富文本。HTML 是描述富文本最简单和最常用的方式，相对于 YYText 或 TextKit 那样描述富文本的方式来说，更加简洁和标准。不过，UIWebView 的缺点也比较明显，同时创建多个 UIWebView 实例，对于内存的占用会非常大。

所以，我对于富文本展示的建议是，如果是列表展示富文本建议使用 TextKit 或者 YYText，其他情况可以选择使用 UIWebView 来展示富文本。

课后作业

使用 STMURLCache预加载你工程中的一个 Web 页面，看看打开速度提升了多少，预加载成功后，在弱网环境和无网络的环境都可以试试。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

29 | 如何在 iOS 中进行面向测试驱动开发和面向行为驱动开发？

你好，我是戴铭。今天，我要和你分享的话题是，如何在 iOS 中进行面向测试驱动开发和面向行为驱动开发。

每当你编写完代码后，都会编译看看运行结果是否符合预期。如果这段代码的影响范围小，你很容易就能看出结果是否符合预期，而如果验证的结果是不符合预期，那么你就会检查刚才编写的代码是否有问题。

但是，如果这段代码的影响范围比较大，这时需要检查的地方就会非常多，相应地，人工检查的时间成本也会非常大。特别是团队成员多、工程代码量大时，判断这段代码的影响面都需要耗费很多时间。那么，每次编写完代码，先判断它的影响面，然后再手动编译进行检查的开发方式，效率就非常低了，会浪费大量时间。

虽说一般公司都会有专门的测试团队对产品进行大量测试，但是如果不能在开发阶段及时发现问题，当各团队代码集成到一起，把所有问题都堆积到测试阶段去发现、解决，就会浪费大量的沟通时间，不光是开发同学和测试同学之间的沟通时间，还有开发团队之间的沟通时间也会呈指数级增加。

那么，有没有什么好的开发方式，能够提高在编写代码后及时检验结果的效率呢？

所谓好的开发方式，就是开发、测试同步进行，尽早发现问题。从测试范围和开发模式的角度，我们还可以把这种开发模式细分出更多类型。

从测试范围上来划分的话，软件测试可以分为单元测试、集成测试、系统测试。测试团队负责的是集成测试以及系统测试，而单元测试则是有开发者负责的。对于开发者来说，通过单元测试就可以有效提高编写代码后快速发现问题的效率。

概括来说，单元测试，也叫作模块测试，就是对单一的功能代码进行测试。这个功能代码，可能是一个类的方法，也可能是一个模块的某个函数。

单元测试会使用 Mock 方式模拟外部使用，通过编写的各种测试用例去检验代码的功能是否正常。一个系统都是由各个功能组合而成，功能模块划分得越小，功能职责就越清晰。清晰的功能职责可以确保单个功能的测试不会出现问题，是单元测试的基础。

从开发模式划分的话，开发方式可以分为 TDD（Test-driven development，面向测试驱动开发）和 BDD（Behavior-driven development ，面向行为驱动开发）。

TDD 的开发思路是，先编写测试用例，然后在不考虑代码优化的情况下快速编写功能实现代码，等功能开发完成后，在测试用例的保障下，再进行代码重构，以提高代码质量。

BDD 是 TDD 的进化，基于行为进行功能测试，使用 DSL（Domain Specific Language，领域特定语言）来描述测试用例，让测试用例看起来和文档一样，更易读、更好维护。

TDD 编写的测试用例主要针对的是开发中最小单元进行测试，适合单元测试。而 BDD 的测试用例是对行为的描述，测试范围可以更大一些，在集成测试和系统测试时都可以使用。同时，不仅开发者可以使用 BDD 的测试用例高效地发现问题，测试团队也能够很容易参与编写。这，都得益于 BDD 可以使用易于编写行为功能测试的 DSL 语言。

接下来，我就和你详细聊聊 TDD 和 BDD。

TDD

我刚刚也已经提到了，TDD 在确定功能需求后，首先就会开始编写测试用例，用来检验每次的代码更新，能够让我们更快地发现问题，并能保正不会漏掉问题。其实，这就是通过测试用例来推动开发。

在思想上，和拿到功能需求后直接开发功能的区别是，TDD 会先考虑如何对功能进行测试，然后再去考虑如何编写代码，这就给优化代码提供了更多的时间和空间，即使几个版本过后再来优化，只要能够通过先前写好的测试用例，就能够保证代码质量。

所以说，TDD 非常适合快速迭代的节奏，先尽快实现功能，然后再进行重构和优化。如果我们不使用 TDD 来进行快速迭代开发，虽然在最开始的时候开发效率会比 TDD 高，但是过几个版本再进行功能更新时，就需要在功能验证上花费大量的时间，反而得不偿失。

其实，TDD 这种开发模式和画漫画的工作方式非常类似：草稿就类似 TDD 中的测试用例，漫画家先画草稿，细节由漫画家和助手一起完成，无论助手怎么换，有了草稿的保障，内容都不会有偏差。分镜的草稿没有细节，人物眼睛、鼻子都可能没有，场景也只需要几条透视线就可以。虽然没有细节，但是草稿基本就确定了漫画完成后要表达的所有内容。

BDD

相比 TDD，BDD 更关注的是行为方式的设计，通过对行为的描述来验证功能的可用性。行为描述使用的 DSL，规范、标准而且可读性高，可以当作文档来使用。

BDD 的 Objective-C 框架有 Kiwi、Specta、Expecta等，Swift 框架有 Quick。

Kiwi 框架不光有 Specta 的 DSL 模式，Expecta 框架的期望语法，还有 Mocks 和 Stubs 这样的模拟存根能力。所以接下来，我就跟你说说这个 iOS 中非常有名并且好用的 BDD 框架，以及怎么用它来进行 BDD 开发。

Kiwi

将 Kiwi 集成到你的 App 里，只需要在 Podfile 里添加 pod ‘Kiwi’ 即可。下面这段代码，是 Kiwi 的使用示例：

// describe 表示要测试的对象

describe(@"RSSListViewController", ^{

    // context 表示的是不同场景下的行为

    context(@"when get RSS data", ^{

        // 同一个 context 下每个 it 调用之前会调用一次 beforeEach

        beforeEach(^{

            id dataStore = [DataStore new];

        });

        // it 表示测试内容，一个 context 可以有多个 it

        it(@"load data", ^{

            // Kiwi 使用链式调用，should 表示一个期待，用来验证对象行为是否满足期望

            [[theValue(dataStore.count) shouldNot] beNil];

        });

    });

});

上面这代码描述的是在 RSS 列表页面，当获取 RSS 数据时去读取数据这个行为的测试用例。这段测试用例代码，包含了 Kiwi 的基本元素，也就是 describe、context、it。这些元素间的关系可以表述为：

describe 表示要测试的对象，context 表示的是不同场景下的行为，一个 describe 里可以包含多个 context。

it 表示的是需要测试的内容，同一个场景下的行为会有多个需要测试的内容，也就是说一个 context 下可以有多个 it。

测试内容使用的是 Kiwi 的 DSL 语法，采用的是链式调用。上面示例代码中 shouldNot 是期望语法，期望是用来验证对象行为是否满足期望。

期望语法可以是期望数值和数字，也可以是期望字符串的匹配，比如：

[[string should] containString:@"rss"];

should containString 语法表示的是，期望 string 包含了 rss 字符串。Kiwi 里的期望语法非常丰富，还有正则表达式匹配、数量变化、对象测试、集合、交互和消息、通知、异步调用、异常等。完整的期望语法描述，你可以查看 Wiki 的 Expectations 部分。

除了期望语法外，Kiwi 还支持模拟对象和存根语法。

模拟对象能够降低对象之间的依赖，可以模拟难以出现的情况。模拟对象包含了模拟 Null 对象、模拟类的实例、模拟协议的实例等。存根可以返回指定选择器或消息模式的请求，可以存根对象和模拟对象。

模拟对象和存根的详细语法定义，你可以查看 Wiki 的 Mocks and Stubs 部分。

小结

按照 TDD 和 BDD 方式开发，有助于更好地进行模块化设计，划清模块边界，让代码更容易维护。TDD 在测试用例的保障下更容易进行代码重构优化，减少 debug 时间。而使用 BDD 编写的测试用例，则更是好的文档，可读性非常强。通过这些测试用例，在修改代码时，我们能够更方便地了解开发 App 的工作状态。同时，修改完代码后还能够快速全面地测试验证问题。

无论是 TDD 还是 BDD，开发中对于每个实现的方法都要编写测试用例，而且要注意先编写测试用例代码，再编写方法实现代码。测试用例需要考虑到各种异常条件，以及输入输出的边界。编写完测试用例还需要检查如果输入为错时，测试用例是否会显示为错。

最后需要强调一点，好的模块化架构和 TDD 、BDD 是相辅相成的。TDD 和 BDD 开发方式会让你的代码更加模块化，而模块化的架构更容易使用 TDD 和 BDD 的方式进行开发。

在团队中推行 TDD 和 BDD 的最大困难，就是业务迭代太快时，没有时间去写测试用例。我的建议是，优先对基础能力的功能开发使用 TDD 和 BDD，保证了基础能力的稳定，业务怎么变，底子还都是稳固的；当有了业务迭代、有了间隙时，再考虑在核心业务上采用 BDD，最大程度的保证 App 核心功能的稳定。

课后作业

今天我跟你聊了很多 TDD 和 BDD 的优点，但是很多团队并没有使用这样的开发方式，你觉得这其中的原因是什么呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

30 | 如何制定一套适合自己团队的 iOS 编码规范？

你好，我是戴铭。

如果团队成员的编码规范各不相同，那么你在接收其他人的代码时是不是总会因为无法认同他的代码风格，而想着去重写呢。但是，重写这个事儿不只会增加梳理逻辑和开发成本，而且重写后出现问题的风险也会相应增加。那么，这个问题应该如何解决呢？

在我看来，如果出现这种情况，你的团队急需制定出一套适合自己团队的编码规范。有了统一的编码规范，就能有效避免团队成员由于代码风格不一致而导致的相互认同感缺失问题。

那么，如何制定编码规范呢？在接下来的内容里，我会先跟你说说，我认为的好的编码规范。你在制定编码规范时，也可以按照这个思路去细化出更多、更适合自己的规范，从而制定出团队的编码规范。然后，我会再和你聊聊如何通过 Code Review 的方式将你制定的编码规范进行落地。

好的代码规范

关于好的代码规范，接下来我会从常量、变量、属性、条件语句、循环语句、函数、类，以及分类这 8 个方面和你一一说明。

常量

在常量的使用上，我建议你要尽量使用类型常量，而不是使用宏定义。比如，你要定义一个字符串常量，可以写成：

static NSString * const STMProjectName = @"GCDFetchFeed"

变量

对于变量来说，我认为好的编码习惯是：

变量名应该可以明确体现出功能，最好再加上类型做后缀。这样也就明确了每个变量都是做什么的，而不是把一个变量当作不同的值用在不同的地方。

在使用之前，需要先对变量做初始化，并且初始化的地方离使用它的地方越近越好。

不要滥用全局变量，尽量少用它来传递值，通过参数传值可以减少功能模块间的耦合。

比如，下面这段代码中，当名字为字符串时，就可以把字符串类型作为后缀加到变量名后面。

let nameString = "Tom"

print("\(nameString)")

nameLabel.text = nameString

属性

在 iOS 开发中，关于属性的编码规范，需要针对开发语言做区分：

Objective-C 里的属性，要尽量通过 get 方法来进行懒加载，以避免无用的内存占用和多余的计算。

Swift 的计算属性如果是只读，可以省掉 get 子句。示例代码如下：

var rectangleArea: Double {

  return long * wide

}

条件语句

在条件语句中，需要考虑到条件语句中可能涉及的所有分支条件，对于每个分支条件都需要考虑到，并进行处理，减少或不使用默认处理。特别是使用 Switch 处理枚举时，不要有 default 分支。

在 iOS 开发中，你使用 Swift 语言编写 Switch 语句时，如果不加 default 分支的话，当枚举有新增值时，编译器会提醒你增加分支处理。这样，就可以有效避免分支漏处理的情况。

另外，条件语句的嵌套分支不宜过多，可以充分利用 Swift 中的 guard 语法。比如，这一段处理登录的示例代码：

if let userName = login.userNameOK {

    if let password = login.passwordOK {

        // 登录处理

        ...

    } else {

        fatalError("login wrong")

    }

} else {

    fatalError("login wrong")

}

上面这段代码表示的是，当用户名和密码都没有问题时再进行登录处理。那么，我们使用 guard 语法时，可以改写如下：

guard

  let userName = login.userNameOK,

  let password = login.passwordOK,

  else {

    fatalError("login wrong")

}

// 登录处理

...

可以看到，改写后的代码更易读了，异常处理都在一个区域，guard 语句真正起到了守卫的职责。而且你一旦声明了 guard，编译器就会强制你去处理异常，否则就会报错。异常处理越完善，代码就会越健壮。所以，条件语句的嵌套处理，你可以考虑使用 guard 语法。

循环语句

在循环语句中，我们应该尽量少地使用 continue 和 break，同样可以使用 guard 语法来解决这个问题。解决方法是：所有需要 continue 和 break 的地方统一使用 guard 去处理，将所有异常都放到一处。这样做的好处是，在维护的时候方便逻辑阅读，使得代码更加易读和易于理解。

函数

对于函数来说，体积不宜过大，最好控制在百行代码以内。如果函数内部逻辑多，我们可以将复杂逻辑分解成多个小逻辑，并将每个小逻辑提取出来作为一个单独的函数。每个函数处理最小单位的逻辑，然后一层一层往上组合。

这样，我们就可以通过函数名明确那段逻辑处理的目的，提高代码的可读性。

拆分成多个逻辑简单的函数后，我们需要注意的是，要对函数的入参进行验证，guard 语法同样适用于检查入参。比如下面的这个函数：

func saveRSS(rss: RSS?, store: Store?) {

    guard let rss = rss else {

        return

    }

    guard let store = store else {

        return

    }

    // 保存 RSS

    return

}

如上面代码所示，通过 guard 语法检查入参 rss 和 store 是否异常，提高函数的健壮性会来得更容易些。

另外，函数内尽量避免使用全局变量来传递数据，使用参数或者局部变量传递数据能够减少函数对外部的依赖，减少耦合，提高函数的独立性，提高单元测试的准确性。

类

在 Objective-C 中，类的头文件应该尽可能少地引入其他类的头文件。你可以通过 class 关键字来声明，然后在实现文件里引入需要的其他类的头文件。

对于继承和遵循协议的情况，无法避免引入其他类的头文件，所以你在代码设计时还是要尽量减少继承，特别是继承关系太多时不利于代码的维护和修改，比如说修改父类时还需要考虑对所有子类的影响，如果评估不全，影响就难以控制。

分类

在写分类时，分类里增加的方法名要尽量加上前缀，而如果是系统自带类的分类的话，方法名就一定要加上前缀，来避免方法名重复的问题。

分类的作用如其名，就是对类做分类用的，所以我建议你，能够把一个类里的公共方法放到不同的分类里，便于管理维护。分类特别适合多人负责同一个类时，根据不同分类来进行各自不同功能的代码维护。

Code Review

上面的内容，就是在我看来比较好的 iOS 编码规范了。除此之外，你还可以参考其他公司对 iOS 开发制定的编码规范来完善自己团队的编码规范，比如 Spotify 的 Objective-C 编码规范、纽约时报的 Objective-C 的编码规范、Raywenderlich 的 Objective-C 编码规范、Raywenderlich 的 Swift 编码规范。

在我看来，好的代码规范首先要保证代码逻辑清晰，然后再考虑简洁、扩展、重用等问题。逻辑清晰的代码几乎不需要注释来说明，通过命名和清晰地编写逻辑就能够让其他人快速读懂。

不需要注释就能轻松读懂的代码，使用的语言特性也必然是通用和经典的，过新的语言特性和黑魔法不利于代码逻辑的阅读，应该减少使用，即使使用也需要多加注释，避免他人无法理解。

当你制定出好的代码规范后，就需要考虑如何将代码规范落地执行了。代码规范落地最好的方式就是 Code Review 。通过 Code Review ，你可以去检查代码规范是否被团队成员执行，同时还可以在 Code Review 时，及时指导代码编写不规范的同学。

那么，怎么做 Code Review 会比较好呢？

首先，我觉得要利用好 Code Review 这个卡点，先使用静态检查工具对提交的代码进行一次全面检查。

如果是 Swift 语言的话，你可以使用 SwiftLint工具来检查代码规范。Swift 通过 Hook Clang 和 SourceKit 中 AST 的回调来检查源代码，如何使用 SourceKit 开发工具可以参看这篇文章“Uncovering SourceKit”。

SwiftLint 检查的默认规则，你可以参考它的规则说明。SwiftLint 也支持自定义检查规则，支持你添加自己制定的代码规范。你可以在 SwiftLint 目录下添加一个 .swiftlint.yml 配置文件来自定义基于正则表达式的自定义规则。具体方法，你可以参看官方定义自定义规则的说明。

如果你是使用 Objective-C 语言开发的话，可以使用 OCLint 来做代码规范检查。关于 OCLint 如何定制自己的代码规范检查，你可以参看杨萧玉的这篇博文“使用 OCLint 自定义 MVVM 规则”。

然后，进行人工检查。

人工检查，就是使用类似 Phabricator 这样的 Code Review 工具平台，来分配人员审核提交代码，审核完代码后，审核人可以进行通过、打回、评论等操作。这里需要注意的是，人工检查最容易沦为形式主义，因此为了避免团队成员人工检查成为形式，在开始阶段最好能让团队中编码习惯好、喜欢交流的人来做审核人，以起到良好的示范作用，并以此作为后续的执行标准。

你可能会有疑问，既然工具可以检查代码规范，为什么还需要人工再检查一遍？我想说的是，工具确实可以通过不断完善，甚至引入 AI 分析来提高检查结果的准确性，但是，我认为 Code Review 之所以最终还是需要人工检查的原因是，通过团队成员之间互相检查代码的方式，希望能够达到相互沟通交流，甚至相互学习的效果。

试想一下，如果你经过了大量的思考，花费了很多心思写出来一段自认为完美的代码，这时候可以再得到团队其他成员的鼓励，是不是会干劲儿十足呢。相反地，如果你马虎大意，或者经验不足而写出了不好的代码，通过 Code Review 而得到了团队其他成员的建议和指导，是不是能够让你的编码水平快速提高，同时还能够吸纳更多人的经验呢。

Code Review 的过程也能够对代码规范进行迭代改进，最后形成一份能体现出团队整体智慧的代码规范。以后再有新成员加入时，他们也能够快速达到团队整体的编码水平，这就好比一锅老汤，新食材放进来涮涮，很快就有了相同的味道。

小结

在今天这篇文章中，我和你分享了什么是好的代码规范，以及如何通过 Code Review 将编码规范落实到团队中。

对于编码规范来说，我认为不用过于复杂，只要坚持能够让代码逻辑清晰这个原则就可以了，剩下的所有规则都围绕着这个原则来。代码逻辑清晰是高质量的代码工程最基本、最必要的条件。如果代码不清晰的话，那么其他的扩展、重用、简洁优雅都免谈。

写代码的首要任务是能让其他人看得懂，千万不要优先过度工程化。难懂的代码无论工程化做得多好，到最后都会被其他人弃用、重构掉。这是一种资源浪费，损己又损人。

课后作业

你的团队是如何做 Code Review 的？如果你的团队还没有 Code Review，那原因是什么呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

31 | iOS 开发学习资料和书单推荐

你好，我是戴铭。

在更新专栏的这段时间里，我收到很多同学的反馈，希望我能推荐些 iOS 开发的优秀学习资料和图书，来帮助自己高效学习。确实，现在各种学习资料非常丰富，但这些资料一方面质量参差不齐，另一方面搜索出适合自己的内容也需要花费不少时间。

快速找到经过检验的、适合自己的学习资料，不仅可以提升我们的学习效率，还能帮助我们快速解决现阶段遇到的问题。所以，今天我就来跟你分享一些我觉得不错的学习资料和图书。

学习资料

iOS 开发往往会涉及界面交互，iOS Examples和Cocoa Controls这两个网站收集了大量的开源组件和库，并且进行了非常细致的分类，你能够非常方便地找到适合自己的“轮子”。

如果你希望通过完整的例子来系统学习 App 开发，我推荐你查看一下 GitHub 上的Open-Source iOS Apps项目。作者在这个项目中收录了很多优秀的、完整的开源 iOS App，并做了详细分类，还专门标出了上架了 App Store 的开源 iOS App。

AFNetworking 和 Alamofire 的作者 Mattt 维护着一个 NSHipster的网站，主要关注的是一些不常用的 iOS 知识点。如果你想扩展自己的 iOS 知识面，这个网站里的内容会非常适合你。

Awesome iOS也是一个值得推荐的网站，里面包含了 iOS 开发的方方面面，而且内容都是经过人工筛选、分类的。我觉得，你遇到任何 iOS 的问题，都应该先到这个网站看看。

Awesome iOS 最大的特点就是大而全，囊括了从开发、调试到发布 App Store 的各种学习资料，也包含了博客、书籍、教程、邮件订阅、播客的推荐。同时，这个网站还包括了 iOS 开发更细分的 Awesome 推荐，比如关于 ARKit 的 Awesome ARKit，关于面试问题收集的 Awesome iOS Interview question list 等等。

你还可以通过关注一些知名开发者动态的方式，来为自己学习方向的判断做输入。

这里有份列表，列出了 iOS 领域那些知名开发者，你可以通过关注他们的博客、Twitter、GitHub ，来了解走在 iOS 领域前沿开发者的视野和 iOS 最新的动向。除了关注知名开发者外，你还可以关注下开源项目团队的列表，如果你正在使用他们的开源项目，通过关注他们的动向，随时了解这些开源项目的最新进展。

图书推荐

通过上面我和你推荐的学习资料，你可以去分析并解决在开发中遇到的问题，也可以通过知名开发者和优秀开源项目的团队动态，来了解 iOS 开发的技术动向。但是，如果你想要透彻地掌握某领域的专业知识，还是需要静下心去慢慢学习。而，阅读相关书籍，就是一种很好的学习方式。

那么，接下来我再跟你推荐一些我觉得还不错的书籍，希望能够对你的学习有所帮助。

Raywenderlich出版的图书质量都非常不错，可以一步一步教你掌握一些开发知识，内容非常实用，而且这些图书的涉及面广。比如，这些图书包括有 ARKit、Swift 服务端的 Vapor 和 Kitura、Metal、数据结构和算法的 Swift 版、设计模式、Core Data、iOS 动画、Apple 调试和逆向工程、RxSwift、Realm、2D 和 3D 游戏开发等各个方面。

另外，objc.io家的图书会从原理和源代码实现的角度来讲解知识点，也非常不错，内容比 Raywenderlich 出版的图书更深入，适合有一定 iOS 开发经验的人阅读。

Raywenderlich 和 objc.io 的书基本都是 Swift 来写的。如果你想更深入地理解 Objective-C 的话，我推荐《Objective-C 高级编程》这本书。这本书里的知识点并不多，主要讲的是内存管理、Block、GCD（Grand Central Dispatch）。

这三个知识点对 Objective-C 来说非常重要，如果使用不当将会置你的工程于风险之中。正是因为涉及的知识点不多，所以全书能基于苹果公司公开的源码，集中讲清楚这三个知识点。这，非常难得。因此，如果你对内存管理、Block、GCD 了解地不是很透彻，我建议你仔细阅读这本书。

如果你想要了解系统工作原理的话，我推荐阅读《程序员的自我修养 - 链接、装载与库》。这本书详细且深入地讲解了硬件、操作系统、线程的知识。

阅读这本书之前，你需要先掌握 CPU、计算机原理、汇编、编译原理、C 语言、C++ 语言等计算机学科的基本知识。掌握了这些知识后再阅读这本书，它能帮你把知识串起来，帮你从代码编译、链接到运行时内存虚拟空间和物理空间映射的角度，了解一个程序从编写到运行时的底层知识的方方面面。

现在编程技术不断推陈出新，不断通过添加中间层将底层埋住，新一代开发人员也越来越不重视底层知识，所以当他们学到的上层知识被更新替代以后就会感叹赶不上技术更新的脚步，知识焦虑感越来越严重。

而读完这本书，你就会发现，有些知识是不会变的，不管上层如何变化，只要抓住这些知识就能够抓住核心，掌握技术的走向。

《程序员的自我修养 - 链接、装载与库》耗时 30 年才被出版，期间作者不断优化其中的内容，最终成为一本经典图书。正如其名，程序员的自我修养，就是来自对程序运行背后机制的学习，而不是一味地追新。除了内容地道以外，这本书的作者们精益求精的精神也非常值得我们学习。

当你有了大量的编程经验，需要考虑工程架构合理性的时候，我推荐你看看《架构整洁之道》这本书。架构设计的思想也不会过时，并适用于所有的知识领域。

这本书详细分析了架构的目标价值、编程范式、架构设计原则、组件拆分和边界处理。其中，编程范式介绍了结构化编程、面向对象编程、函数式编程等。设计原则包含了开闭原则 OCP、单一职责原则 SRP、里氏替换原则 LSP、接口隔离原则 ISP、依赖反转原则 DIP 等等，内容十分丰富。熟练掌握这些架构设计原则，会让你对架构的理解更深，也可以从更多方面去思考。

值得一提的是，这本书还通过实践案例把所讲知识都串了起来，让你更容易理解架构设计的知识。

小结

在今天这篇文章中，我和你分享了很多关于 iOS 开发的学习资料，这其中有优秀的开源项目，也有一些经典的图书。尤其是《程序员的自我修养 - 链接、装载与库》和《架构整洁之道》这两本书，值得你反复阅读。在不同阶段去阅读这两本书，你会有不同的体会，也会有不同的收获。

如果你还想阅读更多计算机编程相关的经典书，可以再看看《编码》、《代码整洁之道》、《代码大全》、《算法》。

每个人的学习时间都是有限的，上班时要争分夺秒的完成任务，下班放假还要放松休息。与其无止境的寻找资料，还不如静下心来阅读经典和多一些思考。所以，希望我今天的这篇文章可以帮到你。

课后作业

你在读过的书里，哪一本对你帮助最大呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

32 | 热点问题答疑（三）

你好，我是戴铭。

这是我们《iOS 开发高手课》专栏的第三期答疑文章，我将继续和你分享大家在学习前面文章时遇到的最普遍的问题。

今天，我在这段时间的留言问题中，挑选了几个 iOS 开发者普遍关注的问题，在这篇答疑文章里来做一个统一回复。

A/B 测试 SDK

@鼠辈同学在第 24 篇文章《A/B 测试：验证决策效果的利器》留言中问道：

最近一直在找一个好的 A/B 测试的 SDK，不知道作者之前用过什么好的 A/B 测试的 SDK（三方的，可以后台控制的）

我认为带后台功能的 A/B 测试 SDK 没什么必要，原因有二：

A/B 测试本身就是为业务服务的，需要对会影响产品决策的业务场景做大量定制化开发；

A/B 测试功能本身并不复杂，第三方后台定制化开发，成本也不会节省多少。

因此，我推荐后台功能自己来做，端上使用我在第 24 篇文章中提到的 SkyLab 就完全没有问题了。另外，SkyLab 也可以很方便地集成到你自己的后台中。

如何衡量性能监控的优劣？

@ RiverLi 同学在第 16 篇文章《性能监控：衡量 App 质量的那把尺》的评论区留言问到：

对于性能的监控有没有衡量标准，如何衡量优劣？

我觉得，如果给所有 App 制定相同的衡量标准是不现实的，这样的标准，也是无法落地的。为什么这么说呢，很有可能由于历史原因或者 App 的特性决定了有些 App 的性能无法达到另一个 App 的标准。又或者说，有些 App 需要进行大量的重构，才能要达到另一个 App 的性能标准，而这些重构明显一朝一夕就能落地执行的。特别是业务还在快跑的情况下，你只能够有针对性地去做优化，而不是大量的重构。

回到性能监控的初衷，它主要是希望通过监控手段去发现突发的性能问题，这也是我们再做线上性能监控时需要重点关注的。

对于 App 运行普遍存在的性能问题，我们应该在上线前就设法优化完成。因为，线下的性能问题是可控的，而线上的性能问题往往是“摸不着”的，也正是这个原因，我们需要监控线上性能问题。

因此，性能监控的标准一定是针对 App 线下的性能表现来制定的。比如，你的 App 在线下连续 3 秒 CPU 占比都是在 70% 以下，那么 CPU 占比的监控值就可以设置为 3 秒内占比在 70% 以下。如果超过这个阈值就属于突发情况，就做报警处理，进行问题跟踪排查，然后有针对性地修复问题。

关于 WatchDog

我在第 13 篇文章中讲解如何用 RunLoop 原理去监控卡顿的时候，用到了 WatchDog 机制。Xqqq0 同学在文后留言中，希望我解释一下这个机制，并推荐一些相关的学习资料。

WatchDog 是苹果公司设计的一种机制，主要是为了避免 App 界面无响应造成用户无法操作，而强杀掉 App 进程。造成 App 界面无响应的原因种类太多，于是苹果公司采用了一刀切的做法：凡是主线程卡死一定的时间就会被 WatchDog 机制强杀掉。这个卡死时间，WatchDog 在启动时设置的是 20 秒，前台时设置的是 10 秒，后台时设置的是 10 分钟。

由于 WatchDog 强杀日志属于系统日志，所以你的 App 上线后需要自己来监控卡顿，这样才能够在 WatchDog 强杀之前捕获到 App 卡死的情况。关于这部分内容的详细讲解，你可以参看苹果公司关于崩溃分析的文档。

关于 iOS 崩溃

在专栏的第 12 篇文章《iOS 崩溃千奇百怪，如何全面监控？》后，(Jet) 黄仲平同学提了这么几个问题。考虑到这几个问题涉及知识点比较有代表性，所以我特意在今天这篇答疑文章中和你详细展开下。

关于实现崩溃问题自动定位到人，我认为通过堆栈信息来匹配到人是没有问题的。关于实现方法的问题，也就是第一个问题，你可以先做个映射表，每个类都能够对应到一个负责人，当获取到崩溃堆栈信息时，根据映射表就能够快速定位到人了。

对于第二个问题关于日志的收集方法，我想说的是 PLCrashReporter 就是用 handleSignalException 方法来收集的。

第三个关于 dSYM 解析堆栈信息工作原理的问题，也不是很复杂。dSYM 会根据线程中方法调用栈的指针，去符号表里找到这些指针所对应的符号信息进行解析，解析完之后就能够展示出可读的方法调用栈。

接下来，我来和你说说通过堆栈匹配到人的具体实现的问题。

第一步，通过 task_threads 获取当前所有的线程，遍历所有线程，通过 thread_info 获取各个线程的详细信息。

第二步，遍历线程，每个线程都通过 thread_get_state 得到 machine context 里面函数调用栈的指针。

thread_get_state 获取函数调用栈指针的具体实现代码如下：

_STRUCT_MCONTEXT machineContext; // 线程栈里所有的栈指针

// 通过 thread_get_state 获取完整的 machineContext 信息，包含 thread 状态信息

mach_msg_type_number_t state_count = smThreadStateCountByCPU();

kern_return_t kr = thread_get_state(thread, smThreadStateByCPU(), (thread_state_t)&machineContext.__ss, &state_count);

获取到的这些函数调用栈，需要一个栈结构体来保存。

第三步，创建栈结构体。创建后通过栈基地址指针获取到当前栈帧地址，然后往前查找函数调用帧地址，并将它们保存到创建的栈结构体中。具体代码如下：

// 为通用回溯设计结构支持栈地址由小到大，地址里存储上个栈指针的地址

typedef struct SMStackFrame {

    const struct SMStackFrame *const previous;

    const uintptr_t return_address;

} SMStackFrame;

SMStackFrame stackFrame = {0};

// 通过栈基址指针获取当前栈帧地址

const uintptr_t framePointer = smMachStackBasePointerByCPU(&machineContext);

if (framePointer == 0 || smMemCopySafely((void *)framePointer, &stackFrame, sizeof(stackFrame)) != KERN_SUCCESS) {

    return @"Fail frame pointer";

}

for (; i < 32; i++) {

    buffer[i] = stackFrame.return_address;

    if (buffer[i] == 0 || stackFrame.previous == 0 || smMemCopySafely(stackFrame.previous, &stackFrame, sizeof(stackFrame)) != KERN_SUCCESS) {

        break;

    }

}

第四步，根据获取到的栈帧地址，找到对应的 image 的游标，从而能够获取 image 的更多信息。代码如下：

// 初始化保存符号结果的结构体 Dl_info

info->dli_fname = NULL;

info->dli_fbase = NULL;

info->dli_sname = NULL;

info->dli_saddr = NULL;

// 根据地址获取是哪个 image

const uint32_t idx = smDyldImageIndexFromAddress(address);

if (idx == UINT_MAX) {

    return false;

}

第五步，在知道了是哪个 image 后，根据 Mach-O 文件的结构，要想获取符号表所在的 segment，需要先找到 Mach-O 里对应的 Header。通过 _dyld_get_image_header 方法，我们可以找到 mach_header 结构体。然后，使用 _dyld_get_image_vmaddr_slide 方法，我们就能够获取虚拟内存地址 slide 的数量。而动态链接器就是通过添加 slide 数量到 image 基地址，以实现将 image 映射到未占用地址的进程虚拟地址空间来加载 image 的。具体实现代码如下：

/*

 Header

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

 Load commands

 Segment command 1 -------------|

 Segment command 2              |

 ------------------             |

 Data                           |

 Section 1 data |segment 1 <----|

 Section 2 data |          <----|

 Section 3 data |          <----|

 Section 4 data |segment 2

 Section 5 data |

 ...            |

 Section n data |

*/

/*----------Mach Header---------*/

// 根据 image 的序号获取 mach_header

const struct mach_header* machHeader = _dyld_get_image_header(idx);

// 将 header 的名字和 machHeader 记录到 Dl_info 结构体里

info->dli_fname = _dyld_get_image_name(idx);

info->dli_fbase = (void*)machHeader;

// 返回 image_index 索引的 image 的虚拟内存地址 slide 的数量

// 动态链接器就是通过添加 slide 数量到 image 基地址，以实现将 image 映射到未占用地址的进程虚拟地址空间来加载 image 的。

const uintptr_t imageVMAddressSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);

第六步，计算 ASLR（地址空间布局随机化） 偏移量。

ASLR 是一种防范内存损坏漏洞被利用的计算机安全技术，想详细了解 ASLR 的话，你可以参看它的 Wiki 页面。

通过 ASLR 偏移量可以获取 segment 的基地址，segment 定义 Mach-O 文件中的字节范围以及动态链接器加载应用程序时这些字节映射到虚拟内存中的地址和内存保护属性。 所以，segment 总是虚拟内存页对齐。

/*-----------ASLR 偏移量 ---------*/

// https://en.wikipedia.org/wiki/Address_space_layout_randomization

const uintptr_t addressWithSlide = address - imageVMAddressSlide;

// 通过 ASLR 偏移量可以获取 segment 的基地址

// segment 定义 Mach-O 文件中的字节范围以及动态链接器加载应用程序时这些字节映射到虚拟内存中的地址和内存保护属性。 所以，segment 总是虚拟内存页对齐。

const uintptr_t segmentBase = smSegmentBaseOfImageIndex(idx) + imageVMAddressSlide;

if (segmentBase == 0) {

    return false;

}

第七步，遍历所有 segment，查找目标地址在哪个 segment 里。

除了 __TEXT segment 和 __DATA segment 外，还有 __LINKEDIT segment。__LINKEDIT segment 里包含了动态链接器使用的原始数据，比如符号、字符串、重定位表项。LC_SYMTAB 描述的是，__LINKEDIT segment 里查找的字符串在符号表的位置。有了符号表里字符串的位置，就能找到目标地址对应的字符串，从而完成函数调用栈地址的符号化。

这个过程的详细实现代码如下：

/*--------------Mach Segment-------------*/

// 地址最匹配的 symbol

const nlistByCPU* bestMatch = NULL;

uintptr_t bestDistance = ULONG_MAX;

uintptr_t cmdPointer = smCmdFirstPointerFromMachHeader(machHeader);

if (cmdPointer == 0) {

    return false;

}

// 遍历每个 segment 判断目标地址是否落在该 segment 包含的范围里

for (uint32_t iCmd = 0; iCmd < machHeader->ncmds; iCmd++) {

    const struct load_command* loadCmd = (struct load_command*)cmdPointer;

    /*---------- 目标 Image 的符号表 ----------*/

    // Segment 除了 __TEXT 和 __DATA 外还有 __LINKEDIT segment，它里面包含动态链接器使用的原始数据，比如符号，字符串和重定位表项。

    // LC_SYMTAB 描述了 __LINKEDIT segment 内查找字符串和符号表的位置

    if (loadCmd->cmd == LC_SYMTAB) {

        // 获取字符串和符号表的虚拟内存偏移量。

        const struct symtab_command* symtabCmd = (struct symtab_command*)cmdPointer;

        const nlistByCPU* symbolTable = (nlistByCPU*)(segmentBase + symtabCmd->symoff);

        const uintptr_t stringTable = segmentBase + symtabCmd->stroff;

        for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {

            // 如果 n_value 是 0，symbol 指向外部对象

            if (symbolTable[iSym].n_value != 0) {

                // 给定的偏移量是文件偏移量，减去 __LINKEDIT segment 的文件偏移量获得字符串和符号表的虚拟内存偏移量

                uintptr_t symbolBase = symbolTable[iSym].n_value;

                uintptr_t currentDistance = addressWithSlide - symbolBase;

                // 寻找最小的距离 bestDistance，因为 addressWithSlide 是某个方法的指令地址，要大于这个方法的入口。

                // 离 addressWithSlide 越近的函数入口越匹配

                if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistance)) {

                    bestMatch = symbolTable + iSym;

                    bestDistance = currentDistance;

                }

            }

        }

        if (bestMatch != NULL) {

            // 将虚拟内存偏移量添加到 __LINKEDIT segment 的虚拟内存地址可以提供字符串和符号表的内存 address。

            info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddressSlide);

            info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);

            if (*info->dli_sname == '_') {

                info->dli_sname++;

            }

            // 所有的 symbols 的已经被处理好了

            if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {

                info->dli_sname = NULL;

            }

            break;

        }

    }

    cmdPointer += loadCmd->cmdsize;

}

总结

在今天这篇文章中，我针对一些比较有代表性、你大概率会遇到的留言问题做了解答。这其中，包括第三方库的选择、性能衡量标准，以及崩溃分析方面的问题。

最后，对于第三方库的使用，我的建议是：如果和业务强相关，比如埋点或者 A/B 测试这样的库，最好是自建，你可以借鉴开源库的思路；一些基础的、通用性强的库，比如网络库和持续化存储的库，直接使用成熟的第三方库，既可以节省开发和维护时间，还能够提高产品质量；还有种情况就是，如果你所在团队较小，只有几个 iOS 开发人员，那么还是要尽可能地使用开源项目，你可以在 Awesome iOS上去找到适合团队的项目。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

33 | iOS 系统内核 XNU：App 如何加载？

你好，我是戴铭。

在专栏的第 2 篇文章《App 启动速度怎么做优化与监控？》更新完之后，我看到很多同学对启动加载 App 的底层原理表示出了浓厚兴趣。所谓工欲善其事，必先利其器，相信有着好奇心的你，一定也会对支撑着 App 运行的操作系统有着各种各样的疑问。

我曾在专栏的第 5 篇文章《链接器：符号是怎么绑定到地址上的？》中，和你分享了链接器在编译时和程序启动时会做的事情。而今天这篇文章，我会重点与你说说加载动态链接器之前，系统是怎么加载 App 的。

所以，今天我会先跟你说说 iOS 系统的架构是怎样的，各部分的作用是什么，帮助你理解 iOS 系统的原理，进而更全面地理解它在 App 加载时做了哪些事情？

接下来，我就先跟你聊聊 iOS 的系统架构是怎样的。在理解 iOS 系统架构之前，你最好掌握一些操作系统原理的基础知识。

iOS 系统架构

iOS 系统是基于 ARM 架构的，大致可以分为四层：

最上层是用户体验层，主要是提供用户界面。这一层包含了 SpringBoard、Spotlight、Accessibility。

第二层是应用框架层，是开发者会用到的。这一层包含了开发框架 Cocoa Touch。

第三层是核心框架层，是系统核心功能的框架层。这一层包含了各种图形和媒体核心框架、Metal 等。

第四层是 Darwin 层，是操作系统的核心，属于操作系统的内核态。这一层包含了系统内核 XNU、驱动等。

图 1 iOS 系统架构

其中，用户体验层、应用框架层和核心框架层，属于用户态，是上层 App 的活动空间。Darwin 是用户态的下层支撑，是 iOS 系统的核心。

Darwin 的内核是 XNU，而 XNU 是在 UNIX 的基础上做了很多改进以及创新。了解 XNU 的内部是怎么样的，将有助于我们解决系统层面的问题。

所以接下来，我们就一起看看 XNU 的架构，看看它的内部到底都包含了些什么。

XNU

XNU 内部由 Mach、BSD、驱动 API IOKit 组成，这些都依赖于 libkern、libsa、Platform Expert。如下图所示：

图 2 XNU 系统架构

其中，Mach是作为 UNIX 内核的替代，主要解决 UNIX 一切皆文件导致抽象机制不足的问题，为现代操作系统做了进一步的抽象工作。 Mach 负责操作系统最基本的工作，包括进程和线程抽象、处理器调度、进程间通信、消息机制、虚拟内存管理、内存保护等。

进程对应到 Mach 是 Mach Task，Mach Task 可以看做是线程执行环境的抽象，包含虚拟地址空间、IPC 空间、处理器资源、调度控制、线程容器。

进程在 BSD 里是由 BSD Process 处理，BSD Process 扩展了 Mach Task，增加了进程 ID、信号信息等，BSD Process 里面包含了扩展 Mach Thread 结构的 Uthread。

Mach 的模块包括进程和线程都是对象，对象之间不能直接调用，只能通过 Mach Msg 进行通信，也就是 mach_msg() 函数。在用户态的那三层中，也就是在用户体验层、应用框架层和核心框架层中，你可以通过 mach_msg_trap() 函数触发陷阱，从而切至 Mach，由 Mach 里的 mach_msg() 函数完成实际通信，具体实现可以参看 NSHipster 的这篇文章“Inter-Process Communication”。

每个 Mach Thread 表示一个线程，是 Mach 里的最小执行单位。Mach Thread 有自己的状态，包括机器状态、线程栈、调度优先级（有 128 个，数字越大表示优先级越高）、调度策略、内核 Port、异常 Port。

Mach Thread 既可以由 Mach Task 处理，也可以扩展为 Uthread，通过 BSD Process 处理。这是因为 XNU 采用的是微内核 Mach 和 宏内核 BSD 的混合内核，具备微内核和宏内核的优点。

微内核可以提高系统的模块化程度，提供内存保护的消息传递机制；

宏内核也可以叫单内核，在出现高负荷状态时依然能够让系统保持高效运作。

Mach 是微内核，可以将操作系统的核心独立在进程上运行，不过，内核层和用户态各层之间切换上下文和进程间消息传递都会降低性能。为了提高性能，苹果深度定制了 BSD 宏内核，使其和 Mach 混合使用。

宏内核 BSD 是对 Mach 封装，提供进程管理、安全、网络、驱动、内存、文件系统（HFS+）、网络文件系统（NFS）、虚拟文件系统（VFS）、POSIX（Portable Operating System Interface of UNIX，可移植操作系统接口）兼容。

早期的 BSD 是 UNIX 衍生出的操作系统，现在 BSD 是类 UNIX 操作系统的统称。XNU 的 BSD 来源于 FreeBSD 内核，经过深度定制而成。IEEE 为了保证软件可以在各个 UNIX 系统上运行而制定了 POSIX 标准，iOS 也是通过 BSD 对 POSIX 的兼容而成为了类 UNIX 系统。

BSD 提供了更现代、更易用的内核接口，以及 POSIX 的兼容，比如通过扩展 Mach Task 进程结构为 BSD Process。对于 Mach 使用 mach_msg_trap() 函数触发陷阱来处理异常消息，BSD 则在异常消息机制的基础上建立了信号处理机制，用户态产生的信号会先被 Mach 转换成异常，BSD 将异常再转换成信号。对于进程和线程，BSD 会构建 UNIX 进程模型，创建 POSIX 兼容的线程模型 pthread。

iOS 6 后，为了增强系统安全，BSD 实行了 ASLR（Address Space Layout Randomization，地址空间布局随机化）。随着 iPhone 硬件升级，为了更好地利用多核，BSD 加入了工作队列，以支持多核多线程处理，这也是 GCD 能更高效工作的基础。 BSD 还从 TrustdBSD 引入了 MAC 框架以增强权限 entitlement 机制的安全。

除了微内核 Mach 和宏内核 BSD 外，XNU 还有 IOKit。IOKit 是硬件驱动程序的运行环境，包含电源、内存、CPU 等信息。IOKit 底层 libkern 使用 C++ 子集 Embedded C++ 编写了驱动程序基类，比如 OSObject、OSArray、OSString 等，新驱动可以继承这些基类来写。

了解了 XNU 后，接下来，我再跟你聊聊 XNU 怎么加载 App 的？

XNU 怎么加载 App？

iOS 的可执行文件和动态库都是 Mach-O 格式，所以加载 APP 实际上就是加载 Mach-O 文件。

Mach-O header 信息结构代码如下：

struct mach_header_64 {

    uint32_t        magic;      // 64 位还是 32 位

    cpu_type_t      cputype;    // CPU 类型，比如 arm 或 X86

    cpu_subtype_t   cpusubtype; // CPU 子类型，比如 armv8

    uint32_t        filetype;   // 文件类型

    uint32_t        ncmds;      // load commands 的数量

    uint32_t        sizeofcmds; // load commands 大小

    uint32_t        flags;      // 标签

    uint32_t        reserved;   // 保留字段

};

如上面代码所示，包含了表示是 64 位还是 32 位的 magic、CPU 类型 cputype、CPU 子类型 cpusubtype、文件类型 filetype、描述文件在虚拟内存中逻辑结构和布局的 load commands 数量和大小等文件信息。

其中，文件类型 filetype 表示了当前 Mach-O 属于哪种类型。Mach-O 包括以下几种类型。

OBJECT，指的是 .o 文件或者 .a 文件；

EXECUTE，指的是 IPA 拆包后的文件；

DYLIB，指的是 .dylib 或 .framework 文件；

DYLINKER，指的是动态链接器；

DSYM，指的是保存有符号信息用于分析闪退信息的文件。

加载 Mach-O 文件，内核会 fork 进程，并对进程进行一些基本设置，比如为进程分配虚拟内存、为进程创建主线程、代码签名等。用户态 dyld 会对 Mach-O 文件做库加载和符号解析。

苹果公司已经将 XNU 开源，并在 GitHub 上创建了镜像。要想编译 XNU，你可以查看“Building the XNU kernel on Mac OS X Sierra (10.12.X)”这篇文章；要想调试 XNU，可以查看“Source Level Debugging the XNU Kernel”这篇文章。

整个 fork 进程，加载解析 Mach-O 文件的过程可以在 XNU 的源代码中查看，代码路径是 darwin-xnu/bsd/kern/kern_exec.c，地址是https://github.com/apple/darwin-xnu/blob/master/bsd/kern/kern_exec.c，相关代码在 __mac_execve 函数里，代码如下：

int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval)

{

    // 字段设置

    ...

    int is_64 = IS_64BIT_PROCESS(p);

    struct vfs_context context;

    struct uthread  *uthread; // 线程

    task_t new_task = NULL;   // Mach Task

    ...

    context.vc_thread = current_thread();

    context.vc_ucred = kauth_cred_proc_ref(p);

    // 分配大块内存，不用堆栈是因为 Mach-O 结构很大。

    MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO);

    imgp = (struct image_params *) bufp;

    // 初始化 imgp 结构里的公共数据

    ...

    uthread = get_bsdthread_info(current_thread());

    if (uthread->uu_flag & UT_VFORK) {

        imgp->ip_flags |= IMGPF_VFORK_EXEC;

        in_vfexec = TRUE;

    } else {

        // 程序如果是启动态，就需要 fork 新进程

        imgp->ip_flags |= IMGPF_EXEC;

        // fork 进程

        imgp->ip_new_thread = fork_create_child(current_task(),

                    NULL, p, FALSE, p->p_flag & P_LP64, TRUE);

        // 异常处理

        ...

        new_task = get_threadtask(imgp->ip_new_thread);

        context.vc_thread = imgp->ip_new_thread;

    }

    // 加载解析 Mach-O

    error = exec_activate_image(imgp);

    if (imgp->ip_new_thread != NULL) {

        new_task = get_threadtask(imgp->ip_new_thread);

    }

    if (!error && !in_vfexec) {

        p = proc_exec_switch_task(p, current_task(), new_task, imgp->ip_new_thread);

        should_release_proc_ref = TRUE;

    }

    kauth_cred_unref(&context.vc_ucred);

    if (!error) {

        task_bank_init(get_threadtask(imgp->ip_new_thread));

        proc_transend(p, 0);

        thread_affinity_exec(current_thread());

        // 继承进程处理

        if (!in_vfexec) {

            proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task());

        }

        // 设置进程的主线程

        thread_t main_thread = imgp->ip_new_thread;

        task_set_main_thread_qos(new_task, main_thread);

    }

    ...

}

可以看出，由于 Mach-O 文件很大， __mac_execve 函数会先为 Mach-O 分配一大块内存 imgp，接下来会初始化 imgp 里的公共数据。内存处理完，__mac_execve 函数就会通过 fork_create_child() 函数 fork 出一个新的进程。新进程 fork 后，会通过 exec_activate_image() 函数解析加载 Mach-O 文件到内存 imgp 里。最后，使用 task_set_main_thread_qos() 函数设置新 fork 出进程的主线程。

exec_activate_image() 函数会调用不同格式对应的加载函数，代码如下：

struct execsw {

    int (*ex_imgact)(struct image_params *);

    const char *ex_name;

} execsw[] = {

    { exec_mach_imgact,     "Mach-o Binary" },

    { exec_fat_imgact,      "Fat Binary" },

    { exec_shell_imgact,        "Interpreter Script" },

    { NULL, NULL}

};

可以看出，加载 Mach-O 文件的是 exec_mach_imgact() 函数。exec_mach_imgact() 会通过 load_machfile() 函数加载 Mach-O 文件，根据解析 Mach-O 后得到的 load command 信息，通过映射方式加载到内存中。还会使用 activate_exec_state() 函数处理解析加载 Mach-O 后的结构信息，设置执行 App 的入口点。

设置完入口点后会通过 load_dylinker() 函数来解析加载 dyld，然后将入口点地址改成 dyld 的入口地址。这一步完后，内核部分就完成了 Mach-O 文件的加载。剩下的就是用户态层 dyld 加载 App 了。

Dyld 的入口函数是 __dyld_start，dyld 属于用户态进程，不在 XNU 里，__dyld_start 函数的实现代码在 dyld 仓库中的 dyldStartup.s 文件里。__dyld_start 会加载 App 相关的动态库，处理完成后会返回 App 的入口地址，然后到 App 的 main 函数。

小结

今天我跟你介绍了 iOS 系统的内核 XNU，以及 XNU 是如何加载 App 的。总体来说，XNU 加载就是为 Mach-O 创建一个新进程，建立虚拟内存空间，解析 Mach-O 文件，最后映射到内存空间。流程可以概括为：

fork 新进程；

为 Mach-O 分配内存；

解析 Mach-O；

读取 Mach-O 头信息；

遍历 load command 信息，将 Mach-O 映射到内存；

启动 dyld。

课后作业

在今天这篇文章中，我主要和你分享的是系统内核加载 App 的流程，而关于用户态 dyld 加载过程没有展开说。如果你想了解 dyld 加载过程的话，可以看看 Mike Ash 的“dyld: Dynamic Linking On OS X”这篇文章。

相应地，今天的课后思考题，我希望你能够和我分享一下这篇文章的读后感。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

34 | iOS 黑魔法 Runtime Method Swizzling 背后的原理

你好，我是戴铭。

提到 Object-C 中的 Runtime，你可能一下就想到了 iOS 的黑魔法 Method Swizzling。毕竟，这个黑魔法可以帮助我们在运行时进行方法交换，或者在原方法执行之前插入自定义方法，以保证在业务面向对象编程方式不被改变的情况下，进行切面功能的开发。但是，运行时进行方法交换同时也会带来一定的风险。所以，今天我就来和你详细聊聊 Runtime Method Swizzling 的原理。

Runtime Method Swizzling 编程方式，也可以叫作 AOP（Aspect-Oriented Programming，面向切面编程）。

AOP 是一种编程范式，也可以说是一种编程思想，使用 AOP 可以解决 OOP（Object Oriented Programming，面向对象编程）由于切面需求导致单一职责被破坏的问题。通过 AOP 可以不侵入 OOP 开发，非常方便地插入切面需求功能。

比如，我在专栏第 9 篇文章中介绍无侵入埋点方案时，就提到了通过 AOP 在不侵入原有功能代码的情况下插入收集埋点的功能。

除此之外，还有一些主业务无关的逻辑功能，也可以通过 AOP 来完成，这样主业务逻辑就能够满足 OOP 单一职责的要求。而如果没有使用 AOP，鉴于 OOP 的局限性，这些与主业务无关的代码就会到处都是，增大了工作量不说，还会加大维护成本。

但是我们也知道，iOS 在运行时进行 AOP 开发会有风险，不能简单地使用 Runtime 进行方法交换来实现 AOP 开发。因此，我今天就来跟你说下直接使用 Runtime 方法交换开发的风险有哪些，而安全的方法交换原理又是怎样的？

直接使用 Runtime 方法交换开发的风险有哪些？

Objective-C 是门动态语言，可以在运行时做任何它能做的事情。这其中的功劳离不开 Runtime 这个库。正因为如此，Runtime 成为了 iOS 开发中 Objective-C 和 C 的分水岭。

Runtime 不光能够进行方法交换，还能够在运行时处理 Objective-C 特性相关（比如类、成员函数、继承）的增删改操作。

苹果公司已经开源了 Runtime，在 GitHub 上有可编译的 Runtime 开源版本。你可以通过于德志 (@halfrost) 博客的三篇 Runtime 文章，即isa 和 Class、消息发送与转发，以及如何正确使用 Runtime，来一边学习一边调试。

直接使用 Runtime 进行方法交换非常简单，代码如下：

#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

如代码所示：通过 class_getInstanceMethod() 函数可以得到被交换类的实例方法和交换类的实例方法。使用 class_addMethod() 函数来添加方法，返回成功表示被交换的方法没被实现，然后通过 class_addMethod() 函数实现；返回失败则表示被交换方法已存在，可以通过 method_exchangeImplementations() 函数直接进行 IMP 指针交换以实现方法交换。

但是，像上面这段代码一样，直接使用 Runtime 的方法进行方法交换会有很多风险，RSSwizzle库里指出了四个典型的直接使用 Runtime 方法进行方法交换的风险。我稍作整理，以方便你查看，并便于你理解后续的内容。

第一个风险是，需要在 +load 方法中进行方法交换。因为如果在其他时候进行方法交换，难以保证另外一个线程中不会同时调用被交换的方法，从而导致程序不能按预期执行。

第二个风险是，被交换的方法必须是当前类的方法，不能是父类的方法，直接把父类的实现拷贝过来不会起作用。父类的方法必须在调用的时候使用，而不是方法交换时使用。

第三个风险是，交换的方法如果依赖了 cmd，那么交换后，如果 cmd 发生了变化，就会出现各种奇怪问题，而且这些问题还很难排查。特别是交换了系统方法，你无法保证系统方法内部是否依赖了 cmd。

第四个风险是，方法交换命名冲突。如果出现冲突，可能会导致方法交换失败。

更多关于运行时方法交换的风险，你可以查看 Stackoverflow 上的问题讨论“What are the Dangers of Method Swizzling in Objective C?”。

可以看到，直接使用 Runtime 进行方法交换的风险非常大，那么安全的方法交换是怎样的呢？接下来，我就来跟你介绍一个更安全的运行时方法交换库 Aspects。

更安全的方法交换库 Aspects

Aspects 是一个通过 Runtime 消息转发机制来实现方法交换的库。它将所有的方法调用都指到 _objc_msgForward 函数调用上，按照自己的方式实现了消息转发，自己处理参数列表，处理返回值，最后通过 NSInvocation 调用来实现方法交换。同时，Aspects 还考虑了一些方法交换可能会引发的风险，并进行了处理。

通过学习 Aspects 的源码，你能够从中学习到如何处理这些风险。 比如，热修复框架 JSPatch就是学习了 Aspects 的实现方式。因此，接下来我会展开 Aspects 的源码，带你一起看看它是如何解决这些问题的。这样，你再遇到类似问题时，或借鉴其中的解决思路，或经过实践、思考后形成自己的更优雅的解决方法。

虽然 Aspects 对于一些风险进行了规避，但是在使用不当的情况下依然会有风险，比如 hook 已经被 hook 过的方法，那么之前的 hook 会失效，而且新的 hook 也会出错。所以，即使是 Aspects， 在工程中也不能滥用。

现在，我们先一起看一段如何使用 Aspects 的示例代码：

[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {

    NSLog(@"View Controller %@ will appear animated: %tu", aspectInfo.instance, animated);

} error:NULL];

上面这段代码是 Aspects 通过运行时方法交换，按照 AOP 方式添加埋点的实现。代码简单，可读性高，接口使用 Block 也非常易用。按照这种方式，直接使用 Aspects 即可。

接下来，我就跟你说下 Aspect 实现方法交换的原理。

Aspects 的整体流程是，先判断是否可进行方法交换。这一步会进行安全问题的判断处理。如果没有风险的话，再针对要交换的是类对象还是实例对象分别进行处理。

对于类对象的方法交换，会先修改类的 forwardInvocation ，将类的实现转成自己的。然后，重新生成一个方法用来交换。最后，交换方法的 IMP，方法调用时就会直接对交换方法进行消息转发。

对于实例对象的方法交换，会先创建一个新的类，并将当前实例对象的 isa 指针指向新创建的类，然后再修改类的方法。

整个流程的入口是 aspect_add() 方法，这个方法里包含了 Aspects 的两个核心方法，第一个是进行安全判断的 aspect_isSelectorAllowedAndTrack 方法，第二个是执行类对象和实例对象方法交换的 aspect_prepareClassAndHookSelector 方法。

aspect_isSelectorAllowedAndTrack 方法，会对一些方法比如 retain、release、autorelease、forwardInvocation 进行过滤，并对 dealloc 方法交换做了限制，要求只能使用 AspectPositionBefore 选项。同时，它还会过滤没有响应的方法，直接返回 NO。

安全判断执行完，就开始执行方法交换的 aspect_prepareClassAndHookSelector 方法，其实现代码如下：

static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {

    NSCParameterAssert(selector);

    Class klass = aspect_hookClass(self, error);

    Method targetMethod = class_getInstanceMethod(klass, selector);

    IMP targetMethodIMP = method_getImplementation(targetMethod);

    if (!aspect_isMsgForwardIMP(targetMethodIMP)) {

        // 创建方法别名

        const char *typeEncoding = method_getTypeEncoding(targetMethod);

        SEL aliasSelector = aspect_aliasForSelector(selector);

        if (![klass instancesRespondToSelector:aliasSelector]) {

            __unused BOOL addedAlias = class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);

            NSCAssert(addedAlias, @"Original implementation for %@ is already copied to %@ on %@", NSStringFromSelector(selector), NSStringFromSelector(aliasSelector), klass);

        }

        // 使用 forwardInvocation 进行方法交换.

        class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding);

        AspectLog(@"Aspects: Installed hook for -[%@ %@].", klass, NSStringFromSelector(selector));

    }

}

可以看到，通过 aspect_hookClass() 函数可以判断出 class 的 selector 是实例方法还是类方法，如果是实例方法，会通过 class_addMethod 方法生成一个交换方法，这样在 forwordInvocation 时就能够直接执行交换方法。aspect_hookClass 还会对类对象、元类、KVO 子类化的实例对象、class 和 isa 指向不同的情况进行处理，使用 aspect_swizzleClassInPlace 混写 baseClass。

小结

在今天这篇文章中，我和你梳理了直接使用 Runtime 进行方法交换会有哪些问题，进而为了解决这些问题，我又和你分享了一个更安全的方法交换库 Aspects。

在文章最后，我想和你说的是，对于运行时进行方法交换，有的开发者在碰到了几次问题之后，就敬而远之了，但其实很多问题在你了解了原因后就不那么可怕了。就比如说，了解更多运行时原理和优秀方法交换库的实现细节，能够增强你使用运行时方法交换的信心，从而这个技术能够更好地为你提供服务，去帮助你更加高效地去解决某一类问题。

课后作业

你是怎么使用方法交换的？用的什么库？和 Aspects 比，这些库好在哪儿？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

35 | libffi：动态调用和定义 C 函数

你好，我是戴铭。

在 iOS 开发中，我们可以使用 Runtime 接口动态地调用 Objective-C 方法，但是却无法动态调用 C 的函数。那么，我们怎么才能动态地调用 C 语言函数呢？

C 语言编译后，在可执行文件里会有原函数名信息，我们可以通过函数名字符串来找到函数的地址。现在，我们只要能够通过函数名找到函数地址，就能够实现动态地去调用 C 语言函数。

而在动态链接器中，有一个接口 dlsym() 可以通过函数名字符串拿到函数地址，如果所有 C 函数的参数类型和数量都一样，而且返回类型也一样，那么我们使用 dlsym() 就能实现动态地调用 C 函数。

但是，在实际项目中，函数的参数定义不可能都一样，返回类型也不会都是 void 或者 int 类型。所以， dlsym() 这条路走不通。那么，还有什么办法可以实现动态地调用 C 函数呢？

如何动态地调用 C 函数？

要想动态地调用 C 函数，你需要先了解函数底层是怎么调用的。

高级编程语言的函数在调用时，需要约定好参数的传递顺序、传递方式，栈维护的方式，名字修饰。这种函数调用者和被调用者对函数如何调用的约定，就叫作调用惯例（Calling Convention）。高级语言编译时，会生成遵循调用惯例的代码。

不同 CPU 架构的调用惯例不一样，比如 64 位机器的寄存器多些、传递参数快些，所以参数传递会优先采用寄存器传递，当参数数量超出寄存器数量后才会使用栈传递。

所以，编译时需要按照调用惯例针对不同 CPU 架构编译，生成汇编代码，确定好栈和寄存器。 如果少了编译过程，直接在运行时去动态地调用函数，就需要先生成动态调用相应寄存器和栈状态的汇编指令。而要达到事先生成相应寄存器和栈的目的，就不能使用遵循调用惯例的高级编程语言，而需要使用汇编语言。

Objective-C 的函数调用采用的是发送消息的方式，使用的是 objc_msgSend 函数。objc_msgSend 函数就是使用汇编语言编写的，其结构分为序言准备（Prologue）、函数体（Body）、结束收尾（Epilogue）三部分。

序言准备部分的作用是会保存之前程序执行的状态，还会将输入的参数保存到寄存器和栈上。这样，objc_msgSend 就能够先将未知的参数保存到寄存器和栈上，然后在函数体执行自身指令或者跳转其他函数，最后在结束收尾部分恢复寄存器，回到调用函数之前的状态。

得益于序言准备部分可以事先准备好寄存器和栈，objc_msgSend 可以做到函数调用无需通过编译生成汇编代码来遵循调用惯例，进而使得 Objective-C 具备了动态调用函数的能力。

但是，不同的 CPU 架构，在编译时会执行不同的 objc_msgSend 函数，而且 objc_msgSend 函数无法直接调用 C 函数，所以想要实现动态地调用 C 函数就需要使用另一个用汇编语言编写的库 libffi。

那么，libffi 是什么呢，又怎么使用 libffi 来动态地调用 C 函数？接下来，我就和你分析一下这两个问题应该如何解决。

libffi 原理分析

libffi 中 ffi 的全称是 Foreign Function Interface（外部函数接口），提供最底层的接口，在不确定参数个数和类型的情况下，根据相应规则，完成所需数据的准备，生成相应汇编指令的代码来完成函数调用。

libffi 还提供了可移植的高级语言接口，可以不使用函数签名间接调用 C 函数。比如，脚本语言 Python 在运行时会使用 libffi 高级语言的接口去调用 C 函数。libffi 的作用类似于一个动态的编译器，在运行时就能够完成编译时所做的调用惯例函数调用代码生成。

libffi 通过调用 ffi_call（函数调用） 来进行函数调用，ffi_call 的输入是 ffi_cif（模板）、函数指针、参数地址。其中，ffi_cif 由 ffi_type（参数类型） 和 参数个数生成，也可以是 ffi_closure（闭包）。

libffi 是开源的，代码在 GitHub 上。接下来，我将结合 libffi 中的关键代码，和你详细说下 ffi_call 调用函数的过程。这样，可以帮助你更好地了解 libffi 的原理。

首先，我们来看看 ffi_type。

ffi_type（参数类型）

ffi_type 的作用是，描述 C 语言的基本类型，比如 uint32、void *、struct 等，定义如下：

typedef struct _ffi_type

{

  size_t size; // 所占大小

  unsigned short alignment; // 对齐大小

  unsigned short type; // 标记类型的数字

  struct _ffi_type **elements; // 结构体中的元素

} ffi_type;

其中，size 表述该类型所占的大小，alignment 表示该类型的对齐大小，type 表示标记类型的数字，element 表示结构体的元素。

当类型是 uint32 时，size 的值是 4，alignment 也是 4，type 的值是 9，elements 是空。

ffi_cif（模板）

ffi_cif 由参数类型（ffi_type） 和参数个数生成，定义如下：

typedef struct {

  ffi_abi abi; // 不同 CPU 架构下的 ABI，一般设置为 FFI_DEFAULT_ABI

  unsigned nargs; // 参数个数

  ffi_type **arg_types; // 参数类型

  ffi_type *rtype; // 返回值类型

  unsigned bytes; // 参数所占空间大小，16 的倍数

  unsigned flags; // 返回类型是结构体时要做的标记

#ifdef FFI_EXTRA_CIF_FIELDS

  FFI_EXTRA_CIF_FIELDS;

#endif

} ffi_cif;

如代码所示，ffi_cif 包含了函数调用时需要的一些信息。

abi 表示的是不同 CPU 架构下的 ABI，一般设置为 FFI_DEFAULT_ABI：在移动设备上 CPU 架构是 ARM64 时，FFI_DEFAULT_ABI 就是 FFI_SYSV；使用苹果公司笔记本 CPU 架构是 X86_DARWIN 时，FFI_DEFAULT_ABI 就是 FFI_UNIX64。

nargs 表示输入参数的个数。arg_types 表示参数的类型，比如 ffi_type_uint32。rtype 表示返回类型，如果返回类型是结构体，字段 flags 需要设置数值作为标记，以便在 ffi_prep_cif_machdep 函数中处理，如果返回的不是结构体，flags 不做标记。

bytes 表示输入参数所占空间的大小，是 16 的倍数。

ffi_cif 是由 ffi_prep_cif 函数生成的，而 ffi_prep_cif 实际上调用的又是 ffi_prep_cif_core 函数。

了解 ffi_prep_cif_core 就能够知道 ffi_cif 是怎么生成的。接下来，我继续跟你说说 ffi_prep_cif_core 里是怎么生成 ffi_cif 的。ffi_prep_cif_core 函数会先初始化返回类型，然后对返回类型使用 ffi_type_test 进行完整性检查，为返回类型留出空间。

接着，使用 initialize_aggregate 函数初始化栈，对参数类型进行完整性检查，对栈进行填充，通过 ffi_prep_cif_machdep 函数执行 ffi_cif 平台相关处理。具体实现代码，你可以点击这个链接查看，其所在文件路径是 libffi/src/prep_cif.c。

之所以将准备 ffi_cif 和 ffi_call 分开，是因为 ffi_call 可能会调用多次参数个数、参数类型、函数指针相同，只有参数地址不同的函数。将它们分开，ffi_call 只需要处理不同参数地址，而其他工作只需要 ffi_cif 做一遍就行了。

接着，准备好了 ffi_cif 后，我们就可以开始函数调用了。

ffi_call（函数调用）

ffi_call 函数的主要处理都交给了 ffi_call_SYSV 这个汇编函数。ffi_call_SYSV 的实现代码，你可以点击这个链接，其所在文件路径是 libffi/src/aarch64/sysv.S。

下面，我来跟你说说 ffi_call_SYSV 汇编函数做了什么。

首先，我们一起看看 ffi_call_SYSV 函数的定义：

extern void ffi_call_SYSV (void *stack, void *frame,

                  void (*fn)(void), void *rvalue,

                  int flags, void *closure);

可以看到，通过 ffi_call_SYSV 函数，我们可以得到 stack、frame、fn、rvalue、flags、closure 参数。

各参数会依次保存在参数寄存器中，参数栈 stack 在 x0 寄存器中，参数地址 frame 在 x1 寄存器中，函数指针 fn 在 x2 寄存器中，用于存放返回值的 rvalue 在 x3 里，结构体标识 flags 在 x4 寄存器中，闭包 closure 在 x5 寄存器中。

然后，我们再看看 ffi_call_SYSV 处理的核心代码：

    // 分配 stack 和 frame

    cfi_def_cfa(x1, 32);

    stp x29, x30, [x1]

    mov x29, x1

    mov sp, x0

    cfi_def_cfa_register(x29)

    cfi_rel_offset (x29, 0)

    cfi_rel_offset (x30, 8)

    // 记录函数指针 fn

    mov x9, x2          /* save fn */

    // 记录返回值 rvalue

    mov x8, x3          /* install structure return */

#ifdef FFI_GO_CLOSURES

    // 记录闭包 closure

    mov x18, x5         /* install static chain */

#endif

    // 保存 rvalue 和 flags

    stp x3, x4, [x29, #16]  /* save rvalue and flags */

    // 先将向量参数传到寄存器

    tbz w4, #AARCH64_FLAG_ARG_V_BIT, 1f

    ldp     q0, q1, [sp, #0]

    ldp     q2, q3, [sp, #32]

    ldp     q4, q5, [sp, #64]

    ldp     q6, q7, [sp, #96]

1:

    // 再将参数传到寄存器

    ldp     x0, x1, [sp, #16*N_V_ARG_REG + 0]

    ldp     x2, x3, [sp, #16*N_V_ARG_REG + 16]

    ldp     x4, x5, [sp, #16*N_V_ARG_REG + 32]

    ldp     x6, x7, [sp, #16*N_V_ARG_REG + 48]

    // 释放上下文，留下栈里参数

    add sp, sp, #CALL_CONTEXT_SIZE

    // 调用函数指针 fn

    blr     x9

    // 重新读取 rvalue 和 flags

    ldp x3, x4, [x29, #16]

    // 析构部分栈指针

    mov     sp, x29

    cfi_def_cfa_register (sp)

    ldp     x29, x30, [x29]

    // 保存返回值

    adr x5, 0f

    and w4, w4, #AARCH64_RET_MASK

    add x5, x5, x4, lsl #3

    br  x5

如上面代码所示，ffi_call_SYSV 处理过程分为下面几步：

第一步，ffi_call_SYSV 会先分配 stack 和 frame，保存记录 fn、rvalue、closure、flags。

第二步，将向量参数传到寄存器，按照参数放置规则，调整 sp 的位置，

第三步，将参数放入寄存器，存放完毕，就开始释放上下文，留下栈里的参数。

第四步，通过 blr 指令调用 x9 中的函数指针 fn ，以调用函数。

第五步，调用完函数指针，就重新读取 rvalue 和 flags，析构部分栈指针。

第六步，保存返回值。

可以看出，libffi 调用函数的原理和 objc_msgSend 的实现原理非常类似。objc_msgSend 原理，你可以参考 Mike Ash 的“Dissecting objc_msgSend on ARM64”这篇文章。

这里我要多说一句，在专栏第 2 篇文章中我和你分享 App 启动速度优化时，用到了些汇编代码，有很多用户反馈看不懂这部分内容。针对这个情况，我特意在第 11 篇答疑文章中，和你分享了些汇编语言学习的方法、参考资料。如果你对上述的汇编代码感兴趣，但又感觉读起来有些吃力的话，建议你再看一下第 11 篇文章中的相关内容。

了解了 libffi 调用函数的原理后，相信你迫不及待就想在你的 iOS 工程中集成 libffi 了吧。

如何使用 libffi？

孙源在 GitHub 上有个 Demo，已经集成了 iOS 可以用的 libffi 库，你可以将这个库集成到自己的工程中。接下来，我借用孙源这个 Demo 中的示例代码，来分别和你说说如何使用 libffi 库来调用 C 函数和定义 C 函数。代码所在文件路径是 libffi-iOS/Demo/ViewController.m。在这里，我也特别感谢孙源的这个 Demo。

调用 C 函数

首先，声明一个函数，实现两个整数相加：

- (int)fooWithBar:(int)bar baz:(int)baz {

    return bar + baz;

}

然后，定义一个函数，使用 libffi 来调用 fooWithBar:baz 函数，也就是刚刚声明的实现两个整数相加的函数。

void testFFICall() {

    // ffi_call 调用需要准备的模板 ffi_cif

    ffi_cif cif;

    // 参数类型指针数组，根据被调用的函数入参的类型来定

    ffi_type *argumentTypes[] = {&ffi_type_pointer, &ffi_type_pointer, &ffi_type_sint32, &ffi_type_sint32};

    // 通过 ffi_prep_cif 内 ffi_prep_cif_core 来设置 ffi_cif 结构体所需要的数据，包括 ABI、参数个数、参数类型等。

    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 4, &ffi_type_pointer, argumentTypes);

    Sark *sark = [Sark new];

    SEL selector = @selector(fooWithBar:baz:);

    // 函数参数的设置

    int bar = 123;

    int baz = 456;

    void *arguments[] = {&sark, &selector, &bar, &baz};

    // 函数指针 fn

    IMP imp = [sark methodForSelector:selector];

    // 返回值声明

    int retValue;

    // ffi_call 所需的 ffi_cif、函数指针、返回值、函数参数都准备好，就可以通过 ffi_call 进行函数调用了

    ffi_call(&cif, imp, &retValue, arguments);

    NSLog(@"ffi_call: %d", retValue);

}

如上面代码所示，先将 ffi_call 所需要的 ffi_cif 通过 ffi_prep_cif 函数准备好，然后设置好参数，通过 Runtime 接口获取 fooWithBar:baz 方法的函数指针 imp，最后就可以通过 ffi_call 进行函数调用了。

在这个例子中，函数指针是使用 Objective-C 的 Runtime 得到的。如果是 C 语言函数，你就可以通过 dlsym 函数获得。dlsym 获得函数指针示例如下：

// 计算矩形面积

int rectangleArea(int length, int width) {

    printf("Rectangle length is %d, and with is %d, so area is %d \n", length, width, length * width);

    return length * width;

}

void run() {

    // dlsym 返回 rectangleArea 函数指针

    void *dlsymFuncPtr = dlsym(RTLD_DEFAULT, "rectangleArea");

}

如上代码所示，dlsym 根据计算矩形面积的函数 rectangleArea 的函数名，返回 rectangleArea 函数指针给 dlsymFuncPtr。

无论是 Runtime 获取的函数指针还是 dlsym 获取的函数指针都可以在运行时去完成，接着使用 libffi 在运行时处理好参数。这样，就能够实现运行时动态地调用 C 函数了。

接下来，我再跟你说下如何使用 libffi 定义 C 函数。

定义 C 函数

首先，声明一个两数相乘的函数。

void closureCalled(ffi_cif *cif, void *ret, void **args, void *userdata) {

    int bar = *((int *)args[2]);

    int baz = *((int *)args[3]);

    *((int *)ret) = bar * baz;

}

然后，再写个函数，用来定义 C 函数。

void testFFIClosure() {

    ffi_cif cif;

    ffi_type *argumentTypes[] = {&ffi_type_pointer, &ffi_type_pointer, &ffi_type_sint32, &ffi_type_sint32};

    // 准备模板 cif

    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 4, &ffi_type_pointer, argumentTypes);

    // 声明一个新的函数指针

    IMP newIMP;

    // 分配一个 closure 关联新声明的函数指针

    ffi_closure *closure = ffi_closure_alloc(sizeof(ffi_closure), (void *)&newIMP);

    // ffi_closure 关联 cif、closure、函数实体 closureCalled

    ffi_prep_closure_loc(closure, &cif, closureCalled, NULL, NULL);

    // 使用 Runtime 接口动态地将 fooWithBar:baz 方法绑定到 closureCalled 函数指针上

    Method method = class_getInstanceMethod([Sark class], @selector(fooWithBar:baz:));

    method_setImplementation(method, newIMP);

    // after hook

    Sark *sark = [Sark new];

    int ret = [sark fooWithBar:123 baz:456];

    NSLog(@"ffi_closure: %d", ret);

}

如上面代码所示，在 testFFIClosure 函数准备好 cif 后，会声明一个新的函数指针，这个新的函数指针会和分配的 ffi_closure 关联，ffi_closure 还会通过 ffi_prep_closure_loc 函数关联到 cif、closure、函数实体 closureCalled。

有了这种能力，你就具备了在运行时将一个函数指针和函数实体绑定的能力，也就能够很容易地实现动态地定义一个 C 函数了。

小结

今天，我和你分享了 libffi 的原理，以及怎么使用 libffi 调用和定义 C 函数。

当你理解了 libffi 的原理以后，再面对语言之间运行时动态调用的问题，也就做到了心中有数。在方案选择动态调用方式时，也就能够找出更多的方案，更加得心应手。

比如，使用 Aspect 进行方法替换，如果使用不当，会有较大的风险；再比如，hook 已经被 hook 过的方法，那么之前的 hook 会失效，新的 hook 也会出错，而使用 libffi 进行 hook 不会出现这样的问题。

课后作业

Block 是一个 Objective-C 对象，表面看类似 C 函数，实际上却有很大不同。你可以点击这个链接查看 Block 的定义，也可以再看看 Mike Ash 的 MABlockClosure库。然后，请你在留言区说说如何通过 libffi 调用 Block。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

36 | iOS 是怎么管理内存的？

你好，我是戴铭。今天，我来和你聊聊 iOS 是怎么管理内存的。

不同的系统版本对 App 运行时占用内存的限制不同，你可以利用我在第 14 篇文章中提到的方法，去查看不同版本系统对 App 占用内存的具体限制是多少。另外，系统版本的升级也会增加占用的内存，同时 App 功能的增多也会要求越来越多的内存。

然而，移动设备的内存资源是有限的，当 App 运行时占用的内存大小超过了限制后，就会被强杀掉，从而导致用户体验被降低。所以，为了提升 App 质量，开发者要非常重视应用的内存管理问题。

移动端的内存管理技术，主要有 GC（Garbage Collection，垃圾回收）的标记清除算法和苹果公司使用的引用计数方法。

相比较于 GC 标记清除算法，引用计数法可以及时地回收引用计数为 0 的对象，减少查找次数。但是，引用计数会带来循环引用的问题，比如当外部的变量强引用 Block 时，Block 也会强引用外部的变量，就会出现循环引用。我们需要通过弱引用，来解除循环引用的问题。

另外，在 ARC（自动引用计数）之前，一直都是通过 MRC（手动引用计数）这种手写大量内存管理代码的方式来管理内存，因此苹果公司开发了 ARC 技术，由编译器来完成这部分代码管理工作。但是，ARC 依然需要注意循环引用的问题。

当 ARC 的内存管理代码交由编译器自动添加后，有些情况下会比手动管理内存效率低，所以对于一些内存要求较高的场景，我们还是要通过 MRC 的方式来管理、优化内存的使用。

要想深入理解 iOS 管理内存的方式，我们就不仅仅要关注用户态接口层面，比如引用计数算法和循环引用监控技巧，还需要从管理内存的演进过程，去了解现代内存管理系统的前世今生，知其然知其所以然。

说到内存管理的演进过程，在最开始的时候，程序是直接访问物理内存，但后来有了多程序多任务同时运行，就出现了很多问题。比如，同时运行的程序占用的总内存必须要小于实际物理内存大小。再比如，程序能够直接访问和修改物理内存，也就能够直接访问和修改其他程序所使用的物理内存，程序运行时的安全就无法保障。

虚拟内存

由于要解决多程序多任务同时运行的这些问题，所以增加了一个中间层来间接访问物理内存，这个中间层就是虚拟内存。虚拟内存通过映射，可以将虚拟地址转化成物理地址。

虚拟内存会给每个程序创建一个单独的执行环境，也就是一个独立的虚拟空间，这样每个程序就只能访问自己的地址空间（Address Space），程序与程序间也就能被安全地隔离开了。

32 位的地址空间是 2^32 = 4294967296 个字节，共 4GB，如果内存没有达到 4GB 时，虚拟内存比实际的物理内存要大，这会让程序感觉自己能够支配更多的内存。如同虚拟内存只供当前程序使用，操作起来和物理内存一样高效。

有了虚拟内存这样一个中间层，极大地节省了物理内存。iOS 的共享库就是利用了这一点，只占用一份物理内存，却能够在不同应用的多份虚拟内存中，去使用同一份共享库的物理内存。

每个程序都有自己的进程，进程的内存布局主要由代码段、数据段、栈、堆组成。程序生成的汇编代码会放在代码段。如果每个进程的内存布局都是连在一起的话，每个进程分配的空间就没法灵活变更，栈和堆没用满时就会有很多没用的空间。如果虚拟地址和物理地址的翻译内存管理单元（Memory Management Unit，MMU）只是简单地通过进程开始地址加上虚拟地址，来获取物理地址，就会造成很大的内存空间浪费。

分段

分段就是将进程里连在一起的代码段、数据段、栈、堆分开成独立的段，每个段内空间是连续的，段之间不连续。这样，内存的空间管理 MMU 就可以更加灵活地进行内存管理。

那么，段和进程关系是怎么表示的呢？进程中内存地址会用前两个字节表示对应的段。比如 00 表示代码段，01 标识堆。

段里的进程又是如何管理内存的呢？每个段大小增长的方向 Grows Positive 也需要记录，是否可读写也要记录，为的是能够更有效地管理段增长。每个段的大小不一样，在申请的内存被释放后，容易产生碎片，这样在申请新内存时，很可能就会出现所剩内存空间够用，但是却不连续，于是造成无法申请的情况。这时，就需要暂停运行进程，对段进行修改，然后再将内存拷贝到连续的地址空间中。但是，连续拷贝会耗费较多时间。

那么，怎么才能降低内存的碎片化程度，进而提高性能呢？

分页

App 在运行时，大多数的时间只会使用很小部分的内存，所以我们可以使用比段粒度更小的空间管理技术，也就是分页。

分页就是把地址空间切分成固定大小的单元，这样我们就不用去考虑堆和栈会具体申请多少空间，而只要考虑需要多少页就可以了。这，对于操作系统管理来说也会简单很多，只需要维护一份页表（Page Table）来记录虚拟页（Virtual Page）和物理页（Physical Page）的关系即可。

虚拟页的前两位是 VPN（Virtual Page Number），根据页表，翻译为物理地址 PFN（Physical Frame Number）。

虚拟页与物理页之间的映射关系，就是虚拟内存和物理内存的关系，如下图所示：

如图所示，多个进程虚拟页和物理页的关系通过箭头关联起来了，而页表就可以记录下箭头指向的映射关系。

这里，我们需要注意的是，虚拟页和物理页的个数是不一样的。比如，在 64 位操作系统中使用的是 48 位寻址空间，之所以使用 48 位寻址空间，是因为推出 64 位系统时硬件还不能支持 64 位寻址空间，所以就一直延续下来了。虚拟页大小是 16K，那么虚拟页最多能有 2^48 / 2^14 = 16M 个，物理内存为 16G 对应物理页个数是 2^34 / 2^14 = 524k 个。

维护虚拟页和物理页关系的页表会随着进程增多而变得越来越大，当页表大于寄存器大小时，就无法放到寄存器中，只能放到内存中。当要通过虚拟地址获取物理地址的时候，就要对页表进行访问翻译，而在内存中进行访问翻译的速度会比 CPU 的寄存器慢很多。

那么，怎么加速页表翻译速度呢？

我们知道，缓存可以加速访问。MMU 中有一个 TLB（Translation-Lookaside Buffer），可以作为缓存加速访问。所以，在访问页表前，首先检查 TLB 有没有缓存的虚拟地址对应的物理地址：

如果有的话，就可以直接返回，而不用再去访问页表了；

如果没有的话，就需要继续访问页表。

每次都要访问整个列表去查找我们需要的物理地址，终归还是会影响效率，所以又引入了多级页表技术。也就是，根据一定的算法灵活分配多级页表，保证一级页表最小的内存占用。其中，一级页表对应多个二级页表，再由二级页表对应虚拟页。

这样内存中只需要保存一级页表就可以，不仅减少了内存占用，而且还提高了访问效率。根据多级页表分配页表层级算法，空间占用多时，页表级别增多，访问页表层级次数也会增多，所以多级页表机制属于典型的支持时间换空间的灵活方案。

iOS 的 XNU Mach 微内核中有很多分页器提供分页操作，比如 Freezer 分页器、VNode 分页器。还有一点需要注意的是，这些分页器不负责调度，调度都是由 Pageout 守护线程执行。

由于移动设备的内存资源限制，虚拟分页在 iOS 系统中的控制方式更严格。移动设备的磁盘空间也不够用，因此没有使用 DRAM（动态 RAM）的方式控制内存。为了减少磁盘空间占用，iOS 采用了 Jetsam 机制来控制内存的使用。

备注：DRAM 内存控制方式，是在虚拟页不命中的情况下采用磁盘来缓存。

占用内存过多的进程会被强杀，这也就对 App 占用的内存提出了更高的要求。同时，Jetsam 机制也可以避免磁盘和内存交换带来的效率问题，因为磁盘的速度要比 DRAM 慢上几万倍。

小结

今天，我和你分享了 iOS 系统内存管理的原理。理解这些原理，能够加深你对系统管理内存方式的理解。

对于在 iOS 开发过程中如何优化内存，苹果公司在 2018 年的 WWDC Session 416: iOS Memory Deep Dive上进行了详细讲解，其中就包含了 iOS 虚拟内存机制的变化。

Xcode 开发工具对内存分析方面所做的更新，比如 debugger 可以自动捕获内存占用触发系统限制的 EXC_RESOURCE RESOURCE_TYPE_MEMORY 异常，并断点在触发异常的位置。对 Xcode 中存储 App 内存信息的 memgrah 文件，我们可以使用 vmmap、leaks、heap、malloc_history 等命令行工具来分析。

在这个 Session 中，苹果公司还推荐我们使用 UIGraphicsImageRenderer 替代 UIGraphicsBeginImageContextWithOptions，让系统自动选择最佳的图片格式，这样也能够降低占用的内存。对于图片的缩放，苹果公司推荐使用 ImageIO 直接读取图片的大小和元数据，也就避免了以前将原始图片加载到内存然后进行转换而带来的额外内存开销。

其实，图片资源不仅是影响 App 包大小的重要因素，也是内存的消耗大户。苹果公司在 2018 年的 WWDC Session 219: Images and Graphics Best Practices中，还专门介绍了关于图片的最佳实践，并针对减少内存消耗进行了详细讲解。

对于 App 处在低内存时如何处理，你可以看看这篇文章“No pressure, Mon! Handling low memory conditions in iOS and Mavericks”。

课后作业

第三方内存检测工具有 MLeaksFinder、FBRetainCycleDetector、OOMDetector。你知道这些工具进行内存检测的原理吗？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

37 | 如何编写 Clang 插件？

你好，我是戴铭。今天，我和你分享的主题是，如何编写 Clang 插件。

Clang 使用的是模块化设计，可以将自身功能以库的方式来供上层应用来调用。比如，编码规范检查、IDE 中的语法高亮、语法检查等上层应用，都是使用 Clang 库的接口开发出来的。Clang 库对接上层应用有三个接口库，分别是 LibClang、Clang 插件、LibTooling。关于这三个接口库的介绍，我已经在第 8 篇文章中和你详细分享过。

其中，LibClang 为了兼容更多 Clang 版本，相比 Clang 少了很多功能；Clang 插件和 LibTooling 具备 Clang 的全量能力。Clang 插件编写代码的方式，和 LibTooling 几乎一样，不同的是 Clang 插件还能够控制编译过程，可以加 warning，或者直接中断编译提示错误。另外，编写好的 LibTooling 还能够非常方便地转成 Clang 插件。

所以说，Clang 插件在功能上是最全的。今天这篇文章，我们就一起来看看怎样编写和运行 Clang 插件。

Clang 插件代码编写后进行编译的前置条件是编译 Clang。要想编译 Clang ，你就需要先安装 CMake 工具，来解决跨平台编译规范问题。

我们可以先通过 CMakeList.txt 文件，来定制 CMake 编译流程，再根据 CMakeList.txt 文件生成目标平台所需的编译文件。这个编译文件，在类 UNIX 平台就是 Makefile，在 Windows 平台就是 Visual Studio 工程，macOS 里还可以生成 Xcode 工程。所以，你可以使用熟悉的 Xcode 来编译 Clang。

接下来，我就和你说说怎么拉 Clang 的代码，以及编译 Clang 的过程是什么样的。

在 macOS 平台上编译 Clang

接下来的内容，我会以 macOS 平台编译 Clang 为例。如果你想在其他平台编译，可以参看官方说明。

首先，从 GitHub 上拉下 Clang 的代码，命令如下：

git clone https://github.com/llvm/llvm-project.git

然后，执行以下命令，来创建构建所需要的目录：

cd llvm-project

mkdir build (in-tree build is not supported)

cd build

目录结构如下图：

其中，clang 目录就是类 C 语言编译器的代码目录；llvm 目录的代码包含两部分，一部分是对源码进行平台无关优化的优化器代码，另一部分是生成平台相关汇编代码的生成器代码；lldb 目录里是调试器的代码；lld 里是链接器代码。

macOS 属于类 UNIX 平台，因此既可以生成 Makefile 文件来编译，也可以生成 Xcode 工程来编译。生成 Makefile 文件，你可以使用如下命令：

cmake -DLLVM_ENABLE_PROJECTS=clang -G "Unix Makefiles" ../llvm

make

生成 Xcode 工程，你可以使用这个命令：

cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm

执行完后，会在 build 目录下生成 Xcode 工程，路径如下图：

执行 cmake 命令时，你可能会遇到下面的提示：

-- The C compiler identification is unknown

-- The CXX compiler identification is unknown

CMake Error at CMakeLists.txt:39 (project):

  No CMAKE_C_COMPILER could be found.

CMake Error at CMakeLists.txt:39 (project):

  No CMAKE_CXX_COMPILER could be found.

这表示 cmake 没有找到代码编译器的命令行工具。这包括两种情况：

一是，如果你没有安装 Xcode Commandline Tools 的话，可以执行如下命令安装：

xcode-select --install

二是，如果你已经安装了 Xcode Commandline Tools 的话，直接 reset 即可，命令如下：

sudo xcode-select --reset

生成 Xcode 工程后，打开生成的 LLVM.xcodeproj 文件，选择 Automatically Create Schemes。编译完后生成的库文件，就在 llvm-project⁩/build⁩/⁨Debug/⁩⁨lib/ 目录下。

有了可以编写编译插件的 Xcode 工程，接下来你就可以着手编写 Clang 插件了。

准备编写 Clang 插件

编写之前，先在 llvm-project/clang/tools/ 目录下创建 Clang 插件的目录，添加 YourPlugin.cpp 文件和 CMakeLists.txt 文件。其中，CMake 编译需要通过 CMakeLists.txt 文件来指导编译，cpp 是源文件。

接下来，我们可以使用如下代码编写 CMakeLists.txt 文件，来定制编译流程：

add_llvm_library(YourPlugin MODULE YourPlugin.cpp PLUGIN_TOOL clang)

这段代码是指，要将 Clang 插件代码集成到 LLVM 的 Xcode 工程中，并作为一个模块进行编写调试。

想要更多地了解 CMake 的语法和功能，你可以查看官方文档。添加了 Clang 插件的目录和文件后，再次用 cmake 命令生成 Xcode 工程，里面就能够集成 YourPlugin.cpp 文件。

到这里，我们已经准备好了 Clang 插件开发环境。接下来，我们就能够在 Xcode 编译器里开发 Clang 插件了。

编写 Clang 插件代码，入口就是 FrontActions。接下来，我们就一起看看 FrontActions 是什么？

FrontAction 是什么？

FrontActions 是编写 Clang 插件的入口，也是一个接口，是基于 ASTFrontendAction 的抽象基类。其实，FrontActions 并没干什么实际的事情，只是为接下来基于 AST 操作的函数提供了一个入口和工作环境。

通过这个接口，你可以编写你要在编译过程中自定义的操作，具体方式是：通过 ASTFrontendAction 在 AST 上自定义操作，重载 CreateASTConsumer 函数返回你自己的 Consumer，以获取 AST 上的 ASTConsumer 单元。

代码示例如下所示：

class FindNamedClassAction : public clang::ASTFrontendAction {

public:

  // 实现 CreateASTConsumer 方法

  virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(

    clang::CompilerInstance &Compiler, llvm::StringRef InFile) {

    // 返回 ASTConsumer 单元

    return std::unique_ptr<clang::ASTConsumer>(

        new FindNamedClassConsumer);

  }

};

ASTConsumer 可以提供很多入口，是一个可以访问 AST 的抽象基类，可以重载 HandleTopLevelDecl() 和 HandleTranslationUnit() 两个函数，以接收访问 AST 时的回调。其中，HandleTopLevelDecl() 函数是在访问到全局变量、函数定义这样最上层声明时进行回调，HandleTranslationUnit() 函数会在接收每个节点访问时的回调。

下面有一个示例，会重载 HandleTranslationUnit() 函数，使用 ASTContext 为单元调用，通过 RecursiveASTVisitor 来遍历 decl 单元。具体代码如下：

class FindNamedClassConsumer : public clang::ASTConsumer {

public:

  virtual void HandleTranslationUnit(clang::ASTContext &Context) {

    // 通过 RecursiveASTVisitor 来遍历 decl 单元。会访问所有 AST 里的节点。

    Visitor.TraverseDecl(Context.getTranslationUnitDecl());

  }

private:

  // 一个 RecursiveASTVisitor 的实现

  FindNamedClassVisitor Visitor;

};

上面代码可以看出，接收 AST 节点回调的 TranslationUnitDecl 函数通过重载已经准备就绪，为接下来 RecursiveASTVisitor 访问 AST 节点做好准备工作。

RecursiveASTVisitor 使用深度优先的方式访问 AST 的所有节点。RecursiveASTVisitor 使用的是访问者模式，支持前序遍历和后序遍历来访问 AST 节点。RecursiveASTVisitor 会遍历 AST 的每个节点，遍历节点的同时会回溯，回溯节点类型的基类，再调用节点对应的 Visit 函数。如果重写了节点对应的 Visit 函数，就会调用重写后的 Visit 函数。可以看出真正在干活的是 RecursiveASTVistor，它基本完成了编写 Clang 插件里最多、最重的活儿。

接下来，我就跟你说说怎么用 RecursiveASTVisitor 来查找指定名称的 CXXRecordDecl 类型的 AST 节点。也就是说，你需要通过 RecursiveASTVisitor 实现从 AST 里面提取所需要内容。

CXXRecordDecl 类型，表示 C++ struct/union/class。更多的节点类型，你可以参看官方文档。

使用 RecursiveASTVisitor

RecursiveASTVisitor，可以为大多数的 AST 节点提供布尔类型的 VisitNodeType(Nodetype *)。VisitNodeType 返回的布尔值可以控制 RecursiveASTVisitor 的访问，决定对 AST 节点的访问是否要继续下去。

下面，我们来重写一个访问所有 CXXRecordDecl 的 RecursiveASTVisitor。

class FindNamedClassVisitor

  : public RecursiveASTVisitor<FindNamedClassVisitor> {

public:

  bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {

    // dump 出已经访问的 AST 的声明节点。

    Declaration->dump();

    // 返回 true 表示继续遍历 AST，false 表示停止遍历 AST。

    return true;

  }

};

在 RecursiveASTVisitor 的方法里，可以使用 Clang AST 的全部功能获取想要的内容。比如，通过重写 VisitCXXRecordDecl 函数，找到指定名称的所有类声明。示例代码如下：

bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {

  if (Declaration->getQualifiedNameAsString() == "n::m::C")

    Declaration->dump();

  return true;

}

将代码保存成文件 FindClassDecls.cpp，并创建 CMakeLists.txt 文件来进行链接。CMakeLists.txt 的内容如下：

add_clang_executable(find-class-decls FindClassDecls.cpp)

target_link_libraries(find-class-decls clangTooling)

使用这个工具能够找到 n :: m :: C 的所有声明，然后输出如下信息：

$ ./bin/find-class-decls "namespace n { namespace m { class C {}; } }"

编写 PluginASTAction 代码

由于 Clang 插件是没有 main 函数的，入口是 PluginASTAction 的 ParseArgs 函数。所以，编写 Clang 插件还要实现 ParseArgs 来处理入口参数。代码如下所示：

bool ParseArgs(const CompilerInstance &CI,

               const std::vector<std::string>& args) {

  for (unsigned i = 0, e = args.size(); i != e; ++i) {

    if (args[i] == "-some-arg") {

      // 处理命令行参数

    }

  }

  return true;

}

注册 Clang 插件

最后，还需要在 Clang 插件源码中编写注册代码。编译器会在编译过程中从动态库加载 Clang 插件。使用 FrontendPluginRegistry::Add<> 在库中注册插件。注册 Clang 插件的代码如下：

static FrontendPluginRegistry::Add<MyPlugin> X("my-plugin-name", "my plugin description");

在 Clang 插件代码的最下面，定义的 my-plugin-name 字符串是命令行字符串，供以后调用时使用，my plugin description 是对 Clang 插件的描述。

现在，我们已经编写完了 Clang 插件，我来和你汇总下编写过程：

第一步，编写 FrontAction 入口。

第二步，通过 RecursiveASTVisitor 访问所有 AST 节点，获取想要的内容。

第三步，编写 PluginASTAction 代码处理入口参数。

第四步，注册 Clang 插件，提供外部使用。

接下来，我们再看看如何使用编写好的 Clang 插件吧。

使用 Clang 插件

LLVM 官方有一个完整可用的 Clang 插件示例，可以帮我们打印出最上层函数的名字，你可以点击这个链接查看这个示例。

接下来，通过这个插件示例，看看如何使用 Clang 插件。

使用 Clang 插件可以通过 -load 命令行选项加载包含插件注册表的动态库，-load 命令行会加载已经注册了的所有 Clang 插件。使用 -plugin 选项选择要运行的 Clang 插件。Clang 插件的其他参数通过 -plugin-arg- 来传递。

cc1 进程类似一种预处理，这种预处理会发生在编译之前。cc1 和 Clang driver 是两个单独的实体，cc1 负责前端预处理，Clang driver 则主要负责管理编译任务调度，每个编译任务都会接受 cc1 前端预处理的参数，然后进行调整。

有两个方法可以让 -load 和 -plugin 等选项到 Clang 的 cc1 进程中：

一种是，直接使用 -cc1 选项，缺点是要在命令行上指定完整的系统路径配置；

另一种是，使用 -Xclang 来为 cc1 进程添加这些选项。-Xclang 参数只运行预处理器，直接将后面参数传递给 cc1 进程，而不影响 clang driver 的工作。

下面是一个编译 Clang 插件，然后使用 -Xclang 加载使用 Clang 插件的例子：

$ export BD=/path/to/build/directory

$ (cd $BD && make PrintFunctionNames )

$ clang++ -D_GNU_SOURCE -D_DEBUG -D__STDC_CONSTANT_MACROS \

          -D__STDC_FORMAT_MACROS -D__STDC_LIMIT_MACROS -D_GNU_SOURCE \

          -I$BD/tools/clang/include -Itools/clang/include -I$BD/include -Iinclude \

          tools/clang/tools/clang-check/ClangCheck.cpp -fsyntax-only \

          -Xclang -load -Xclang $BD/lib/PrintFunctionNames.so -Xclang \

          -plugin -Xclang print-fns

上面命令中，先设置构建的路径，再通过 make 命令进行编译生成 PrintFunctionNames.so，最后使用 clang 命令配合 -Xclang 参数加载使用 Clang 插件。

你也可以直接使用 -cc1 参数，但是就需要按照下面的方式来指定完整的文件路径：

$ clang -cc1 -load ../../Debug+Asserts/lib/libPrintFunctionNames.dylib -plugin print-fns some-input-file.c

小结

今天这篇文章，我主要和你解决的问题是，如何编写 Clang 插件。

Clang 作为编译前端，已经具有很强大的类 C 语言代码解析能力，利用 Clang 的分析能力，你可以在它对代码 Clang AST 分析过程中，获取到 AST 各个节点的信息。

Clang AST 节点都是派生自 Type、Decl、Stmt。Clang AST 中最基本的两个节点就是语句 Stmt 和 声明 Decl，表达式 Expr 也是 Stmt。官方有份完整的 Clang AST 节点说明，你可以点击链接查看使用。

获取到源码全量信息后，就可以更加精准的分析源码，然后统计出不满足编码规范的地方。同时，访问 SourceManager 和 ASTContext，还能够获取到节点所在源代码中的位置信息。这样的话，我们就可以直接通过 Clang 插件，在问题节点原地修改不规范的代码。

我们可以在 CreateASTConsumer 期间从 CompilerInstance 中获取 ASTContext，进而使用其中的 SourceManager 里的 getFullLoc 方法，来获取 AST 节点所在源码的位置。

我们可以把获得的位置信息，分成行和列两个部分，据此就能够确定代码具体位置了。获取源码中位置方法如下面代码所示：

// 使用 ASTContext 的 SourceManager 里的 getFullLoc 方法来获取到 AST 节点所在源码中的位置。

FullSourceLoc FullLocation = Context->getFullLoc(Declaration->getBeginLoc());

if (FullLocation.isValid())

  // 按行和列输出

  llvm::outs() << "Found declaration at "

               << FullLocation.getSpellingLineNumber() << ":"

               << FullLocation.getSpellingColumnNumber() << "\n";

课后作业

Clang 插件本身的编写和使用并不复杂，关键是如何更好地应用到工作中，通过 Clang 插件不光能够检查代码规范，还能够进行无用代码分析、自动埋点打桩、线下测试分析、方法名混淆等。

结合现在的工作，你还能够想到 Clang 插件的其他应用场景吗？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

38 | 热点问题答疑（四）

你好，我是戴铭。今天这篇答疑文章，我要针对近期留言中的热点问题，进行一次集中解答。

目前，我们专栏已经更新完了基础篇、应用开发篇和原理篇 3 大模块的内容。其中，原理篇的内容，因为涉及到的都是底层原理，比如系统内核 XNU、AOP、内存管理和编译等，学习起来会很辛苦。但所谓良药苦口，你只有搞明白了这些最最底层的原理，才可以帮你抓住开发知识的规律，达到融会贯通的效果，进而提升自己造轮子、解决问题的能力。

也正因为这些底层知识比较难啃，需要细细琢磨，所以在这期答疑文章中，我并没有展开这个模块的内容。如果你对这个模块的文章有哪里不理解，或者觉得哪里有问题的话，可以在评论区留下你的观点，我会挑选合适的时机，给你答复。

接下来，我们就看看今天这篇文章要展开讨论的问题吧。

关于监控卡顿

@凡在第 13 篇文章《如何利用 RunLoop 原理去监控卡顿？》后问道：

大多数的卡顿监控，都是在主线程上做的。音视频播放以及直播的卡顿，能否使用这种方式来监控呢？另外，我们公司对接的直播都是第三方的库和知识平台，我应该如何把这种监控放到客户端来做呢？

针对这个同学的问题，我想说的是，只有在主线程上卡了，用户才会感知到，而监控卡顿主要就是要监控什么时候会卡。只要我们在发生卡顿的时刻，想办法去收集卡顿信息，就能够定位到问题，找出具体是由谁引起的卡顿。

比如，@凡同学提到的音视频播放卡顿问题，监控到发生卡顿的时刻，通过获取当时方法调用堆栈的方式，就能够确定出具体是哪个方法在调用，从而找到发生卡顿问题的原因。

当然，有些时候只通过各个线程中的方法调用栈来分析问题，可能信息还不太够，这时你还可以捕获各线程卡顿时的 CPU 使用率，进而发现哪个方法占用资源过高。同时，你还能够通过业务场景和环境数据埋点信息，综合分析发生卡顿时，业务场景以及数据是否出现了异常。

关于 SMLogger 的实现

@梁华建在第 9 篇文章《无侵入的埋点方案如何实现？》后留言，想要知道 SMLogger 是如何实现的。

SMLogger，是我对日志记录的一个封装。我在第 9 篇文章中使用 SMLogger 的方式，是这样的：

[[[[SMLogger create]

   message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]

  classify:ProjectClassifyOperation]

 save];

可以看出，我把 SMLogger 的接口设计成了链式调用的方式。这样的接口接收外部数据后，能够更加灵活地进行组合。

对于日志记录来说，可以设置默认的日志分类和日志级别，简单记录日志描述就只需要一个日志描述数据。

当使用者需要日志库记录一个对象时，就需要增加一个新的接口来支持记录对象。接下来，就会面对外部输入会进行不同组合的情况，比如日志记录对象、日志描述、日志分类、日志级别这四个数据的不同组合。为了满足这些不同的组合，你设置的接口数量也会增加很多。如果都放到一个统一接口中当作不同参数，那么参数的个数就会非常多，导致接口使用起来非常不方便。比如，你每次只需要设置日志描述这个参数，但是使用了多参数的统一接口后，需要手动去设置其他参数值。

使用链式调用的好处就是可以随意组合。而且，当有新的输入类型加入，要和以前接口组合时，也不需要额外工作。我定义的 SMLogger 的链式接口，如下所示：

// 初始化

+ (SMLogger *)create;

// 可选设置

- (SMLogger *)object:(id)obj;                        //object 对象记录

- (SMLogger *)message:(NSString *)msg;               // 描述

- (SMLogger *)classify:(SMProjectClassify)classify;  // 分类

- (SMLogger *)level:(SMLoggerLevel)level;            // 级别

// 场景记录

- (SMLogger *)scene:(SceneType)scene;

// 最后需要执行这个方法进行保存，什么都不设置也会记录文件名，函数名，行数等信息

- (void)save;

可以看出，日志记录对象、日志描述、日志分类、日志级别分别为 object、message、classity、level。当需要在日志记录中增加业务场景数据时，只需要简单增加一个 scene 链式接口，就能够达到组合使用业务场景数据和其他链式接口的目的。

在 SMLogger 中，我还在链式基础上实现了宏的方式，来简化一些常用的日志记录接口调用方式。宏的定义如下：

// 宏接口

FOUNDATION_EXPORT void SMLoggerDebugFunc(NSUInteger lineNumber, const char *functionName, SMProjectClassify classify, SMLoggerLevel level, NSString *format, ...) NS_FORMAT_FUNCTION(5,6);

// debug 方式打印日志，不会上报

#ifdef DEBUG

    #define SMLoggerDebug(frmt, ...) SMLoggerCustom(SMProjectClassifyNormal,SMLoggerLevelDebug,frmt, ##__VA_ARGS__)

#else

    #define SMLoggerDebug(frmt, ...) do {} while (0)

#endif

// 简单的上报日志

#define SMLoggerSimple(classify,frmt, ...) SMLoggerCustom(classify,SMLoggerLevelInfo,frmt, ##__VA_ARGS__)

// 自定义 classify 和 level 的日志，可上报

#define SMLoggerCustom(classify,level,frmt, ...) \

do { SMLoggerDebugFunc(__LINE__,__FUNCTION__,classify,level,frmt, ##__VA_ARGS__);} while(0)

可以看到，宏定义最终调用的是 SMLoggerDebugFunc 函数，这个函数的实现如下所示：

void SMLoggerDebugFunc(NSUInteger lineNumber, const char *functionName, SMProjectClassify classify, SMLoggerLevel level, NSString *format, ...) {

    va_list args;

    if (format) {

        va_start(args, format);

        // 输出方法名和行号

        NSString *msg = [[NSString alloc] initWithFormat:format arguments:args];

        msg = [NSString stringWithFormat:@"[%s:%lu]%@",functionName,(unsigned long)lineNumber,msg];

        // SMLogger 链式调用

        [[[[[SMLogger create] message:msg] classify:classify] level:level] save];

        va_end(args);

    }

}

通过上面代码可以看到，SMLoggerDebugFunc 在处理完方法名和行号后，最终使用的就是 SMLogger 链式调用。

通过宏的定义，日志记录接口调用起来也会简化很多，使用效果如下：

// 宏方式使用，会记录具体调用地方的函数名和行数

SMLoggerDebug(@" 此处必改：%@ 此处也必改： %@",arr,dict); // 仅调试，不上报

SMLoggerSimple(SMProjectClassifyNormal,@" 此处必改：%@ 此处也必改： %@",arr,dict); // 会上报

SMLoggerCustom(SMProjectClassifyNormal,SMLoggerLevelDebug, @" 这两个需要上报 %@%@",arr,dict); //level 为 debug 不上报

NSURLProtocol 相关

@熊在第 28 篇文章《怎么应对各种富文本表现需求？》后留言到：

WKWebView 对 NSURLProtocol 的支持不太好，我在网上找到的方法都不适用，连 Ajax 请求都不好去拦截。

其实，WKWebView 处理资源缓存的思路和 UIWebView 类似，需要创建一个 WKURLSchemeHandler，然后使用 -[WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 方法注册到 WKWebView 配置里。

WKURLSchemeHandler 实例可以用来处理对应的 URLScheme 加载的资源，使用它的 webView:startURLSchemeTask 方法可以加载特定资源的数据。这样就能够起到和 NSURLProtocol 同样的效果。

关于 JSON 解析的问题

@大太阳在第 26 篇文章《如何提高 JSON 解析的性能？》中留言到：

我现在项目是用 Swift 语言开发的，绝大部分的 JSON 解析用的是 SwiftyJSON，很少一部分用到了 KVC。我想问下，SwiftyJSON 的效率怎么样？我怎么才能评测这个效率？市面上比较出名的第三方库，它们的效率排名是什么样的？

其实，市面上的大多数第三方库，在解析 JSON 时用的都是系统自带的 JSONSerialization。因此，从本质上来看，它们的解析效率并无差别，只是在易用性、容错率、缓存效率上有些许差异。

比如，@大太阳提到的 SwiftyJSON 库，初始化方法如下：

public init(data: Data, options opt: JSONSerialization.ReadingOptions = []) throws {

    let object: Any = try JSONSerialization.jsonObject(with: data, options: opt)

    self.init(jsonObject: object)

}

可以看到，SwiftyJSON 库在解析 JSON 时，使用的是 JSONSerialization。你可以点击这个链接，查看 SwiftJSON 的完整代码。

既然 SwiftyJSON 也是使用 JSONSerialization 来解析 JSON 的，那么解析效率就和其他使用 JSONSerialization 解析的第三方库相比，没有本质上的差别。

JSON 案例相关

@徐秀滨在第 23 篇文章《如何构造酷炫的物理效果和过场动画效果？》后留言反馈，对通过 JSON 来控制代码逻辑的能力这块内容，感觉理解起来有些困难。接下来，针对这个问题，我再多说两句，希望能够对你有多帮助。

我在第 26 篇文章《如何提高 JSON 解析的性能？》中，举了个更加具体的例子，使用 JSON 描述了一段 JavaScript 代码逻辑，你可以先看一下这篇文章的相关内容。

对于开发者来说，App 中的任何逻辑都可以通过代码来描述，而代码又能够转换成抽象语法树结构。JSON 作为一种数据结构的表示，同样可以表示代码的抽象语法树，自然也能够具有控制代码逻辑的能力。

总结

今天这篇答疑文章，我和你分享了监控卡顿、SMLogger、NSURLProtocol、JSON 相关的问题。

监控卡顿的方案实际上是通用的，和具体的场景没有关系。卡只是表现在主线程上，根本原因还是需要分析每个线程。

通过 NSURLProtocol 对 WKWebView 支持不好的问题，我们可以看出，苹果公司为了更好地管控 WKWebView 而增加了一层，将资源的加载处理单独提供出来供开发者使用，以满足开发者自定义提速的需求。

最后，JSON 解析效率的提高，还是需要从根本上去解决，封装层解决的是易用性问题，所加缓存也只能解决重复解析的问题。

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

39 | 打通前端与原生的桥梁：JavaScriptCore 能干哪些事情？

你好，我是戴铭。

今天这篇文章是原生与前端共舞模块的第一篇，我来跟你聊聊前端和原生之间的桥梁 JavaScriptCore，看看它是什么、能做哪些事儿。

总结来说，JavaScriptCore 为原生编程语言 Objective-C、Swift 提供调用 JavaScript 程序的动态能力，还能为 JavaScript 提供原生能力来弥补前端所缺能力。

正是因为 JavaScriptCore 的这种桥梁作用，所以出现了很多使用 JavaScriptCore 开发 App 的框架 ，比如 React Native、Weex、小程序、WebView Hybird 等框架。

接下来，我们再回过头来看看，JavaScriptCore 的来头是啥，为什么这些框架不约而同地都要用 JavaScriptCore 引擎来作为前端和原生的桥梁呢？

要回答这个问题的话，你还需要了解 JavaScriptCore 的背景。

JavaScriptCore，原本是 WebKit 中用来解释执行 JavaScript 代码的核心引擎。解释执行 JavaScript 代码的引擎自 JavaScript 诞生起就有，不断演进，一直发展到现在，如今苹果公司有 JavaScriptCore 引擎、谷歌有 V8 引擎、Mozilla 有 SpiderMonkey。对于 iOS 开发者来说，你只要深入理解苹果公司的 JavaScriptCore 框架就可以了。

iOS7 之前，苹果公司没有开放 JavaScriptCore 引擎。如果你想使用 JavaScriptCore 的话，需要手动地从开源 WebKit 中编译出来，其接口都是 C 语言，这对于 iOS 开发者来说非常不友好。

但是从 iOS7 开始，苹果公司开始将 JavaScriptCore 框架引入 iOS 系统，并将其作为系统级的框架提供给开发者使用。这时，接口使用 Objective-C 进行包装，这对于原生 Objective-C 开发者来说，体验上就非常友好了。

JavaScriptCore 框架的框架名是 JavaScriptCore.framework。由于苹果公司的系统已经内置了 JavaScriptCore 框架，而且性能不逊色于 V8 和 SpiderMonkey 等其他引擎，所以前端开发 App 框架就都不约而同将 JavaScriptCore 框架作为自己和原生的桥梁。

接下来，我就和你详细分析一下 JavaScriptCore 框架的原理。

JavaScriptCore 框架

苹果官方对 JavaScriptCore 框架的说明，你可以点击这个链接查看。从结构上看，JavaScriptCore 框架主要由 JSVirtualMachine 、JSContext、JSValue 类组成。

JSVirturalMachine 的作用，是为 JavaScript 代码的运行提供一个虚拟机环境。在同一时间内，JSVirtualMachine 只能执行一个线程。如果想要多个线程执行任务，你可以创建多个 JSVirtualMachine。每个 JSVirtualMachine 都有自己的 GC（Garbage Collector，垃圾回收器），以便进行内存管理，所以多个 JSVirtualMachine 之间的对象无法传递。

JSContext 是 JavaScript 运行环境的上下文，负责原生和 JavaScript 的数据传递。

JSValue 是 JavaScript 的值对象，用来记录 JavaScript 的原始值，并提供进行原生值对象转换的接口方法。

JSVirtualMachine、JSContext、JSValue 之间的关系，如下图所示：

可以看出，JSVirtualMachine 里包含了多个 JSContext， 同一个 JSContext 中又可以有多个 JSValue。

JSVirtualMachine 、JSContext、JSValue 类提供的接口，能够让原生应用执行 JavaScript 代码，访问 JavaScript 变量，访问和执行 JavaScript 函数；也能够让 JavaScript 执行原生代码，使用原生输出的类。

那么，解释执行 JavaScript 代码的 JavaScriptCore 和原生应用是怎么交互的呢？

要理解这个问题，我们先来看看下面这张图：

可以看到，每个 JavaScriptCore 中的 JSVirtualMachine 对应着一个原生线程，同一个 JSVirtualMachine 中可以使用 JSValue 与原生线程通信，遵循的是 JSExport 协议：原生线程可以将类方法和属性提供给 JavaScriptCore 使用，JavaScriptCore 可以将 JSValue 提供给原生线程使用。

JavaScriptCore 和原生应用要想交互，首先要有 JSContext。JSContext 直接使用 init 初始化，会默认使用系统创建的 JSVirtualMachine。如果 JSContext 要自己指定使用哪个 JSVirtualMachine，可以使用 initWithVirtualMachine 方法来指定，代码如下：

// 创建 JSVirtualMachine 对象 jsvm

JSVirtualMachine *jsvm = [[JSVirtualMachine alloc] init];

// 使用 jsvm 的 JSContext 对象 ct

JSContext *ct = [[JSContext alloc] initWithVirtualMachine:jsvm];

如上面代码所示，首先初始化一个 JSVirtualMachine 对象 jsvm，再初始化一个使用 jsvm 的 JSContext 对象 ct。

下面我再举一个通过 JavaScriptCore 在原生代码中调用 JavaScript 变量的例子。

这里有一段 JavaScript 代码，我定义了一个 JavaScript 变量 i ，然后我们一起看看如何通过 JavaScriptCore 在原生中调用变量 i。代码如下：

JSContext *context  = [[JSContext alloc] init];

// 解析执行 JavaScript 脚本

[context evaluateScript:@"var i = 4 + 8"];

// 转换 i 变量为原生对象

NSNumber *number = [context[@"i"] toNumber];

NSLog(@"var i is %@, number is %@",context[@"i"], number);

上面代码中，JSContext 会调用 evaluateScript 方法，返回 JSValue 对象。

JSValue 类提供了一组将 JavaScript 对象值类型转成原生类型的接口，你可以点击这个链接，查看官方文档中对 JSValue 接口的详细说明。

其中，有 3 个转换类型的接口比较常用，我来和你稍作展开：

在这个示例中，我们使用的是 toNumber 方法，来将 JavaScript 值转换成 NSNumber 对象。

如果 JavaScript 代码中的变量是数组对象，可以使用 toArray 方法将其转换成 NSArray 对象。

如果变量是 Object 类型，可以使用 toDictionary 方法将其转换成 NSDictionary。

如果你想在原生代码中使用 JavaScript 中的函数对象，可以通过 callWithArguments 方法传入参数，然后实现它的调用。使用示例如下：

// 解析执行 JavaScript 脚本

[context evaluateScript:@"function addition(x, y) { return x + y}"];

// 获得 addition 函数

JSValue *addition = context[@"addition"];

// 传入参数执行 addition 函数

JSValue *resultValue = [addition callWithArguments:@[@(4), @(8)]];

// 将 addition 函数执行的结果转成原生 NSNumber 来使用。

NSLog(@"function is %@; reslutValue is %@",addition, [resultValue toNumber]);

如上面代码所示：首先，JSContext 通过 evaluateScript 方法获取 JavaScript 代码中 addition 函数，并保存为一个 JSValue 对象；然后，通过 JSValue 的 callWithArguments 方法，传入 addition 函数所需参数 x、y 以执行函数。

而如果要在原生代码中调用 JavaScript 全局函数，你需要使用 JSValue 的 invokeMethod:withArguments 方法。比如，Weex框架就是使用这个方法，来获取 JavaScript 函数的。

相关代码路径是 incubator-weex/ios/sdk/WeexSDK/Sources/Bridge/WXJSCoreBridge.mm ，核心代码如下：

- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args {

    WXLogDebug(@"Calling JS... method:%@, args:%@", method, args);

    return [[_jsContext globalObject] invokeMethod:method withArguments:args];

}

可以看到，JSContext 中有一个 globalObject 属性。globalObject 是 JSValue 类型，里面记录了 JSContext 的全局对象，使用 globalObject 执行的 JavaScript 函数能够使用全局 JavaScript 对象。因此，通过 globalObject 执行 invokeMethod:withArguments 方法就能够去使用全局 JavaScript 对象了。

通过上面的分析，我们可以知道，通过 evaluateScript 方法，就能够在原生代码中执行 JavaScript 脚本，并使用 JavaScript 的值对象和函数对象。那么，JavaScript 又是如何调用原生代码呢？

我先给出一段代码示例，你可以思考一下是如何实现的：

// 在 JSContext 中使用原生 Block 设置一个减法 subtraction 函数

context[@"subtraction"] = ^(int x, int y) {

    return x - y;

};

// 在同一个 JSContext 里用 JavaScript 代码来调用原生 subtraction 函数

JSValue *subValue = [context evaluateScript:@"subtraction(4,8);"];

NSLog(@"substraction(4,8) is %@",[subValue toNumber]);

可以看出，JavaScript 调用原生代码的方式，就是：

首先，在 JSContext 中使用原生 Block 设置一个减法函数 subtraction；

然后，在同一个 JSContext 里用 JavaScript 代码来调用原生 subtraction 函数。

除了 Block 外，我们还可以通过 JSExport 协议来实现在 JavaScript 中调用原生代码，也就是原生代码中让遵循 JSExport 协议的类，能够供 JavaScript 使用。Weex 框架里，就有个遵循了 JSExport 协议的 WXPolyfillSet 类，使得 JavaScript 也能够使用原生代码中的 NSMutableSet 类型。

WXPolyfillSet 的头文件代码路径是 incubator-weex/ios/sdk/WeexSDK/Sources/Bridge/WXPolyfillSet.h ，内容如下：

@protocol WXPolyfillSetJSExports <JSExport>

// JavaScript 可以使用的方法

+ (instancetype)create;

- (BOOL)has:(id)value;

- (NSUInteger)size;

- (void)add:(id)value;

- (BOOL)delete:(id)value;

- (void)clear;

@end

// WXPolyfillSet 遵循 JSExport 协议

@interface WXPolyfillSet : NSObject <WXPolyfillSetJSExports>

@end

可以看到，WXPolyfillSet 通过 JSExport 协议，提供了一系列方法给 JavaScript 使用。

现在我们已经理解了原生和 JavaScript 的互通方式，知道了它们的互通依赖于虚拟机环境 JSVirtualMachine。接下来，我们需要对 JavaScriptCore 引擎进行更深入地理解，才能更好地用好这个框架。比如，JavaScriptCore 是怎么通过直接使用缓存 JIT 编译的机器码来提高性能的，又是怎么对部分函数进行针对性测试编译优化的。

JSVirtualMachine 是一个抽象的 JavaScript 虚拟机，是提供给开发者进行开发的，而其核心的 JavaScriptCore 引擎则是一个真实的虚拟机，包含了虚拟机都有的解释器和运行时部分。其中，解释器主要用来将高级的脚本语言编译成字节码，运行时主要用来管理运行时的内存空间。当内存出现问题，需要调试内存问题时，你可以使用 JavaScriptCore 里的 Web Inspector，或者通过手动触发 Full GC 的方式来排查内存问题。

接下来，我跟你说下 JavaScriptCore 引擎内部的组成。

JavaScriptCore 引擎的组成

JavaScriptCore 内部是由 Parser、Interpreter、Compiler、GC 等部分组成，其中 Compiler 负责把字节码翻译成机器码，并进行优化。你可以点击这个链接，来查看 WebKit 官方对 JavaScriptCore 引擎的介绍。

JavaScriptCore 解释执行 JavaScript 代码的流程，可以分为两步。

第一步，由 Parser 进行词法分析、语法分析，生成字节码。

第二步，由 Interpreter 进行解释执行，解释执行的过程是先由 LLInt（Low Level Interpreter）来执行 Parser 生成的字节码，JavaScriptCore 会对运行频次高的函数或者循环进行优化。优化器有 Baseline JIT、DFG JIT、FTL JIT。对于多优化层级切换， JavaScriptCore 使用 OSR（On Stack Replacement）来管理。

如果你想更深入地理解 JavaScriptCore 引擎的内容，可以参考我以前的一篇博文“深入剖析 JavaScriptCore”。

小结

今天这篇文章，我主要和你分享的是 iOS 中 JavaScriptCore 能干的事情。

总结来说，JavaScriptCore 提供了前端与原生相互调用的接口，接口层上主要用的是 JSContext 和 JSValue 这两个类，通过 JSValue 的 evaluateScript 方法、Block 赋值 context、JSExport 协议导出来达到互通的效果。

前端的优势在于快速编写 UI，原生的优势在于对平台特性的天然支持，现在我们有了能够打通前端和原生的武器，就可以充分利用二者的优势，互为补充地去做更多、更有意思的事儿。而你，也可以充分发挥自己的想象力，去实现更有意思的 App。

课后作业

如果原生方法没有遵循 JSExport 协议，也没有使用 Block 方式设置给 JSContext，那还有没有其他办法可以在 JavaScript 中调用原生代码呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

40 | React Native、Flutter 等，这些跨端方案怎么选？

你好，我是戴铭。

为了一份代码能够运行在多个平台，从而节省开发和沟通成本，各公司都开始关注和使用跨端方案。目前，主流的跨端方案，主要分为两种：一种是，将 JavaScriptCore 引擎当作虚拟机的方案，代表框架是 React Native；另一种是，使用非 JavaScriptCore 虚拟机的方案，代表框架是 Flutter。

使用跨端方案进行开发，必然会替代原有平台的开发技术，所以我们在选择跨端方案时，不能只依赖于某几项指标，比如编程语言、性能、技术架构等，来判断是否适合自己团队和产品，更多的还要考虑开发效率、社区支持、构建发布、 DevOps、 CI 支持等工程化方面的指标。

所以说，我们在做出选择时，既要着眼于团队现状和所选方案生态，还要考虑技术未来的发展走向。

接下来，我就以 React Native 和 Flutter 为例，和你说明如何选择适合自己的跨端方案。

React Native 框架的优势

跨端方案的初衷是要解决多平台重复开发的问题，也就是说，使用跨端方案的话，多个平台的开发者可以使用相同的开发语言来开发适合不同系统的 App。

React Native 使用 JavaScript 语言来开发，Flutter 使用的是 Dart 语言。这两门编程语言，对 iOS 开发者来说都有一定的再学习成本，而使用何种编程语言，其实决定了团队未来的技术栈。

JavaScript 的历史和流行程度都远超 Dart ，生态也更加完善，开发者也远多于 Dart 程序员。所以，从编程语言的角度来看，虽然 Dart 语言入门简单，但从长远考虑，还是选择 React Native 会更好一些。

同时，从页面框架和自动化工具的角度来看，React Native 也要领先于 Flutter。这，主要得益于 Web 技术这么多年的积累，其工具链非常完善。前端开发者能够很轻松地掌握 React Native，并进行移动端 App 的开发。

当然，方案选择如同擂台赛，第一回合的输赢无法决定最后的结果。

Flutter 框架的优势

除了编程语言、页面框架和自动化工具以外，React Native 的表现就处处不如 Flutter 了。总体来说，相比于 React Native 框架，Flutter 的优势最主要体现在性能、开发效率和体验这两大方面。

Flutter 的优势，首先在于其性能。

我们先从最核心的虚拟机说起吧。

React Native 所使用的 JavaScriptCore， 原本用在浏览器中，用于解释执行网页中的 JavaScript 代码。为了兼容 Web 标准留下的历史包袱，无法专门针对移动端进行性能优化。

Flutter 却不一样。它一开始就抛弃了历史包袱，使用全新的 Dart 语言编写，同时支持 AOT 和 JIT 两种编译方式，而没有采用 HTML/CSS/JavaScript 组合方式开发，在执行效率上明显高于 JavaScriptCore 。

除了编程语言的虚拟机，Flutter 的优势还体现在UI 框架的实现上。它重写了 UI 框架，从 UI 控件到渲染，全部重新实现了，依赖 Skia 图形库和系统图形绘制相关的接口，保证了不同平台上能有相同的体验。

想要了解 Flutter 的布局和渲染，你可以看看这两个视频“The Mahogany Staircase - Flutter’s Layered Design”和“Flutter’s Rendering Pipeline”。

除了性能上的优势外，Flutter 在开发效率和体验上也有很大的建树。

凭借热重载（Hot Reload）这种极速调试技术，极大地提升了开发效率，因此 Flutter 吸引了大量开发者的眼球。

同时，Flutter 因为重新实现了 UI 框架，可以不依赖 iOS 和 Android 平台的原生控件，所以无需专门去处理平台差异，在开发体验上实现了真正的统一。

此外，Flutter 的学习资源也非常丰富。Flutter 的官方文档，分门别类整理得仅仅有条。YouTube 上有一个专门的频道，提供了许多讲座、演讲、教程资源。

或许，你还会说 Flutter 包大小是个问题。Flutter 的渲染引擎是自研的，并没有用到系统的渲染，所以 App 包必然会大些。但是，我觉得从长远来看，App Store 对包大小的限制只会越来越小，所以说这个问题一定不会成为卡点。

除了上面两大优势外，我再和你说说 Flutter 对动态化能力的支持。

虽然 Flutter 计划会推出动态化能力，但我觉得动态化本身就是一个伪命题。软件架构如果足够健壮和灵活，发现问题、解决问题和验证问题的速度一定会非常快，再次发布上线也能够快速推进。而如果软件架构本就一团糟，解决问题的速度是怎么也快不起来的，即使具有了动态化能力，从解决问题到灰度发布再到全量上线的过程也一定会很曲折。

所以，我认为如果你想通过动态化技术来解决发布周期不够快的问题的话，那你首先应该解决的是架构本身的问题。长远考虑，架构上的治理和优化带来的收益，一定会高于使用具有动态化能力的框架。

当然，如果你选择使用动态化能力的框架，是抱着绕过 App Store 审核的目的，那就不在本文的讨论范围之内了。

如何选择适合自己的跨端方案？

看到这，你一定在想，跨端方案不是只有 Rect Native 和 Flutter，还有小程序、快应用、Weex 等框架。没错，跨端方案确实有非常多。

但，我今天与你分享的 React Native 代表了以 JavaScriptCore 引擎为虚拟机的所有方案，对于这一类方案的选择来说，道理都大同小异。只要你打算转向前端开发，选择它们中的哪一个方案都差不多，而且方案间的切换也很容易。

着眼未来，决定跨端方案最终赢家的关键因素，不是编程语言，也不是开发生态，更不是开发者，而是用户。

如果谷歌的新系统 Fuchsia 能够如谷歌所计划的五年之内应用到移动端的话，那么五年后即使使用 Fuchsia 的用户只有 10%，你的 App 也要去支持 Fuchsia。Fuchsia 系统的最上层就是 Flutter，这时使用 Flutter 来开发 App 就成了首选。而 Flutter 本身就是一种跨端方案，一旦使用 Flutter 开发成为团队的必选项，那么其他技术栈就没有存在的价值了。

其实，我本人还是很看好 Fuchsia 系统的。它的内核是 Zircon，Fuchsia 是整个系统的统称，在 Fuchsia 技术的选择上，谷歌选择了微内核、优于 OpenGL 高内核低开销的图像接口 Vulkan、3D 桌面渲染 Scenic、Flutter 开发框架。谷歌的打算是，三年内在一些非主流的设备上对 Fuchsia 内核进行完善，待成熟后推向移动端。

Fuchsia 架构分为四层，包括微内核的第一层 Zircon，提供系统服务的第二层 Garnet，用户体验基础设施的第三层 Peridot，Flutter 所在基础应⽤的第四层 Topaz。结合 Android 系统的经验，在设计架构之初，谷歌就考虑了厂商对深度定制的诉求，使得每层都可以进行替换，模块化做得比 Android 系统更加彻底。

Fuchsia 架构，如下图所示：

你可以通过这个视频，查看Fuchsia 最近的动向。如果你有 Pixel 3 XL 手机，可以动手尝试下。你可以点击这个链接，来查看支持 Pixel 3 XL 的 Fuchsia 项目。Fuchsia 官方 Git 仓库的地址是https://fuchsia.googlesource.com，你可以点击查看其源码。

当然，不管操作系统多么牛，最后还要由用户来选。

所以，跨端技术方案的赢家是谁，最终还是要看使用移动设备的用户选择了谁，就好像游戏机市场中的 Nintendo Switch 和 PlayStation Vita。PlayStation Vita 在硬件、性能、系统各方面都领先 Nintendo Switch，但最终游戏开发者还是选择在 Nintendo Switch 上开发，而这其实都取决于购买游戏机的玩家。当 Nintendo Switch 成为了流行和热点以后，所有的游戏开发者都会跟着它走。

虽然我们不能决定未来，但我们可以去预测，然后选择一款大概率会赢的跨端框架，以此来奠定自己的竞争力。

总结

在今天这篇文章中，我将跨平台方案分成了两种：一种是，将 JavaScriptCore 引擎当作虚拟机的方案，代表框架是 React Native；另一种是，使用非 JavaScriptCore 虚拟机的方案，代表框架是 Flutter。

然后，在此基础上，我从编程语言、性能、开发效率和体验等方面和你分析了这两类方案。但是，选择一款适合自己团队的跨平台开发方案，仅仅考虑这几个方面还不够，我们还要着眼于未来。

在我看来，从长远考虑的话，你可以选择 Flutter 作为跨平台开发方案。但是，最终 Flutter 是否能成功，还要看谷歌新系统 Fuchsia 的成败。

课后作业

如果最终 Fuchsia 失败了，而 iOS 继续突飞猛进，SwiftUI 也支持跨端了，那你也就不用换技术栈了，继续使用 Swift 开发就好了。你对此是什么看法呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

41 | 原生布局转到前端布局，开发思路有哪些转变？

你好，我是戴铭。今天，我来跟你聊聊原生布局转到前端布局的过程中，开发思路会有哪些转变。

最开始的时候，iOS 原生布局只支持通过横纵坐标和宽高确定布局的方式，后来引入了 Auto Layout 来优化布局。但，Auto Layout 的写法繁琐，开发者需要编写大量的代码，无法将精力集中在界面布局本身。再后来，苹果公司意识到 Auto Layout 的这个问题，于是推出了 VFL（Visual Format Language，可视化格式语言）来简化 Auto Layout 的写法。

其实，包装 Auto Layout 的第三方库，通过支持链式写法，也能达到简化编写 Auto Layout 的目的。

比如，适用于 Objective-C 的 Masonry 和适用于 Swift 的 SnapKit，都是非常优秀的第三方库。这两个库的实际使用数量，明显高于苹果自身推出的 VFL。关于这两个库的实现原理和源码分析，你可以查看我以前写的“读 SnapKit 和 Masonry 自动布局框架源码”这篇文章。

UIStackView

虽然 Masonry 和 SnapKit 能够简化布局写法，但和前端的布局思路相比，Auto Layout 的布局思路还处在处理两个视图之间关系的初级阶段，而前端的 Flexbox 已经进化到处理一组堆栈视图关系的地步了。

关于 Flexbox 布局的思路，我在第 27 篇文章中已经跟你详细分析过了。你可以借此机会再复习一下相关内容。

苹果公司也意识到了这一点，于是借鉴 Flexbox 的思路创造了 UIStackView，来简化一组堆栈视图之间的关系。

和 Flexbox 一样，按照 UIStackView 设置的规则，一组堆栈视图在可用空间中进行动态适应。这组视图按照堆栈中的顺序，沿着轴的方向排列。这里的轴，可以设置为横轴或纵轴。所以，UIStackView 和 Flexbox 布局框架一样，布局都取决于这组堆栈视图设置的各个属性，比如轴方向、对齐方式、间距等等。

UIStackView 虽然在布局思路上，做到了和 Flexbox 对齐，但写法上还是不够直观。前端布局通过 HTML + CSS 组合，增强了界面布局的可读性。那么，苹果公司打算如何让自己的布局写法也能和 Flexbox 一样既简洁，可读性又强呢？

SwiftUI

在 WWDC 2019 上，苹果公司公布了新的界面布局框架 SwiftUI。SwiftUI 在写法上非常简洁，可读性也很强。

GitHub 上有个叫 About-SwiftUI 的项目，收集了 SwiftUI 的相关资料，包括官方文档教程、WWDC SwiftUI 相关视频、相关博客文章、基于 SwiftUI 开源项目、各类视频，非常齐全，可以全方位地满足你的学习需求。

除了支持简洁的链式调用外，它还通过 DSL 定制了 UIStackView 的语法。这套 DSL 的实现，使用的是 Function Builders 技术，可以让 DSL 得到编译器的支持。有了这样的能力，可以说苹果公司未来可能会诞生出更多编译器支持的特定领域 DSL。

可以想象，未来 iOS 的开发会更加快捷、方便，效率提高了，门槛降低了，高质量 App 的数量也会增加。这，也是苹果公司最想看到的吧。

至此，原生布局的开发思路从布局思路优化转向了 DSL。

DSL 编写后的处理方式分为两种：

一种是，通过解析将其转化成语言本来的面目，SwiftUI 使用的就是这种方式；

另一种是，在运行时解释执行 DSL。SQL 就是在运行时解释执行的 DSL。

对于这两种 DSL，我都实践过。所以接下来，我就跟你分享下我以前对这两种 DSL 的实现。理解了这两种实现方式以后，你也就可以根据项目的实际情况去选择适合自己的方式。

解析转换 DSL

在做 iOS 开发之前，我做过很长一段时间的前端开发。转到 iOS 开发后，我就一直觉得布局思路不如前端简单，编写也不够简洁。于是，我就想能不能通过 Flexbox 这种布局思路将前端和原生结合在一起，使用前端 HTML + CSS 的组合作为布局 DSL，通过解析将其转换成原生代码。

后来，我按照这个思路实现了一个项目，叫作HTN（HTML To Native）：通过解析 HTML ，将其生成 DOM 树，然后解析 CSS，生成渲染树，最后计算布局生成原生 Texture 代码。

下图展示的是，我借鉴 Flexbox 布局思路，使用 HTML + CSS 编写的在浏览器中的显示页面。

可以看到，通过 Inspect 观察，HTML 结合 CSS 能够简洁直观地描述界面元素的各种属性和多组界面元素的布局。

通过 HTN 的转换生成的代码，如下图所示：

可以看出，和前端代码相比，原生 Texture 的代码繁琐、难读。转换后的完整代码在 HTN 工程中的路径是 HTN/HTNExamples/Sample/Flexbox.m。编译后的效果如下图所示：

可以看到，手机上显示的内容布局和浏览器上基本一致，从而实现了用前端布局编写原生界面布局的目标。

我专门写了一篇文章用于记录这个项目的开发过程，“HTML 转原生 HTN 项目开发记录”，你可以参考解析 HTML 生成 DOM 树的部分，解析使用的是状态机，能够很轻松地处理复杂的逻辑判断。

HTML 是标准界面布局 DSL，语法上还是会有些啰嗦，这也是 XML 格式和 JSON 格式的区别。基于这点，我设计了一个基于前端布局思想的 DSL，同时编写了能够解释执行这个 DSL 的程序。之所以不使用 JSON，是为了在运行时对 DSL 的解释更快。在这个项目里，我精简了冗余的格式。

另外，GitHub 上有个利用 Swift 5.1 的 Function Builders 开发了一个能通过 Swift DSL 编写 HTML 的项目 Vaux。你也可以通过这个项目学习如何自定义一个 Swift DSL。

接下来，我和你说说我对第二种运行时解释执行的 DSL ，是怎么设计实现的。

运行时解释执行 DSL

我设计的这个 DSL 库，叫作STMAssembleView。对于这种水平居中排列：

STMAssembleView 中的 DSL 如下：

{

    hc(padding:30)

    [(imageName:starmingicon)]

    [(imageName:starmingicon)]

    [(imageName:starmingicon)]

}

上面代码中，hc 的两个单词分别表示了轴方向和对齐方式：

第一个字母 h 表示按水平轴方向排列，取的是 horizontal 的首字母。如果要表示垂直轴方向排列，可以取 vertical 的首字母，也就是用 v 表示。

第二个字母 c 表示居中对齐方式。如果要表示左对齐可以用 l（left），表示右对齐可以用 r（right），表示上对齐用 t（top），表示下对齐可以用 b（bottom）。

padding 表示的是，大括号里视图之间的间距。其中大括号表示的是一组堆栈视图的集合，中括号表示的是单个视图单元，中括号内的小括号表示的是描述视图的属性。

设计的 DSL 解释执行的实现代码，在 STMAssembleView 工程中的代码路径是 STMAssembleView/Classes/STMAssembleView.m。

小结

总结来说，原生和前端都是面向用户做交互的，只是前端在某些方面，比如布局，比原生发展得要快些。不过，原生后来者居上，通过融合演进、相互促进，实现了原生和前端共舞的局面。由此可以看出，和文化的发展一样，技术只有融合才能够丰富多彩，相互刺激才会进步。

苹果公司对技术演进节奏的把握和对产品一样，都是一步一步递进。也就是说，新技术都依赖于上一个技术，只有上一个技术完善后才会推出新的技术，而不是一次快速打包推出后再依赖后期完善。

这样，苹果公司就可以把每一步都做到最好，每次推出的技术都是用户真正想要的。除此之外，一步一步推出技术，有两个好处：一方面可以将眼前的技术做到极致；另一方面，能够有足够时间验证已推功能的完善性，并观察用户下一步需要的是什么，然后通过观察，砍掉计划中的用户不需要的功能，将精力集中在用户急需的功能上，将其做到极致，形成良性循环。

比如，SwiftUI 可能很早就在苹果公司的计划中了，当时的方案应该远没有现在的优秀，于是苹果公司优先解决 Auto Layout 处理视图关系繁琐的问题，推出了 UIStackView。之后，苹果公司继续观察用户使用情况，发现仅仅吸取布局思想还不够，编程语言写法不够简洁、编译器没有突破，用户依然不会买单。

于是，苹果公司推出了语法更加简洁的 Swift 语言和支持 Hot Reload（热重载）的 Playground，得到了很多开发者的认同。这样，原生编写布局就具备了和前端编写布局的基本竞争条件。

最后只差一个 DSL ，苹果公司就能够将原生布局开发，推到和前端一样的便利程度。就这一步，苹果公司考虑得更加长远：通过一种能和编译器相结合的编程语言特性 Function Builders ，不仅支持了现在的界面开发 DSL，也具备了结合其他领域 DSL 的能力。之所以苹果公司不急着发布 SwiftUI，也符合它一贯的作风，没想清楚，做不到极致，就不推出来。

有了 DSL，配合编译器的 Hot Reload 调试强力支持，再加上 Swift 语言本身的优势，最后的胜者不言而喻。

通过苹果公司从原生布局转到前端布局的思路演进，你会发现，苹果公司对技术演进的判断思考方式很独特，也很有效。这种思想，非常值得我们学习。同时，对于我们开发者来说，苹果公司布局思路的演进，也会推动着我们跟上技术的发展。拥抱技术变化，让开发更高效。

课后作业

不光 iOS 开发者会用 SwiftUI，macOS 和 iPadOS 的应用开发也会用到。因为写法简单，SwiftUI 必将成为广大苹果开发者的首选。因此，你就更加应该好好理解 SwiftUI ，以及它是如何利用 Swift 语言特性来简化代码的。比如，@State 这样的写法到底简化了什么呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

42 | iOS原生、大前端和Flutter分别是怎么渲染的？

你好，我是戴铭。今天，我来和你聊聊 iOS 原生、大前端和 Flutter 分别是怎么渲染的。

用户在使用 App 时，界面的设计、流畅程度是最直接的体验。为此，苹果公司提供了各个层级的库，比如 SwiftUI、UIKit、Core Animation、Core Graphic、OpenGL ，以方便 App 界面的开发。

说起来，即使你不了解这些库的实现原理，也可以通过它们提供的易用接口上手去开发 App，特别是 SwiftUI 大大简化了界面的开发，也确实能够解决大部分问题。但是，一旦遇到性能问题，完全依靠搜索获得的不完整的、拼凑来的知识，大概率只能解一时之需，要想系统地解决问题，还是要知道这些库的实现原理。

而这些与界面相关的库，背后的知识其实就是渲染。接下来，我就和你说说渲染的原理。

渲染原理

我们看到的 App 界面，都是由 CPU 和 GPU 共同计算处理的。

CPU 内部流水线结构拥有并行计算能力，一般用于显示内容的计算。而 GPU 的并行计算能力更强，能够通过计算将图形结果显示在屏幕像素中。内存中的图形数据，经过转换显示到屏幕上的这个过程，就是渲染。而负责执行这个过程的，就是 GPU。

渲染的过程中，GPU 需要处理屏幕上的每一个像素点，并保证这些像素点的更新是流畅的，这就对 GPU 的并行计算能力要求非常高。

早期，图形渲染是由 VGA（Video Graphics Array，视频图形阵列）来完成的，随着 3D 加速的需要，带来了比如三角形生成、光栅化、纹理贴图等技术。处理这一系列技术的处理器，就被统称为 GPU。

GPU 的主要工作是将 3D 坐标转化成 2D 坐标，然后再把 2D 坐标转成实际像素，具体实现可以分为顶点着色器（确定形状的点）、形状装配（确定形状的线）、几何着色器（确定三角形个数）、光栅化（确定屏幕像素点）、片段着色器（对像素点着色）、测试与混合（检查深度和透明度进行混合）六个阶段。

为了能够更方便地控制 GPU 的运算，GPU 的可编程能力也不断加强，开始支持 C 和 C++ 语言。通过 OpenGL 标准定义的库，可以更容易地操作 GPU。

在渲染过程中，CPU 专门用来处理渲染内容的计算，比如视图创建、布局、图片解码等，内容计算完成后，再传输给 GPU 进行渲染。

在这个过程中，CPU 和 GPU 的相互结合，能够充分利用手机硬件来提升用户使用 App 的体验。当然，在这个过程中，如果 CPU 的计算时间超过了屏幕刷新频率要求的时间，界面操作就会变得不流畅。

那么，如果你想要知道原生、大前端和 Flutter 谁会更流畅，就要分别去了解在渲染过程中，谁的 CPU 计算内容会更快。

接下来，我们先看看原生渲染中的计算。

原生渲染

原生界面更新渲染的流程，可以分为以下四步。

第一步，更新视图树，同步更新图层树。

第二步，CPU 计算要显示的内容，包括视图创建（设置 Layer 的属性）、布局计算、视图绘制（创建 Layer 的 Backing Image）、图像解码转换。当 runloop 在 BeforeWaiting 和 Exit 时，会通知注册的监听，然后对图层打包，打完包后，将打包数据发送给一个独立负责渲染的进程 Render Server。

第三步，数据到达 Render Server 后会被反序列化，得到图层树，按照图层树中图层顺序、RGBA 值、图层 frame 过滤图层中被遮挡的部分，过滤后将图层树转成渲染树，渲染树的信息会转给 OpenGL ES/Metal。前面 CPU 所处理的这些事情统称为 Commit Transaction。

第四步，Render Server 会调用 GPU，GPU 开始进行前面提到的顶点着色器、形状装配、几何着色器、光栅化、片段着色器、测试与混合六个阶段。完成这六个阶段的工作后，再将 CPU 和 GPU 计算后的数据显示在屏幕的每个像素点上。

整个渲染过程，如下图所示：

如上图所示，CPU 处理完渲染内容会输入到 Render Server 中，经图层树和渲染树的转换，通过 OpenGL 接口提供给 GPU，GPU 处理完后在屏幕上显示。

渲染过程中 Commit Trasaction 的布局计算会重载视图 LayoutSubviews 方法，以及执行 addSubview 方法来添加视图。视图绘制会重载视图的 drawRect 方法。这几个方法都是 iOS 开发中常用的。

移动视图位置、删除视图、隐藏或显示视图、调用 setNeedsDisplay 或 setNeedsDisplayInRect 方法，都会触发界面更新，执行渲染流程。

这些就是原生渲染计算的所有内容了，接下来我们再一起看看大前端的渲染。

大前端渲染

大前端的开发框架主要分为两类：第一类是基于 WebView 的，第二类是类似 React Native 的。

对于第一类 WebView 的大前端渲染，主要工作在 WebKit 中完成。WebKit 的渲染层来自以前 macOS 的 Layer Rendering 架构，而 iOS 也是基于这一套架构。所以，从本质上来看，WebKit 和 iOS 原生渲染差别不大。

第二类的类 React Native 更简单，渲染直接走的是 iOS 原生的渲染。那么，我们为什么会感觉 WebView 和类 React Native 比原生渲染得慢呢？

从第一次内容加载来看，即使是本地加载，大前端也要比原生多出脚本代码解析的工作。

WebView 需要额外解析 HTML + CSS + JavaScript 代码，而类 React Native 方案则需要解析 JSON + JavaScript。HTML + CSS 的复杂度要高于 JSON，所以解析起来会比 JSON 慢。也就是说，首次内容加载时， WebView 会比类 React Native 慢。

从语言本身的解释执行性能来看，大前端加载后的界面更新会通过 JavaScript 解释执行，而 JavaScript 解释执行性能要比原生差，特别是解释执行复杂逻辑或大量计算时。所以，大前端的运算速度，要比原生慢不少。

除了首次加载解析要耗时，以及 JavaScript 语言本身解释慢导致的性能问题外，WebView 的渲染进程是单独的，每帧的更新都要通过 IPC 调用 GPU 进程。频繁的 IPC 进程通信也会有性能损耗。

WebView 的单独渲染进程还无法访问 GPU 的 context，这样两个进程就没有办法共享纹理资源。纹理资源无法直接使用 GPU 的 Context 光栅化，那就只能通过 IPC 传给 GPU 进程，这也就导致 GPU 无法发挥自身的性能优势。由于 WebView 的光栅化无法及时同步到 GPU，滑动时容易出现白屏，就很难避免了。

说完了大前端的渲染，你会发现，相对于原生渲染，无论是 WebView 还是类 React Native 都会因为脚本语言本身的性能问题而在存在性能差距。那么，对于 Flutter 这种没有使用脚本语言，并且渲染引擎也是全新的框架，其渲染方式有什么不同，性能又怎样呢？

Flutter 渲染

Flutter 界面是由 Widget 组成的，所有 Widget 组成 Widget Tree，界面更新时会更新 Widget Tree，然后再更新 Element Tree，最后更新 RenderObject Tree。

接下来的渲染流程，Flutter 渲染在 Framework 层会有 Build、Wiget Tree、Element Tree、RenderObject Tree、Layout、Paint、Composited Layer 等几个阶段。将 Layer 进行组合，生成纹理，使用 OpenGL 的接口向 GPU 提交渲染内容进行光栅化与合成，是在 Flutter 的 C++ 层，使用的是 Skia 库。包括提交到 GPU 进程后，合成计算，显示屏幕的过程和 iOS 原生基本是类似的，因此性能也差不多。

Flutter 的主要优势，在于它能够同时运行于 Android 和 iOS 这两个平台。但是，苹果公司在 WWDC 2019 上推出 SwiftUI 和 Preview 后，Flutter 在界面编写和 Hot Reload 上的优势会逐渐降低。

小结

今天这篇文章，我首先和你说了渲染的原理，然后分别和你展开了原生、大前端、Flutter 是怎么渲染的。整体来看，大前端中的 WebView 方式渲染性能会差些，React Native 和其他方案在渲染上的性能都差不多。

而关于如何选择这三种开发方案，我的建议是结合自身情况和工作需要来确定就好。

当你所在团队已经偏向于大前端时，那么你可以选择拥抱变化，毕竟前端技术生态已经非常完善了。

如果你开始喜欢谷歌的技术，也想多了解 Android 或者谷歌的新操作系统 Fuchsia 的话，Flutter 无疑是最好的选择。

当然，如果你和我一样是一名果粉的话，那我相信苹果公司的产品会不断给你惊喜，可以继续你的 iOS 原生开发之旅。相信在 SwiftUI、Project Catalyst、Combine 这些项目的帮助下，你一定能够开发出更多、更优秀的 App 。

课后作业

在你看来，Chrome 和 WebKit 的渲染引擎，有什么区别呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

43 | 剖析使 App 具有动态化和热更新能力的方案

你好，我是戴铭。今天，我来和你聊聊 iOS 开发中的动态化和热更新方案。

热更新能力的初衷是，能够及时修复线上问题，减少 Bug 对用户的伤害。而动态化的目的，除了修复线上问题外，还要能够灵活更新 App 版本。

要实现动态化，就需要具备在运行时动态执行程序的能力。同时，实现了动态化，也就具备了热更新能力。通常情况下，实现动态化的方案有三种，分别是 JavaScriptCore 解释器方案、代码转译方案、自建解释器方案。接下来，我就和你详细说说这三种方案。

JavaScriptCore 解释器方案

iOS 系统内置的 JavaScriptCore，是能够在 App 运行过程中解释执行脚本的解释器。

JavaScriptCore 提供了易用的原生语言接口，配合 iOS 运行时提供的方法替换能力，出现了使用 JavaScript 语言修复线上问题的 JSPatch，以及把 JavaScriptCore 作为前端和原生桥梁的 React Native 和 Weex开发框架。这些库，让 App 具有了动态化能力。

但是，对于原生开发者来说，只能解释执行 JavaScript 语言的解释器 JSPatch、React Native 等，我们用起来不是很顺手，还是更喜欢用原生语言来开发。那么，有没有办法能够解决语言栈的问题呢？

代码转译方案

DynamicCocoa 方案将 Objective-C 转换成 JavaScript 代码，然后下发动态执行。这样一来，原生开发者只要使用原生语言去开发调试即可，避免了使用 JavaScript 开发不畅的问题，也就解决了语言栈的问题。

当然，语言之间的转译过程需要解决语言差异的问题，比如 Objective-C 是强类型，而 JavaScript 是弱类型，这两种语言间的差异点就很多。但，好在 JavaScriptCore 解释执行完后，还会对应到原生代码上，所以我们只要做好各种情况的规则匹配，就可以解决这个问题。

手段上，语言转译可以使用现有的成熟工具，比如类 C 语言的转译，可以使用 LLVM 套件中 Clang 提供的 LibTooling，通过重载 HandleTranslationUnit() 函数，使用 RecursiveASTVistor 来遍历 AST，获取代码的完整信息，然后转换成新语言的代码。

在这里，我无法穷尽两种编程语言间的转译，但是如果你想要快速了解转译过程的话，最好的方法就是看一个实现的雏形。

比如，我以前用 Swift 写过一个 Lisp 语言到 C 语言转译的雏形。你可以点击这个链接，查看具体的代码。通过这个代码，你能够了解到完成转译依次需要用到词法分析器、语法分析器、遍历器、转换器和代码生成器。它们的实现分别对应 LispToC 里的 JTokenizer.swif、JParser.swift、JTraverser.swift、JTransformer.swift 和 CodeGenerator.swift。

再比如，你可以查看SwiftRewrite 项目的完整转译实现。SwiftRewriter 使用 Swift 开发，可以完成 Objective-C 到 Swift 的转换。

自建解释器方案

可以发现，我在前面提到的 JSPatch、React Native 等库，到最后能够具有动态性，用的都是系统内置的 JavaScriptCore 来解释执行 JavaScript 语言。

虽然直接使用内置的 JavaScriptCore 非常方便，但却限制了对性能的优化。比如，系统限制了第三方 App 对 JavaScriptCore JIT（即时编译）的使用。再比如，由于 JavaScript 使用的是弱类型，而类型推断只能在 LLInt 这一层进行，无法得到足够的优化。

再加上 JSContext 多线程的处理也没有原生多线程处理得高效、频繁的 JavaScriptCore 和原生间的切换、内存管理方式不一致带来的风险、线程管理不一致的风险、消息转发时的解析转换效率低下等等原因，使得 JavaScriptCore 作为解释器的方案，始终无法比拟原生。

虽然通过引入前端技术栈和利用转译技术能够满足大部分动态化和热修复的需求，但一些对性能要求高的团队，还是会考虑使用性能更好的解释器。

如果想要不依赖系统解释器实现动态化和热修复，我们可以集成一个新的解释器，毕竟解释器也是用代码写出来的，使用开源解释器甚至是自己编写解释器，也不是不可以。

因此，腾讯公司曾公布的 OCS 方案，自己实现了一个虚拟器 OCSVM 作为解释器，用来解释执行自定义的字节码指令集语言 OCScript，同时提供了将 Objective-C 转成 OCScript 基于 LLVM 定制的编译器 OCS。

腾讯公司自研一个解释器的好处，就是可以最大程度地提高动态化的执行效率，能够解释执行针对 iOS 运行时特性定制的字节码指令。这套定制的指令，不光有基本运算指令，还有内存操作、地址跳转、强类型转换指令。

OCSVM 解释执行 OCScript 指令，能够达到和原生媲美的稳定和高性能，完成运行时 App 的内存管理、解释执行、线程管理等各种任务。OCS 没有开源，所以你无法直接在工程中使用 OCS 方案，但是有些公司自己内部的动态化方案其实就是参考了这个方案。这些方案都没有开源，实现的难度也比较大。

因此，你想要在工程中使用高效的解释器，最好的方案就是，先找找看有没有其他的开源解释器能够满足需求。

这时，如果你仔细思考，一定会想到 LLVM。LLVM 作为标准的 iOS 编译器套件，对 iOS 开发语言的解析是最标准、最全面的。那么，LLVM 套件里面难道就没有提供一个解释器用来动态解释执行吗？

按理说，LLVM 来实现这个功能是最合适不过了。其实 LLVM 里是有解释器的。

只不过，ExecutionEngine 里的 Interpreter，是专门用来解释 LLVM IR 的，缺少对 Objective-C 语法特性的支持，所以无法直接使用。除此之外，ExecutionEngine 里还有个 MCJIT，可以通过 JIT 来实现动态化，但因为 iOS 系统的限制也无法使用。

其实，LLVM 之所以没有专门针对 iOS 做解释器，是因为 iOS 动态化在 LLVM 所有工作中的优先级并不高。

不过，好在 GitHub 上有一个基于 LLVM 的 C++ 解释器 Cling，可以帮助我们学习怎样通过扩展 LLVM 来自制解释器。

解释器分为解释执行 AST 和解释执行字节码两种，其中 Cling 属于前者，而 LLVM 自带解释器属于后者。

从效率上来说，解释执行字节码的方案会更好一些，因为字节码可以在编译阶段进行优化，所以使用 LLVM IR 这种字节码，可以让你无需担心类似寄存器使用效率，以及不断重复计算相同值的问题。LLVM 通过优化器可以提高效率，生成紧凑的 IR。而这些优化都在编译时完成，也就提高了运行时的解释效率。

那么，LLVM 是怎么做到的呢？

LLVM IR 是 SSA（Static Single-Assignment，静态单赋值） 形式的，LLVM IR 通过 mem2reg Pass 能够识别 alloca 模式，将局部变量变成 SSA value，这样就不再需要 alloca、load、store 了。

SSA 主要解决的是，多种数据流分析时种类多、难以维护的问题。它可以提供一种通用分析方法，把数据流和控制流都写在 LLVM IR 里。比如，LLVM IR 在循环体外生成一个 phi 指令，其中每个值仅分配一次，并且用特殊的 phi 节点合并多个可能的值，LLVM 的 mem2reg 传递将我们初始堆栈使用的代码，转成带有虚拟寄存器的 SSA。这样 ，LLVM 就能够更容易地分析和优化 IR 了。

LLVM 只是静态计算 0 和 1 地址，并且只用 0 和 1 处理虚拟寄存器。在高级编程语言中，一个函数可能就会有几十个变量要跟踪，虚拟寄存器计算量大后，如何有效使用虚拟寄存器就是一个很大的问题。SSA 形式的 LLVM IR 的 emitter 不用担心虚拟寄存器的使用效率，所有变量都会分配到堆栈里，由 LLVM 去优化。

其实，我和你分享的 OCS 和 Cling 解释器，都是基于 LLVM 扩展实现的。那么，如果我们不用 LLVM 的话，应该怎么写解释器呢？

要了解如何写解释器，就要先了解解释器的工作流程。

解释器首先将代码编译为字节码，然后执行字节码，对于使用频次多的代码才会使用 JIT 生成机器代码执行。因此，解释器编译的最初目标不是可执行的机器代码，而是专门用在解释器里解释执行的字节码。

因为编译器编译的机器代码是专门在编译时优化过的，所以解释器的优化就需要推迟到运行时再做。这时，就需要 Tracing JIT 来跟踪最热的循环优化，比如相同的循环调用超过一百万次，循环就会编译成优化的机器代码。浏览器的引擎，比如 JavaScriptCore、V8，都是基于字节码解释器加上 Tracing JIT 来解释执行 JavaScript 代码的。

其实，JIT 技术就是在 App 运行时创建机器代码，同时执行这些机器代码。编译过程，将高级语言转换成汇编语言，Assembler（汇编器） 会将汇编语言转换成实际的机器代码。

仅基于字节码的解释器的实现，我们只需要做好解析工作，然后优化字节码和解释字节码的效率，对应上原生的基本方法执行，或者方法替换就可以实现动态化了。

但是，自己实现 JIT 就难多了，一方面编写代码和维护代码的成本都很高，另一方面还需要支持多 CPU 架构，如果搭载 iOS 系统的硬件 CPU 架构有了更新还要再去实现支持。所以，JIT 的标签和跳转都不对外提供调用。

那如果要想实现一个自制 JIT 的话，应该如何入手呢？

用 C++ 库实现的 JIT AsmJit，是一个完整的 JIT 和 AOT 的 Assembler，可以生成支持整个 x86 和 x64 架构指令集（从 MMX 到 AVX512）的机器代码。AsmJit 的体积很小，在 300KB 以内，并且没有外部依赖，非常适合用来实现自己的 JIT。使用 AsmJit 库后，我们再自己动手去为字节码编写 JIT 能力的解释器，就更容易了。

小结

今天这篇文章，我跟你分享了使 App 具有动态化和热更新能力的方案，其中包含了目前大多数项目在使用的 JavaScriptCore 解释器方案。

但由于 JavaScriptCore 方案更适合前端开发者，于是出现了对原生开发者更友好的代码转译方案，代码转译最终解释执行还是 JavaScriptCore，在效率上会受到种种限制。为了更好的性能，便有了在 App 内集成自建解释器的方案。

我觉得热更新用哪种方案问题都不大，毕竟只是修复代码。但是，动态化方案的选择，就要更慎重些了，毕竟整个业务都要用。

动态化方案的选择主要由团队人员自身情况决定，比如原生开发者居多时可以选择代码转译或自建解释器方案；前端开发者居多或者原生开发者有意转向前端开发时，可以选择 JavaScriptCore 方案。

另外，动态化方案本身，对大团队的意义会更加明显。因为大团队一般会根据业务分成若干小团队，由这些不同团队组成的超级大 App 每次发版，都会相互掣肘，而动态化就能够解决不同团队灵活发版的问题，让各个小团队按照自己的节奏来迭代业务。

课后作业

如果你负责的 App 出现了线上问题，你是采用什么方案来修复这个问题的呢？

感谢你的收听，欢迎你在评论区给我留言分享你的观点，也欢迎把它分享给更多的朋友一起阅读。

用户故事 | 我是如何学习这个专栏的？

你好，我是戴铭。

专栏上线后，有些同学觉得学起来太吃力，也有些同学觉得学起来很顺利，那么这中间是因为学习方法的问题吗？有道是，方法比努力更重要，只有找到适合自己的学习方法，才能达到事半功倍的效果。

因此，我今天特意邀请了 4 位同学来分享他们学习专栏的方法，这不仅是要给各位同学提供一个分享自己故事的平台，更是希望这些同学们分享的学习方法，能够让其他同学受益，从而帮助你可以更高效地学习专栏内容。

小万叔叔

你好，我是小万叔叔，目前在某鱼带领 iOS 架构组团队做一些工程优化方面的工作。我希望通过自己不断地学习、积累，可以把握团队的技术方向，进而把架构组发展成一流的技术团队。

其实，我在很早之前就知道戴铭老师，也看过他写的很多文章。这些文章，给我的总体感觉是有深度，有独特的见解。因此，当我在微信里面看到有分享订阅的时候，就第一时间订阅了。

说到专栏文章的学习时机，我一般是在文章更新后，当天早晨的上班路上去听。因为我对大前端技术非常感兴趣，所以听音频的时候自然也就比较专注，可以比较轻松地吸收文章中好的建议和思路。

说到学习专栏文章的方法，我的学习方式可能和其他同学不太一样。大部分同学应该会跟踪一个知识点，学习完专栏文章然后落地到实践，就类似看了专栏文章，然后做课后学习的方式。而我的学习方法是，会看很多文章，然后把这些知识点汇聚到一起，落地到一个项目中。也就是说，我更希望的是我所学的知识能构成一个体系。

比如说，包大小优化就是一个明显的案例，我在做包大小优化时用到的方案中，汇聚了头条团队的一些技术分享，图片格式的存储算法、识别算法，源码格式的二进制内容，CocoaPods 打包的方式等等，这些所有的知识点才能构成整个项目的推进树。

目前来看，这个专栏课程中提到的很多技术，我们都在实践，并且在持续优化，希望能做得更好。比如，在专栏的第 4 篇文章中，戴铭老师谈到了架构如何设计得更合理，也是我们目前在做全量组件化痛并快乐的一个过程，从最开始组件之间的严重耦合，到划分好相关的层级，业务组件按一定粒度进行划分，然后通过路由中间件进行解耦调用，都和我们全量组件化一路走来的过程极其类似。也许，这就是大型业务从堆积到细分，以适应未来业务变迁必须要经历的过程。

针对已经更新的三十多篇文章，我的感触是从架构、性能监控、测试，到代码规范，涉及到了 iOS 开发的方方面面。与我而言，很多内容都没有作者看得深，所以在工程实践的过程中，我也在不断地扩充自己的知识面，去更深入地理解背后原理。也正因为如此，这个专栏确实让我受益匪浅。

如果说让我感触最深的文章的话，就是讲App 包瘦身的那篇文章。我所在公司的业务体量庞大，而且由于前期的业务堆积没有处理好 App 包的问题，导致 App 安装包的大小一度高达 270MB。经过我们的努力，现在的包大小在 170MB 左右，而且仍在继续瘦身中。

总结我们的实践经验，再匹配这篇文章的内容，我们主要是从两个方面做出了努力：

图片优化方面，包括图片格式、Assert 分包、无用图片等方面的优化，为我们缩减了近 50% 的包体积。

在二进制代码瘦身这块，我们做的更多的是，静态库和动态库的平衡、无用代码的删除精简、多套功能同化组件的合并统一。专栏文章里面提到的运行时检查类的方法，是我以前没有想到的。所以，我也会在接下来的二进制减包工作中，继续落地实践这个思路。

对于还在犹豫是否要购买《iOS 开发高手课》的同学，我的建议是： 不管是否是初学者，里面涵盖的信息量和思考问题的方式，都值得你去购买。

谭鹏

你好，我是谭鹏，是河南省郑州市的一名 iOS 开发者。从 2011 年底的第一份工作开始到现在，我已经工作快 8 年了。

在订阅《iOS 开发高手课》专栏之前，其实我已经通过工作实践、阅读相关技术资料，积累了不少 iOS 开发知识。或许和我不擅于总结有关，这些知识都是零散的，没能构成属于我自己的知识体系。

比如说，我了解 iOS 的底层原理，对 RunLoop、Runtime、KVC、KVO、多线程、内存管理这些都很熟悉了，但也仅限于记忆，并没有进行深入挖掘进而形成自己的输出，更没能用这些知识储备去优化 App 性能。

因为我清楚自己的短板在哪里，在看到这个专栏的目录以及开篇词之后，发现这个专栏包含的知识非常系统，加之我现在的工作重心就是性能优化、架构，所以我就毫不犹豫的订阅了。等真正开始学习的时候，我发现专栏文章，大多是实战经验的总结，无论从知识的广度还是深度来看，内容都相当不错。

在我看来，学习专栏文章的过程，是和工作相结合的过程，也是扩展自己知识边界的过程。比如，我自己做原生开发的时候使用过 Promise，但是从来没想过要使用 Promise 的思想去解决一些问题。学完专栏的第 25 篇文章后，我就马上在项目中引入了 PromiseKit，解决了多个网络请求串行的问题。

在学习完专栏文章之后，可以快速在工作中应用实践，实践的结果又会反过来扩充自己的知识体系，并增加学习的成就感、激情。这是一个正向反馈，可以让我不断地提高自己。

使用极客时间的这段时间，我的最大感触就是，高效学习，需要先搭建知识的金字塔结构。 有了完善的知识结构，我们才能看到完整的知识地图，学习起来才能做到有的放矢。

所以，当我们在选择学习资料的时候，还需要结合自己的实际情况，以尽量完善自己的知识结构为目标。比如说，iOS 开发者要想提升自己，想要成为一名高手的话，就不能只盯着具体的语言（比如，Objective-C）和业务逻辑，还要在 iOS 底层原理、跨平台开发、网络协议、数据结构和算法等方面多下功夫。

因为，只有具备了完整的知识结构，我们才能走得更远。

大太阳

你好，我是温超，是一个北漂三年有余的 iOS 开发工程师。你也可以叫我大太阳，因为我喜欢阳光，也很乐观，所以就用了这样一个昵称。现在，我在一家做教育的互联网创业公司工作。在这之前，我还做过棋牌游戏，所以现在的工作也算是跨界啦。

其实，我以前也听说过极客时间这个平台，但是真正接触还是从戴铭老师的《iOS 开发高手课》开始。让我订阅这个专栏的原因，在于课程目录，以及开篇词中的“我们程序员都是手艺人”这个观点，这些内容真的是都说到我的心坎里了。

百度百科是这么解释“手艺人”的：以手工技能或其他技艺为业的人，包括陶工、瓦工、铁匠、织工、木匠、厨子等等，也包括米开朗基罗等文化艺术巨匠。在我看来，能创造、会精心打磨作品，正是广大技术开发工程师的标签。

千里之行始于足下，订阅课程是我们迈出学习之路的第一步，那接下来的路要怎么走呢？在此，我就和你分享一下我的学习方法吧。

在我看来，当选择一种资料去学习一门知识时，最重要的是要弄清楚自己要收获什么。这也很好理解，只有当我们知道自己要什么以后，才能有足够的动力去学习。可能同学们接触 iOS 开发的程度不同，对自己需要学习的 iOS 知识的认知也不同。针对我自己的情况来说 ，在我还没开始学习之前，我就清楚自己的弱点，以及想要理解、学习的知识点。比如，我的 iOS 基础薄弱，对 Runtime、RunLoop 的原理掌握得不够透彻，也没有过大型项目经验等等。

现在，这个专栏每更新出一篇文章后，我都会第一时间去收听音频，先对这篇文章涉及了哪些知识点、这些知识点的难度如何、我需要花多少时间去消化等问题，有个大概的了解。接下来，我会再去看对应的文稿，根据自己的情况进行针对性的学习，查找些相关的技术资料，并且一定要动手去做。正如戴铭老师在开篇词中所说，对咱们手艺人来说，不动手都是空谈，只看不练是不会有进步的。

对有些同学来说，专栏更新的速度可能有些快，前面的知识还没吃透，这时候要怎么办呢？我的建议是，不要着急，踏踏实实地学习，能学明白一个就是一个，学到就是赚到。

但是，当我们遇到一个难啃的知识点时，尤其是你已经在这上面花了很长时间了，那我的建议是不要一直钻牛角尖，你可以先去学其他的知识，等你觉得自己又思路了再回过头来继续钻研的效率会更高，这也是一种曲线救国的思想吧。

因为，很多知识都是相通的，就和链表一样，有的知识点是单向链表，有的是双向链表，而有的是循环链表等，你可能在学习其中的某一环，当你了解到它的上一个节点的知识点之后，你就能更快地学会这个知识点啦。

之所以这么说，是因为曾经在一个项目开发的初期，我因为语音播放的问题钻了牛角尖，导致项目工期延后了三天。其实，在我第一天卡在这个问题上的时候，技术老大就劝我说可以先写其他部分代码，然后再回顾头来解决这个问题。但当时我坚持己见，一直到把这个问题解决完才继续写其他部分的代码。等到项目上线后，我才反应过来，当时就应该先把这个问题放一放，等有思路了再去完成这部分代码，就不会有延误工期的事情了。

上面这些内容，就是我学习这个专栏的方法和情况了。接下来，我想再和你聊聊戴老师的《iOS 开发高手课》。

说实话，大家在订阅一门课程的时候，看到的只有简介、目录和开篇词，很难去判断值不值得我为此付费。那么，关于这个专栏，我可以肯定的和你说，它值得。

就目前已经更新的三十多篇文章来说，其中涉及的知识点，需要我下很大功夫才能掌握。毕竟，这些内容与我来说都是干货，而且涉及的这些知识点，是我们在 iOS 开发过程中真真切切会遇到的，需要也值得我花时间去思考、去学习、去实践。

对我来说，从我做 iOS 开发到现在，一直没有在一个像样的、大规模的公司工作过，而且基本上都是独立开发，遇到问题基本上都是自己解决。这个过程，就要求我必须不断地去了解、去学习相关的知识。

不是说通过这种方式学习的知识不深刻，而是会很零散，没有系统的知识脉络。在这之前，我也通过其他平台，尝试了很多 iOS 开发相关的课程、学习资料，但效果一般，没有沟通和交流，感觉我就是在孤立、被动地学习。

相比之下，在极客时间这个平台上，同学们可以在评论区各抒己见，分享自己遇到的问题，以及对应的解决方法，作者还会帮我们解答那些我们自己解决不了的问题，这让我感觉收获非常大。我可以看到自己的问题，也可以通过其他同学的分享去避免自己犯类似的错误。所以，每篇文章后面的留言我都会认真看，除了收获知识外，我也更加坚定自己还有很大的进步空间。

在我看来，这才是一个正常的学习交流模式。

除此之外，我还想对广大的对 iOS 开发有兴趣的同学们说几句，只有你去做了，才会知道自己的能力多强，自己的不足在哪里，自己有需要提高些什么。学习是一个持续的过程，知识是不断积累的，如果你想深入了解 iOS 开发，你想成为一名优秀的 iOS 开发者，或者是想要多学习与 iOS 相关的课程的话，那就和我们一起来学习这个专栏吧。

冷秋

你好，我是冷秋，从事 iOS 开发有 3 年了，现在瑞幸咖啡厦门总部负责 iOS 平台架构工作。

作为开发者，业务大多是重复性工作，你并不会因为多两年经验而有多么明显的竞争力。你要想比别人优秀，就必须跳出业务圈子，学习业务之外的知识，这样才能获得更高、更快的成长。所以，我每天都会抽出两到三个小时的时间，去学习，去丰富自己的知识体系。

在通过网站来学习 iOS 开发知识这件事儿上，我总结得出，入门级的内容超过 90% 都是重复的，而进阶级的内容你又要花时间去确认正确性的问题。而我们要利用好有限的时间去高效学习的前提，就是要学会挑选适合自己的学习资料。

所以，当我在看到戴铭老师的《iOS 开发高手课》专栏时，就毫不犹豫地订阅了。当然，在这之前我就知道戴铭，看过他很多的技术分享，干货十足，而且尤其喜欢他 PPT 的风格，佩服他能把事情做到极致的匠人精神。

说到我是如何学习专栏文章，或者其他资料去完善知识体系的，我觉得最重要的是要找到适合自己的方法，并养成一个良好的习惯持续学习。

对于任何一个开发人员来说，他的成长路径都是从 0 开始。那，对于 0 基础的入门同学，我总结了一个由浅入深的学习步骤：会基本使用–> 会完全使用–> 会自己改造–> 会自己创造。这些步骤的谓语都是一个库或者是一个工具。而在这整个的学习过程中，都离不开一个词，也就是举一反三。

接下来，我以一个发送网络请求的问题为例，和你分享我是怎么用这种方法去学习的。

在日常开发中，我们经常会遇到问题，而这些问题就是学习的起点。遇到问题后，我们通常会在网上寻找解决方案，而解决方案可能就是一个库，或者一个工具。假设我需要发送一个网络请求，搜到了 AFNetworking，于是就开始了对网络框架的学习。

第一个阶段，会基本使用。通常，我们遇到的问题大概率是紧急的，所以找到一个解决方案时，看一些基本的使用文档，然后复制粘贴地开始使用了。这对新手来说是没问题的，但我建议你可以多去思考一层，而不是简单地去 copy。

比如说，你可以举一反三。除了 AFNetworking 外，还有别的库也可以解决这个问题吗？ 这时，你需要在网络上进行深入搜索，查看更多的类似分享，然后就会发现还有很多网络库可以发请求。你可以先粗略看一下这些库的接口差异，选择最适合自己的网络库，而不是一定要用 AFNetworking。

第二个阶段是，会完全使用。解决完发送网络请求的问题后，你又遇到了更多的网络问题，依旧需要靠 AFNetworking 来解决。但前提是，你需要对这个库有更深的理解。

而深入理解一个三方库的最好办法，就是阅读官方示例以及文档，甚至阅读源码。因为，我们从其他技术文章里面得到的大多是基础用法，距离你了解这个库的所有功能，进而去更优雅地解决问题还差很远。

到这个阶段，你还可以举一反三，针对 AFNetworking 这个网络库去思考更深入的问题。比如说，除了网络请求，AFNetworking 还能帮我解决什么问题吗？

通过阅读 AFNetworking 的源码，你可以了解到 AFNetworking 还有很多很强大的功能。比如，AFNetworking 不仅能支持传递明文的 JSON 数据，同时也能支持各种类型的加解密；除此之外，还能帮我监控当前的网络环境，是离线还是 4G 还是 WiFi，可以让你在即将有大流量消耗的操作前提示用户是否继续。

第三个阶段是，会自己改造。随着业务发展，现有的网络库可能没办法支撑你的业务需求，你需要考虑改造现有的网络库。此时，你就可以根据之前阅读的源码，掌握大佬的设计思路，并按照这个思路去修改和扩展 AFNetworking。

在此基础上，你可以继续举一反三。你通过搜索发现，另一个网络库可以解决你的问题，这时你可否考虑手动修改现有的网络库，来增加其他网络库的功能呢。

第四个阶段，会自己创造。你在 App 开发过程中，会用到大量的第三方库，可以通过阅读源码去了解它们的设计思想。这样，当你遇到现有的第三库不能解决自己的问题时，就可以通过这些设计思想去封装一个库来使用，甚至开源出来供其他人使用。

我认为，创造是学习的最后一步。当你开始创造自己的网络库时，所考虑的就不仅仅是接口调用了，还要考虑接口设计的易扩展性等，甚至还要去深入学习 HTTP 传输协议，以及其他平台的网络库的设计思路。相比于会基本使用，这时你学习到的知识将会更加全面。

这时，你可以再举一反三。在开发 App 过程中，你为解决网络问题开发了一个库。你可以将这个库的接口设计得更完整，将架构设计得更灵活易扩展，使得它可以被用于公司的其他 App。然后，你可以将这个库从 App 中独立出来，变为一个大家都可以用的私有库，甚至将它放到 GitHub 上，供更多的开发者使用、完善其功能。

这就是我这几年来一直在坚持的学习方法了，而关于学习时间，我习惯在每天的早晨和临睡前，花上 2~3 个小时，去阅读些技术资料来提升自己。我坚持每天利用好好这两三个小时的时间，直到现在这已经形成了我的习惯。

希望我的分享，可以对你有所帮助。

在最后，我特别感谢这 4 位同学的分享。如果你也有自己的故事想要分享，欢迎在留言区分享。我会在发布结束语的时候，选出留言点赞数最高的那位同学，送出一份“学习加油礼包”，期待你的故事。

结束语 | 慢几步，深几度

我觉得，每个 iOS 开发者，都是喜欢苹果公司的产品、喜欢好的产品设计的人。

苹果公司一年一度的全球开发者大会（WWDC），不仅会推出新技术，还会向我们展示被筛选出的、年度最优秀的 App。现在，WWDC 就像是开发者的节日，总会在不经意间给我们惊喜。当年 Swift 的横空出世，让喜爱编程语言的我激动不已；如今，SwiftUI、Combine、Project Catalyst 的推出，更是让我无法入眠。每年，我都会熬夜看 WWDC；每次看完，我总是忍不住想快点儿试试新推出的功能。这些新功能，意味着新的灵感、新的创意。

看着自己钟爱的苹果公司，每年都在不断完善自己的基础技术能力，并不断推新和融合，我的心里都非常满足。苹果公司为我们这些开发者们这么努力着，那我们是不是也可以放心地将最基础的能力交给苹果公司呢？少造些轮子，把更多的时间放到业务相关的技术深度挖掘中。

苹果公司的策略有很多值得学习的地方，我觉得最重要的是，对推出新的产品功能和开发技术都非常慎重，只做对客户最有价值的事情。对应到个人学习来说，就是要有选择地学习，学习当前工作中最需要的知识，围绕着当前最紧急的事情做稳、做深。特别是在大团队中，不要把事情摊大了，难收场。正所谓慢几步，深几度。

在工作上，我觉得即使带团队，也不要因为事情实施都可以由其他人来做，而丢了技术。当你觉得时间都花在任务拆分分配、技术方案评审、代码风险识别，以及保障开发任务交付的计划、协作、沟通、跟进上的时候，你就要制定好开发流程规范、发展中台，来提高完成这些工作的效率。规范和中台，是保障团队合作高效的核心。只有这样，你才能够从繁杂的项目流程中解脱出来，把时间花在更重要的事情上。

关于技术学习的时间分配，我觉得实践是最好的学习。再简单的工作，也能够做得更漂亮。只要是你用了心，就能够在任何一个点上进行更深入的学习和工作实践。有了这样的心态，工作和学习就不会冲突，那你也就可以利用周末的时间去放松自己，去享受自己的兴趣，而不会感觉过于疲惫。

工作和学习都是急不来的。有时，停下来多想想当前什么是最值得去多做和更深入学习的，可能会收获更多。不妨试试慢几步，做到深几度。

知识太多，学习时间却有限。我们经常面临的一个误区是，学习时很容易陷入大量知识细节中无法自拔，从而看不清知识间的内在联系、原理和方向。但，一旦你理清了，你就能够判断出什么是最紧急的事情，进而用最大的努力去把最紧急的事情给做漂亮、做到最好。

关于团队合作，我觉得最重要的就是达成对技术方向与工作重心的共识。不断地重复灌输，只说达成共识的事情，对没想清楚的事情，要多思考，等想清楚了再说。这，也就需要识别出当前所做事情是不是重要而紧急的。

我认为比较失败的情况就是，团队成员对所做事情的看法不一，都认为自己做的才是重要的，结果就是多任务并行造成了资源浪费，无法合力完成重要且紧急的事情。其实，懂得取舍才是团队的核心。

多从业务的视角去考虑，考虑投入收益比，千万不要理想主义，为了优化而优化，导致业务做了一大堆，却收效甚微。所以，团队合力做事情时也不能着急，要停下来想想做什么对团队最有益，再合力做深、做透。

最后，想和你说说我对这个专栏的后续规划。

虽说更新了结束语，但专栏内容还没完全结束。在接下来的时间里，我还会更新些内容，可能是配图，也可能是文字，也可能会做一些加餐和你分享些未来得及写的内容。不过，我需要你再耐心等待一下，也希望你在等待的过程中，可以持续学习专栏内容，并多动手实践。

另外，你可以关注我的微博 @戴铭和博客，我会在上面继续记录我的工作和兴趣。

在这里，也祝你能够在工作中取得自己满意的成绩，生活丰富有趣。与君共勉！