Question

So I've asked a couple of questions regarding the UICollectionView. Understanding how it works, I'm trying to implement lazy loading to load 15 images onto the view controller. I found many examples 1, 2, 3...first and third examples deal with only one operation, second example I don't think uses operations at all, only threads. My question is would it be possible to use a NSOperation class and use/reuse operations? I read that you can't rerun operations but I think you are able to once you initialize them again. Here's my code:

view controller:

UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc]init];
layout.sectionInset = UIEdgeInsetsMake(10, 20, 10, 20);
[layout setItemSize:CGSizeMake(75, 75)];
self.images = [[UICollectionView alloc]initWithFrame:CGRectMake(0, 230, self.view.frame.size.width, 200) collectionViewLayout:layout];
self.images.delegate = self;
self.images.dataSource = self;
[self.images registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"cellIdentifier"];
[self.view addSubview:self.images];
self.operation = [[NSOperationQueue alloc]init];
[self.operation addOperationWithBlock:^{
    NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"getgallery.php?user=%@", userId] relativeToURL:[NSURL URLWithString:@"http://www.mywebsite.com/"]];
    NSData *data = [NSData dataWithContentsOfURL:url];
//datasource for all images
    self.imagesGalleryPaths = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
        [self.images reloadData];
//reload collection view to place placeholders
    }];
}];

