理解和消除 App 中的卡死
作者:Rickey 王小吉,字节跳动直播中台,一个苹果中毒的程序员。欢迎踩踩主页:https://github.com/RickeyBoy/Rickey-iOS-Notes
审核:Damien, 老司机技术周报编辑,TikTok iOS 工程师
目录
本文将通过四个部分,让开发者理解并消除 App 中的卡死问题。
一、什么是卡死
当用户触摸了屏幕,但几秒钟之后 App 才有响应,那么这种情况就被称作卡死,换句话说也就是未响应、响应迟缓等。任何 App 都不会想给用户卡顿的体验。
为了理解卡死,我们需要先知道什么是主线程 runloop。如下图所示,每一个 App 都会依附一个主线程 runloop,它就是一个不会停止的循环,App 底层会在这个循环里不停地对事件进行响应,处理源源不断的外部事件,从而响应最重要的用户操作。
当用户与 App 进行交互时,App 会经历接收事件、处理事件、(如果有需要的话)更新 UI 这三个阶段,这样三个阶段的事情都发生在主线程 runloop 的一个循环之中,连续响应到每个用户触发的事件。
而如果处理事件的时间过长,那在接收事件和更新 UI 这两个事件直接就会产生延迟,甚至还会造成主线程任务堆积,阻塞后续事件的响应。在卡死阶段触发的后续任务将无法被响应,直到第一个造成卡死的任务结束,与此同时后续任务也将会增加这个卡死的时间,造成恶性循环。
总的来说延迟超过一秒就会让用户感觉到卡死。不过有些情况下一次小延迟相对容易接受,比如对于 0.5 秒的延迟,如果出现在列表滑动阶段那么用户就会觉得异常反感,但如果在页面跳转过程中发生一次,就没那么容易察觉。
二、造成卡死的原因
当主线程上有超出其负荷的任务被执行时,就会发生卡死,此时的卡死可能包含下面两种情况:
主线程本身处于忙碌状态,而造成卡死,有可能是处理单个长任务,也有可能是多个连续短任务。 主线程被其他线程的任务阻塞,或是被系统资源阻塞。
接下来让我们分情况来具体讨论。
主线程被卡死
主线程本身处于忙碌状态,而造成卡死
执行多余的前置任务
我们先来举例看下最常见的第一种情况。通常在前置执行任务时,有可能会执行一些多余操作,从而导致主线程卡死。
比如在上面这个官方演示的 App(Deserted) 中,页面只平铺展示 4 张食物配料图片,因此只需要加载四张图片即可,而不是加载全部图片。如果在进入这个页面的时候一次性加载所有图片,每一张图片都会进行消耗大量时间,从而造成主线程卡死,而实际上绝大部分的时间消耗都不会影响页面展示的内容。
执行不相关操作
另一个常见造成卡死的原因,是在主线程执行了其他派发队列中不相关的任务。主线程是串行执行,所以不仅会执行主线程队列里的 block,同时也会执行其他队列里同步执行的 block。所以其他队列中派发到主线程同步执行的 block,就有可能阻塞主线程后续的任务。
而当主线程向存储队列派发了一个同步执行的 block,那么主线程上接下来的操作,就都需要等待存储队列上的任务执行完毕,才能继续执行。这样一来,实际上主线程上大部分的时间都被无谓的浪费了。
如下图所示:App 中一些低优先级的队列,比如一个存储队列(maintenance queue),如果它的任务(maintenance work)被派发到主线程上同步执行,那么这种耗时长却低优先级的维护任务,就会造成主线程卡死。
类似的情况还有:如果存储队列向主线程派发了一个同步执行的 block,那么主线程也必须要等待这个 block 执行完成才能继续。
没有使用合适 API
没有使用合适 API 也是常见造成卡死的原因之一,实现同样的效果有多种不同的路径,所以一定要熟读 API 文档,确保使用合适的 API。
以演示 App Desserted 为例,它给图片加圆角的方式,是通过基于 bitmap 的 UIGraphics 方法,将图片转换为 bitmap 之后,再用圆角矩形的贝塞尔曲线进行裁剪,最后将裁剪后的 bitmap 转换回图片格式。
这一系列的操作会导致 CPU 负载过重,耗时长且消耗大量内存。这是因为使用了错误的系统硬件,这里应该利用 GPU 而不是 CPU:通过使用 CoreAnimation 中 layer.cornerRadius 属性和 masksToBounds 属性,就能轻松且高效地给图片添加圆角。
主线程被阻塞
主线程被其他线程的任务阻塞,或是被系统资源阻塞
错误使用同步 API
使用同步 API 会阻塞线程上后续任务的执行,从调用开始阻塞直到 API 返回结果。如果这类方法内部进行了大量的工作,或者有可能延时返回,那么它们就不应该在主线程上被使用,因为它们可能导致延迟,以及会增加失败的几率。
一个典型的案例是在主线程进行同步的网络请求。对于那些使用 5G 网络的用户,这样可能不会有任何延迟,但是网络条件差一些的话,请求就可能会花更长的时间。对于那些信号很差的用户,有可能就会一直卡死了。谁也不能保证网络请求的耗时长短,所以这类的同步操作都应该避免在主线程执行。
File I/O 受限
另一种主线程阻塞的情况是被系统资源限制了,因为系统资源经常会不够用。File I/O 文件接口又是最常用且最容易资源不足的系统资源,因为文件接口有很多不确定因素,比如系统硬件性能,以及可能其他 App 也同时在进行读写操作。因此 App 需要尽量避免这些影响因素,比如避免在主线程使用 I/O 接口。
不支持并发的数据存储尤其容易产生问题。当主线程读取一个正在进行写操作的数据,由于不支持并发,那么这个读取操作就会被延迟到写操作结束,在写操作以及读操作完成之前,App 都无法响应。
同步操作阻塞
同步原语(synchronization primitive)会阻塞读写任务执行,因此要减少在主线程上进行同步,即使要用也需要非常小心。进行同步操作的线程,通常会加锁后长时间才会将锁释放,不论是隐式锁还是显示锁。
下面有一些常见需要注意的同步原语:
尤其需要注意信号量的使用,因为信号量不使用优先级策略,因此信号量的抢占有可能导致更长时间的卡死。一个常见的错误如下图所示:想通过信号量之间的等待,使异步方法串行执行。一定要在主线程中避免这种操作。
重复获取不变量
还有一种阻塞主线程的原因,是花费大量代价去不断获取一些不经常改变的数据。
比如官方演示 App Desserted 中的这个代表社交功能的按钮,只有当我拥有通信录好友的时候才进行展示。我们可以每次都获取一下通讯录中的联系人,从而判断是否拥有好友。但这样做会增加很多额外开销和延迟,因为主线程调用通讯录的相关框架,会在底层重复大量操作,产生高昂的开销。更何况需要获取的联系人信息并不经常变动,因此没必要频繁的获取联系人,这只会增加系统资源的压力。
系统资源受限
卡死:主线程过度使用,导致系统资源受限。
系统资源包括 CPU、内存、存储空间等的状态都对卡死有较大的影响,现实情况下千差万别的设备硬件情况,和开发时本地测试遇到的零星情况大不相同。所以我们应该尽量去预防这样的情形,比如使用自动化测试,或者以最古老的设备进行压力测试。
总的来说,造成卡死的原因就是主线程被过度使用了。因此为了保证良好的性能体验,我们需要让主线程尽量只去做一些 UI 更新必须的任务。
三、如何分析卡死原因
了解了常见的卡死原因之后,让我们来看看有哪些有用的工具可以对 App 中的卡死来监控和分类。
System Trace
进一步了解:System Trace in depth - WWDC16
为了对卡死进行分类,通常首先要了解 App 正在做的任务。而在 Instrument 中的 Time Profile 这个工具可以清楚地展示当前 App 的调用栈信息,能精准地分析出正在执行的任务信息。而 Instrument 中的 System Trace 工具能够展示更多信息,包含系统调用、分页错误、I/O 接口信息等,甚至包括进程内以及进程间的合作情况。
接下来我将演示使用这两个工具,来分析官方示例 App 中的卡死原因。在使用 System Trace 对 App 进行了分析之后,Instrument 界面大概会这样显示成这样:
标记 1 的地方,system trace 输出的红色细线条代表系统方法的调用 标记 2 的紫色条形图代表虚拟内存的分页错误 标记 3 的水平蓝条,代表主线程在忙碌状态 标记 4 的位置,可以选择想要查看具体的调用信息 Instrument 会展示这 4.7s 的卡顿内主线程的调用信息,标记为 5 的这部分内容说明了 loadAllImages()
导致了其中 4.6s 的卡顿。这就是前文所说的问题,实际上官方示例 App 中加载了过多多余的图片,导致了卡死。
MetricKit
进一步了解:What's new in MetricKit - WWDC20
一旦你的 App 发布之后,就可以通过 MetricKit 来收集卡死时的调用栈了,这样能让你发现哪些卡死的调用栈是用户更容易命中的。我们现在来看一个例子:
MetricKit 同样也会统计卡死时的调用栈,呈现出的调用栈和 Time Profile 中的很像。通过分析调用栈,我们发现这个卡死和我们刚才分析的卡死不一样,这个卡死是新加的社交功能导致的,由于一次性获取了所有联系人而阻塞了当前队列。而如果不是使用 MetricKit,我可能永远也不会发现这个卡死问题。
Xcode Organizer
可以通过下面两个 session 来了解 Xcode Organizer 的更多信息:
Diagnose power and performance regressions in your App - WWDC21
Improving battery life and performance - WWDC19
在修复卡死问题时,能够量化 App 的整体性能情况是非常重要的一点。而 Xcode Organizer 工具能够展示性能表现相关的数据,包括能展示各个版本应用的卡死率的图表,这对于我们分析 App 用户流失的原因很有帮助。
四、如何消除卡死
那么现在我们来了解一些常见解决 App 卡死问题的策略。同时需要记住,每一种策略都能解决一些卡死问题,为了找到最合适的解决方案,你必须要了解这些方案的副作用并进行抉择。
减少主线程上的工作
减少主线程上进行的工作总量,能够有效地消除并预防卡死问题。为了达到这个目的,我们通常有两个方法。第一个是优化主线程上任务的效率,从而减少总时间。第二个方法是将一些任务从主线程移除,改用一些不会阻塞主线程的方式进行,从而保证主线程能及时响应。
使用缓存
对于经常使用的资源,采用缓存策略是一种很好的方式。缓存通常是一种存在于内存中的,如果需要多 App 同步的话也可以持久化到硬盘空间。有可能被用到的固定资源很适合使用缓存,比如 Desserted 中的那些配料图片,因为如果每次用到时都去创建就会有大量开销。
通过使用 NSCache 缓存,每次创建图片的大量开销就能被简化为内存的访问,这样就能消除我们之前通过 Instrument 分析出,因为过度加载图片而导致的卡死。
不过重要的是,需要有一个准确的缓存检查机制,在维护缓存的增删,保证缓存内容总量的平衡。这些操作通常会异步地在另一个线程上进行,从而保证主线程能够及时响应其他事件。
添加观察者
添加观察者来监听通知的方案也能减少主线程工作量,这种方法能针对数值或者状态进行监听,从而避免昂贵的计算量。任何类都能够发出通知,甚至是自定义的类,查看某些类的 API 说明文档就可以找到相应的事件通知。可以通过 Apple developer 官方文档中的找到 NSNotification.Name,也就是可监听的系统通知名称。
而 Desserted 中的社交按钮,就是一个很好的应用示例:通过注册对 abDatabaseChangedExternally 通知的监听,主线程不再需要等待获取联系人列表的过程了,只需要等待通知发出、观察者进行响应就行了。而在 Desserted 的例子中,收到通知后将会更新一下缓存的联系人列表。而这些更新操作也需要放在另一个异步线程中,这样保证主线程不被卡死。
转移主线程上的工作
核心:主线程只负责足够重要的任务
将主线程上的工作就行转移,给主线程减负,这也是一个好方法。那究竟哪些工作该在主线程上执行呢?
只有足够重要的任务,也就是为 UI 展示提供支持的任务才应该在主线程上执行。并且所有视图和视图控制器,他们的创建、修改和销毁也都应该在主线程上执行。
而用于更新 UI 元素的计算过程,就可以从主线程转移到其他线程上,只需要在计算结束时在主线程执行真正的 UI 更新操作即可,这种模式非常适合与计算耗时很长的任务。一些不太重要的任务,或是对时间不敏感的任务,都应该像这样转移到其他线程上异步执行。
异步 API
将任务从主线程转移到异步线程,使用异步 API 是最直接的方法。我们以网络请求为例,通过使用 NSURL 类的异步 API,我们就能让主线程在网络请求期间保持可以响应:
通常来讲,异步 API 名字都会包含 "asynchronously" 单词或者其缩写,而 API 的回调方法名通常包括 "completion",非常好辨认。
GCD 方法
进一步了解:Modernizing Grand Central Dispatch - WWDC17
GCD 是一个功能强大的多线程架构,能够方便地实现异步操作。GCD 提供了非常简单的接口,可以将任何 block 从主线程移至子线程,既可以同步执行也可以异步执行。因此,使用 GCD 方法能够有效地解决绝大部分的卡死问题。通过使用 GCD 的异步调用方法,将 block 转移到另一个线程执行,就能保证主线程不被卡死;同时在完成后调用 completion 方法,再回到主线程执行必要的方法即可:
GCD 也能轻松实现预加载逻辑。将预加载逻辑绑定在一个线程(比如 prefetchQueue)上,再异步执行预加载 block,就能在保证主线程不卡顿的情况下执行预加载任务。而当主线程需要预加载任务的结果时,使用 sync 方法在同一个线程(prefetchQueue)上串行执行后续逻辑即可。
理解交换的代价
以上说的这些方法,本质上来讲都是一些交换,能解决卡死问题的同时也有可能导致其他问题。
使用缓存:空间换时间。使用缓存时需要考虑内存的过度增长,必须要保证缓存的清理机制是有效的。 添加观察者:通知可能被频繁触发,因此在监听通知的时候最好先加一些过滤条件,避免额外的操作,减轻 CPU 的负担。 异步 API:必须要理解哪些操作可以异步执行,特别是直接涉及 UI 更新的任务一定不能放到异步线程,因为异步线程的优先级不高,不会优先执行。 GCD 方法:使用 GCD 的代价就是你必须要调整代码执行的顺序,因此你需要时刻保持清醒,避免程序出错。使用 sync 方法来保证执行顺序也是一个不错的选择。
不过考虑到这些方法能够有效解决卡死问题,这些交换一定是值得的。
一些其他建议
尽量使用 Apple 的框架以及接口,因为它们已经针对所有苹果设备都进行了有效地优化,以及会保持不断地更新与优化。 不断迭代优化代码。可以不断制定小的优化目标,看到每次优化的效果,再积少成多。 合理地使用系统资源。过度使用资源会不仅仅降低 App 的性能体验,也会导致整个系统的卡顿。
五、总结
卡死问题是导致用户体验的最大问题之一,当用户遭遇卡死时很可能就退后台杀进程了,这会导致非常高的用户流失率。所以一旦涉及到卡死,一定是需要较高优先级解决的。
通过本文的学习,在我们知道理解原理、善用工具的情况下,分析并解决卡死问题就变得容易起来。同时希望本文也能起到抛砖引玉的作用,毕竟真实的卡死情况更加复杂,还有很多需要进一步探索的地方。最重要的是能理解导致卡死的原理,以及学会解决卡死问题的基本思路,遇到问题才能迎刃而解。
关注我们
我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号。欢迎关注。
关注有礼,关注【老司机技术周报】,回复「2021」,领取 2017/2018/2019/2020 内参
支持作者
这篇文章的内容来自于 《WWDC21 内参》。专栏一共有 102 篇关于 WWDC21 的内容,如果你看的还不过瘾,可以点击【阅读原文】继续阅读哦 ~
WWDC 内参 系列是由老司机牵头组织的精品原创内容系列。已经做了几年了,口碑一直不错。主要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实际开发经验、苹果文档和视频内容做二次创作。