背景
某天收到通知,有人气大主播要做语音房间活动,需要做质量保障工作。
因为房间已经借鉴了之前做 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 | a=白色 |
那么这时分别去除一次 a/b/c 的影响,最后发现:
1 | d=a+b=白色 |
就很容易知道 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,开启异步绘制后,会空白一阵后再显示,比较明显的滞后显示体验并不是太好,建议还是极端情况再选择性的开启。
这也是一种性能与体验的取舍问题。
总结
虽然是一篇关于性能优化的文章,仍然要说一句:过度过早的优化是万恶之源。
例如提到的多线程和异步绘制,可能优化后会带来别的体验问题。避免多线程问题的一个最好办法,就是不使用多线程。
进行性能优化,更多的是得到查问题/改问题的经验,也掌握了一些方法与思考。
最重要的是找问题的过程,往往可能就一行或者几行代码导致了影响,定位到却要花费不菲的时间。
希望大家有更好的思考和经验多多交流!