@NewPan 贝聊科技 iOS 菜鸟工程师
大家好,我是 NewPan,我之前写过一篇 iOS一次立竿见影的启动时间优化 - 简书,从标题也可以看得出来,那篇文章是关于启动时间优化的,得到了大家不错的反响。这次我们来讲讲如何优化首页的渲染时间。
01. 贝聊首页页面介绍
上图是贝聊家长版首页的设计图,从上图可以看出,这个首页还是很复杂的,郭耀源在他的 深入理解RunLoop | Garan no dou 里提到:
UI 线程中一旦出现繁重的任务就会导致界面卡顿,这类任务通常分为3类:排版,绘制,UI 对象操作。
- 排版通常包括计算视图大小、计算文本高度、重新计算子式图的排版等操作。
- 绘制一般有文本绘制 (例如
CoreText
)、图片绘制 (例如预先解压)、元素绘制 (Quartz)等操作。
3.UI 对象操作通常包括UIView
/CALayer
等 UI 对象的创建、设置属性和销毁。
贝聊这个首页已经把“排版,绘制,UI 对象操作”这三个方面耗时操作全部涵盖了,如果直接基于 UIKit
那一套去写的话,需要花很多时间去做性能调优。所以贝聊的首页直接采用了 AsyncDisplayKit,虽然需要重新去学习 AsyncDisplayKit
那套 boxing 布局规则,但是效果很明显,我们的列表在很老的 iPhone 5 上快速滚动都不会出现明显卡顿。
02. 贝聊首页渲染耗时分析
我们先看一下优化前的首页耗时,这个耗时是指从后台数据加载到设备以后进行解析最后渲染成 UI 这个过程的耗时。测试设备为我自己的 iPhone 6s Plus(国行 64GB),我总共测试了十组数据。
// 第 1 组.
2018-08-14 16:20:38.831014+0800 beiliao[2429:991848] 从数据加载完成到首页开始渲染耗时: 1.172843
// 第 2 组.
2018-08-14 16:21:15.409550+0800 beiliao[2431:992484] 从数据加载完成到首页开始渲染耗时: 1.199685
// 第 3 组.
2018-08-14 16:21:50.329775+0800 beiliao[2433:993092] 从数据加载完成到首页开始渲染耗时: 1.203976
// 第 4 组.
2018-08-14 16:22:30.805793+0800 beiliao[2435:993740] 从数据加载完成到首页开始渲染耗时: 1.022340
// 第 5 组.
2018-08-14 16:23:10.874299+0800 beiliao[2437:994402] 从数据加载完成到首页开始渲染耗时: 1.127660
// 第 6 组.
2018-08-14 16:23:43.988901+0800 beiliao[2439:994997] 从数据加载完成到首页开始渲染耗时: 0.991278
// 第 7 组.
2018-08-14 16:24:19.291121+0800 beiliao[2441:995581] 从数据加载完成到首页开始渲染耗时: 0.970286
// 第 8 组.
2018-08-14 16:24:53.831283+0800 beiliao[2444:996330] 从数据加载完成到首页开始渲染耗时: 0.550910
// 第 9 组.
2018-08-14 16:25:30.564408+0800 beiliao[2446:996948] 从数据加载完成到首页开始渲染耗时: 1.339828
// 第 10 组.
2018-08-14 16:26:07.003846+0800 beiliao[2452:997656] 从数据加载完成到首页开始渲染耗时: 0.978076
可以看到,数据范围从 0.550910 - 1.339828,平均值为 1.05563。而且这里有个特点就是,不管是 iPhone X 还是更旧的设备,都一样的耗时,因为这里阻塞的是 UI 线程。
上一小节说贝聊的首页采用的是 AsyncDisplayKit
,对于排版,绘制,UI 对象操作这三项,前两项已经被 AsyncDisplayKit
扔到后台线程,最后前两项的结果会被同步到 UI 线程进行视图渲染。所以影响贝聊首页渲染的应该是“UI 对象操作”。
接下来祭出”Time Profiler“,找到耗时的代码,如果有使用 Time Profiler 的问题,请参考我之前写的文章 iOS用 TimeProfiler 揪出那些耗时函数 - 简书。
上图有一个 -fetchAnimationImages
方法,它的实现是下面这样的。就是对一组序列帧进行加载,这个方法耗时 0.284 秒。
+ (NSArray<NSString *> *)fetchAnimationImageNames {
NSMutableArray<NSString *> *names = @[].mutableCopy;
for (int i = 2; i <= 23; i++) {
[names addObject:[NSString stringWithFormat:@"BLDKLoadMoreAnimation-000%02d", i]];
}
return names.copy;
}
+ (NSArray<UIImage *> *)fetchAnimationImages {
NSMutableArray<UIImage *> *images = @[].mutableCopy;
for (NSString *imageName in [self fetchAnimationImageNames]) {
[images addObject:[UIImage imageNamed:imageName]];
}
return images.copy;
}
同样的,我又分析了其他的方法,最后,这些方法都调用了一个系统的方法 -imageNamed:
。于是我把 -fetchAnimationImages
中调用-imageNamed:
的地方注释掉。
+ (NSArray<UIImage *> *)fetchAnimationImages {
NSMutableArray<UIImage *> *images = @[].mutableCopy;
for (NSString *imageName in [self fetchAnimationImageNames]) {
// [images addObject:[UIImage imageNamed:imageName]];
}
return images.copy;
}
再次打开”Time Profiler“,看到耗时函数里已经没有 -fetchAnimationImages
这个方法了。
至此,我们验证了,影响首页渲染耗时的最大凶手是 -imageNamed:
这个方法。
03. 优化策略
我们天天都在用这个方法加载 UI 元素,但是从来没想过这个方法是压死骆驼的最后一根稻草。
从系统文档来看,这个方法会去 bundle
中加载图片资源,解码数据,最后根据用户的设备分辨率的不同渲染到屏幕上。我们知道这个过程可能会很耗时,尤其当图片文件很大的时候,所以 SDWebImage
、AFNetworking
、YYWebImage
把图片解码这样的操作都放到了子线程。
文档上面没有写 ,经评论里朋友提醒,再仔细看了一下文档,-imageNamed:
这个方法是否是线程安全的In iOS 9 and later, this method is thread safe.
,也就是说 iOS 9 以后这个方法是线程安全的。受这些第三方库的启发,我开始尝试把 -imageNamed:
这个方法放到子线程运行,并在各个机型上测试,发现没有出现问题。
我们都知道, -imageNamed:
这个方法会有缓存,只要加载过一次,再次加载就会得到缓存的优化。于是,我开始尝试将本地资源图片提前进行预加载。
那为什么这个预加载可行呢?因为这个时机很重要,从 -application:didFinishLaunchingWithOptions:
到首页请求回来这个时间,刚好 CPU 和 IO 都是空闲的(或者你可以通过其他手段把这段时间的 CPU 和 IO 预留出来,具体请参考 iOS一次立竿见影的启动时间优化 - 简书),这段时间你就可以把本地图片资源都加载好,等请求回来的时候,首页需要调用的 -imageNamed:
方法都已预加载过一遍,再次加载都会享受高速缓存的优化,这样就能达到优化的效果。
04. 具体实现
实现思路大致如下:
- 自行 hook
-imageNamed:
方法到自定义的实现,在这个实现中把图片名字缓存到本地。
- 自行 hook
- 再次启动时,在
-application:didFinishLaunchingWithOptions:
方法中开始预加载上次 APP 启动缓存好的图片。具体应该使用 GCD 并发的在子线程中加载。
- 再次启动时,在
具体实现代码如下:
BLImagePreloadManager.h
文件如下:
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface BLImagePreloadManager : NSObject
/**
* 手动添加需要预加载的图片名(图片名数组).
*
* @warning 在 load 方法中添加才能执行到.
*/
+ (void)preloadImagesWithImageNames:(NSArray<NSString *> *)imageNames;
/**
* 手动添加需要预加载的图片名.
*
* @warning 在 load 方法中添加才能执行到.
*/
+ (void)preloadImageWithImageName:(NSString *)imageName;
/**
* 尝试预加载 `-imageName:` 的图片(方法会自动切换到子线程).
*/
+ (void)preloadImagesIfNeed;
/**
* 存储预加载的图片名称.
*/
+ (void)storeImageNameForPreload:(NSString *)imageName;
@end
NS_ASSUME_NONNULL_END
BLImagePreloadManager.m
文件如下:
#import "BLImagePreloadManager.h"
#import "UIImage+ImageDetect.h"
#import "BLGCDExtensions.h"
static NSString *const kBLImagePreloadManagerStoreKey = @"com.ibeiliao.preload.images.store.key.www";
static BOOL _isStoreTimeTick = NO;
static NSTimeInterval const kBLImagePreloadManagerStoreImageTimeInterval = 10;
static NSMutableSet<NSString *> *_kImageNameCollectSetM = nil;
static dispatch_queue_t _ioQueue;
static NSMutableArray<NSString *> *_manualPreloadImageNames = nil;
@implementation BLImagePreloadManager
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_ioQueue = dispatch_queue_create("com.ibeiliao.image.preload.queue", DISPATCH_QUEUE_SERIAL);
});
}
+ (void)preloadImagesWithImageNames:(NSArray<NSString *> *)imageNames {
NSParameterAssert(imageNames.count);
BLAssertMainThread;
if (!imageNames.count) {
return;
}
[self manualPreloadArrayInitIfNeed];
NSAssert(_manualPreloadImageNames, @"添加预加载行为时机太晚, 预加载已经完成, 请在 load 方法中执行添加预加载图片行为");
if (!_manualPreloadImageNames) {
return;
}
[_manualPreloadImageNames addObjectsFromArray:imageNames];
}
+ (void)preloadImageWithImageName:(NSString *)imageName {
NSParameterAssert(imageName);
if (!imageName.length) {
return;
}
if (![imageName isKindOfClass:[NSString class]]) {
return;
}
[self preloadImagesWithImageNames:@[imageName]];
}
+ (void)preloadImagesIfNeed {
if (@available(iOS 9.0, *)) {
[self manualPreloadArrayInitIfNeed];
NSArray<NSString *> *imageNames = [[NSUserDefaults standardUserDefaults] valueForKey:kBLImagePreloadManagerStoreKey];
if (imageNames.count) {
[_manualPreloadImageNames addObjectsFromArray:imageNames];
}
if (!_manualPreloadImageNames || !_manualPreloadImageNames.count) {
return;
}
BOOL bl_imageWithNameEnable = [UIImage respondsToSelector:@selector(bl_imageNamed:)];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
for (NSString *imageName in _manualPreloadImageNames) {
if ([imageName isKindOfClass:[NSString class]]) {
bl_imageWithNameEnable ? [UIImage bl_imageNamed:imageName] : [UIImage imageNamed:imageName];
}
}
});
}
}
+ (void)storeImageNameForPreload:(NSString *)imageName {
NSParameterAssert(imageName);
if (![imageName isKindOfClass:[NSString class]]) {
return;
}
if (_isStoreTimeTick) {
return;
}
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_kImageNameCollectSetM = [NSMutableSet set];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kBLImagePreloadManagerStoreImageTimeInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
_isStoreTimeTick = YES;
[self internalFinishCollectImageName];
});
});
dispatch_async(_ioQueue, ^{
if (_kImageNameCollectSetM && imageName.length) {
[_kImageNameCollectSetM addObject:imageName];
}
});
}
+ (void)internalFinishCollectImageName {
if (!_kImageNameCollectSetM || !_kImageNameCollectSetM.count) {
[self releaseResources];
return;
}
dispatch_async(_ioQueue, ^{
[[NSUserDefaults standardUserDefaults] setObject:[_kImageNameCollectSetM allObjects] forKey:kBLImagePreloadManagerStoreKey];
[self releaseResources];
});
}
+ (void)releaseResources {
_kImageNameCollectSetM = nil;
_ioQueue = nil;
_manualPreloadImageNames = nil;
}
+ (void)manualPreloadArrayInitIfNeed {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if(!_manualPreloadImageNames && !_isStoreTimeTick) {
_manualPreloadImageNames = @[].mutableCopy;
}
});
}
@end
05. 优化效果
有了这一层优化以后,仍然在我的 iPhone 6s Plus 上进行十组测试,我们一起来看下优化后的结果:
// 第 1 组.
2018-08-14 18:37:03.434442+0800 beiliao[2603:1056626] 从数据加载完成到首页开始渲染耗时: 0.253540
// 第 2 组.
2018-08-14 18:38:11.953393+0800 beiliao[2608:1057951] 从数据加载完成到首页开始渲染耗时: 0.265548
// 第 3 组.
2018-08-14 18:38:41.851729+0800 beiliao[2610:1058585] 从数据加载完成到首页开始渲染耗时: 0.263075
// 第 4 组.
2018-08-14 18:39:13.515297+0800 beiliao[2612:1059171] 从数据加载完成到首页开始渲染耗时: 0.293209
// 第 5 组.
2018-08-14 18:39:47.610475+0800 beiliao[2614:1059832] 从数据加载完成到首页开始渲染耗时: 0.268341
// 第 6 组.
2018-08-14 18:40:55.798904+0800 beiliao[2618:1061142] 从数据加载完成到首页开始渲染耗时: 0.263902
// 第 7 组.
2018-08-14 18:41:25.785528+0800 beiliao[2621:1061772] 从数据加载完成到首页开始渲染耗时: 0.257506
// 第 8 组.
2018-08-14 18:41:56.550695+0800 beiliao[2623:1062409] 从数据加载完成到首页开始渲染耗时: 0.291573
// 第 9 组.
2018-08-14 18:42:27.200791+0800 beiliao[2625:1063009] 从数据加载完成到首页开始渲染耗时: 0.233717
// 第 10 组.
2018-08-14 18:42:58.853888+0800 beiliao[2627:1063666] 从数据加载完成到首页开始渲染耗时: 0.299298
可以看到,数据范围从 0.253540 - 0.299298,平均值为0.268981。比优化前的平均值1.05563,减少 75%,效果非常明显。
06. 最后插播一个招聘广告
贝聊科技招聘 iOS 开发工程师,坐标广州。如果你想和我一起共事,机会不等人,赶紧动手吧!简历发到 13246884282@163.com。
网友评论