【问题标题】:How to improve performance of UCollectionView containing lots of small images?如何提高包含大量小图像的 UCollectionView 的性能?
【发布时间】:2015-10-25 10:53:27
【问题描述】:

在我的 iOS 应用程序中,我有 UICollectionView,它显示了大约 1200 个小(35x35 点)图像。图像存储在应用程序包中。

我正确地重用了UICollectionViewCells,但仍然存在性能问题,具体取决于我处理图像加载的方式:

  • 我的应用程序是应用程序扩展,内存有限(本例中为 40 MB)。将所有 1200 张图像放入资产目录并使用 UIImage(named: "imageName") 加载它们导致内存崩溃 - 系统缓存的图像填满了内存。在某些时候,应用程序需要分配更大的内存,但由于缓存的图像,这些内存不可用。操作系统没有触发内存警告和清理缓存,而是直接杀死了应用程序。

  • 我更改了方法以避免图像缓存。我将图像作为 png 文件放入我的项目(而不是资产目录),我现在使用 NSBundle.mainBundle().pathForResource("imageName", ofType: "png") 加载它们。该应用程序不再因内存错误而崩溃,但加载单个图像需要更长的时间,即使在最新的 iPhone 上,快速滚动也会滞后。

我可以完全控制图像,并且可以将它们转换为 .jpeg 或优化它们(我已经尝试过 ImageOptim 和其他一些选项但没有成功)。

如何同时解决这两个性能问题?


编辑 1:

我还尝试在后台线程中加载图像。这是我的 UICollectionViewCell 子类中的代码:

private func loadImageNamed(name: String) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { [weak self] () in
        let image = bundle.pathForResource(name, ofType: "png")?.CGImage
        if name == self?.displayedImageName {
            dispatch_async(dispatch_get_main_queue(), {
                if name == self?.displayedImageName {
                    self?.contentView.layer.contents = image
                }
            })
        }
    })
}

这使得滚动平滑而不消耗额外的缓存内存但是当以编程方式滚动到某个位置时(例如当UICollectionView滚动到顶部时)它会导致另一个问题:在滚动动画期间,图像不会更新(滚动速度太快,无法加载),滚动完成后,会在几分之一秒内显示错误的图像 - 并且一个接一个地替换为正确的图像。这在视觉上非常令人不安。


编辑 2:

我无法将小图像组合成更大的组合图像并按照this answer 的建议显示这些图像。

原因:

  • 考虑不同的屏幕尺寸和方向。它们中的每一个都必须有预先合成的图像,这会使应用程序下载量很大。
  • 小图像可以以不同的顺序显示,其中一些可能在某些情况下被隐藏。我肯定无法为每种可能的组合和顺序预先组合图像。

【问题讨论】:

  • 您需要某种缓存,可以根据需要从您的标准图像中填充,然后在启动并运行后快速访问,以便滚动等顺畅。以下链接似乎提供了这种机制。看起来它使用内存映射文件并避免了很多开销。可能值得一看? github.com/path/FastImageCache

标签: ios image performance memory uicollectionview


【解决方案1】:
  1. 无需每次出现单元格时都从文档目录中获取图像。
  2. 获取图像后,您可以将其保存在 NSCache 中,下次您只需从 NSCache 获取图像,而不是再次从文档目录中获取图像。
  3. 为 NSCache objCache 创建一个对象;
  4. 在你的 cellForItemAtIndexPath 中,写下

    UIImage *cachedImage = [objCache objectForKey:arr_PathFromDocumentDirectory[indexPath.row]];
    
    if (cachedImage) {
        imgView_Cell.image = cachedImage;
    } else {
        dispatch_queue_t q = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul);
        dispatch_async(q, ^{
            /* Fetch the image from the Document directory... */
            [self downloadImageWithURL:arr_PathFromDocument[indexPath.row] completionBlock:^(BOOL succeeded, CGImageRef data, NSString* path) {
                if (succeeded) {
                    dispatch_async(dispatch_get_main_queue(), ^{
                        UIImage *img =[UIImage imageWithCGImage:data];
                        imgView_Cell.image = img;
                        [objCache setObject:img forKey::arr_PathFromDocument[indexPath.row]];
                    });
                }
            }];
        });
    }
    
  5. 获取图像后,将其设置在带有路径的 NSCache 中。下次它会检查是否已经下载然后仅从缓存中设置。

如果您需要任何帮助,请告诉我。

谢谢!

