How to get a snappy UICollectionView with lots of images (50-200)?

Alistair Holt picture Alistair Holt · Dec 13, 2012 · Viewed 13.4k times · Source

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.

Answer

Jonathan Penn picture Jonathan Penn · Dec 13, 2012

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.