A long road to a simple solution

05 Jan 2010

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.

organizer-crash-logs-450x.png

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!)


Older: BikeFixTO: iPhone app source code for sale

Newer: Xcode Trick - Shortcut to duplicate a line of code


View Comments

Related Posts

Recent Posts