- (void)viewDidAppear:(BOOL)animated{
//get the visible cells right away
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
self.processedImages = [[NSMutableDictionary alloc]initWithCapacity:visibleCellPaths.count];
}
#pragma mark - collection view
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView{
return 1;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section{
int i;
if (self.imagesGalleryPaths.count == 0)
    i = 25;
else
    i = self.imagesGalleryPaths.count;
return i;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath{
UICollectionViewCell *cell = [UICollectionViewCell new];
cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];
cell.layer.borderWidth = 1;
cell.layer.borderColor = [[UIColor whiteColor]CGColor];
UIImageView *image = [[UIImageView alloc]init];
if (self.imagesGalleryPaths.count != 0) {
    if ([visibleCellPaths containsObject:indexPath]) {
        [self setUpDownloads:visibleCellPaths];
    }
    image.image = [UIImage imageNamed:@"default.png"];
    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
    [cell.contentView addSubview:image];
}
return cell;
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
[self setUpDownloads:visibleCellPaths];
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
[self setUpDownloads:visibleCellPaths];
}

- (void)setUpDownloads:(NSArray *)visiblePaths{
//I want to pass the visiblePaths to the NSOperation class if visible cells changed
GalleryOps *gallery = [[GalleryOps alloc]init];
//I will use a dictionary to keep track of which indexPaths are being downloaded...
//...so there are no duplicate downloads
}

GalleryOps.m

@implementation GalleryOps

- (void)main{
    //would I initialize all the operations here and perform them?
}

It's almost pointless to show the empty GalleryOps class because I have no idea how to initialize it with multiple operations. I know I have to override the main method, and once I get the image data from URL, I'll need to update the UI, for which I need a delegate method...another thing I don't yet know how to do but there are many examples to figure that out. My biggest question is how to pass the visible cells into this class and run multiple operations? When new visible cells come in, I'll run a check to see which to cancel, which to keep. Any advice here? Thanks in advance!

Was it helpful?

Solution

Looking at your proposed solution, it looks like you want to defer the question of making the operations cancelable. Furthermore, it looks like you want to defer the use of the cache (even though it's no more complicated than your NSMutableDictionary property).

So, setting that aside, your revised code sample has two "big picture" issues:

  1. You can dramatically simplify the image retrieval process. The use of startOperationForVisibleCells and the two scroll delegates is unnecessarily complicated. There is a much simpler pattern in which you can retire those three methods (and achieve an even better UX).

  2. Your cellForItemForIndexPath has a problem, that you're adding subviews. The issue is that cells are reused, so every time a cell is reused, you're adding more redundant subviews.

    You really should subclass UICollectionViewCell (CustomCell in my example below), put the configuration of the cell, including the adding of subviews, there. It simplifies your cellForItemAtIndexPath and eliminates the problem of extra subviews being added.

In addition to these two major issues, there were a bunch of little issues:

  1. You neglected to set maxConcurrentOperationCount for your operation queue. You really want to set that to 4 or 5 to avoid operation timeout errors.

  2. You are keying your imageGalleryData with the NSIndexPath. The problem is that if you ever deleted a row, all of your subsequent indexes would be wrong. I suspect this isn't an issue right now (you're probably not anticipating deleting of items), but if you keyed it by something else, such as the URL, it's just as easy, but it is more future-proof.

  3. I'd suggest renaming your operation queue from operation to queue. Likewise, I'd rename the UICollectionView from images (which might be incorrectly inferred to be an array of images) to something like collectionView. This is stylistic, and you don't have to do that if you don't want, but it's the convention I used below.

  4. Rather than saving the NSData in your NSMutableDictionary called imageGalleryData, you might want to save the UIImage instead. This saves you from having to reconvert from NSData to UIImage (which should make the scrolling process smoother) as you scroll back to previously downloaded cells.

So, pulling that all together, you get something like:

static NSString * const kCellIdentifier = @"CustomCellIdentifier";

- (void)viewDidLoad
{
    [super viewDidLoad];

    UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc]init];
    layout.sectionInset = UIEdgeInsetsMake(10, 20, 10, 20);
    [layout setItemSize:CGSizeMake(75, 75)];

    // renamed `images` collection view to `collectionView` to follow common conventions

    self.collectionView = [[UICollectionView alloc]initWithFrame:CGRectMake(0, 230, self.view.frame.size.width, 200) collectionViewLayout:layout];
    self.collectionView.delegate = self;
    self.collectionView.dataSource = self;

    // you didn't show where you instantiated this in your examples, but I'll do it here

    self.imageGalleryData = [NSMutableDictionary dictionary];

    // register a custom class, not `UICollectionViewCell`

    [self.collectionView registerClass:[CustomCell class] forCellWithReuseIdentifier:kCellIdentifier];
    [self.view addSubview:self.collectionView];

    // (a) change queue variable name;
    // (b) set maxConcurrentOperationCount to prevent timeouts

    self.queue = [[NSOperationQueue alloc]init];
    self.queue.maxConcurrentOperationCount = 5;

    [self.queue addOperationWithBlock:^{
        NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"getgallery.php?user=%@", userId] relativeToURL:[NSURL URLWithString:@"http://www.mywebsite.com/"]];
        NSData *data = [NSData dataWithContentsOfURL:url];
        //datasource for all images
        self.imagesGalleryPaths = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
        [[NSOperationQueue mainQueue]addOperationWithBlock:^{
            [self.collectionView reloadData];
        }];
    }];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return [self.imagesGalleryPaths count];          // just use whatever is the right value here, don't make this unnecessarily smaller
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    CustomCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kCellIdentifier forIndexPath:indexPath];

    NSString *key = self.imagesGalleryPaths[indexPath.row];                 // I don't know whether this was simply array, or some nested structure, so tweak this accordingly
    UIImage *image = self.imageGalleryData[key]; 

    if (image) {
        cell.imageView.image = image;                                       // if we have image already, just use it
    } else {
        cell.imageView.image = [UIImage imageNamed:@"profile_default.png"]; // otherwise set the placeholder ...

        [self.queue addOperationWithBlock:^{                                // ... and initiate the asynchronous retrieval
            NSURL *url = [NSURL URLWithString:...];                         // build your URL from the `key` as appropriate
            NSData *responseData = [NSData dataWithContentsOfURL:url];
            if (responseData != nil) {
                UIImage *downloadedImage = [UIImage imageWithData:responseData];
                if (downloadedImage) {
                    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                        self.imageGalleryData[key] = downloadedImage;
                        CustomCell *updateCell = (id)[collectionView cellForItemAtIndexPath:indexPath];
                        if (updateCell) {
                            updateCell.imageView.image = downloadedImage;
                        }
                    }];
                }
            }
        }];
    }

    return cell;
}

// don't forget to purge your gallery data if you run low in memory

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    [self.imageGalleryData removeAllObjects];
}

