iOS 性能优化 - 被压测卡爆的语音房间

背景

某天收到通知,有人气大主播要做语音房间活动,需要做质量保障工作。

因为房间已经借鉴了之前做 IM 的预排版经验,加上 iPhone 机器本身性能都不错,我以为稳如老狗…

然而 iPhone6 Plus 测试机随着压测数据上升到每秒上百条,直接卡爆了,整个屏幕没有任何响应。

问题定位

相信很多开发者都看过性能优化的文章,然而我们现在需要对症下药

按着文章一顿操作猛如虎,低头一看零杠五。

关键问题的定位,并不是照猫画虎就能解决的,特别是在我们已经做了一些常规优化设计的情况下。

CPU or GPU

首先确定是属于哪种原因卡顿:

  • CPU 卡顿

  • GPU 卡顿

我们可以利用工具观察卡顿发生时 CPU / GPU 的情况,Xcode 也自带这些功能。

安利一下滴滴团队的 DoraemonKit ,集成了不少提升效率的开发工具,比如查看视图层级,网络请求,以及 CPU 变化等等..

因为工程本身已经有了,我直接用它来观察 CPU 变化情况,方便的一匹。

再次进行压测,CPU 的变化曲线一下子就升到 100%,肉眼可见的秒爆了。

其实 CPU 消耗大还有一个特征就是发热,但我们要用数据说话,做业务的抓手是什么?数据 : )

而关于卡顿发生的原因,这篇 《iOS 保持界面流畅的技巧》是非常推荐阅读的,值得反复学习,确实是佳作。

哪些操作造成 CPU 卡顿问题?

既然已经定位到了 CPU ,那么是不是就马上找到答案呢?

答案是否定的,我们离问题仍然还有一段距离。

如果直接按照造成 CPU 消耗问题的操作去搜索答案,不外乎是下面这七项:

  • 对象创建
  • 对象调整
  • 对象销毁
  • 文本计算
  • 文本渲染
  • 图片解码
  • 图片绘制

实际上,真的一行行代码查看过去,你会发现几乎每行代码都被囊括在这七项当中:对象创建/调整/销毁,文本计算/渲染,图片/解码/绘制。

到底哪里一行才是真正的问题代码,接下来该怎么办呢?如果是有十分丰富经验的工程师,或许能一眼扫出问题代码所在。

如果不是大佬的话,显然我们还是需要一些方法。

整理业务流程

就像打仗一样,知己知彼才能百战百胜,我们要对问题的业务情况做掌握。

先整理出问题场景的业务流程,详细了解整个过程。

整理业务流程,比较建议的方式是画图,能直观看到业务里各部分的关系,处理流程,以及数据流向。

对于大而复杂的模块,大多数时候是很多人都经手或共同维护的。这时候一定要学会和团队成员合作,找来每个部分最了解的人,能尽可能快的完成整理的步骤。

针对这次出问题的语音房间/直播房间场景,就可以先做大的分类:礼物消息(带横幅动画,全屏动画等),图片消息,文字消息等几个大类型。

再分别对大类型做梳理,例如文字消息类型的大概流程:

当然上面的图只是一个比较粗略的例子🌰,真实场景里,光是 RoomChatHandle 里的处理流程都不少。

整理流程是理顺业务的好机会,同时也能发现一些设计问题:如本应是上下层的关系,却产生互相依赖。如果将业务关系画图表示,就可以很好的暴露出来。

总之,先把自己的业务思路理清。

控制变量法

整理完业务情况后,就可以开始真正动手了。

建议使用控制变量法,更通俗具体的说,就是排除法!

其实就是最简单的 Debug 方式了…大家只要中学做过一点实验,或者平时会 Debug 的应该都懂了。

例如:

1
2
3
4
5
a=白色
b=白色
c=蓝色
//实际预期 d=白色
d=a+b+c=蓝色

那么这时分别去除一次 a/b/c 的影响,最后发现:

1
2
3
d=a+b=白色
d=a+c=蓝色
d=b+c=蓝色

就很容易知道 c 这个变量出问题了,影响了我们的预期结果。

办法比较原始,毕竟最高端的食材往往需要最朴素的烹饪方法,高端的问题只需要最朴素的 Debug 功夫!

利用控制变量来发现问题,确实也很像做饭,找问题的速度在于掌握火候,也就是粒度问题:

每次做排除的是一行代码?还是1 个方法调用?还是好几个方法调用?

通过逐渐缩小问题范围,然后一步步推进。

说起来很轻松,其实整个过程也是比较枯燥,越复杂越大的模块越辛苦。

最终发现了问题的关键代码:

1
[tableView reload]

对于原来的方式来说,来 1 条数据就会去刷 1 次 UI,1 秒 100 条数据就刷 100 次 UI。刷新频率过高,服务器来的数据很多,那么刷新的频繁,负荷太大直接造成卡死。

解决问题

定位问题之后,就可以针对性解决,目前核心问题在于:

短时间内 UI 刷新次数过多,造成 CPU 压力过大

那第一步要做的就是控制频率

控制频率

