8

I have a UICollectionView in my app, and each cell is a UIImageView and some text labels. The problem is that when I have the UIImageViews displaying their images, the scrolling performance is terrible. It's nowhere near as smooth as the scrolling experience of a UITableView or even the same UICollectionView without the UIImageView.

I found this question from a few months ago, and it seems like an answer was found, but it's written in RubyMotion, and I don't understand that. I tried to see how to convert it to Xcode, but since I have never used NSCache either, it's a little hard to. The poster there also pointed to here about implementing something in addition to their solution, but I'm not sure where to put that code either. Possibly because I don't understand the code from the first question.

Would someone be able to help translate this into Xcode?

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

Here is the code from the second question that the asker of the first question said solved the problem:

UIImage *productImage = [[UIImage alloc] initWithContentsOfFile:path];

CGSize imageSize = productImage.size;
UIGraphicsBeginImageContext(imageSize);
[productImage drawInRect:CGRectMake(0, 0, imageSize.width, imageSize.height)];
productImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

Again, I'm not sure how/where to implement that.

Many thanks.

Community
  • 1
  • 1
Nick
  • 517
  • 1
  • 6
  • 24
  • Post your code! What have you tried? Why is your app's performance slow? What's the result of profiling it to see what's going on? – Jim Puls Apr 03 '13 at 22:49
  • It isn't my entire app that is having slow performance, it's only scrolling through the cells in the UICollectionView. The rest of the app performs very well. I'm currently using `cell.cardImageView.image = [UIImage imageWithData:[NSData dataWithContentsOfFile:tempCard]];` to set the image. The image(s) being used is not part of the app bundle, but usually downloaded and stored in the Caches folder. – Nick Apr 04 '13 at 02:11

3 Answers3

19

Here's the pattern I follow. Always load asynch and cache the result. Make no assumption about the state of the view when the asynch load finishes. I have a class that simplifies the loads as follows:

//
//  ImageRequest.h

// This class keeps track of in-flight instances, creating only one NSURLConnection for
// multiple matching requests (requests with matching URLs).  It also uses NSCache to cache
// retrieved images.  Set the cache count limit with the macro in this file.

#define kIMAGE_REQUEST_CACHE_LIMIT  100
typedef void (^CompletionBlock) (UIImage *, NSError *);

@interface ImageRequest : NSMutableURLRequest

- (UIImage *)cachedResult;
- (void)startWithCompletion:(CompletionBlock)completion;

@end

//
//  ImageRequest.m

#import "ImageRequest.h"

NSMutableDictionary *_inflight;
NSCache *_imageCache;

@implementation ImageRequest

- (NSMutableDictionary *)inflight {

    if (!_inflight) {
        _inflight = [NSMutableDictionary dictionary];
    }
    return _inflight;
}

- (NSCache *)imageCache {

    if (!_imageCache) {
        _imageCache = [[NSCache alloc] init];
        _imageCache.countLimit = kIMAGE_REQUEST_CACHE_LIMIT;
    }
    return _imageCache;
}

- (UIImage *)cachedResult {

    return [self.imageCache objectForKey:self];
}

- (void)startWithCompletion:(CompletionBlock)completion {

    UIImage *image = [self cachedResult];
    if (image) return completion(image, nil);

    NSMutableArray *inflightCompletionBlocks = [self.inflight objectForKey:self];
    if (inflightCompletionBlocks) {
        // a matching request is in flight, keep the completion block to run when we're finished
        [inflightCompletionBlocks addObject:completion];
    } else {
        [self.inflight setObject:[NSMutableArray arrayWithObject:completion] forKey:self];

        [NSURLConnection sendAsynchronousRequest:self queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) {
            if (!error) {
                // build an image, cache the result and run completion blocks for this request
                UIImage *image = [UIImage imageWithData:data];
                [self.imageCache setObject:image forKey:self];

                id value = [self.inflight objectForKey:self];
                [self.inflight removeObjectForKey:self];

                for (CompletionBlock block in (NSMutableArray *)value) {
                    block(image, nil);
                }
            } else {
                [self.inflight removeObjectForKey:self];
                completion(nil, error);
            }
        }];
    }
}

@end

