Search code examples
iosuitableviewios7pull-to-refresh

Pull to refresh crashes app


I have an app, that takes events from 3 shared Google Calendars and displays it in a table view.

I wanted to implement pull to refresh, but the app keeps crashing, if I let go from the pull, before the data is loaded. (if I hold the pull for a couple of seconds everything is fine - if I let go immediately it crashes.

Code:

-(void)viewDidLoad
{
    [super viewDidLoad];    
    UIRefreshControl *refresh = [[UIRefreshControl alloc] init];
    refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Pull to Refresh"];
    [refresh addTarget:self action:@selector(getEvents) forControlEvents:UIControlEventValueChanged];
    self.refreshControl = refresh;
    startDates = [[NSMutableArray alloc] init];
    [self getEvents];
}

- (void)stopRefresh
{
    [self.refreshControl endRefreshing];
}

-(void)getEvents
{
    [startDates removeAllObjects];
    startDates = [NSMutableArray array];
    sectionEntries = [NSMutableArray array];
    entries = [NSMutableArray array];
    sortedStartDates = [[NSArray alloc]init];
    _imageForCalendarType = [[NSDictionary alloc]init];
    _imageForCalendarType = @{
                              @"The Irish House Music Calendar" : [UIImage imageNamed:@"music.png"]
                              ,   @"FixedEvents-Student Night"  : [UIImage imageNamed:@"student.png"]
                              ,   @"FixedEvents-Ladies Night"         : [UIImage imageNamed:@"cocktail.png"]
                              ,   @"AppTest"         : [UIImage imageNamed:@"football.png"]
                              };
    dispatch_async(kBgQueue, ^{

        NSData* data = [NSData dataWithContentsOfURL:sportsCalendarURL];
        [self performSelectorOnMainThread:@selector(fetchedData:) withObject:data waitUntilDone:YES];

        NSData* data2 = [NSData dataWithContentsOfURL:musicCalendarURL];
        [self performSelectorOnMainThread:@selector(fetchedData:) withObject:data2 waitUntilDone:YES];

        NSData* data3 = [NSData dataWithContentsOfURL:fixedCalendarURL];
        [self performSelectorOnMainThread:@selector(fetchedData:) withObject:data3 waitUntilDone:YES];

        // Reload table view - UI operation, so must be run on main thread
        dispatch_async(dispatch_get_main_queue(), ^{

            sortedStartDates = [startDates sortedArrayUsingSelector:@selector(compare:)];
            [self.tableView reloadData];
            [self performSelector:@selector(stopRefresh) withObject:nil afterDelay:2.5];
        });
    });


}

It gives me a SIGABRT error in this line in the cellForRowAtIndexPath method:

 NSInteger index = [self getRow:sortedStartDates[indexPath.section]];  // get correct index for sectionEntries

Error: * Terminating app due to uncaught exception 'NSRangeException', reason: '* -[__NSArrayI objectAtIndex:]: index 4 beyond bounds for empty array'

It seems like the error is because theres no data in my startDates NSMutableArray, but if i comment the line [startDates removeAllObjects] I get redundant cells.


Solution

  • At the very least, I'd suggest checking to make sure a refresh is not already in progress. You might also want to change your getEvents to take the refresh control as a parameter and for it to update the pull down accordingly (so the user will know that a refresh is in progress):

    - (void)viewDidLoad
    {
        [super viewDidLoad];
    
        _imageForCalendarType = @{
                                  @"The Irish House Music Calendar" : [UIImage imageNamed:@"music.png"]
                              ,   @"FixedEvents-Student Night"      : [UIImage imageNamed:@"student.png"]
                              ,   @"FixedEvents-Ladies Night"       : [UIImage imageNamed:@"cocktail.png"]
                              ,   @"AppTest"                        : [UIImage imageNamed:@"football.png"]
                              };
    
        UIRefreshControl *refresh = [[UIRefreshControl alloc] init];
        refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Pull to Refresh"];
        [refresh addTarget:self action:@selector(getEvents:) forControlEvents:UIControlEventValueChanged];
        self.refreshControl = refresh;
        [self getEvents:refresh];
    }
    
    - (void)getEvents:(UIRefreshControl *)refresh
    {
        static BOOL refreshInProgress = NO;
    
        if (!refreshInProgress)
        {
            refreshInProgress = YES;
    
            refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Refreshing"]; // let the user know refresh is in progress
    
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    
                // get the data here
    
                dispatch_async(dispatch_get_main_queue(), ^{
    
                    // when done, update the model and the UI here
    
                    refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Pull to Refresh"]; // reset the message
                    [refresh endRefreshing];
    
                    refreshInProgress = NO;
                });
            });
        }
    }
    

    But, you should be very careful about updating your model data asynchronously (because your main queue could try to retrieve information from the model while your update is in progress). You really should defer the update of the model until that final dispatch to the main queue. But don't update the model in the middle of your asynchronous process, or else your model and UI can momentarily end up in a inconsistent state.

    Also, as a bit of a refinement, you might want to retrieve those three data sources concurrently, and you may observe a discernible performance improvement.

    - (void)getEvents:(UIRefreshControl *)refresh
    {
        static BOOL refreshInProgress = NO;
    
        if (!refreshInProgress)
        {
            refreshInProgress = YES;
    
            refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Refreshing"]; // let the user know refresh is in progress
    
            // get the data here
    
            __block NSData *data1 = nil;
            __block NSData *data2 = nil;
            __block NSData *data3 = nil;
    
            dispatch_queue_t queue = dispatch_queue_create([[[[NSBundle mainBundle] bundleIdentifier] stringByAppendingString:@".network"] UTF8String], DISPATCH_QUEUE_CONCURRENT);
    
            dispatch_async(queue, ^{
                data1 = [NSData dataWithContentsOfURL:sportsCalendarURL];
            });
    
            dispatch_async(queue, ^{
                data2 = [NSData dataWithContentsOfURL:musicCalendarURL];
            });
    
            dispatch_async(queue, ^{
                data3 = [NSData dataWithContentsOfURL:fixedCalendarURL];
            });
    
            // use dispatch barrier here, which will only fire when the previous three requests are done
    
            dispatch_barrier_async(queue, ^{
    
                // update the UI here
    
                dispatch_async(dispatch_get_main_queue(), ^{
    
                    startDates     = [NSMutableArray array];
                    sectionEntries = [NSMutableArray array];
                    entries        = [NSMutableArray array];
    
                    [self fetchedData:data1];
                    [self fetchedData:data2];
                    [self fetchedData:data3];
    
                    refresh.attributedTitle = [[NSAttributedString alloc] initWithString:@"Pull to Refresh"]; // reset the message
                    [refresh endRefreshing];
    
                    sortedStartDates = [startDates sortedArrayUsingSelector:@selector(compare:)];
                    [self.tableView reloadData];
    
                    refreshInProgress = NO;
                });
            });
        }
    }
    

    You can probably get away with a GCD concurrent queue if you have only three data sources, but if you might have more than that, you might want to use an operation queue in which you can constrain the number of concurrent requests. Also, you might consider using AFNetworking, which can better coordinate these network requests with other network requests you might have going on concurrently elsewhere.

    But the main observations here are (a) don't update your model until the refresh is done and you're ready to update the UI; and (b) make sure you don't initiate a new refresh while the prior one is in progress (or, if you really need it, move to an operation queue model where you make cancelable NSOperation subclasses, and then you could theoretically cancel the prior request, if any, prior to issuing another update request).


    Completely unrelated to the question at hand, but in my first code snippet, you'll see that I moved the setting of the _imageForCalendarType out of this block (as you're always setting it to the same thing) and into viewDidLoad. I also eliminated this unnecessary line:

    _imageForCalendarType = [[NSDictionary alloc]init];
    

    You discard this instantiated dictionary for the dictionary literal in the next line, so the above line is not needed.

    Frankly, you probably shouldn't even have a dictionary of UIImage objects anyway, but rather just a dictionary of image names, and have cellForRowAtIndexPath instantiate the UIImage there. It probably doesn't matter when you have only three images, but if you ever had more, the existing array of UIImage objects construct could be problematic in memory pressure situations. Yes, you could insert the appropriate didReceiveMemoryWarning handling, but it's far simpler to just never maintain a dictionary with UIImage objects in the first place.