【讨论】:

  • 这是一个很好的答案,可以解决您的问题。要研究的另一件事是像github.com/path/FastImageCache 这样的开源项目,但它使用相同的原理。您可能仍会遇到的问题是 40MB 的扩展上限。
  • @mclaughlinj 我认为缓存在这里不是一个可行的选择,因为可用内存非常有限:所有东西都需要 40MB。这适用于所有视图、动画、数据结构。此外,键盘扩展的内存管理非常奇怪——在应用程序因内存限制而被终止之前,您并不总是会收到内存警告。我也许有 10MB 的预留空间,所以我可以使用 5MB 进行缓存,但这太少了,没有用处。
【解决方案2】:

你可以为此做几件事,

  1. 您应该只加载那些被重新获取的图像并在没有重新获取时卸载它们,这将减少您的应用程序的使用 记忆。
  2. 在后台加载图像时的其他事情在将其设置为图像视图之前在后台线程中对其进行解码...默认情况下 图像将在您设置时解码,这通常会在 main 上发生 线程,这将使滚动平滑,您可以通过下面的代码解码图像。

    static UIImage *decodedImageFromData:(NSData *data, BOOL isJPEG)
    {
        // Create dataProider object from provided data
        CGDataProviderRef dataProvider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);
        // Create CGImageRef from dataProviderObject
        CGImageRef newImage = (isJPEG) ? CGImageCreateWithJPEGDataProvider(dataProvider, NULL, NO, kCGRenderingIntentDefault) : CGImageCreateWithPNGDataProvider(dataProvider, NULL, NO, kCGRenderingIntentDefault);
    
    
    
    // Get width and height info from image
    const size_t width = CGImageGetWidth(newImage);
    const size_t height = CGImageGetHeight(newImage);
    
    // Create colorspace
    const CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
    // Create context object, provide data ref if wean to store other wise NULL
    // Set width and height of image
    // Number of bits per comoponent
    // Number of bytes per row
    // set color space
    // And tell what CGBitMapInfo
    const CGContextRef context = CGBitmapContextCreate( NULL,width, height,8, width * 4, colorspace, (CGBitmapInfo)kCGImageAlphaPremultipliedLast);
    
    //Now we can draw image
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), newImage);
    // Get CGImage from drwan context
    CGImageRef drawnImage = CGBitmapContextCreateImage(context);
    CGContextRelease(context);
    CGColorSpaceRelease(colorspace);
    
    // Create UIImage from CGImage
    UIImage *image = [UIImage imageWithCGImage:drawnImage];
    
    CGDataProviderRelease(dataProvider);
    CGImageRelease(newImage);
    CGImageRelease(drawnImage);
    return image;
    

    }