Now, clearly I don't have access to your server, so I couldn't check this (notably, I don't know if your JSON is returning a full URL or just a filename, or whether there was some nested array of dictionaries). But I don't want to you to get too lost in the details of my code, but rather look at the basic pattern: Eliminate your looping through visible cells and responding to scroll events, and let cellForItemAtIndexPath do all the work for you.

Now, the one thing that I introduced was the concept of CustomCell, which is a UICollectionViewCell subclass that might look like:

//  CustomCell.h

#import <UIKit/UIKit.h>

@interface CustomCell : UICollectionViewCell

@property (nonatomic, weak) UIImageView *imageView;

@end

and then move cell configuration and adding of the subview here to the @implementation:

//  CustomCell.m

#import "CustomCell.h"

@implementation CustomCell

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        self.layer.borderWidth = 1;
        self.layer.borderColor = [[UIColor whiteColor]CGColor];

        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
        [self addSubview:imageView];
        _imageView = imageView;
    }
    return self;
}
    
@end

By the way, I still contend that if you want to do this properly, you should refer to my other answer. And if you don't want to get lost in those weeds of the proper implementation, then just use a third party UIImageView category that supports asynchronous image retrieval, caching, prioritizing network requests of visible cells, etc., such as SDWebImage.

OTHER TIPS

You can use the queue and operations themselves to manage multiple operations. If I am reading your question correctly, you have one operation (get the list of image URLs from JSON) that you want to spawn child operations. You can do this by having the parent operation add the child operations to the queue, or by using dependent operations (child would have the parent as a dependancy).

For what you're trying to do you do not need to subclass NSOperation, NSBlockOperation should meet your needs. Subclassing NSOperation is trickier than it looks because of the KVO dependancies (it's very easy to get wrong).

But to the specifics of your question:

My question is would it be possible to use a NSOperation class and use/reuse operations? I read that you can't rerun operations but I think you are able to once you initialize them again

If you're initializing them again they're new objects (or at least, they should be). NSOperations can't be re-run because they have internal state - the tricky KVO bits I mention above. Once they go to "finished", that instance can't be returned to a clean state.

Operations should be fairly lightweight objects and there should not be any significant value in reusing them, and plenty of potential trouble. Creating new operations should be the way to go.

The Apple sample code "LazyTableImages" may give you some hints as how to accomplish what you're trying to do.

The constituent elements of an NSOperation-based lazy loading of images might include:

  1. Create a dedicated NSOperationQueue that will be used for the download operations. Generally this is configured with a maxConcurrentOperationCount of 4 or 5 so that you enjoy concurrency, but so that you won't exceed the maximum number of concurrent network operations.

    If you don't use this maxConcurrentOperationCount, with slow network connections (e.g. cellular), you risk having network requests time out.

  2. Have a model object (e.g. an array) that backs your collection view or table view. This would generally only have some identifier for the image (e.g. the URL) not the image itself.

  3. Implement a cache mechanism to store the downloaded images, to prevent the need to re-download images that have already been downloaded. Some implementations only do memory based cache (via NSCache). Others (e.g. SDWebImage) will do two tiers of cache, both memory (NSCache, for optimal performance) and a secondary persistent storage cache (so that when memory pressure forces you to purge the NSCache, you still have a rendition saved on the device so you don't have to re-retrieve it from the network). Others (e.g. AFNetworking) rely upon NSURLCache to cache the responses from the server into persistent storage.

  4. Write a NSOperation subclass for downloading a single image. You want to make this cancelable operation. That implies two different design considerations

    • First, regarding the operation itself, you probably want to make a concurrent operation (see the Configuring Operations for Concurrent Execution section in the Concurrency Programming Guide).

    • Second, regarding the network request, you want a cancelable network request. If using NSURLConnection, this means using the delegate-based rendition (not any of the convenience methods). And if using NSURLConnection, the trick is that you have to schedule it in a run loop that persists. There are a number of tricks to accomplish this, but the easiest is to schedule it (with scheduleInRunLoop:forMode:) in the main run loop (though there are more elegant approaches), even though you will be running this from an operation in an NSOperationQueue. Personally I launch a new dedicated thread (like AFNetworking does) for this purpose, but the main run loop is easier and is fine for this sort of process.

      If using NSURLSession, this process is conceivably a tad easier, because you can get away with using the completion block rendition of dataTaskWithRequest and not get into the delegate-based implementation if you don't want to. But this is iOS 7+ only (and if you need to do anything fancy like handle authentication challenge requests, you'll end up going the delegate-based approach anyway).

    • And combining those two prior points, the custom NSOperation subclass would detect when the operation is canceled and then cancel the network request and complete the operation.

    By the way, instances of operations are never reused. You create a new operation instance for each image you are downloading.

  5. By the way, if the images you've downloaded are large (e.g. they have dimensions greater than the number of pixels that the image view needs), you may want to resize the images before using them. When JPG or PNG images are downloaded, they are compressed, but when you use them in an image view they are uncompressed, usually require 4 bytes per pixel (e.g. a 1000x1000 image will require 4mb, even though the JPG is much smaller than that).

    There are lots of image resizing algorithms out there, but here is one: https://stackoverflow.com/a/10859625/1271826

  6. You will want a cellForItemAtIndexPath that then pulls the above pieces together, namely:

    • Check to see if the image is already in the cache, if so, use it.

    • If not, you will start a network request to retrieve the image. You might want to see if this cell (which may be a reused cell from your table view) already has an image operation already in progress, and if so, just cancel it.

      Anyway, you can then instantiate a new NSOperation subclass for the downloading of the image and have the completion block update the cache and then also cell's image view.

    • By the way, when you asynchronously update the cell's image view, make sure the cell is still visible and that the cell hasn't been reused in the interim. You can do this my calling [collectionView cellForItemAtIndexPath:indexPath] (which should not be confused with the similarly named UICollectionViewDataSource method that you're writing here).

Those are the constituent parts of the process, and I'd suggest you tackle them one at a time. There's a lot involved in writing an elegant implementation of lazy loading.

The easiest solution is to consider using an existing UIImageView category (such as provided with SDWebImage) which does all of this for you. Even if you don't use that library, you'll might be able to learn quite a bit by reviewing the source code.

I figure my collection view is not something the user will come back to over and over again, just once in a while. So no reason to cache all the images.

The viewDidAppear is still the same, I get the visible cells right away. The reason why initially I put 25 cells into numberOfItems... is just to get the visible cells right away. So now my cellForItemAtIndexPath is this:

UICollectionViewCell *cell = [UICollectionViewCell new];
cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"cellIdentifier" forIndexPath:indexPath];
cell.layer.borderWidth = 1;
cell.layer.borderColor = [[UIColor whiteColor]CGColor];
UIImageView *image = [[UIImageView alloc]init];
if (self.imagesGalleryPaths.count != 0) {
    image.image = [UIImage imageNamed:@"profile_default.png"];
    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
    [cell.contentView addSubview:image];
}
return cell;

