0

I have created a UItableview with custom cells. The cells have two UILabels and an imageview that calls for an image on a background thread. The cells first load some placeholder data in case the API call takes long. Then, using a notification, the tableview is reloaded with the new data. When the app starts on the iPhone, it scrolls fine up and down. However, after scrolling fast both up and down, the cells start to break down. Sometimes a whole new list is generated below the current list. Other times, the last cell may be cut in half. What's the reason for this? Would you please help? Thank you very much!

TableViewVC.m:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return [self.appEntries count];
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {

    return CELL_HEIGHT;
}


- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    ELAppCell *elAppCell = (ELAppCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    AppEntry *appEntry = [self.appEntries objectAtIndex:indexPath.row];

    if (!elAppCell) {
        elAppCell = [[ELAppCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier appEntry:appEntry];
    }
    else {
        [elAppCell configureCellWithAppEntry:appEntry];
    }
    return elAppCell;
}

And the custom cell class:

@implementation ELAppCell

- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier appEntry:(AppEntry *)appEntry
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {

        self.appNameLabel = [self buildAppNameLabel:appEntry.name];
        self.thumbnailImageView = [self buildThumbnailImageView:appEntry.smallPictureURl];
        self.appArtistLabel = [self buildAppArtistLabel:appEntry.artist];

        [self.contentView addSubview:self.appNameLabel];
        [self.contentView addSubview:self.thumbnailImageView];
        [self.contentView addSubview:self.appArtistLabel];
    }
    return self;
}

- (void)configureCellWithAppEntry:(AppEntry *)appEntry{

    dispatch_queue_t fetchQ = dispatch_queue_create("ConfigureCell", NULL);
    dispatch_async(fetchQ, ^{

        NSURL *address = [NSURL URLWithString:appEntry.smallPictureURl];
        UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:address]];
        dispatch_async(dispatch_get_main_queue(), ^{

            self.appNameLabel.text = appEntry.name;
            self.thumbnailImageView.image = image;
            self.appArtistLabel.text = [NSString stringWithFormat:@"By %@", appEntry.artist];

        });
    });
}
rmaddy
  • 314,917
  • 42
  • 532
  • 579
Edan
  • 167
  • 14
  • I solved this problem by changing making it a subclass of a view controller instead of a tableview controller. The I eliminated the `initWithStyle` method and moved all the initialization functionality into `viewDidLoad`. – Edan Jul 23 '14 at 06:26

2 Answers2

