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.有任何问题欢迎留言评论。

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

推荐阅读更多精彩内容