How to get a snappy UICollectionView with lots of

2019-03-09 12:50发布

问题:

I'm using UICollectionView in an app that is displaying quite a lot of photos (50-200) and I'm having issues getting it to be snappy (as snappy as Photos app for example).

I have a custom UICollectionViewCell with a UIImageView as it's subview. I'm loading images from the filesystem, with UIImage.imageWithContentsOfFile:, into the UIImageViews inside the cells.

I've tried quite a few approaches now but they have either been buggy or had performance issues.

NOTE: I'm using RubyMotion so I'll write any code snippets out in the Ruby-style.

First of all, here's my custom UICollectionViewCell class for reference...

class CustomCell < UICollectionViewCell
  def initWithFrame(rect)
    super

    @image_view = UIImageView.alloc.initWithFrame(self.bounds).tap do |iv|
      iv.contentMode = UIViewContentModeScaleAspectFill
      iv.clipsToBounds = true
      iv.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth
      self.contentView.addSubview(iv)
    end

    self
  end

  def prepareForReuse
    super
    @image_view.image = nil
  end

  def image=(image)
    @image_view.image = image
  end
end

Approach #1

Keeping things simple..

def collectionView(collection_view, cellForItemAtIndexPath: index_path)
  cell = collection_view.dequeueReusableCellWithReuseIdentifier(CELL_IDENTIFIER, forIndexPath: index_path)
  image_path = @image_paths[index_path.row]
  cell.image = UIImage.imageWithContentsOfFile(image_path)
end

Scrolling up/down is horrible using this. It's jumpy and slow.

Approach #2

Add a bit of caching via NSCache...

def viewDidLoad
  ...
  @images_cache = NSCache.alloc.init
  ...
end

def collectionView(collection_view, cellForItemAtIndexPath: index_path)
  cell = collection_view.dequeueReusableCellWithReuseIdentifier(CELL_IDENTIFIER, forIndexPath: index_path)
  image_path = @image_paths[index_path.row]

  if cached_image = @images_cache.objectForKey(image_path)
    cell.image = cached_image
  else
    image = UIImage.imageWithContentsOfFile(image_path)
    @images_cache.setObject(image, forKey: image_path)
    cell.image = image
  end
end

Again, jumpy and slow on the first full scroll but from then it's smooth sailing.

Approach #3

Load the images asynchronously... (and keep the caching)

def viewDidLoad
  ...
  @images_cache = NSCache.alloc.init
  @image_loading_queue = NSOperationQueue.alloc.init
  @image_loading_queue.maxConcurrentOperationCount = 3
  ...
end

def collectionView(collection_view, cellForItemAtIndexPath: index_path)
  cell = collection_view.dequeueReusableCellWithReuseIdentifier(CELL_IDENTIFIER, forIndexPath: index_path)
  image_path = @image_paths[index_path.row]

  if cached_image = @images_cache.objectForKey(image_path)
    cell.image = cached_image
  else
    @operation = NSBlockOperation.blockOperationWithBlock lambda {
      @image = UIImage.imageWithContentsOfFile(image_path)
      Dispatch::Queue.main.async do
        return unless collectionView.indexPathsForVisibleItems.containsObject(index_path)
        @images_cache.setObject(@image, forKey: image_path)
        cell = collectionView.cellForItemAtIndexPath(index_path)
        cell.image = @image
      end
    }
    @image_loading_queue.addOperation(@operation)
  end
end

This looked promising but there is a bug that I can't track down. On the initial view load all of the images are the same image and as you scroll whole blocks load with another image but all have that image. I've tried debugging it but I can't figure it out.

I've also tried substituting the NSOperation with Dispatch::Queue.concurrent.async { ... } but that seems to be the same.

I think this is probably the correct approach if I could get it to work properly.

Approach #4

In frustration I decided to just pre-load all the images as UIImages...

def viewDidLoad
  ...
  @preloaded_images = @image_paths.map do |path|
    UIImage.imageWithContentsOfFile(path)
  end
  ...
end

def collectionView(collection_view, cellForItemAtIndexPath: index_path)
  cell = collection_view.dequeueReusableCellWithReuseIdentifier(CELL_IDENTIFIER, forIndexPath: index_path)
  cell.image = @preloaded_images[index_path.row]