1
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    NSString *CellIdentifier = [NSString stringWithFormat:@"cell %ld %ld",(long)indexPath.row,(long)indexPath.section];

    ELAppCell *elAppCell = (ELAppCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    elAppCell=nil;

    AppEntry *appEntry = [self.appEntries objectAtIndex:indexPath.row];


    if (elAppCell == nil)
    {

        NSArray *topLevelObjects = [[NSBundle mainBundle] loadNibNamed:@"ELAppCell" owner:nil options:nil];

        for(id currentObject in topLevelObjects)
        {
            if([currentObject isKindOfClass:[MYTableViewCell class]])
            {
                cell = (MYTableViewCell *)currentObject;
                break;
            }
        }


        elAppCell = [[ELAppCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier appEntry:appEntry];

    }
    return elAppCell;

}
Bhavesh Nayi
  • 3,626
  • 1
  • 27
  • 42
  • Do you intend for MYTableViewCell to be ELAppCell and for cell to be elAppCell? When I do change it to the names I gave, I am getting a crash - *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Could not load NIB in bundle: 'NSBundle <.......app> (loaded)' with name 'ELAppCell'' – Edan Jul 17 '14 at 05:13
  • Please don't ask others to see your answer. They're already notified when you post the answer, and comments you've left to this regard have been getting flagged and removed. – Brad Larson Jul 17 '14 at 16:14
0

There is a fundamental problem with your code. Consider fetching of images in view controller. The problem that you can face with current code is if it would take a long time to load an image, there is possibility(quite hight in case user scrolls quickly) that cell can be dequeued for rendering of another row but image from previous row will be just download and displayed which will cause inconsistent UI.

There are 2 places in ELAppCell that cause the described bug:

  • - (void)configureCellWithAppEntry:(AppEntry *)appEntry{ that we already fixed
  • - (UIImageView *)buildThumbnailImageView: (NSString *)imageUrl you should remove(or comment out) all dispatch calls. Calls to dispatch... are redundant and cause the bug; TableViewController in charge of loading images. Should you need to load small images then consider loading them in view controller as well.

There is also a serious bug in your ELAppListTableVC, your should remove appListTableView property and all places in the code where you use it. And in receiveEvent:notification method you should call [self.tableView reloadData]; instead of reloading appListTableView. Basically you had two table views laying one on another, where appListTableView was added manually. NOTE: your ELAppListTableVC already inherited from UITableViewController so it already has tableView that get automatically instantiated by the view controller.

Code that loads image can be moved to view controller or be a part of a model class. Details bellow:

Load image in view controller

After image gets loaded you need to update the cell with particular index path because the index path represent AppEntry instance for which image was downloaded. It means that you should ask table view to return cell that represents that index path. It may turn that cell currently invisible, it this case you will get nil. So in callback block that gets invoked on main queue once download completed you need to have a reference to that index path. Since code is better that hundred of worlds, here is how it should look:

- (UITableViewCell *)tableView:(UITableView *)tableView 
         cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    ELAppCell *elAppCell = (ELAppCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    AppEntry *appEntry = [self.appEntries objectAtIndex:indexPath.row];

    if (!elAppCell) {
        elAppCell = [[ELAppCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier appEntry:appEntry];
    } 

    [elAppCell configureCellWithAppEntry:appEnty];

    self.thumbnailImageView.image = nil;
    dispatch_queue_t fetchQ = dispatch_queue_create("ConfigureCell", NULL);
    dispatch_async(fetchQ, ^{
        NSURL *address = [NSURL URLWithString:appEntry.smallPictureURl];
        UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:address]];
        dispatch_async(dispatch_get_main_queue(), ^{
            ELAppCell *updateCell = (ELAppCell *)[tableView cellForRowAtIndexPath:indexPath];
            if (updateCell) { // if nil then cell is not visible hence no need to update
                updateCell.thumbnailImageView.image = image;
            }
        });
    });

    return elAppCell;
}

after refactoring this configureCellWithAppEntry: code should look like this:

- (void)configureCellWithAppEntry:(AppEntry *)appEntry {
    self.appNameLabel.text = appEntry.name;
    self.appArtistLabel.text = [NSString stringWithFormat:@"By %@", appEntry.artist];
}

Much better code but still youэдд be re-downloading images that were already downloaded when user scrolls back.

Move image loading code to model class (further improvement of the code above)

You perform download each time cell gets displayed, i.e. image that was already downloaded will be downloaded again. To cache images you can add thumbnailImage property to AppEntry class and load this image lazily, only when user need is. To do that you can update your AppEntry class with the code bellow:

AppEntry.h

@interface AppEntry // if it is CoreData object here should be AppEntry: NSManagedObject

@property (nonatomic, readonly) UIImage *thumbnailImage;

- (void)loadThumbnailImage:(void (^)())completionBlock;

@end

AppEntry.m

@interface AppEntry ()

@property (nonatomic, readwrite) UIImage *thumbnailImage;

@end

@implementation AppEntry 

- (void)loadThumbnailImage:(void (^)())completionBlock {
    dispatch_queue_t fetchQ = dispatch_queue_create("ConfigureCell", NULL);
    dispatch_async(fetchQ, ^{
        NSURL *address = [NSURL URLWithString:self.smallPictureURl];
        UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:address]];
        dispatch_async(dispatch_get_main_queue(), ^{
            self.thumbnailImage = image;
            completionBlock();
        });
    });
}

@end

of course view controller should be updated as well:

