iOS 多图列表优化处理

作者: 小時間光 | 来源:发表于2019-01-12 18:17 被阅读1次

          在平时的开发中,经常遇到会UITableViewUICollectionView用来展示列表图片,为了提升APP的流畅度,提高用户体验,需要开发者在不影响图片质量的条件下作出最优的优化处理。

    1、为什么要做优化

    一般地,如果不用第三方也不做优化处理,加载一张网络图片的代码如下:

    NSURL *imgURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@",Model.imageURL]];
    NSData *imgData = [NSData dataWithContentsOfURL:imgURL];
    cell.imageView.image = [UIImage imageWithData:imgData];
    

    运行代码,上下滚动几下,在手机和模拟器上都运行对比一下,在网络良好的情况下,发现不论上拉还是下拉都很卡,如下图是我在iPhone 7上做的测试;

    demo1

    分析:上下滑动时造成卡顿的原因

          1、根据UITableViewCellUICollectionViewCell的复用机制,当上拉时应该不会像下拉时一样卡顿,这是为什么呢?由于没有对已经下载的图片做缓存处理,所以在下拉滑动时和上拉滑动是一样的,把图片又重新下载了一次。也就是说我滑了几次,这张图片就下载了几次!
          如果服务器存储的图片非常大,一张图片可能有几百M,那么下载一张图片就消耗的时间很长,即使在网络很好的情况下。另外由于没有做缓存处理,每次上下滑动,图片都会重新下载,这样就会浪费很多流量。
          2、由于下载图片是耗时操作,此处是直接放到主线程中操作的,所以下拉时非常卡;
          这样带给用户最直接的体验就是APP的流畅度很差、体验很Low。解决这些问题,优化APP体验是非常必要的,本文的主要目的就是介绍如何从上面这两个角度来大量图片的列表做优化处理。当然对多图列表的优化要从各个细节入手,本文所用到的是能够带来明显效果的优化处理方式。对于更多的深层次的优化处理也有很多优秀的博客和开源框架可以参考。


    2、图片缓存简单处理

    (1)、使用NSMutableDictionary缓存

          针对图片重复下载的问题,先从简单的开始优化,把已经下载的图片做本地缓存处理。一般情况下,可以用数组或者字典把已经下载的图片存储到内存中,考虑到每张图片都是不同的,这里可以选择使用字典来存储已经下载的图片,由于每行Cell都是唯一的(当然使用图片的URL来标记也可以),这里可以使用indexPath.row来标记图片,修改代码如下:

    @property (strong, nonatomic) NSMutableDictionary *imgCacheHashMap;
    
    -(NSMutableDictionary *)imgCacheHashMap{
       if(_imgCacheHashMap == nil){
           _imgCacheHashMap = [NSMutableDictionary dictionary];
       }
       return _imgCacheHashMap;
    }
    
    UIImage *cacheImg = [self. imgCacheHashMap objectForKey:[NSString stringWithFormat:@"%ld",indexPath.row]];
       if(cacheImg){
           cell.bookImg.image = cacheImg;
           NSLog(@"======================\n");
       }else{
       NSURL *imgURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@",Model.images]];
       NSData *imgData = [NSData dataWithContentsOfURL:imgURL];
       cell.imageView.image = [UIImage imageWithData:imgData];
       
       [self.imgCacheHashMap setObject:[UIImage imageWithData:imgData] forKey:[NSString stringWithFormat:@"%ld",indexPath.row]];
       }
    

    运行代码,如下图所示,这是在iPhone 8P上运行的效果,可以发现上拉时还是和之前一样非常卡,但是在上拉返回的时候比之前流畅一些来。

    demo2

    为了验证数据是否成功缓存到了内存中,我把网络关了,然后在重复上下滑动操作,此时发现图片依然可以加载出来,说明缓存是成功的。

    (2)、使用NSCache缓存

          通过上面采用字典缓存的方法,虽然优化了上拉的流畅度,避免了重复下载的图片问题,但是这只是对图片优化处理的第一步,还需要更多的细节需要处理,iOS系统为开发者提供了一个专门用于缓存的类NSCache类,NSCache相对于NSDictionary具有更多优势。
          《Effective Objective-C 2.0》一书中第50条也推荐在缓存处理时采用NSCache
          NSCache比较重要的两个属性countLimittotalCostLimit,分别用来设置缓存数据数量和缓存数据占据内存大小,修改代码如下:

    ///
    @property (strong, nonatomic) NSCache *imgCacheData;
    
    -(NSCache *)imgCacheData{
        if(_imgCacheData == nil){
            _imgCacheData = [[NSCache alloc]init];
            _imgCacheData.delegate = self;
            //设置缓存数据数量
            _imgCacheData.countLimit = LINK_MAX;
            //设置缓存数据占据内存大小
            _imgCacheData.totalCostLimit = 180 * MAX_CANON * MAX_CANON;
        }
        return _imgCacheData;
    }
    - (void)cache:(NSCache *)cache willEvictObject:(id)obj{
        printf("\n================== remove old data \n");
    }
    

    Apple为开发者的提供NSCache的使用方法和NSDictionary是一样的,在Xcode中摁住command键点击NSCache即可看到系统提供的方法。
    那么NSCache相对与NSDictionary有那些优势呢?
    1、NSCache能在内存将要被耗尽时自动清理缓存,而不需要开发者在收到内存警告时手动清理内存;
    2、NSCache是线程安全的,也就是说可以在不同的线程中访问数据

    3、当缓存的数据超过设置的数量时,NSCache默认优先移除最先添加的数据,遵循先进先出规则
    为了验证当缓存数据超过设置NSCache的最大缓存数量时,系统是如何做内存处理的,我做了一个测试:

     NSCache *testCache = [[NSCache alloc]init];
     //设置缓存数据数量
     testCache.countLimit = 5;
     for(int i = 0;i< 10;i++){
       [testCache setObject:[NSNumber numberWithInt:i] forKey:[NSString stringWithFormat:@"%d",i]];
     }
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
         for(int x = 0;x<10;x++){
             NSNumber *number = [testCache objectForKey:[NSString stringWithFormat:@"%d",x]];
             NSLog(@"cache data ==============%@",number);
            }
        });
    
    demo 3
    通过上面的Log可以看出,设置testCache最大缓存数量为5,但是我添加了10条数据,此时可以确定testCache中只存了5条数据。打印testCache中的数据,可以看出之前缓存的5条数据已经被清理了。

    那么问题来了,当用户干掉进程之后再重新打开APP时,之前NSCache缓存的数据还在内存中吗,此处来做个测试,用Log来展示,核心代码如下:

        UIImage *cacheImg = [self.imgCacheData objectForKey:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
        //检查内存缓存
        if(cacheImg){
            cell.bookImg.image = cacheImg;
            NSLog(@"======================cache\n");
    }else{
           NSLog(@"=====================download\n");
    }
    
    demo4

    cache表示从缓存中获取的图片,download表示重新下载的图片,查看打印的日志当我杀掉进程重新打开APP时图片又重新下载来一次,根据打印的日志可以得出结论:
    4、NSCache存储的数据,在APP被杀掉或者重启后缓存的数据就没有了;
    那么,有没有一种方法,当我下载一次图片之后就缓存起来以后再也不用浪费流量下载了呢?当然可以,作为开发者,当下载完成之后可以把图片存入硬盘,这样就不用担心APP被进程被干掉后,下次打开还要继续下载图片的问题了。


    3、二级缓存--图片硬盘缓存处理

    既然使用NSCache缓存的图片在APP被干掉后再重新打开数据就没有了,可以把图片存储到手机磁盘中,这样就一劳永逸了,只要不清理,以后都不用再次下载了。那么该如何处理呢,此处结合前面的内存缓存逻辑,我整理了一下现在的业务逻辑:

    业务流程图 1

          1、先判断内存中是否有图片缓存,如果有直接使用;
          2、判断手机硬盘中是否有图片缓存,如果有直接使用,并加入到磁盘缓存中,此处是考虑到从内存中取图片会比硬盘中取图片性能高很多;
          3、如果手机硬盘中没有图片缓存,开启线程去下载,下载完成后刷新列表,然后在分别加入到内存和硬盘中;

    // 获取沙盒路径
    -(NSString *)getComponentFile:(NSString *)fileName{
        //获取沙盒路径
        NSString *ComponentFileName = [fileName lastPathComponent];
        //获取Cache路径
        NSString *cachePahtStr = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
        //获取完整路径
        NSString *fullPathStr = [cachePahtStr stringByAppendingPathComponent:ComponentFileName];
        
        return fullPathStr;
    }   
    

    cellForItemAtIndexPathcellForRowAtIndexPath代理中实现上面的逻辑代码为:

     // 先判断内存中是否有缓存数据
     UIImage *cacheImg = [weakSelf.imgCacheData objectForKey:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
       // 检查内存缓存
            if(cacheImg){
                cell.imageView.image = cacheImg;
                NSLog(@"======================cache\n");
            }else{
                NSString *fullPathStr = [weakSelf getComponentFile:[NSString stringWithFormat:@"%d",(int)indexPath.row]];
                // 是否有硬盘缓存
                NSData *imgData = [NSData dataWithContentsOfFile:fullPathStr];
                if(imgData){
                    NSLog(@"=====================hard disk\n");
                    // 赋值操作
                    cell.imageView.image = [UIImage imageWithData:imgData];
                    // 加入到内存中
                    [weakSelf.imgCacheData setObject:[UIImage imageWithData:imgData] forKey:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
                }else{
                    NSURL *imgURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@", Model.images]];
                    NSData *imgData = [NSData dataWithContentsOfURL:imgURL];
                    NSLog(@"======================download\n");
                    //加入内存缓存中
                    [weakSelf.imgCacheData setObject:[UIImage imageWithData:imgData] forKey:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
                    //同时缓存到硬盘中
                    [imgData writeToFile:fullPathStr atomically:YES];
                    //返回主线程
                    dispatch_async(dispatch_get_main_queue(), ^{
                    //下载完成后 刷新cell
                        [collectionView reloadItemsAtIndexPaths:@[indexPath]];
                     });
               }
        }
    

    4、图片异步下载

    通过上面的二级缓存方式,已经解决了图片重复下载的问题,后面就主要解决图片在第一次下载时在主线程下载造成的卡顿问题,由于下载图片成功的时间不确定,跟网络、设备等一些条件有关,可以把图片下载操作异步执行。
    主要思路为:下载图片时开启一个队列去下载,下载完成之后再返回主线程刷新UI,使用GCDNSOperationQueue都可以到达需求:

    ///
    @property (retain, nonatomic) dispatch_queue_t GCDQueue;
    
    -(dispatch_queue_t)GCDQueue{
        if(_GCDQueue == nil){
            _GCDQueue = dispatch_queue_create(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        }
        return _GCDQueue;
    }
    
    ///
    @property (strong, nonatomic) NSOperationQueue *queue;
    
    -(NSOperationQueue *)queue{
        if(_queue == nil){
            _queue = [[NSOperationQueue alloc]init];
        }
        return _queue;
    }
    

    cellForItemAtIndexPathcellForRowAtIndexPath代理方法中实现:

    // 强引用 ---> 弱引用
     __weak typeof (self) weakSelf = self;
     //开启下载队列
     dispatch_async(weakSelf.GCDQueue, ^{
           //图片下载代码 
           //下载完成后加入到内存缓存和硬盘缓存中
           //返回主线程刷新列表
           dispatch_async(dispatch_get_main_queue(), ^{
              [collectionView reloadItemsAtIndexPaths:@[indexPath]];
            });
    });
    
    // 强引用 ---> 弱引用
    __weak typeof (self) weakSelf = self;
    //图片下载代码 
    //下载完成后加入到内存缓存和硬盘缓存中
    NSBlockOperation *downloadBlock = [NSBlockOperation  blockOperationWithBlock:^{
       // 返回主线程
            [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                //此处 数据源已经变了 直接刷新Cell会再次调用cellForItemAtIndexPath代理,
                [collectionView reloadItemsAtIndexPaths:@[indexPath]];
            }];
     }];
    //加入到队列中
    [self.queue addOperation:downloadBlock];
    

    5、当列表滑动时暂停下载图片 停止后恢复下载

          我们知道,iOS系统的RunLoop可以用来处理APP中的各种事件,所以可以用RunLoop来监听列表的滑动操作。
    这里也可以使用UIScrollViewDelegate代理来监听列表的滑动操作,Apple官方提供的LazyTablbeImages Demo就是用的这个思路。

    方案1:监听UIScrollView 的 delegate 回调

    那么现在业务逻辑是:(1)、先设置一张默认占位图片,(2)、在开始下载图片之前先判断列表是否在滑动中,如果列表正在上下滑动,先暂停下载,(3)、等到列表停止滑动后再开始下载。此处结合前面的处理逻辑,我画了一张流程图:


    业务流程图 2

    此处主要在scrollViewDidEndDragging(用户停止拖拽时)和scrollViewDidEndDecelerating(列表完全停止滑动时)这两个代理中来处理,核心代码为:

    // MARK: - download Image
    -(void)loadImageForOnscreenRows{
        if(self.bookList.count > 0){
            NSArray *visiblePaths = [self.listView indexPathsForVisibleItems];
            for (NSIndexPath *indexPath in visiblePaths){
                //============================= get Model data
               Model *model = self.dataList[indexPath.row];
                [self startImageDownload:model forIndexPath:indexPath];
            }
        }else{
            return;
        }
    }
    
    //download
    -(void)startImageDownload:(Model*)imgModel forIndexPath:(NSIndexPath *)indexPath{
        
        // 强引用 ---> 弱引用
        __weak typeof (self) weakSelf = self;
        //开启下载队列
    //    dispatch_async(weakSelf.GCDQueue, ^{
    //
    //        //返回主线程
    //        dispatch_async(dispatch_get_main_queue(), ^{
    //
    //            MainCollectionCell *cell = (MainCollectionCell *)[self.listView cellForItemAtIndexPath:indexPath];
    //            cell.bookImg.image = [UIImage imageWithData:imgData];
    //        });
    //    });
        
        NSBlockOperation *downloadBlock = [NSBlockOperation  blockOperationWithBlock:^{
            //获取图片URL
            NSURL *imgURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@",Model.imageURL]];
            NSData *imgData = [NSData dataWithContentsOfURL:imgURL];
            NSLog(@"======================download\n");
            NSData *finallyImgData = UIImageJPEGRepresentation([UIImage imageWithData:imgData], 0.8);
            //
            //加入内存缓存中
            [self.imgCacheData setObject:[UIImage imageWithData:finallyImgData] forKey:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
            //获取沙盒路径
            NSString *fullPathStr = [weakSelf getComponentFile:[NSString stringWithFormat:@"%ld",(long)indexPath.row]];
            //同时缓存到硬盘中
            [finallyImgData writeToFile:fullPathStr atomically:YES];
            // 返回主线程
            [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                //此处 数据源已经变了 直接刷新Cell会再次调用cellForItemAtIndexPath代理,
                [weakSelf.listView reloadItemsAtIndexPaths:@[indexPath]];
            }];
        }];
        //加入到队列中
        [self.queue addOperation:downloadBlock];
    }
    
    
    // MARK: - UIScrollViewDelegate
    //用户停止拖拽时
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
        if(decelerate){
            [self loadImageForOnscreenRows];
        }
    }
    // 完全停止滚动时
    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
        
        [self loadImageForOnscreenRows];
    }
    
    finally demo.gif
    方案2:利用RunLoop优化处理

          RunLoop有两种模式:NSDefaultRunLoopModeNSRunLoopCommonModes,当用户滑动时,切换到NSRunLoopCommonModes模式下,此时NSDefaultRunLoopMode下的任务就暂停,直到再次切换到NSDefaultRunLoopMode下的时候,再继续之前的下载任务。这和官方提供的思路虽不同但是也可以到达优化的效果,核心代码为:

    //runLoop tasks blocks
    typedef void(^runloopBlock)(void);
    // tasks Array
    @property (nonatomic, strong) NSMutableArray *tasksList;
    // Max tasks
    @property (nonatomic, assign) NSUInteger maxTaskCount;
    
    -(void)loadView{
        [super loadView];
       // Set the maximum number of tasks
        self.maxTaskCount = 6;
        // create Timer
        CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(runLoopStayActive)];
        // join to RunLoop
        [displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
        // add runloop observer
        [self addRunLoopObserver];
    }
    
    // MARK: - ================================= About RunLoop
    /// RunLoop Stay active
    -(void)runLoopStayActive{
        //do-nothing
    }
    /// RunLoop Observer
    -(void)addRunLoopObserver{
        // get current now RunLoop
        CFRunLoopRef nowRunloop = CFRunLoopGetCurrent();
        // create tasks
        CFRunLoopObserverContext context = {
            0,
            (__bridge void *)(self),
            &CFRetain,
            &CFRelease,
            NULL
        };
        //
        static CFRunLoopObserverRef runLoopDefaultModeObserver;
        runLoopDefaultModeObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                      kCFRunLoopBeforeWaiting,
                                                      YES,
                                                      0,
                                                      &callBack,
                                                      &context);
        // add Observer for RunLoop
        CFRunLoopAddObserver(nowRunloop, runLoopDefaultModeObserver, kCFRunLoopCommonModes);
        // memory release
        CFRelease(runLoopDefaultModeObserver);
    }
    ///
    static void callBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
        MainViewController *mainView = (__bridge MainViewController *)info;
        //
        if (mainView.tasksList.count == 0) return;
        // Take the task from the array
        runloopBlock block = [mainView.tasksList firstObject];
        // Performing tasks
        if (block) {
            block();
        }
        // Remove the task after performing the task
        [mainView.tasksList removeObjectAtIndex:0];
    }
    /// add tasks
    -(void)addTasks:(runloopBlock)blocks{
        // sava new tasks
        [self.tasksList addObject:blocks];
        // if the maximum number of tasks is exceeded, remove the previous task
        if(self.tasksList.count > self.maxTaskCount){
            [self.tasksList removeObjectAtIndex:0];
        }
    }
    

    cellForRowAtIndexPathcellForItemAtIndexPath代理中添加代码:

     [self addTasks:^{
        // do samething
     }
    

    以上的思路逐步完成了对一个列表的优化处理,但是可做优化的地方还有很多,各位小伙伴可以阅读这篇博客iOS 保持界面流畅的技巧,根据该文中的所说的角度来优化自己的APP.

    因为简书上传的图片不能超过10M,所以在视频转gif的时候掉帧严重,建议各位小伙伴运行一下本文demo,这样能很明显看到优化前和优化后的效果。


    本文是我在列表图片加载上的一些优化处理的总结,如果有不对或者有歧义的地方欢迎指出交流。

    demo下载,请戳这里。

    本文参考:
    Apple Developer LazyTablbeImages Demo
    《Effective Objective-C 2.0》第50条:构建缓存时选用NSCache而非NSDictionary
    iOS 保持界面流畅的技巧
    iOS线下分享《RunLoop》

    相关文章

      网友评论

        本文标题:iOS 多图列表优化处理

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