UICollectionView
and UITableView
are workhorse classes on iOS. As UICollectionView
and UITableView
contents become more complex and dynamic in your iOS apps it can prove difficult to maintain UI state and performance, especially when dealing with dynamic layout in UICollectionView
or variable row height in UITableView
. Oftentimes, the simplest code path is to simply reloadData
. Unfortunately, calling reloadData
frequently can incur a significant performance penalty. This performance penalty is especially apparent when the underlying data updates frequently, such as rapid updates from a web service or continuous updates in response to user input, causing your app to stutter. In the following article we examine a few possible approaches to improving UI performance when dealing with rapid updates and document an approach that has worked well on our projects.
Common Approaches
In UICollectionView
Apple provides reloadSections:
and reloadItemsAtIndexPaths:
to update contents more granularly. Likewise, the UITableView
class provides reloadRowsAtIndexPaths:withRowAnimation:
and reloadSections:withRowAnimation:
. Using reloadSections:
or reloadItemsAtIndexPaths:
often requires significant state tracking code to maintain synchronization of content and layout with data model objects. Calling reloadData
to update the UI can prove a bit heavy-handed if only a small subset of the UI is actually changing, but it is often by far the simplest code path.
You might choose to observe attribute state by reloading only the impacted index paths or sections using the granular reloading methods Apple provides. This is the preferred approach since the cells may be dynamically sized. Existing code can be reused to update cell contents in a consistent manner. In many cases this is the proper approach, so long as data updates, and thus reloads, are sufficiently far apart temporally so the main thread is not saturated with UI and layout updates.
To solve performance issues, you might choose to observe attribute state with cells directly. This can prove fragile and tricky to implement and it might not present a good fit, especially if cell contents impact layout (i.e. dynamic cell size). Implementations that use this approach also break encapsulation, having the cells observe data model objects for changes directly instead of within a more appropriate controller object.
Throttled Reloading
What about situations where attribute state can change rapidly, or there is a large volume of changes to process in a short period of time? The granular reloading approach mentioned above can certainly help target updates to only those areas that change. However, you may notice performance degradation as the number of data updates increases and the impacted cells reload. When the number of cells in a UICollectionView
or UITableView
needs to change in response to changes in its data source we have often found that inserting and deleting cells can make view to model synchronization difficult. Small errors in state tracking can lead to a crash like the one below.
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
reason: 'Invalid update: invalid number of items in section 0. The number of items
contained in an existing section after the update (35) must be equal to the number
of items contained in that section before the update (35), plus or minus the number
of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or
minus the number of items moved into or out of that section (0 moved in, 0 moved out).'
To address this issue we’ve created a category that extends UICollectionView
and UITableView
. This category limits the rate of calls to reloadData
by setting a minimum rate limiting, or “throttle” interval. Limiting the rate of calls to reloadData
allows coalescing multiple content updates into a single reloadData
call, thus allowing the app to balance update frequency versus responsiveness. We have found that rate limiting UICollectionView
and UITableView
reloads reduces code complexity by eliminating state tracking code for determining which index paths or sections require updating on the UI. This category allows you to maintain your straightforward view controller implementation as the frequency of updates and data you display becomes more complex in your apps.
Get the Code
The NSObject+ENHThrottledReloading category is available on github under the MIT license.
NSObject+ENHThrottledReloading Category
Using Throttled Reloading
Using the throttled reloading category is straightforward. Import the category’s header file and set the enh_minimumNanosecondsBetweenThrottledReloads
property on your instance of UICollectionView
or UITableView
. Then call enh_throttledReloadData
instead of reloadData
as your UI needs to be updated. If a call to enh_throttledReloadData
hasn’t occurred within the minimum time interval the reload will occur immediately. Otherwise the reload will be delayed until the time interval expires. Calling enh_throttledReloadData
multiple times in rapid succession will result in no more than one call per time interval you set using the enh_minimumNanosecondsBetweenThrottledReloads
property.
Note: Any calls made directly to reloadData
will be executed normally. Further, any direct calls to reloadData
will not impact the throttled reloading. Calls to enh_throttledReloadData
will not be canceled by calling ‘reloadData` directly.
Experiment with the value used for enh_minimumNanosecondsBetweenThrottledReloads
to determine the interval that works best for your use case. This will allow you to find the right balance between update frequency and responsiveness. The interval can be set to a fairly short time so the user is unlikely to notice lag. We have found that 0.3 seconds (or 18 frames at 60 fps) is a good starting point for a number of our use cases, and the category uses this as the default value. We have found that this short delay frees the processor sufficiently without introducing a noticeable delay.
When your object is no longer in use, call enh_cancelPendingReload
to cancel any pending reloads. Typically this is called inside the dealloc
implementation of the object that initiates throttled calls. Doing so will prevent exceptions from being raised, otherwise it’s possible that a deferred reload could be called on an object after being deallocated.
Implementation Details
Mach Time
The core of the throttled reload category is in the enh_throttledReloadData
method. Here is that method’s implementation.
-(void)enh_throttledReloadData
{
uint64_t now = mach_absolute_time ();
uint64_t lastReloadMachTime = [self enh_lastReloadMachTime];
uint64_t timeSinceLastUpdate = now - lastReloadMachTime;
mach_timebase_info_data_t timebaseInfo = [self enh_timebaseInfo];
uint64_t nanos = timeSinceLastUpdate * timebaseInfo.numer / timebaseInfo.denom;
uint64_t minimumTimeDiffNanosecondsForUpdate = [self enh_minimumNanosecondsBetweenThrottledReloads];
BOOL awaitingReload = [self enh_awaitingReload];
...
Here mach_absolute_time
is used to determine the current time. A time interval is then calculated using current and previous time stamps and stored in timeSinceLastUpdate
. It is compared to the previous time stamp. The mach time value is converted into nanoseconds and the minimumTimeDiffNanosecondsForUpdate
and awaitingReload
are retrieved in preparation for the following code.
...
if(nanos > minimumTimeDiffNanosecondsForUpdate || lastReloadMachTime == 0.0)
{
[self setEnh_lastReloadMachTime:now];
[self setEnh_awaitingReload:NO];
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:_cmd object:nil];
if ([self respondsToSelector:@selector(reloadData)])
{
[self performSelector:@selector(reloadData)];
}
else
{
NSAssert(NO, @"object does not respond to reloadData selector");
}
}
...
In the above code we check to see if the number of nanoseconds that have elapsed is greater than the minimum time interval. We also check to see if the time stamp of the last reload is zero, which indicates that this is the first time through this code. If either of the above is true, reloadData
is called.
Next the enh_lastReloadMachTime
is set to the the current time stamp (now
) so we can calculate the time interval the next time enh_throttledReloadData
is called. We also set the enh_awaitingReload
flag to NO
and cancel any previous perform requests to enh_throttledReloadData
.
Notice the category extends NSObject
rather than UICollectionView
or UITableView
directly. This allows the code to be applied to either since they both contain a reloadData
method. The category can also be used to extend any class that conforms to the ENHThrottledReloading
protocol by implementing a reloadData
method. Since the category can be used to extend any NSObject
subclass, we perform a check to ensure reloadData
is implemented before attempting to call it.
...
else if (!awaitingReload)
{
NSTimeInterval delay = ((double)minimumTimeDiffNanosecondsForUpdate - nanos) / NSEC_PER_SEC;
[self performSelector:_cmd withObject:nil afterDelay:delay];
[self setEnh_awaitingReload:YES];
}
}
Finally if the class is not already awaiting a reload, one is scheduled to run after a delay and the enh_awaitingReload
flag is set to YES
.
Special thanks to Mark Dalrymple at The Big Nerd Ranch for sharing his “A Timing Utility” post that inspired this category. Mark’s post explains how to use mach time for precise timing on Mac OS X and iOS for performance tuning. Keeping accurate track of time without the overhead of NSDate
is crucial to the throttled reloading category.
Associated Objects
Introduced in Objective-C 2.0, Objective-C associated objects allow objects to be associated at runtime in lieu of storing those associations in instance variables. Our category needs to track some internal state to perform throttled reloading, so we’ve added a few properties in the category. Categories in Objective-C cannot directly add instance variables to an object, which are normally used to store values for properties. Associated objects (also know as associative references) are used to store the values needed by the properties added in the category.
For example, the code above illustrates how associated objects are used in the category to track the last reload mach time state.
#import <objc/runtime.h>
static NSString *kENHLastReloadMachTimeAssociatedObjectKey = @"com.enharmonichq.lastReloadMachTime";
First we import the Objective-C runtime, which contains the C functions for working with associated objects. We also create a constant to use for getting or setting the associated object. This constant acts as a key to associate one object with another in the Objective-C runtime.
Here is the getter for the enh_lastReloadMachTime
property.
-(uint64_t)enh_lastReloadMachTime
{
NSNumber *value = objc_getAssociatedObject(self, (__bridge const void *)kENHLastReloadMachTimeAssociatedObjectKey);
uint64_t lastReloadMachTime = [value unsignedLongLongValue];
return lastReloadMachTime;
}
The Objective-C runtime allows associating objects to each other, not raw values, so we need to wrap any scalar values in an Objective-C object. The getter retrieves the NSNumber
value for the kENHLastReloadMachTimeAssociatedObjectKey
from the Objective-C runtime which is then unwrapped to return the raw uint64_t
value.
Here is the setter.
-(void)setEnh_lastReloadMachTime:(uint64_t)enh_lastReloadMachTime
{
objc_setAssociatedObject(self, (__bridge const void *)kENHLastReloadMachTimeAssociatedObjectKey, @(enh_lastReloadMachTime), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
Similar to the getter, the setter creates a keyed relationship between the instance and an NSNumber
wrapping a raw uint64_t
value.
Wrap Up
With technologies like the Objective-C Associated Objects and Mach Absolute Time Units, iOS and OS X have many tools to round out your repertoire. As shown above these tools can be invaluable when optimizing performance and simplifying code. We hope you find this category helpful for addressing performance issues when dealing with rapid updates to UICollectionView
or UITableView
instances in your iOS applications.