iOS滑动卡顿优化

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 定位卡顿代码


截屏2023-03-10 16.01.20.png

选中某个Hitch,切换到Time Profiler,找出耗时代码。


企业微信截图_cf38a476-1b74-4e15-94db-e8f72e8e1f0d.png

企业微信截图_c5652980-3724-4ad0-950d-088c7b8e42ae.png
企业微信截图_4c276f7b-3633-4ea8-9da7-427da0b02747.png

在主线程绘制图片
[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,或者仅仅调整了布局。优化的方法 :

  1. 将tableHeadView的初始化、更新和调整布局的代码分开,在创建tableView的时候初始化tableHeadView,reloadData内部仅仅做更新和调整布局。
  2. tableHeaderView是由多个复杂subView组成的,如果每次更新都刷新所有的subView那也是比较耗费时间。因此需要在subView的update方法内部添加shouldUpdate判断,判断新的model与原有model的数据是否相同,若相同则无需刷新。
  3. 在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高度等。


企业微信截图_6d05df80-eef0-4b55-b753-93330c4559e4.png
企业微信截图_bfc5bbcf-7ec3-40b5-9305-8e9d7d97a58b.png

例如图中两个展示学校信息的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 毫秒/秒 左右。当然因为时间问题,目前的优化工作还是有限,未来依然有许多优化点需要处理。

Screen Shot 2023-11-28 at 12.17.49.png

同时,我们将考研app中的代码优化和引入的AsyncDisplayKit应用于搜题app中。由于搜题app的历史遗留问题较少,虽然它包含了考研app的绝大部分功能,但其Scroll Hitch Rate一直维持在一个比较优秀的水平。
Screen Shot 2023-11-28 at 11.16.41.png

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容