很显然目标是尽可能减少刷新次数,不过仍然涉及到 2 个问题:

  • 控频策略
  • 取阈值

关于控频策略,在 《iOS 上的函数防抖与节流》 有过说明,主要就是 Throttle / Debounce:

  • Throttle ,单位时间内只执行一次方法。
  • Debounce,单位时间内只要有方法调用,就再等一个周期,直到没有新调用,则执行方法。

由于是刷新方法,如果消息一直不停过来刷,按照 Debounce 的方式,很可能会一直被推迟执行,UI 也就得不到刷新了。所以在控制频率上选择 Throttle 的话是比较合适的,单位时间内执行一次。

经过实验,如果普通文本消息类型修改为 1 秒 1次 ,CPU 直接就减少了 30-40% 的压力。

高/低性能机器区分

通过控制刷新频率,已经非常有成效,然而粒度还是太粗了。

出现卡死的机器都是低性能机器,对于高性能机器的话用一样的策略就不够好了。

对于高性能机器要充分利用,适当放开限制数据处理,提高用户体验。

所以可以用更精细的阀值来测试,对高性能机器选择合适的值,甚至部分强大的 CPU 可以放开管控,在 CPU 使用率到达一定临界点开启控制策略。

数据的分发&处理

批量分发

虽然已经解决了主要问题,但发现原来的分发策略也比较直接:

来 1 条数据,就直接传递 1 条,来 100 条数据就抛 100 次。

对于数据做一个合并分发的策略,或者说是批量分发,例如:

0.5 秒内,只来 1 条数据就传递 1 条,如果连续来 100 条,就等到最后一次,直接全部发出。

这样也能大大减少分发的成本,将多次合并为 1 次,和上面控制 UI 刷新是相同的思想,不过现在是数据层面做控制。

淘汰策略

批量分发还存在一个问题:

因为批量分发数据,中间到的数据会被存起来。

如果到的数据太多,比如我们 1 秒处理 300 条,但是来了 500 条,随着时间过去,就会不断积压消息,甚至引发新的内存问题(OOM)

为了避免出现积压过多的消息,我们还需要做一个 淘汰策略 ,简单的说,就是丢弃一部分数据。

丢弃不必要的消息,就涉及到产品上的问题,需要去与产品或者运营同学确认,哪些是不重要的:比如进出房间消息,普通聊天消息…

当然我相信也有什么都不愿意舍弃的产品:全部都给我一次性刷出来!

技术的提升,往往来源于业务的推动,变态的产品会对技术提出更高的要求。

异步多线程处理

原来处理数据也没有使用异步多线程,所以关于数据的处理,还能再做一次异步处理优化。

异步多线程处理,利用 GCD 就可以了。

看起来简单,其实也有坑,不断的开线程也会产生线程爆炸的问题,线程太多不一定就好

而且机器的核数也是有限的 ,真正能利用起来的线程数量也有限。简单的说,如果机器是双核的,那么我同时 2 个线程, 处理数据就很舒服,如果开 4 个线程,其实就是通过线程调度,来回不断的切换任务,并非真正的多线程。其实就是 并发并行 的区别。

我们可以直接站在前人的肩膀上,使用或仿照 YYKit 里的 YYDispatchQueuePool ,将处理数据的任务放到某一个需要的优先级队列上。

除了线程数量之外,还要注意处理死锁的问题。

进一步思考

做完上面的优化后,不仅是 iPhone6 Plus ,甚至 iPhone6 面对狂风暴雨的服务器消息也毫无波澜。

在此基础上继续思考,如果数据量不断增大,出现我们预想的坏情况:剩下的重要消息,也超过我们的极限,例如全部都是 500 元以上的礼物消息,不能丢弃,全部展示,我们该怎么优化?

协程

在处理数据上,有没有比用多线程处理更好的方案呢?那可能就是协程了。

查看阿里巴巴的 coobjc 这么介绍协程对于多线程的性能优势:

  • 调度性能更快:协程本身不需要进行内核级线程的切换,调度性能快,即使创建上万个协程也毫无压力
  • 减少卡顿卡死:协程的使用以帮助开发减少锁、信号量的滥用,通过封装会引起阻塞的 IO 等协程接口,可以从根源上减少卡顿、卡死,提升应用整体的性能

异步绘制

在 UI 显示上,再更进一步,采用异步绘制的方式,如 YYAsyncLayer 或者 Texture 都是可以借鉴的。

由于我们本身也使用了 YYLabel,开启异步绘制后,会空白一阵后再显示,比较明显的滞后显示体验并不是太好,建议还是极端情况再选择性的开启。

这也是一种性能与体验的取舍问题。

总结

虽然是一篇关于性能优化的文章,仍然要说一句:过度过早的优化是万恶之源。

例如提到的多线程和异步绘制,可能优化后会带来别的体验问题。避免多线程问题的一个最好办法,就是不使用多线程。

进行性能优化,更多的是得到查问题/改问题的经验,也掌握了一些方法与思考。

最重要的是找问题的过程,往往可能就一行或者几行代码导致了影响,定位到却要花费不菲的时间。

希望大家有更好的思考和经验多多交流!