end

This turned out to be a bit slow and jumpy in the first full scroll but all good after that.

Summary

So, if anyone can point me in the right direction I'd be most grateful indeed. How does Photos app do it so well?


Note for anyone else: [The accepted] answer resolved my issue of images appearing in the wrong cells but I was still getting choppy scrolling even after resizing my images to correct sizes. After stripping my code down to the bare essentials I eventually found that UIImage#imageWithContentsOfFile was the culprit. Even though I was pre-warming a cache of UIImages using this method, UIImage seems to do some kind of lazy caching internally. After updating my cache warming code to use the solution detailed here - stackoverflow.com/a/10664707/71810 - I finally had super smooth ~60FPS scrolling.

回答1:

I think that approach #3 is the best way to go, and I think I may have spotted the bug.

You're assigning to @image, a private variable for the whole collection view class, in your operation block. You should probably change:

@image = UIImage.imageWithContentsOfFile(image_path)

to

image = UIImage.imageWithContentsOfFile(image_path)

And change all the references for @image to use the local variable. I'm willing to bet that the problem is that every time you create an operation block and assign to the instance variable, you are replacing what is already there. Due to some of the randomness of how the operation blocks are dequeued, the main queue async callback is getting the same image back because it is accessing the last time that @image was assigned.

In essence, @image acts like a global variable for the operation and async callback blocks.



回答2:

The best way I found of solving this problem was by keeping my own cache of images, and pre-warming it on viewWillAppear on a background thread, like so:

- (void) warmThubnailCache
{
    if (self.isWarmingThumbCache) {
        return;
    }

    if ([NSThread isMainThread])
    {
        // do it on a background thread
        [NSThread detachNewThreadSelector:@selector(warmThubnailCache) toTarget:self withObject:nil];
    }
    else
    {
        self.isWarmingThumbCache = YES;
        NIImageMemoryCache *thumbCache = self.thumbnailImageCache;
        for (GalleryImage *galleryImage in _galleryImages) {
            NSString *cacheKey = galleryImage.thumbImageName;
            UIImage *thumbnail = [thumbCache objectWithName:cacheKey];

            if (thumbnail == nil) {
                // populate cache
                thumbnail = [UIImage imageWithContentsOfFile:galleryImage.thumbImageName];

                [thumbCache storeObject:thumbnail withName:cacheKey];
            }
        }
        self.isWarmingThumbCache = NO;
    }
}

You can also use GCD instead of NSThread, but I opted for NSThread, since only one of these should run at a time, so no queue is necessary. Also, if you have an essentially limitless number of images, you will have to be more careful than I. You will have to be more clever about loading the images around a user's scroll location, rather than all of them, like I do here. I can do this because the number of images, for me, is fixed.

I should also add that your collectionView:cellForItemAtIndexPath: should use your cache, like so:

- (PSUICollectionViewCell *)collectionView:(PSUICollectionView *)collectionView
                  cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"GalleryCell";

    GalleryCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:CellIdentifier
                                                                                forIndexPath:indexPath];

    GalleryImage *galleryImage = [_galleryManager.galleryImages objectAtIndex:indexPath.row];

    // use cached images first
    NIImageMemoryCache *thumbCache = self.galleryManager.thumbnailImageCache;
    NSString *cacheKey = galleryImage.thumbImageName;
    UIImage *thumbnail = [thumbCache objectWithName:cacheKey];

    if (thumbnail != nil) {
        cell.imageView.image = thumbnail;
    } else {
        UIImage *image = [UIImage imageWithContentsOfFile:galleryImage.thumbImageName];

        cell.imageView.image = image;

        // store image in cache
        [thumbCache storeObject:image withName:cacheKey];
    }

    return cell;
}

Make sure your cache clears when you get a memory warning, or use something like NSCache. I'm using a framework called Nimbus, which has something called NIImageMemoryCache, which lets me set a max number of pixels to cache. Quite handy.

Other than that, one gotcha to take note is is to NOT use UIImage's "imageNamed" method, which does it's own caching, and will give you memory problems. Use "imageWithContentsOfFile" instead.