The first iPhone app I wrote was BigNames - a large-text contact list. It’s also one of the apps that I use the most, so I was surprised to hear that a particular user found that it worked the first time, but then crashed on subsequent loads.
The only thing I could think of was that the app was running out of memory and was being killed by the OS. BigNames builds an array of names when it first loads, so that it can load quickly in the future. A huge number of contacts might consume too much memory. This user had around 6700 (!!!) contacts – far more than I had ever tested – but still I would have expected to load hundreds of thousands of strings into an NSArray before hitting the 20-30 MB memory limit.
To replicate the problem, I wrote a method to create thousands of bogus contacts. Sure enough, when there were more than 4000 names being loaded from cache, it would crash on load. One odd point, was that that app would only crash when I wasn’t running it in debug mode. This was annoying, since normally I would add NSLog messages or breakpoints to identify and understand a problem, but the app would only crash when these tools were unavailable.
I remembered that Crash Logs are stored on the device and can be viewed with Xcode’s Organizer.
I was able to see that the crashes weren’t the normal EXC_BAD_ACCESS variety but was something called 0x8badf00d (ha ha, get it?) and was actually the launchd complaining that the app was “failed to launch in time”. So, we aren’t actually taking about a crash, just a slow to load app.
And that explains why this wasn’t happening during a debug session. Debugging is slower than simply running an app, so I assume that launchd skips the timeout check when in debug mode.
The “failed to launch in time” error led me to think that I was doing too much work in applicationDidFinishLaunching:. I moved some of the loading of cached names to a separate thread so the app was able to load without getting killed by launchd – but then was unresponsive for the first 10-15 seconds. Not a huge improvement.
I realized that the problem was some code was taking far too long to load. It appeared to be UITableView reloadData. But since that is an API call - I couldn’t step through Apple’s code to see what it was doing. But I did know what the app was doing when it was killed by launchd. The stack trace from the Crash Log was:
Thread 0: 0 Foundation 0x0005d9e0 -[_NSIndexPathUniqueTree uniqueIndexPath:withIndexes:count:] + 132 1 Foundation 0x0005db8e -[NSIndexPath initWithIndexes:length:] + 86 2 Foundation 0x0005d6ce +[NSIndexPath indexPathWithIndexes:length:] + 126 3 UIKit 0x000a2ed8 +[NSIndexPath(UITableView) indexPathForRow:inSection:] + 40 4 UIKit 0x0009fe84 -[UISectionRowData refreshWithSection:tableView:tableViewRowData:] + 2052 5 UIKit 0x0009f5a0 -[UITableViewRowData rectForFooterInSection:] + 96 6 UIKit 0x000c1380 -[UITableViewRowData rectForTableFooterView] + 168 7 UIKit 0x001596ac -[UITableView setTableFooterView:] + 240 8 BigNames 0x000043ea -[ContactTableViewController loadView] (ContactTableViewController.m:103) 9 UIKit 0x00069750 -[UIViewController view] + 44 10 UIKit 0x00088fd8 -[UIViewController contentScrollView] + 24 11 UIKit 0x00088d90 -[UINavigationController _computeAndApplyScrollContentInsetDeltaForViewController:] + 36 12 UIKit 0x00088c3c -[UINavigationController _layoutViewController:] + 28 13 UIKit 0x0008863c -[UINavigationController _startTransition:fromViewController:toViewController:] + 504 14 UIKit 0x000883a8 -[UINavigationController _startDeferredTransitionIfNeeded] + 256 15 UIKit 0x00088298 -[UINavigationController viewWillLayoutSubviews] + 12 16 UIKit 0x0006c86c -[UILayoutContainerView layoutSubviews] + 76 17 UIKit 0x000482d0 -[UIView(CALayerDelegate) _layoutSublayersOfLayer:] + 32 18 QuartzCore 0x0000c1b8 -[CALayer layoutSublayers] + 80 19 QuartzCore 0x0000bed4 CALayerLayoutIfNeeded + 192 20 QuartzCore 0x0000b83c CA::Context::commit_transaction(CA::Transaction*) + 256 21 QuartzCore 0x0000b46c CA::Transaction::commit() + 276 22 QuartzCore 0x0000b318 +[CATransaction flush] + 32 23 UIKit 0x00052e94 -[UIApplication _reportAppLaunchFinished] + 28 24 UIKit 0x00004a80 -[UIApplication _runWithURL:sourceBundleID:] + 608 25 UIKit 0x00055df8 -[UIApplication handleEvent:withNewEvent:] + 1516 26 UIKit 0x00055634 -[UIApplication sendEvent:] + 60 27 UIKit 0x0005508c _UIApplicationHandleEvent + 4528 28 GraphicsServices 0x00005988 PurpleEventCallback + 1044 29 CoreFoundation 0x00057524 CFRunLoopRunSpecific + 2296 30 CoreFoundation 0x00056c18 CFRunLoopRunInMode + 44 31 UIKit 0x00003c00 -[UIApplication _run] + 512 32 UIKit 0x00002228 UIApplicationMain + 960 33 BigNames 0x0000293e main (main.m:14) 34 BigNames 0x000028d4 start + 44
Only the first few lines are relevant. The last bit of code that I control is loadView, and then it seems to get stuck in some sort of loop creating NSIndexPaths.
I thought perhaps the custom table footer was causing problems (lines 5-7), but removing the footer had no effect.
I was out of ideas, and the problem seemed to be in Apple’s UITableView code, so I decided I needed to send an email for the Apple developer support. If it was a bug that a UITableView couldn’t handle more than 4000 rows maybe they would have a workaround.
I created a new project and made a UITableView with 10,000 rows. It loaded almost instantly. Weird. I then added some of the UITableViewDelegate and UITableViewDataSource methods and it became incredibly slow. The method that was causing the problems: tableView:heightForRowAtIndexPath:
I looked closer at the UITableViewDelegateProtocol documentation and found this blurb under the tableView:heightForRowAtIndexPath:
There are performance implications to using tableView:heightForRowAtIndexPath: instead of rowHeight. Every time a table view is displayed, it calls tableView:heightForRowAtIndexPath: on the delegate for each of its rows, which can result in a significant performance problem with table views having a large number of rows (approximately 1000 or more).
Oh. (Why didn’t I see that before?) So, I removed this method from my UITableViewController subclass:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return ROW_HEIGHT; }
And added the following to the loadView method:
self.tableView.rowHeight = ROW_HEIGHT;
Done! And so ridiculously simple and obvious in hindsight. Is there a moral to this story? Well, a specific lesson is only use cells of a consistent height if your table might be long, and never use the inefficient heightForRowAtIndexPath: if all cells are the same height. A more general take-away for me is that I should spend more time reading and re-reading the documentation. Not only is it easy to miss tiny details on an initial read, later versions of the documentation might specifically address the problem at hand. (I swear the issue on performance problems with heightForRowAtIndexPath: wasn’t there when I first read the docs!)
Permanent link to this post: http://xinsight.ca/blog/a-long-road-to-a-simple-solution/
Older: BikeFixTO: iPhone app source code for sale
Newer: Xcode Trick - Shortcut to duplicate a line of code