随着业务的不断迭代和功能的逐渐增多,或是业务的需要,APP 在体量上来之后或多或少都会遇见性能问题。

笔者所在的直播技术团队就遇见了在很大的直播间里,有着持续的大量的广播发送到客户端,而大量的广播会造成 UI 的更新或者动画的展示造成的性能问题。本篇博客就主要记录了我对所遇到的性能问题的思考和解决办法。

性能问题的发现和定位

一般而言,除非是性能比较敏感的页面,我们在开发中是不会去着重的考虑性能问题,但是随着功能的迭代,原本之前简单的页面变的复杂,多出了动画或者比较多的视图层级和特殊效果,逐渐就会变得笨重。除此之外就是本来没有多少问题的页面,在某些场景下就会有很严重的性能问题。

解决这些问题的第一步就是发现问题。那么,如何做到定位和发现呢?

卡顿检测工具

对于卡顿检测工具,一般来说有两种实现方案:

  1. 主线程卡顿监控。在子线程通过判断主线程的runloop状态来确定是否为卡顿
  2. FPS 监控。原理是计算屏幕刷新的两次间隔,过大时判断为卡顿。

两种方案抖动都比较大,但都具备参考意义,同时可以分析其他参数比如 CPU 和 内存的占用来判断是否是真正的卡顿。

压力测试

卡顿检测工具只能发现常规的性能问题,但是对当只有特殊场景下才会出现的问题就无能为力了。这个情况就需要人为的模拟这些特殊场景。

直播 APP 的业务上有个特点:用户送的道具动画要足够酷炫(毕竟是花了钱的);用户发送的弹幕要尽可能的展示,因为这是核心体验。这两个功能在很大的直播间下性能问题会被放大,因为平时开发中很少会遇见大量且持续的道具和弹幕。

压力测试可以模拟线上一些大活动或者直播间的场景,造成海量弹幕和道具动画,这个时候去分析和检测,可以很轻易的发现各种平时隐藏的小问题。

Instruments 分析工具

上面两个主要讲的是如何发现问题,那么当发现问题之后就需要去定位这些问题,才能再进行解决。

利用官方提供的性能分析工具,Leaks 和 Allocations 可以分析内存相关的问题;Core Animation 分析 UI 相关的问题;最常用的就是 Time Profiler,用来分析CPU 占用和方法耗时来定位卡顿问题。网上相关的资料也比较多,这里就不展开来说了。

问题的治理和优化方案

ibiremeiOS 保持界面流畅的技巧 一文中也有提及 CPU 和 GPU 分别的资源消耗和解决方案。基本上性能问题的治理就是避免大的资源消耗;空间换取时间;让 CPU 和 GPU 做自己擅长的事情。

先简单说下我实现的方案,对于弹幕这个性能大户,我采用了基于 CoreText 排版技术来计算图文混排中的各种尺寸和位置,然后在异步绘制这些文字和图片,最后生成一张位图在主线程去显示。详细可见 demo WDText,基本上参考借鉴了 YYText 和 AsyncDisplayKit 的思路。所实现的排版和渲染框架的核心思想只有一个:对于一条弹幕UI,尽可能的将内容放到后台线程去排版绘制,所有非文字UI都抽象成 Attachment,扩展了 DrawAble 能力之后便支持在后台去绘制。这样可以减少主线程的耗时之外也可以大量的减少视图层级。

减少视图层级 && 手动计算 frame

Time Profiler 中发现 masonry 的方法耗时极高,最后定位到了 cell 上的控件居然是使用的自动布局,究其原因是早期业务使用自动布局也并没有性能问题,随后慢慢的迭代为了保持统一也是继续使用了自动布局技术。

cell 中有多个 image view 组成一个弹幕背景,也有用户的等级、头衔和身份等信息视图,这些 background images 预先在后台线程预先合成一个 image,这样对应的 background image view 也可以减少到一个。

手动计算 frame是一个很简单但是在长期的业务迭代中会积少成多的一个问题,而且当视图足够复杂时,用手动计算frame来替代 autolayout 也是一个很繁琐的任务。不过好在,我在尽可能的减少了视图层级之后,需要计算的 UI 控件由十数个减少到了三个,也算是意外之喜了。

预处理

这块所做的工作主要是业务接收到广播之后,在后台线程去转换数据,使用 CoreText 去预排版,计算该弹幕所占的位置和大小。同时计算富文本弹幕中的图片位置。并把这些数据存放在 cell view model 中,也变相的缓存了 cell 的高度。

预渲染

弹幕中的用户等级信息之类的信息有着不规则路径和背景填充,之前的实现方案是磨切圆角和 draw 来实现。分析发现性能问题之后,直接放入后台去预先绘制成一个 image 再显示,至于如何在后台绘制显示一张图片,后面会提到。

异步绘制

为了更好利用多核多线程的优势,把繁重的渲染任务扔到后台线程去执行,绘制为一张位图之后设置给 layer 的 contents 属性,大体逻辑如下,真实场景会增加一些条件判断:

1
2
3
4
5
6
7
8
9
- (void)_display {
dispatch_async([self _queue], ^{
CGContextRef context = UIGraphicsGetCurrentContext();
// 开始绘制
dispatch_async(dispatch_get_main_queue(), ^{
self.contents = (__bridge id)(image.CGImage);
});
});
}
减少线程数量

性能问题治理完之后,压测的表现情况并不乐观,查看日志有较多的如下输出:

1
2
3
4
5
Event:           wakeups
Wakeups: 45003 wakeups over the last 240 seconds (188 wakeups per second average), exceeding limit of 150 wakeups per second over 300 seconds
Action taken: none
Duration: 239.91s
Steps: 0

最后恍然大悟,发现是线程过多导致。原因就是 Socket 数据的解析,序列化,以及到各个业务层的分发全是使用 GCD 来分发,参考了 WWDC-Modernizing Grand Central Dispatch Usage 的优化指南之后通过设置 target queue 来限制了线程的并发。

降级

没有什么问题是降级解决不了的,如果有,那就再降一次

降级是一个见效很快,但也是最后不得已而为之的手段。对于高频次的性能问题可以通过在线配置限制速率来实现降级,具体到直播业务中就是限制广播的分发速率。对于动画等性能大户可以是动画效果降级使用低配版本的资源。

这些都可以快速的解决问题,但是是以降低用户体验为手段来实现的,是不到最后基本不会采用的方案。

优化结果确认

性能问题不能一拍头脑想当然的来优化,优化之后也需要确认是否有效果。FPS 是否提升了?提升的同时 Memory 和 CPU 的占用是否也跟着提升了,提升了多少?是否可接受?这些都需要在考量之中。我们就需要分析优化前后的几个重要指标来衡量。一般而言有三个基础指标:FPS、Memory 和 CPU usage。我简单的实现了一个小工具,来分析和导出这三个指标数据,详细见 demo GGWatcher

总结

优化的主要工作点:

  • 性能问题定位和分析
  • 缓存视图位置和尺寸,cell 的高度
  • 减少视图层级
  • autolayout 改为手动计算 frame
  • 多张图片预先合成一张
  • 实现排版框架支持后台线程异步排版和渲染
  • 限制线程并发
  • 优化效果确认