Scroll Hitch Rate
Xcode 12增加了一个新的指标,可以客观地跟踪你的应用程序滚动的流畅程度。
- Scroll Hitch 指的就是滑动界面时,已渲染的图像帧没有在预期的时间点从当前屏幕上移除。它通常会导致下一帧图像延迟出现或者被丢弃从而表现出卡顿和抖动。
- Scroll Hitch Rate 将图像帧延迟显示在屏幕上的时间总和,除以用户滑动屏幕的持续时间 ,可以得到一个比例,它可以反映用户感受到的滑动卡顿的严重程度。
- 90th percentile 与 50th percentile 百分位是一种统计学上的测量方法,90th percentile表示在90%的滚动事件中,卡顿率低于或等于这个数值。本文所关注的也是90th percentile下的Scroll Hitch Rate。
我们可以通过Xcode—Organizer—Metrics—Scrolling来查看app的Scroll Hitch Rate。
利用Instrument定位卡顿代码
利用 Instruments—Animation Hitches 定位卡顿代码
选中某个Hitch,切换到Time Profiler,找出耗时代码。
在主线程绘制图片
[ImageUtils applyImage:withAlpha:], 这个方法修改图片透明度并重绘,耗时较多(平均65ms左右),保持流畅的效果需要16.7ms刷新一帧,如果在主线程当中调用此方法,必定会有丢帧。因此类似的代码应该放在异步子线程当中去做。
imageWithContentsOfFile的大量使用
在工程中,一部分的图片已经做成网络图片,客户端在使用的时候需要下载并保存在本地磁盘。之前读取这些图片的时候使用的是imageWithContentsOfFile,即使一张图片多次使用了,仍会每次都从磁盘读取它,这无疑是极为耗时的。因此,我们应当添加memory cache,以减轻这部分开销。
可以通过SDImageCache实现,或者通过类似以下的代码实现。
+ (id)getMemoryCache:(NSString *)fileName {
return _memoryCache[fileName];
}
+ (BOOL)hasMemoryCache:(NSString *)fileName {
return [self getMemoryCache:fileName] != nil;
}
/// fileName use for key
+ (void)cacheImageIfNeeded:(UIImage *)image forFileName:(NSString *)fileName {
if ([self shouldCacheImage:image] && fileName.length) {
if (_memoryCache.allKeys.count >= LimitMemoryCacheCount) {
[self deleteAllMemoryCache];
}
_memoryCache[fileName] = image;
}
}
+ (BOOL)shouldCacheImage:(UIImage *)image {
if (image == nil) {
return NO;
}
return image.size.width < 300 && image.size.height < 300;
}
+ (void)deleteMemoryCache:(NSString *)fileName {
if ([self hasMemoryCache:fileName]) {
[_memoryCache removeObjectForKey:fileName];
}
}
+ (void)deleteAllMemoryCache {
[_memoryCache removeAllObjects];
}
首页优化
首页是由UITableView实现的,在进入时会并发许多数据接口,每个接口返回结果时都会调用reloadData,因此reloadData中不要做过多工作。之前每次reloadData的时候,会重新创建tableHeadView,这并非是必要的。因为我们有时reloadData时,仅仅只需要更新部分UI,或者仅仅调整了布局。优化的方法 :
- 将tableHeadView的初始化、更新和调整布局的代码分开,在创建tableView的时候初始化tableHeadView,reloadData内部仅仅做更新和调整布局。
- tableHeaderView是由多个复杂subView组成的,如果每次更新都刷新所有的subView那也是比较耗费时间。因此需要在subView的update方法内部添加shouldUpdate判断,判断新的model与原有model的数据是否相同,若相同则无需刷新。
- 在iOS12之前,auto layout有性能问题,尤其是使用了嵌套auto layout。而在结构复杂的view中嵌套auto layout是很常见的,因为应尽量少用auto layout,可以自己写Frame布局。 在iOS12之后,苹果优化了auto layout算法,性能已经跟直接写frame布局相当了。
从 Auto Layout 的布局算法谈性能
iOS12 Auto Layout 的春天
TableView Cell滑动优化
UITableView是造成不良Scroll Hitch Rate的主要原因之一。因为UITableView的cell复用机制,需要cell根据不同的model去频繁地刷新视图,计算cell高度等。
例如图中两个展示学校信息的cell,每个学校有自己的标签(如图中红圈所示),且每个学校的标签数量、每个标签的文本长度不固定。这就需要cell复用的时候根据model去动态地生成对应的tagView,计算tagView的布局以及cell的高度等,这无疑会造成一定的性能问题。
我们优化的思路:1. 尽量减少重复地创建tagView和布局计算、cell高度计算 2. 减少主线程的布局计算
具体的步骤如下:
tagView复用
每个cell的tagView从UI上基本是一样的,只有文本不一样。因此cell中可以保留已经创建过的tagView,再绑定新model的时候再根据model的tags数组去更新文本内容和布局。
缓存tagView的布局数据和cell的高度
对于同一个model,其所对应的tagView的布局和cell的高度其实是固定的,因此再计算过一次之后,可以将相应的数据保存在model当中。下次渲染model的cell时无需重新计算,直接采用缓存数据即可。需要注意的是,因为支持ipad横竖屏切换,因此在ipad中需要缓存横屏布局和竖屏布局两套数据。
异步线程计算布局数据
对于tagView的布局数据,可以放在异步线程中计算,计算完成之后再回到主线程做渲染,这可以减少在滑动中因为主线程的大量计算工作而造成的卡顿。需要注意的是cell高度不能放在异步线程中计算,因为这可能导致展示的cell高度错误。(ps:在我们所举的这个例子中,cell高度依赖于tagView的布局数据,因此tagView的布局数据也需在主线程中计算)
实现的大致代码如下:
+ (CacheLayoutModel *)layoutModelFor:(ModelClass *)model viewWidth:(CGFloat)viewWidth {
///
/// 布局计算工作
///
CacheLayoutModel *layoutModel = [CacheLayoutModel new];
layoutModel.tagViewFrames = tagViewFrames;
layoutModel.tagViewsHeight = tagViewsHeight;
layoutModel.viewHeight = layoutModel.tagViewsHeight + [self tagSuperViewEdgeInsets].bottom + [self containerEdgeInsets].top + [self containerEdgeInsets].bottom;
return layoutModel;
}
+ (CGFloat)cellHeight:(ModelClass *)model viewWidth:(CGFloat)viewWidth {
CacheLayoutModel *layoutModel = model.layoutModelDic[@(viewWidth).stringValue];
if (layoutModel == nil) {
layoutModel = [self layoutModelFor:model viewWidth:viewWidth];
}
return layoutModel.viewHeight;
}
- (void)bindWidthModel:(ModelClass *)model viewWidth:(CGFloat)viewWidth {
// 获取layoutModel
CacheLayoutModel *layoutModel = model.layoutModelDic[@(viewWidth).stringValue];
if (layoutModel == nil) {
layoutModel = [self.class layoutModelFor:model viewWidth:viewWidth];
}
// tagView复用
if (_tagViews == nil) {
_tagViews = [NSMutableArray new];
}
for (UIView *tagView in _tagViews) {
tagView.hidden = YES;
}
for (NSInteger i = 0; i < model.tags.count; i++) {
UIView *tagView = nil;
if (i < _tagViews.count) {
tagView = _tagViews[i];
tagView.hidden = NO;
} else {
tagView = [self.class createTagView];
[_containerView addSubview:tagView];
[_tagViews addObject:tagView];
}
tagView.frame = [layoutModel.tagViewFrames[i] CGRectValue];
UILabel *tagLbl = (UILabel *)[tagView viewWithTag:600];
tagLbl.text = school.tags[i];
}
}
CacheLayoutModel的定义如下
@interface CacheLayoutModel : NSObject
@property (nonatomic, assign) CGFloat viewHeight;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSValue *> *cacheFrameDic;
@end
@interface CacheLayoutModel (TagLayout)
@property (nonatomic, assign) CGFloat tagViewsHeight;
@property (nonatomic, strong) NSArray *tagViewFrames;
@end
@interface NSObject (CacheLayout)
@property (nonatomic, strong) NSMutableDictionary<NSString *, CacheLayoutModel *> *layoutModelDic;
// 横竖屏布局一样时,仅需使用defaultLayoutModel即可
@property (nonatomic, strong, nullable) CacheLayoutModel *defaultLayoutModel;
@end
引入AsyncDisplayKit
AsyncDisplayKit是由Facebook开源的一款强大的异步渲染和布局引擎,可显著提升页面的流畅性。想要深入了解其使用方法,可以参考博主的另一篇博客AsyncDisplayKit分析。
优化后
通过几个版本的持续优化,我们成功提升了考研app的Scroll Hitch Rate,目前达到了 6.5 毫秒/秒 左右。当然因为时间问题,目前的优化工作还是有限,未来依然有许多优化点需要处理。
同时,我们将考研app中的代码优化和引入的AsyncDisplayKit应用于搜题app中。由于搜题app的历史遗留问题较少,虽然它包含了考研app的绝大部分功能,但其Scroll Hitch Rate一直维持在一个比较优秀的水平。