- (UITableViewCell *)tableView:(UITableView *)tableView 
         cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    ELAppCell *elAppCell = (ELAppCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    AppEntry *appEntry = [self.appEntries objectAtIndex:indexPath.row];

    if (!elAppCell) {
        elAppCell = [[ELAppCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier appEntry:appEntry];
    } 

    [elAppCell configureCellWithAppEntry:appEnty];

    if (appEntry.thumbnailImage) {
        elAppCell.thumbnailImageView.image = appEntry.thumbnailImage;
    } else {
        [appEntry loadThumbnailImage: ^{            
            elAppCell.thumbnailImageView.image = appEntry.thumbnailImage;
        }];
    }

    return elAppCell;
}

and as soon as we have thumbnail image in AppEntry model we can update cell code to use it in configureCellWithAppEntry: method:

- (void)configureCellWithAppEntry:(AppEntry *)appEntry{
    self.appNameLabel.text = appEntry.name;
    self.appArtistLabel.text = [NSString stringWithFormat:@"By %@", appEntry.artist];
    self.thumbnailImageView.image = appEntry.thumbnailImage;
}

Even with image cache we can have re-downloading when user can scroll up and down so quickly that thumbnailImage can be nil for the AppEntry instance because image has not been downloaded yet. So we need to handle image downloading state some how, details bellow.

Optimization of image downloading and caching

To solve problem with sequential calls to loadThumbnailImage:, when we do perform a new download while we have one in the progress already we need to handle downloadInProgress state. AppEntry need to be tweaked to:

@implementation AppEntry {
    dispatch_group_t _thumbnailDownloadGroup;
    dispatch_queue_t _thumbnailFetchQueue;
    dispatch_once_t _thumbnailQOnceToken;
    BOOL _loadingThumbnail;
}

- (void)loadThumbnailImage:(void (^)())completionBlock {
    if (self.thumbnailImage) { // return immediately since we have image
        completionBlock();
        
    } else if (_loadingThumbnail) { // return when previously requested download complete
        dispatch_group_notify(_thumbnailDownloadGroup, _thumbnailFetchQueue, ^{
            completionBlock();
        });
        
    } else { // download image
        dispatch_once(&_thumbnailQOnceToken, ^{
            _thumbnailDownloadGroup = dispatch_group_create();
            _thumbnailFetchQueue = dispatch_queue_create("ThumbnailDownload", NULL);
        });
        
        __weak typeof(self) weakSelf = self;
        dispatch_group_async(_thumbnailDownloadGroup, _thumbnailFetchQueue, ^{
            NSURL *address = [NSURL URLWithString:weakSelf.smallPictureURl];
            UIImage *image = [UIImage imageWithData:[NSData dataWithContentsOfURL:address]];
            dispatch_async(dispatch_get_main_queue(), ^{
                self.thumbnailImage = image;
                completionBlock();
            });
        });
    }
}

@end

I have not run the examples to test but I hope you can get the idea.

Community
  • 1
  • 1
Keenle
  • 12,010
  • 3
  • 37
  • 46
  • 1
    You number 1 point is not necessary, and in fact might be wrong depending on how the OP is making his cell. The newer method (with forIndexPath) has to return a cell, and will crash if it doesn't. Since it looks like the OP is going into his "if (!elAppCell)" clause, then he must be returning nil on the dequeue. So, he should be using the old method in his particular case. – rdelmar Jul 16 '14 at 03:58
  • Yeah, you are right. Removed that part from the answer. – Keenle Jul 16 '14 at 04:01
  • Thanks @Keenle, to clarify, do you suggest moving the image fetching to the view controller or to the model? The first paragraph says the former and the second says the latter. – Edan Jul 17 '14 at 05:39
  • I've expanded the answer. Now it looks like a tutorial to async loading of images for table view cells. Hope it can help you to improve the code. – Keenle Jul 17 '14 at 13:08
  • @Keenle I've moved the image loading into the VC as you suggested in the first example. Unfortunately that does not solve the problem. Now, when I scroll all the way down, and try to scroll some more, a new set of cells are generated below the last one. When I scroll to the top, I get half a cell (not the cell with the first entry). – Edan Jul 17 '14 at 14:40
  • @Edan, sorry for not noticing your comment. Can you share list of methods that you've implemented for `UITableViewDelegate` and `UITableViewDataSource` protocols for `TableViewVC`? Have you overridden `prepareForReuse` method in `ELAppCell`? – Keenle Jul 21 '14 at 15:01
  • @Keenle The methods I've used are numberOfSectionsInTableView, numberOfRowsInSection, heightForRowAtIndexPath, cellForRowAtIndexPath, and didSelectRowAtIndexPath. I haven't used prepareForReuse. The problem continues to occur when I comment out the UIimage implementation and only use UIlabels in the cells. – Edan Jul 21 '14 at 21:09
  • Can you share code where you build labels and imageView? – Keenle Jul 21 '14 at 21:33
  • @Keenle you can see the whole code for this practice project right on git hub: [https://github.com/Ruckt/iTunesCalling/blob/master/iTunesCalling/iTunesCalling/ELAppListTableVC.m] – Edan Jul 22 '14 at 05:38
  • @Edan, I've checked the code. There was additional problem similar to the one that we already solved, see updated unswear for details. – Keenle Jul 22 '14 at 06:53
  • @Keenle, while repeating the dispatch calls was a stupid mistake on my part, I do not think the image calls are causing this problem. The problem continues - when I reach either the top or bottom end of the table view and pull a second time, a new set of cells forms above or below the 1st/last cell. – Edan Jul 22 '14 at 16:15
  • @Edan, have you tried to remove second table view as I suggested? Does it work? – Keenle Jul 26 '14 at 10:15
  • @Keenle, yes, I did remove it and it worked!! That was the problem. – Edan Jul 28 '14 at 03:38