Now the cell (collection or table) update is fairly simple:

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {

    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];

    NSURL *url = [NSURL URLWithString:@"http:// some url from your model"];
    // note that this can be a web url or file url

    ImageRequest *request = [[ImageRequest alloc] initWithURL:url];

    UIImage *image = [request cachedResult];
    if (image) {
        UIImageView *imageView = (UIImageView *)[cell viewWithTag:127];
        imageView.image = image;
    } else {
        [request startWithCompletion:^(UIImage *image, NSError *error) {
            if (image && [[collectionView indexPathsForVisibleItems] containsObject:indexPath]) {
                [collectionView reloadItemsAtIndexPaths:@[indexPath]];
            }
        }];
    }
    return cell;
}
danh
  • 62,181
  • 10
  • 95
  • 136
  • Thanks for that. I tried to put it in my app, and `url` is always coming up `nil`. Any thought? – Nick Apr 04 '13 at 02:19
  • Did you replace that awful string I used as an example with a real web address? – danh Apr 04 '13 at 02:32
  • Yes, I did replace it. However, I'm using a local URL pointing to the Caches directory rather than a web address. That shouldn't be a problem though, right? Core Data uses an `NSURL` to point to the app's store locally. – Nick Apr 04 '13 at 12:49
  • I've never tried it with local URL, but your idea sounds right. Maybe just force a web address of an image in there to prove it works and rule out the local thing. Also maybe have the cache just answer an imageNamed: just to prove the code. It's good code -I use it all the time - but I had to edit it some to remove my app details. – danh Apr 04 '13 at 14:02
  • I found what was causing `url` to be nil. I was using a **path** rather than **url**. Essentially, I was missing the `file://` part of the string. I'm now able to see the full URL that is generated, but it's still not loading the image. When I put the URL into Safari on my computer, it loads the image perfectly, so I know that the URL is valid. However, loading the image from my server rather than locally does work, and the scrolling is amazingly smooth. By following the steps, both local and remote URLs behave similarly, so I'm not sure where the issue is. – Nick Apr 04 '13 at 16:30
  • So is it your design to always get the images from the local file system? If that's the case, we should just optimize around that and forget about making web requests. I built this code all about remote images. Local ones should be easier. I can cobble something together if pure local is what you're up to. – danh Apr 04 '13 at 16:35
  • Well, yes and no. Typically, by the time the user gets to this part of the app, it will have already saved a local copy of the image. However, I just modified your code here so it can also save a local copy of the image. I'm just not sure how to tell it to load the local version (if it's there) instead of either looking remotely or loading from (I assume) the `Cache.db` file. It seems like it does keep a local copy of the image somewhere because after running the app once, it never went back to `- (void)startWithCompletion:(void (^)(UIImage *, NSError *))completion` – Nick Apr 04 '13 at 17:57
  • @Nick - hopefully for you -- or if you're all set for anyone finding this later -- I fixed it up and tested it in some hard situations, including file urls, including many matching file or web requests. – danh Apr 05 '13 at 00:42
  • You are the best! This code works perfectly! My `UICollectionView` scrolls as smoothly with the images as it does without. Thank you so much! – Nick Apr 05 '13 at 02:24
  • can be improved with collectionView:didEndDisplayingCell:forItemAtIndexPath: i suppose – catamphetamine Jan 08 '14 at 07:40
  • Thanks so much for this! It's working great! For some reason, when I scroll through my collectionview fast (only if I scroll right when opening the app), I get an exception: too many update animations on one view - limit is 31 in flight at a time. Any idea what could be the cause of this? Thanks again! :) – Dehli Jun 06 '14 at 11:16
  • I googled that and it looks like a real phenomenon related to lots of individual actions on a collection view. The consensus fix seems to be to group them, but I'll have to think about how to apply that here. – danh Jun 06 '14 at 17:18
  • Not sure if you could have this problem with how it is constructed here, but if you are asynchronously loading the image inside of the collection view cell code, you can hit an error if the image is still loading when the UICollectionView has gone away (perhaps been popped off a navigation stack). To fix it, use the weak/strong dance on the collectionView variable and only call the reloadItemsAtIndexPaths if the collectionView variable is not nil. – Michael McGuire Aug 12 '14 at 16:28
  • Thanks @MichaelMcGuire. This is a good example of when not to use a weak copy of the block variables. It's the block's retention of the collectionView (and self, the vc) that protects us from them getting deallocated before the request finishes. In the scenario you mention, some navigation controller might release the vc (and therefore the collection), and the requests will finish just fine, harmlessly updating the now invisible views. – danh Aug 12 '14 at 22:00
  • @danh Only that's not what happens at all. If the UICollectionView has been popped from the navigation controller, and you try to execute the reloadItemsAtIndexPaths, you end up getting a EXC_BAD_ACCESS KERN_INVALID_ADDRESS from the UICollectionView. It isn't in a state where you can update the rows. Again, this may have been because I was performing this code inside of the UICollectionViewCell itself via a method that set itself up. One of the parameters being passed in was the UICollectionView itself. – Michael McGuire Aug 12 '14 at 22:03
  • I just checked, and you are correct. When performing this directly in the view controller, you don't get a retain cycle (obviously). However, in my case where I was calling into the cell, passing an instance of the UICollectionView, I was getting a retain cycle, which was causing my error. – Michael McGuire Aug 12 '14 at 22:35
  • In my case the app was crashing when trying to fetch a remote image that didn't exist in the server. So I added the following condition inside ImageRequest.m inside the completion handler line ~47. if (!error && [(NSHTTPURLResponse*)response statusCode] == 200) { [...] cheers – julio Mar 03 '15 at 18:44
  • What is going on in this line `if (image) return completion(image, nil);`. The line returns a `block` inside of a `void` method, so what is the `block` being returned to? – Idr Jun 18 '15 at 04:52
  • That doesn't return the block, that *invokes* the block, giving the caller (who passed the block) the promised image. The return part halts execution of the function. – danh Jun 18 '15 at 05:22
