Mastering iOS Timers: CADisplayLink, NSTimer, RunLoop and Memory‑Leak Solutions Explained
This article dives deep into iOS timing mechanisms, explaining how CADisplayLink synchronizes with the screen refresh, how NSTimer works, the role of RunLoop modes, common memory‑leak pitfalls, and practical code solutions for smooth animations and accurate frame‑rate handling across iOS versions.
Background
During a code review last week, the code review discussion highlighted changes in the CADisplayLink interface, its properties, and usage. The following explanation summarizes the key concepts and practical code.
Test Environment
Compilation environment: Xcode 13.1
Devices: iPhone X (iOS 14.7.1) and iPhone 13 Pro (iOS 15.5)
What CADisplayLink Is
CADisplayLink is a timer that is bound to the display's vertical‑sync (vsync) signal. It calls a selector on a target object each time the screen is about to be refreshed.
#import <QuartzCore/CABase.h>
#import <QuartzCore/CAFrameRateRange.h>
#import <Foundation/NSObject.h>
NS_ASSUME_NONNULL_BEGIN
/** A timer object that is synchronized with the display's refresh rate. */
API_AVAILABLE(ios(3.1), watchos(2.0), tvos(9.0)) API_UNAVAILABLE(macos)
@interface CADisplayLink : NSObject
+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;
- (void)addToRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;
- (void)removeFromRunLoop:(NSRunLoop *)runloop forMode:(NSRunLoopMode)mode;
- (void)invalidate;
@property (readonly, nonatomic) CFTimeInterval timestamp; // time of the last frame
@property (readonly, nonatomic) CFTimeInterval duration; // time between frames
@property (readonly, nonatomic) CFTimeInterval targetTimestamp API_AVAILABLE(ios(10.0), watchos(3.0), tvos(10.0));
@property (getter=isPaused, nonatomic) BOOL paused;
@property (nonatomic) NSInteger frameInterval API_DEPRECATED("preferredFramesPerSecond", ios(3.1, 10.0), watchos(2.0, 3.0), tvos(9.0, 10.0));
@property (nonatomic) NSInteger preferredFramesPerSecond API_DEPRECATED_WITH_REPLACEMENT("preferredFrameRateRange", ios(10.0, TO_BE_DEPRECATED), watchos(3.0, TO_BE_DEPRECATED), tvos(10.0, TO_BE_DEPRECATED));
@property (nonatomic) CAFrameRateRange preferredFrameRateRange API_AVAILABLE(ios(15.0), watchos(8.0), tvos(15.0));
@end
NS_ASSUME_NONNULL_ENDCreating and Using CADisplayLink
#import "ViewController.h"
static CGFloat const kImageViewWidth = 100.0f;
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, assign) CGFloat dynamicImageViewY;
@property (nonatomic, strong) UIButton *startButton;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = UIColor.lightGrayColor;
_dynamicImageViewY = 0;
[self createImageView];
[self createAnimationButton];
[self createDisplayLink];
}
- (void)createAnimationButton {
_startButton = [UIButton buttonWithType:UIButtonTypeCustom];
_startButton.frame = CGRectMake(200, 200, 100, 100);
[_startButton setTitle:@"start" forState:UIControlStateNormal];
[_startButton addTarget:self action:@selector(pauseAnimation) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:_startButton];
}
- (void)createImageView {
_imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, kImageViewWidth, kImageViewWidth)];
_imageView.image = [UIImage imageNamed:@"kobe0"];
[self.view addSubview:_imageView];
}
- (void)createDisplayLink {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(startAnimation:)];
_displayLink.paused = YES;
_displayLink.frameInterval = 2; // deprecated, use preferredFramesPerSecond instead
NSLog(@"0--targetTimestamp:%f,timestamp:%f", _displayLink.targetTimestamp, _displayLink.timestamp);
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
NSLog(@"1--targetTimestamp:%f,timestamp:%f", _displayLink.targetTimestamp, _displayLink.timestamp);
}
- (void)startAnimation:(CADisplayLink *)sender {
NSLog(@"2--targetTimestamp:%f,timestamp:%f", sender.targetTimestamp, sender.timestamp);
_dynamicImageViewY++;
if (_dynamicImageViewY == self.view.frame.size.height - kImageViewWidth) {
_dynamicImageViewY = 0;
}
self.imageView.frame = CGRectMake(0, _dynamicImageViewY, kImageViewWidth, kImageViewWidth);
}
- (void)pauseAnimation {
_displayLink.paused = !self.displayLink.paused;
if (_displayLink.paused) {
[_startButton setTitle:@"start" forState:UIControlStateNormal];
} else {
[_startButton setTitle:@"pause" forState:UIControlStateNormal];
}
}
- (void)stopDisplayLink {
if (_displayLink) {
[_displayLink invalidate];
_displayLink = nil;
}
}
@endKey Properties
timestamp (readonly) – the time of the last frame; available after the selector has been called once.
duration (readonly) – the time interval between frames (e.g., ~16.7 ms on a 60 Hz display).
targetTimestamp – the time for the next frame; used to calculate animation progress.
frameInterval – deprecated; defines how many display frames must pass before the selector is called.
preferredFramesPerSecond – the desired frame rate; deprecated in iOS 15 in favor of preferredFrameRateRange.
preferredFrameRateRange – a range of frame rates; on ProMotion devices it enables the full refresh‑rate range.
Common Pitfalls
If the selector takes longer than the frame interval, frames will be dropped, causing visible stutter. The CPU must be idle enough to finish the callback before the next refresh.
NSTimer Overview
NSTimer is a general‑purpose timer that can be scheduled on a run loop. It holds a strong reference to its target, which can cause retain cycles if not invalidated.
#import <Foundation/NSObject.h>
#import <Foundation/NSDate.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSTimer : NSObject
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void(^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void(^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
@property (copy) NSDate *fireDate; // can be used to pause/resume
@property (readonly) NSTimeInterval timeInterval;
@property (nonatomic) NSTimeInterval tolerance API_AVAILABLE(macos(10.9), ios(7.0), watchos(2.0), tvos(9.0));
@property (readonly, getter=isValid) BOOL valid;
@property (nullable, readonly, retain) id userInfo;
- (void)fire;
- (void)invalidate;
@end
NS_ASSUME_NONNULL_ENDMemory‑Leak Example and Proxy Solution
When a view controller stores an NSTimer as a strong property and the timer also retains the view controller as its target, a retain cycle is created. The same problem exists for CADisplayLink. A common fix is to use an NSProxy that holds a weak reference to the target.
#import "GTProxyTarget.h"
@interface GTProxyTarget : NSProxy
@property (nonatomic, weak) id target;
+ (instancetype)proxyWithTarget:(id)target;
@end
@implementation GTProxyTarget
+ (instancetype)proxyWithTarget:(id)target {
GTProxyTarget *proxy = [GTProxyTarget alloc];
proxy.target = target;
return proxy;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.target methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
[invocation invokeWithTarget:self.target];
}
@end
// Usage in a view controller
self.augusTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[GTProxyTarget proxyWithTarget:self] selector:@selector(testTimer) userInfo:nil repeats:YES];RunLoop Fundamentals
A run loop processes two kinds of input sources: Input Sources (asynchronous events from other threads or processes) and Timer Sources (events that fire at scheduled times). Each run loop contains one or more CFRunLoopMode objects, and each mode holds a set of sources, timers, and observers.
Common modes include:
Mode Name
Description
kCFRunLoopDefaultMode (NSDefaultRunLoopMode)
The main mode used by the app’s main thread.
UITrackingRunLoopMode
Used while tracking touches (e.g., scrolling) so UI updates are not blocked.
UIInitializationRunLoopMode
The first mode during app launch; not used after startup.
kCFRunLoopCommonModes (NSRunLoopCommonModes)
A placeholder that groups multiple modes (e.g., default + tracking).
CFRunLoopSourceRef
Two variants exist: source0: contains only a callback pointer; you must manually signal the source with CFRunLoopSourceSignal and wake the run loop with CFRunLoopWakeUp. source1: includes a Mach port and a callback; the kernel can wake the run loop automatically.
CFRunLoopTimerRef
Underlying timer object used by NSTimer. It registers fire dates with the run loop; when a fire date is reached, the run loop wakes and executes the callback. The tolerance property defines how much the system may delay the fire time to improve power efficiency.
CFRunLoopObserverRef
Observers receive callbacks at specific run‑loop activities such as entry, before timers, before sources, before waiting, after waiting, and exit. The activity constants are:
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1U << 0), // about to enter the loop
kCFRunLoopBeforeTimers = (1U << 1), // about to process timers
kCFRunLoopBeforeSources = (1U << 2), // about to process sources
kCFRunLoopBeforeWaiting = (1U << 5), // about to sleep
kCFRunLoopAfterWaiting = (1U << 6), // just woke up
kCFRunLoopExit = (1U << 7) // about to exit
};CADisplayLink vs NSTimer
CADisplayLinkis essentially a CFRunLoopTimerRef that is synchronized with the display’s refresh. It provides the most accurate timing for UI animation. NSTimer is a generic timer that may drift and is subject to the run‑loop’s tolerance.
On iOS 15 devices with ProMotion, the system changed the underlying driver: instead of a direct source0 Mach‑port driven by VSync, CADisplayLink is now driven by a UIKit source1 that posts a VSync event. The callback is ultimately invoked via the function IODispatchCalloutFromCFMessage. If the internal power‑state check returns false, the callback is not delivered, which explains the observed difference between iOS 14 and iOS 15.
Getting Accurate FPS on iOS 15
Inside the display‑link callback, read the duration property to compute the current refresh interval, then set a wide preferredFrameRateRange so the system can choose the optimal rate without limiting the animation.
NSInteger currentFPS = (NSInteger)ceil(1.0 / _displayLink.duration);
_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(10.0, currentFPS, 0.0);References
https://developer.apple.com/documentation/quartzcore/cadisplaylink?language=objc
https://blog.ibireme.com/2015/05/18/runloop/
https://developer.apple.com/documentation/quartzcore/optimizing_promotion_refresh_rates_for_iphone_13_pro_and_ipad_pro?language=objc
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/RunLoopManagement/RunLoopManagement.html
https://blog.csdn.net/ByteDanceTech/article/details/123437098
Sohu Smart Platform Tech Team
The Sohu News app's technical sharing hub, offering deep tech analyses, the latest industry news, and fun developer anecdotes. Follow us to discover the team's daily joys.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
