美文网首页收藏iosiOS基础
iOS性能优化2:列表加载大图优化

iOS性能优化2:列表加载大图优化

作者: 某非著名程序员 | 来源:发表于2019-08-07 22:52 被阅读0次
    效果图

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

    相关文章

      网友评论

        本文标题:iOS性能优化2:列表加载大图优化

        本文链接:https://www.haomeiwen.com/subject/fazadctx.html