8

In general bad scrolling behaviour for UICollectionViews or UITableViews happens because the cells are dequeued and constructed in the main thread by iOS. There is little freedom to precache cells or construct them in a background thread, instead they are dequeued and constructed as you scroll blocking the UI. (Personally I find this bad design by Apple all though it does simplify matters because you don't have to be aware about potential threading issues. I think they should have given a hook though to provide a custom implementation for a UICollectionViewCell/UITableViewCell pool which can handle dequeuing/reusing of cells.)

The most important causes for performance decrease are indeed related to image data and (in decreasing order of magnitude) are in my experience:

  • Synchronous calls to download image data: always do this asynchronously and call [UIImageView setImage:] with the constructed image when ready in the main thread
  • Synchronous calls to construct images from data on the local file system, or from other serialized data: do this asynchronously as well. (e.g. [UIImage imageWithContentsOfFile:], [UIImage imageWithData:], etc).
  • Calls to [UIImage imageNamed:]: the first time this image is loaded it is served from the file system. You may want to precache images (just by loading [UIImage imageNamed:] before the cell is actually constructed such that they can be served from memory immediately instead.
  • Calling [UIImageView setImage:] is not the fastest method either, but can often not be avoided unless you use static images. For static images it is sometimes faster to used different image views which you set to hidden or not depending on whether they should be displayed instead of changing the image on the same image view.
  • First time a cell is dequeued it is either loaded from a Nib or constructed with alloc-init and some initial layout or properties are set (probably also images if you used them). This causes bad scrolling behaviour the first time a cell is used.

Because I am very picky about smooth scrolling (even if it's only the first time a cell is used) I constructed a whole framework to precache cells by subclassing UINib (this is basically the only hook you get into the dequeuing process used by iOS). But that may be beyond your needs.

Werner Altewischer
  • 10,080
  • 4
  • 53
  • 60
1

I had issues about UICollectionView scrolling.

What worked (almost) like a charm for me: I populated the cells with png thumbnails 90x90. I say almost because the first complete scroll is not so smooth, but never crashed anymore.

In my case, the cell size is 90x90.

I had many original png sizes before, and it was very choppy when png original size was greater than ~1000x1000 (many crashes on first scroll).

So, I select 90x90 (or the like) on the UICollectionView and display the original png's (no matter the size). hope it may help others.

Irfan
  • 4,301
  • 6
  • 29
  • 46