In the viewDidLoad I added this:

if (self.imagesGalleryPaths.count != 0) {
    [[NSOperationQueue mainQueue]addOperationWithBlock:^{
        [self.images reloadData];
        [self startOperationForVisibleCells];
    }];
}

This is my startOperationForVisibleCells:

[self.operation addOperationWithBlock:^{
    int i=0;
    while (i < visibleCellPaths.count) {
        NSIndexPath *indexPath = [visibleCellPaths objectAtIndex:i];
        if (![self.imageGalleryData.allKeys containsObject:indexPath]) {
            NSURL *url = [@"myurl"];
            NSData *responseData = [NSData dataWithContentsOfURL:url];
            if (responseData != nil) {
                [self.imageGalleryData setObject:responseData forKey:indexPath];
                [[NSOperationQueue mainQueue]addOperationWithBlock:^{
                    UICollectionViewCell *cell = [self.images cellForItemAtIndexPath:indexPath];
                    UIImageView *image = [UIImageView new];
                    image.image = [UIImage imageWithData:responseData];
                    image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
                    [cell.contentView addSubview:image];
                }];
            }
        }
        i++;
    }
}];

And that's how I update the cells one by one. Also when the user scrolls away:

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
visibleCellPaths = [NSArray new];
visibleCellPaths = self.images.indexPathsForVisibleItems;
for (int i=0; i<visibleCellPaths.count; i++) {
    NSIndexPath *indexPath = [visibleCellPaths objectAtIndex:i];
    if ([self.imageGalleryData.allKeys containsObject:indexPath]) {
        UICollectionViewCell *cell = [self.images cellForItemAtIndexPath:indexPath];
        UIImageView *image = [UIImageView new];
        image.image = [UIImage imageWithData:[self.imageGalleryData objectForKey:indexPath]];
        image.frame = CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height);
        [cell.contentView addSubview:image];
    }else{
        [self startOperationForVisibleCells];
    }
}
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
//same functions as previous
}

I am sure this is a very bad way of doing it but for now, it works. The images are loaded one by one and they stop loading when the user scrolls away.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top