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.
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.