iOS性能优化2:列表加载大图优化
背景:使用CollectionView加载11张图片,每张图片大小是800*600,一屏展示。
分析:在iPhone 5c上,进页面明显有1s以上的延迟;在iPhone 8上,能感觉卡一下再进入。一张图片710KB,11张图片是7.81MB,性能强悍如iPhone8依然感觉到卡。
一直保持着测卡顿就要兼容性能最差的机器,我使用iPhone5c。
先贴一份原始代码:
@interface ImageIOViewController ()
@property (weak, nonatomic) IBOutlet UICollectionView *collectionView;
@property (nonatomic, copy) NSArray *imagePaths;
@end
@implementation ImageIOViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view from its nib.
self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"];
//register cell class
[self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
return CGSizeMake(CGRectGetWidth(collectionView.frame)/7, CGRectGetWidth(collectionView.frame)/7*4.0/3);
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
return [self.imagePaths count];
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
//add image view
const NSInteger imageTag = 99;
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
imageView.image = nil;
NSString *imagePath = self.imagePaths[indexPath.row];
imageView.image = [UIImage imageWithContentsOfFile:imagePath];
return cell;
}
@end
方法1:后台线程加载
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
//add image view
const NSInteger imageTag = 99;
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
imageView.image = nil;
//switch to background thread
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
//load image
NSInteger index = indexPath.row;
NSString *imagePath = self.imagePaths[index];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
//set image on main thread, but only if index still matches up
dispatch_async(dispatch_get_main_queue(), ^{
if (index == cell.tag) {
imageView.image = image;
}
});
});
return cell;
}
这里我们需要处理一下显示的图片和想展示的位置要对应,要不然会乱掉。发现效果还不错,卡顿没有了,进页面的时候图片无序的加载。这也是大家都能想到的思路。但有没有其他方案呢?
方法2:延迟解压
一旦图片文件被加载就必须要进行解码,解码过程是一个相当复杂的任务,需要消耗非常长的时间。解码后的图片将同样使用相当大的内存。
有三种方法来实现延迟解压:
1.最简单的方法就是使用 UIImage 的 +imageNamed: 方法避免延时加载。问题在于 +imageNamed: 只对从应用资源束中的图片有效,所以对用户生成的图片内容或者是下载的图片就没法使用了。
2.另一种立刻加载图片的方法就是把它设置成图层内容,或者是 UIImageView 的 image 属性。不幸的是,这又需要在主线程执行,所以不会对性能有所提升。
3.第三种方式就是绕过 UIKit ,像下面这样使用ImageIO框架:
NSInteger index = indexPath.row;
NSURL *imageURL = [NSURL fileURLWithPath:self.imagePaths[index]];
NSDictionary *options = @{(__bridge id)kCGImageSourceShouldCache: @YES};
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, NULL);
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0,(__bridge CFDictionaryRef)options);
UIImage *image = [UIImage imageWithCGImage:imageRef];
CGImageRelease(imageRef);
CFRelease(source);
这样就可以使用 kCGImageSourceShouldCache 来创建图片,强制图片立刻解压,然后在图片的生命周期保留解压后的版本。
最后一种方式就是使用UIKit加载图片,但是立刻会知道 CGContext 中去。图片必须要在绘制之前解压,所以就强制了解压的及时性。这样的好处在于绘制图片可以再后台线程(例如加载本身)执行,而不会阻塞UI。
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
//add image view
const NSInteger imageTag = 99;
UIImageView *imageView = (UIImageView *)[cell viewWithTag:imageTag];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame: cell.contentView.bounds];
imageView.tag = imageTag;
[cell.contentView addSubview:imageView];
}
cell.tag = indexPath.row;
imageView.image = nil;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
//load image
NSInteger index = indexPath.row;
NSString *imagePath = self.imagePaths[index];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
//redraw image using device context
UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, YES, 0);
[image drawInRect:imageView.bounds];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
//set image on main thread, but only if index still matches up
dispatch_async(dispatch_get_main_queue(), ^{
if (index == cell.tag) {
imageView.image = image;
}
});
});
}
注意不要在子线程访问imageView的属性,否则XCode会给提示,需要绘制的大小我们是可以提前知道的。
方法3 CATiledLayer替代UIImageView
CATiledLayer 可以用来异步加载和显示大型图片,而不阻塞用户输入。
@interface ImageTiledLayerIOViewController ()<CALayerDelegate>
@property (nonatomic,strong) NSMutableSet<CATiledLayer *> * tiledLayerSet;
@property (weak, nonatomic) IBOutlet UICollectionView *collectionView;
@property (nonatomic, copy) NSArray *imagePaths;
@end
@implementation ImageTiledLayerIOViewController
- (void)viewDidLoad {
[super viewDidLoad];
_tiledLayerSet = [NSMutableSet new];
// Do any additional setup after loading the view from its nib.
self.imagePaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"png" inDirectory:@"Vacation Photos"];
[self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
return CGSizeMake(CGRectGetWidth(collectionView.frame), CGRectGetWidth(collectionView.frame)*4.0/3);
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
return [self.imagePaths count];
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
//add the tiled layer
CATiledLayer *tileLayer = [cell.contentView.layer.sublayers lastObject];
if (!tileLayer) {
tileLayer = [CATiledLayer layer];
tileLayer.frame = cell.bounds;
tileLayer.contentsScale = [UIScreen mainScreen].scale;
tileLayer.tileSize = CGSizeMake(cell.bounds.size.width * [UIScreen mainScreen].scale, cell.bounds.size.height * [UIScreen mainScreen].scale);
tileLayer.delegate = self;
[tileLayer setValue:@(indexPath.row) forKey:@"index"];
[cell.contentView.layer addSublayer:tileLayer];
[_tiledLayerSet addObject:tileLayer];
}
//tag the layer with the correct index and reload
tileLayer.contents = nil;
[tileLayer setValue:@(indexPath.row) forKey:@"index"];
[tileLayer setNeedsDisplay];
return cell;
}
- (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx
{
//get image index
NSInteger index = [[layer valueForKey:@"index"] integerValue];
//load tile image
NSString *imagePath = self.imagePaths[index];
UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath];
//calculate image rect
CGFloat aspectRatio = tileImage.size.height / tileImage.size.width;
CGRect imageRect = CGRectZero;
imageRect.size.width = layer.bounds.size.width;
imageRect.size.height = layer.bounds.size.height * aspectRatio;
imageRect.origin.y = (layer.bounds.size.height - imageRect.size.height)/2;
//draw tile
UIGraphicsPushContext(ctx);
[tileImage drawInRect:imageRect];
UIGraphicsPopContext();
}
- (void)dealloc{
for (CATiledLayer * tiledLayer in _tiledLayerSet.allObjects) {
[tiledLayer removeFromSuperlayer];
}
}
CATiledLayer需要在dealloc中手动异常,否则会产生崩溃。其实后台线程加载、延迟解压或者使用CATiledLayer的方式已经解决的很好了,那我们还有别的方式吗?
方法4 缓存与后台线程的结合
如果是应用程序资源下的图片用 [UIImage imageNamed:] 足以解决我们的问题,但多数情况下是网络图片。我们可以自定义缓存,当然苹果也为我们提供了一种缓存方案NSCache.
NSCache 在系统低内存的时候自动丢弃存储的对象NSCache 用来判断何时丢弃对象的算法并没有在文档中给出,但是你可以使用 -setCountLimit: 方法设置缓存大小,以及 -setObject:forKey:cost: 来对每个存储的对象指定消耗的值来提供一些暗示。
指定消耗数值可以用来指定相对的重建成本。如果对大图指定一个大的消耗值,那么缓存就知道这些物体的存储更加昂贵,于是当有大的性能问题的时候才会丢弃这些物体。你也可以用 -setTotalCostLimit: 方法来指定全体缓存的尺寸。
- (UIImage *)loadImageAtIndex:(NSUInteger)index
{
//set up cache
static NSCache *cache = nil;
if (!cache) {
cache = [[NSCache alloc] init];
}
//if already cached, return immediately
UIImage *image = [cache objectForKey:@(index)];
if (image) {
return [image isKindOfClass:[NSNull class]]? nil: image;
}
//set placeholder to avoid reloading image multiple times
[cache setObject:[NSNull null] forKey:@(index)];
//switch to background thread
dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
//load image
NSString *imagePath = self.imagePaths[index];
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
//redraw image using device context
UIGraphicsBeginImageContextWithOptions(image.size, YES, 0);
[image drawAtPoint:CGPointZero];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
//set image for correct image view
dispatch_async(dispatch_get_main_queue(), ^{ //cache the image
[cache setObject:image forKey:@(index)];
//display the image
NSIndexPath *indexPath = [NSIndexPath indexPathForItem: index inSection:0]; UICollectionViewCell *cell = [self.collectionView cellForItemAtIndexPath:indexPath];
UIImageView *imageView = [cell.contentView.subviews lastObject];
imageView.image = image;
});
});
//not loaded yet
return nil;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
//add image view
UIImageView *imageView = [cell.contentView.subviews lastObject];
if (!imageView) {
imageView = [[UIImageView alloc] initWithFrame:cell.contentView.bounds];
imageView.contentMode = UIViewContentModeScaleAspectFit;
[cell.contentView addSubview:imageView];
}
//set or load image for this index
imageView.image = [self loadImageAtIndex:indexPath.item];
//preload image for previous and next index
if (indexPath.item < [self.imagePaths count] - 1) {
[self loadImageAtIndex:indexPath.item + 1]; }
if (indexPath.item > 0) {
[self loadImageAtIndex:indexPath.item - 1]; }
return cell;
}
方法5 分辨率交换
视网膜分辨率(根据苹果市场定义)代表了人的肉眼在正常视角距离能够分辨的最小像素尺寸。但是这只能应用于静态像素。当观察一个移动图片时,你的眼睛就会对细节不敏感,于是一个低分辨率的图片和视网膜质量的图片没什么区别了。
为了做到图片交换,我们需要利用 UIScrollView 的一些实
现 UIScrollViewDelegate 协议的委托方法(和其他类似于 UITableView 和 UICollectionView 基于滚动视图的控件一样):
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate;
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
你可以使用这几个方法来检测传送器是否停止滚动,然后加载高分辨率的图片。只要高分辨率图片和低分辨率图片尺寸颜色保持一致,你会很难察觉到替换的过程(确保在同一台机器使用相同的图像程序或者脚本生成这些图片)
小结:上图虽然是书中提出的方案,但没有具体实现。实际情况采用大小图,列表仅加载小图,点击放大再展示大图。
方法6 使用RunLoop
我们可以使用[self performSelector:@selector(loadImage) withObject:nil afterDelay:0 inModes:@[NSDefaultRunLoopMode]],仅在RunLoop休眠时加载图片。可与其他几种方式结合使用。
总结:
1.苹果一直以流畅著称,多数情况下并不需要考虑性能问题,但涉及复杂场景,还是需要优化的。
2.本书多数内容参考<<iOS CoreAnimation>>,有兴趣同学可自行阅读。本文demo包含iOS CoreAnimation中所有案例。
3.第四种其实使用SDWebImage就能很好解决了,不需要我们再来实现。第三种可以说是很强大,如果你图片有1个G,你可以分成几等分,用CATiledLayer加载,效果才是真的好。第五种的缺点需要提供大小图了。
4.有任何问题欢迎留言评论。