【讨论】:

    【解决方案3】:

    要解决EDIT 1 中描述的问题,您应该覆盖UICollectionViewCell 子类中的prepareForReuse 方法并将图层的内容重置为nil:

    - (void) prepareForReuse
    {
        self.contentView.layer.contents = nil;
    }
    

    要在后台加载您的图像,您可以使用下一个:

    - (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath
    {
        NSString* imagePath = #image_path#;
        weakself;
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            //load image
            UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
    
            //redraw image using device context
            UIGraphicsBeginImageContextWithOptions(image.size, YES, 0);
            [image drawAtPoint:CGPointZero];
            image = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
    
            dispatch_async(dispatch_get_main_queue(), ^{
                strongself;
                if ([[self.collectionView indexPathsForVisibleItems] indexOfObject:indexPath] != NSNotFound) {
                    cell.contentView.layer.contents = (__bridge id)(image.CGImage);
                } else {
                    // cache image via NSCache with countLimit or custom approach
                }
            });
       });
    }
    

    如何改进:

    1. 使用NSCache 或自定义缓存算法以免加载 一直滚动的图像。
    2. 使用正确的文件格式。对于照片等图像,JPEG 解压缩效果更快。对平面区域的图像使用 PNG 颜色、锐利的线条等。

    【讨论】:

      【解决方案4】:

      您应该创建一个队列来异步加载图像。最好的选择是后进先出队列。你可以看看这个LIFOOperationQueue。 一件重要的事情是防止显示需要单独处理的错误图像。为此,当您创建加载图像的操作时,将当前 indexPath 作为标识符。然后在回调函数中,检查给定的 indexPath 是否可见以更新视图

      if (self.tableView.visibleIndexPath().containsObject(indexPath) {
          cell.imageView.image = img;
      }
      

      您还应该自定义 LIFOOperationQueue 以在队列中拥有最大数量的任务,以便它可以删除不必要的任务。最好设置最大任务数为1.5 * numberOfVisibleCell

      最后一件事,您应该在 willDisplayCell 而不是 cellForRowAtIndexPath 中创建加载图像操作

      【讨论】:

      • 感谢您的有趣回答。但是,我还有一些其他问题: LIFOOperationQueue 与通过我的 EDIT 1 中的代码进行异步加载相比有什么优势吗?在 EDIT 1 中以编程方式描述滚动时,它会解决视觉问题吗?为什么我应该在 willDisplayCell 而不是 cellForRowAtIndexPath 中加载图像?
      • 如果你使用队列,你可以取消不必要的任务,而你的答案不能这样做。它仍然无法解决视觉问题,因此您必须按照我在回答中的描述自己做。当然,willDisplay 总是在 cellForRow 之前调用,所以会更快
      【解决方案5】:

      我可以提出可能可以解决您的问题的替代方法:
      考虑将图像块渲染为单个合成图像。如此大的图像应覆盖应用程序窗口的大小。对于用户来说,它看起来像是小图像的集合,但从技术上讲,它将是大图像的表格。

      你的当前布局:

       |      |      |      |
       | cell | cell | cell |  -> cells outside of screen
       |      |      |      |
      ************************
      *|      |      |      |*
      *| cell | cell | cell |* -> cells displayed on screen
      *|      |      |      |*
      *----------------------*
      *|      |      |      |* 
      *| cell | cell | cell |* -> cells displayed on screen
      *|      |      |      |*
      *----------------------*
      *|      |      |      |*
      *| cell | cell | cell |* -> cells displayed on screen
      *|      |      |      |*
      ************************
       |      |      |      |
       | cell | cell | cell |  -> cells outside of screen
       |      |      |      |
      

      建议布局:

       |                    |
       |     cell with      |
       |   composed image   |  -> cell outside of screen
       |                    |
      ************************
      *|                    |*
      *|                    |*
      *|                    |* 
      *|                    |* 
      *|     cell with      |*
      *|   composed image   |* -> cell displayed on screen
      *|                    |*
      *|                    |*
      *|                    |* 
      *|                    |* 
      *|                    |*
      ************************
       |                    |
       |     cell with      |
       |   composed image   |  -> cell outside of screen
       |                    |
      

      理想情况下,如果您预渲染此类组合图像并在构建时将它们放入项目中,但您也可以在运行时渲染它们。当然,第一个变体的工作速度会更快。但在任何情况下,单个大图像所消耗的内存都比分离该图像的各个部分要少。

      如果您有可能预渲染它们,请使用 JPEG 格式。在这种情况下,您的第一个解决方案(在主线程上使用[UIImage imageNamed:] 加载图像)可能会很好,因为使用的内存更少,布局更简单。

      如果您必须在运行时渲染它们,那么您将需要使用您当前的解决方案(在后台工作),当快速动画发生时您仍然会看到图像错位,但在这种情况下它将是单个错位(一个图像覆盖窗框),所以它应该看起来更好。

      如果您需要知道用户点击了哪些图片(原始小图片 35x35),您可以使用UITapGestureRecognizerattached to cell。当手势被识别时,您可以使用locationInView: 方法来计算小图像的正确索引。

      我不能说它 100% 解决了您的问题,但尝试一下是有意义的。

      【讨论】:

      • 这是个好主意,但很遗憾我不能这样做——我必须将它显示为许多小图像的原因有很多。首先,图像每次可能以不同的顺序出现,有些可能会丢失等。其次,对于不同的屏幕尺寸,特定屏幕上有不同的图像。例如,在 iPhone 6 Plus 上,比在 iPhone 5S 上更多的图像适合屏幕。对于每个屏幕尺寸和方向,我必须有单独的预合成图像,这会使应用程序下载大小很大。我不能按照你的建议做还有很多原因......
      【解决方案6】:

      查看本教程:

      http://www.raywenderlich.com/86365/asyncdisplaykit-tutorial-achieving-60-fps-scrolling

      它使用 AsyncDisplayKit 在后台线程上加载图像。

      【讨论】:

      • 为什么不试试这个教程呢?它正好解决了你的问题。
      • 我正在阅读它。但这似乎并不是我所需要的:它使用 AsyncDisplayKit 在背景中加载图像并在加载时显示一些占位符。但是在我的情况下占位符看起来真的很糟糕 - 假设您在集合视图中有 1200 个 35x35 图像,您在它的底部并按下按钮滚动到顶部。将滚动大约 1100 个占位符。这不会让您对滚动的内容有太多感觉...
      【解决方案7】:

      从 PNG 更改为 JPEG 不会帮助节省内存,因为当您将图像从文件加载到内存时,它会从压缩数据中提取到未压缩的字节。

      对于性能问题,我建议您异步加载图像并使用委托/块更新视图。并在内存中保留一些图像(但不是全部,比如说 100 个)

      希望这会有所帮助!

      【讨论】:

      • @drasto 对于您的“编辑 1”,问题应该是在调度获取图像的新任务之前,您没有将内容的图像设置为 nil。
      猜你喜欢
      • 2011-04-27
      • 2018-07-13
      • 1970-01-01
      • 2013-02-09
      • 2013-11-25
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多