Mobile Development 31 min read

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.

Sohu Smart Platform Tech Team
Sohu Smart Platform Tech Team
Sohu Smart Platform Tech Team
Mastering iOS Timers: CADisplayLink, NSTimer, RunLoop and Memory‑Leak Solutions Explained

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_END

Creating 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;
    }
}
@end

Key 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_END

Memory‑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.

RunLoop structure diagram
RunLoop structure diagram

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

CADisplayLink

is 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.

CADisplayLink iOS14 vs iOS15
CADisplayLink iOS14 vs iOS15

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

Final illustration
Final illustration
animationiOSRunLooptimerCADisplayLinkNSTimer
Sohu Smart Platform Tech Team
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.