Innovative iOS Patterns from Signal's Codebase

Signal is one of the most privacy-focused messaging apps in the world, and its open-source iOS codebase is a goldmine of innovative patterns and solutions to complex problems. After analyzing the parts of codebase, I've identified 10 unique patterns that every iOS developer can learn from.
1. Adaptive Display Link Throttling
The Problem
Signal's conversation view needs to update the UI when database changes occur (new messages, read receipts, reactions, etc.). The naive approach is to update immediately on every change, but this causes severe UI jank when many changes happen rapidly (like during initial sync or when receiving a burst of messages).
A fixed timer approach (e.g., update every 100ms) doesn't work either because:
- On powerful devices, you're wasting potential responsiveness
- On older devices, you might still be updating too frequently and causing jank
Signal's Solution
Signal uses CADisplayLink to batch database change notifications and dynamically adjusts the update frequency based on real-time system load.
// Measure system load by monitoring display link performance
private func updateDisplayLinkFrequency(displayLinkDuration: TimeInterval) {
// If display link is firing slower than expected, system is under load
let actualFrequency = 1.0 / displayLinkDuration
// Use linear interpolation to determine target update interval
let displayLinkAlpha = recentDisplayLinkFrequency.inverseLerp(
lightDisplayLinkFrequency, // 60 fps (system responsive)
heavyDisplayLinkFrequency, // 20 fps (system struggling)
shouldClamp: true
)
// Slow down updates when system is struggling
let targetInterval = displayLinkAlpha.lerp(
fastUpdateInterval, // 0.05s when responsive
slowUpdateInterval // 0.5s when struggling
)
self.targetUpdateInterval = targetInterval
}
How It's Used in Signal
When you receive multiple messages in a conversation:
- Database changes are batched and queued
- Display link fires at screen refresh rate (60 fps on most devices)
- If system load increases (display link slows to 20-30 fps), Signal automatically reduces UI update frequency
- This prevents the dreaded "scroll stuttering" when messages are coming in
Why This Is Better
Traditional approach:
// Updates immediately - causes jank with rapid changes
NotificationCenter.default.addObserver { _ in
self.tableView.reloadData()
}
// Fixed timer - not adaptive
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
self.updateUI()
}
Signal's approach:
// Adaptive throttling based on real device performance
// Fast devices: ~50ms updates (responsive)
// Slow devices under load: ~500ms updates (prevents jank)
// Automatically adjusts based on actual display link fire rate
What You Can Learn
- Use CADisplayLink as a load indicator: If it fires slower than 60fps, your system is under pressure
- Linear interpolation (lerp) is powerful: Smooth transitions between performance states
- Adaptive behavior beats fixed timers: Let the system tell you when it's struggling
- Batch UI updates to frame rate: Never update faster than the screen can display
How to Apply This in Your App
class AdaptiveUpdateManager {
private var displayLink: CADisplayLink?
private var lastUpdateTime: CFTimeInterval = 0
private var targetUpdateInterval: TimeInterval = 0.05
func startMonitoring() {
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func displayLinkFired(_ link: CADisplayLink) {
let duration = link.timestamp - lastUpdateTime
lastUpdateTime = link.timestamp
// Measure how long between display link fires
// If > 16.67ms (60fps), system is under load
let actualFPS = 1.0 / duration
// Adjust update frequency based on load
if actualFPS < 40 {
targetUpdateInterval = 0.5 // Slow down
} else if actualFPS > 55 {
targetUpdateInterval = 0.05 // Speed up
}
performPendingUpdatesIfNeeded()
}
}
Use cases:
- Real-time chat apps with message bursts
- Social media feeds with live updates
- Live data dashboards
- Any app with frequent model updates
2. Multi-Level Window Management
The Problem
Signal has multiple overlapping UI concerns that need precise z-ordering:
- Normal app UI (messages, settings)
- Active call interface (must stay on top during navigation)
- Return-to-call banner (floating pip when you leave a call)
- Screen blocking (privacy screen when app is backgrounded)
- Captcha challenges (must be above everything)
Using a single UIWindow and layering UIViewController presentations creates problems:
- Modal presentations can dismiss unexpectedly
- Navigation interferes with overlays
- Rotation bugs with presented view controllers
- Can't have precise control over what's on top
Signal's Solution
Signal uses multiple UIWindow instances at custom z-levels with a sophisticated WindowManager to coordinate them.
extension UIWindow.Level {
// Below everything - used for background blur
static let _background = UIWindow.Level(rawValue: -1)
// Normal app content
static let _normal = UIWindow.Level.normal
// Return-to-call banner (above normal UI)
static let _returnToCall = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 2)
// Active call view (above banner)
static let _callView = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 3)
// Screen blocking for privacy (above calls)
static let _screenBlocking = UIWindow.Level(rawValue: UIWindow.Level.alert.rawValue + 5)
}
class WindowManager {
private lazy var callViewWindow: UIWindow = {
let window = UIWindow()
window.windowLevel = ._callView
window.isHidden = false
window.backgroundColor = .clear
return window
}()
private lazy var returnToCallWindow: UIWindow = {
let window = UIWindow()
window.windowLevel = ._returnToCall
window.isHidden = false
window.backgroundColor = .clear
return window
}()
func startCall(call: SignalCall) {
let callVC = CallViewController(call: call)
callViewWindow.rootViewController = callVC
ensureWindowState() // Bring to appropriate level
}
// Uses window level changes instead of hidden property
// to avoid UIKit layout bugs
private func ensureWindowState() {
if hasActiveCall {
callViewWindow.windowLevel = ._callView
} else {
callViewWindow.windowLevel = ._background // Hide by lowering
}
}
}
How It's Used in Signal
Scenario 1: Starting a Call
- User initiates a call from a conversation
WindowManagercreates call UI incallViewWindowat level._callView- User can navigate anywhere in the app - call UI stays on top
- Tapping "return to call" switches to
returnToCallWindowwith a floating pip
Scenario 2: App Backgrounding
- User presses home button during sensitive conversation
WindowManagerimmediately raisesscreenBlockingWindowto._screenBlockinglevel- Screen contents are hidden before app snapshot is taken
- Privacy preserved in app switcher
Scenario 3: Captcha Challenge
- Server requires captcha during registration
- Captcha UI presented in dedicated window at appropriate level
- Works regardless of current navigation state
- Can't be accidentally dismissed by user navigation
Why This Is Better
Traditional approach:
// Presenting modally - can be dismissed, interferes with navigation
present(callViewController, animated: true)
// Adding subview to window - wrong z-order, rotation issues
UIApplication.shared.keyWindow?.addSubview(callView)
Signal's approach:
// Dedicated windows with precise z-ordering
// Each window has its own view controller hierarchy
// No interference between different UI concerns
callViewWindow.windowLevel = ._callView
screenBlockingWindow.windowLevel = ._screenBlocking
Advanced Technique: Private API Workarounds
Signal even uses encoded selectors to work around iOS bugs:
// iOS has a rotation bug with certain window configurations
// Access private API safely using encoded selector names
private func applyRotationWorkaround() {
let selectorData = Data(base64Encoded: "X3VwZGF0ZVRvSW50ZXJmYWNlT3JpZW50YXRpb246")!
let selectorString = String(data: selectorData, encoding: .utf8)!
let selector = NSSelectorFromString(selectorString)
if window.responds(to: selector) {
window.perform(selector, with: nil)
}
}
This decodes to _updateToInterfaceOrientation: - a private method to fix rotation bugs.
What You Can Learn
- Multiple windows solve layering problems: Better than view controller presentation for persistent overlays
- Window levels give precise z-ordering: No fighting with UIKit's presentation logic
- Lazy window initialization: Only create windows when needed
- Use level changes instead of hidden property: Avoids UIKit layout recalculation bugs
- Private API access can be done safely: Use encoding to avoid App Store rejection
How to Apply This in Your App
class OverlayWindowManager {
// Main app window (set by system)
// Level: UIWindow.Level.normal
// Custom overlay window
private lazy var overlayWindow: UIWindow = {
let window = UIWindow(frame: UIScreen.main.bounds)
window.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 1)
window.backgroundColor = .clear
window.isHidden = false
return window
}()
func showOverlay(_ viewController: UIViewController) {
overlayWindow.rootViewController = viewController
overlayWindow.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 1)
overlayWindow.makeKey() // Receives touch events
}
func hideOverlay() {
overlayWindow.windowLevel = UIWindow.Level(rawValue: -1)
// Don't set isHidden - can cause layout bugs
}
}
Use cases:
- VoIP apps with persistent call UI
- Music/podcast players with mini player
- Privacy screens for sensitive content
- Picture-in-picture overlays
- Loading/blocking overlays that must not be dismissible
3. Dual-Mode Debouncing
The Problem
Debouncing is a common pattern, but different UI scenarios need different debouncing strategies:
Scenario A: User typing in a search field
- You want to wait until they stop typing before searching
- Traditional "last only" debounce - wait 300ms of silence
Scenario B: User scrolling through a conversation
- You want immediate visual feedback (no delay)
- But you want to throttle expensive operations (database queries)
- You need "first immediately, then throttle subsequent"
Most debouncing libraries only support one mode.
Signal's Solution
Signal implements two distinct debouncing modes in a single, well-designed class.
public class DebouncedEvent {
public enum Mode {
/// Waits before firing - good for batching operations
/// Example: Search API calls while user types
case lastOnly
/// Fires immediately on first request, then throttles subsequent
/// Example: UI updates during scrolling (responsive but not excessive)
case firstLast
}
private let mode: Mode
private let maxFrequencySeconds: TimeInterval
private let queue: DispatchQueue
private var timer: Timer?
private var pendingRequest: (() -> Void)?
private var lastFireDate: Date?
public init(
mode: Mode,
maxFrequencySeconds: TimeInterval,
queue: DispatchQueue = .main
) {
self.mode = mode
self.maxFrequencySeconds = maxFrequencySeconds
self.queue = queue
}
public func request(_ callback: @escaping () -> Void) {
pendingRequest = callback
switch mode {
case .lastOnly:
// Cancel existing timer, restart countdown
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: maxFrequencySeconds,
repeats: false
) { [weak self] _ in
self?.fireIfNeeded()
}
case .firstLast:
// Fire immediately if enough time has passed
let now = Date()
let shouldFireImmediately: Bool
if let lastFireDate = lastFireDate {
let timeSinceLast = now.timeIntervalSince(lastFireDate)
shouldFireImmediately = timeSinceLast >= maxFrequencySeconds
} else {
shouldFireImmediately = true // First request
}
if shouldFireImmediately {
fireIfNeeded()
} else {
// Schedule for later
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: maxFrequencySeconds,
repeats: false
) { [weak self] _ in
self?.fireIfNeeded()
}
}
}
}
private func fireIfNeeded() {
guard let pendingRequest = pendingRequest else { return }
lastFireDate = Date()
self.pendingRequest = nil
queue.async {
pendingRequest()
}
}
}
How It's Used in Signal
Use Case 1: Content Inset Updates (FirstLast mode)
class ConversationViewController: UIViewController {
// User is scrolling - we want immediate first update,
// then throttle subsequent updates to avoid jank
private lazy var contentInsetDebouncer = DebouncedEvent(
mode: .firstLast, // ← Responsive!
maxFrequencySeconds: 0.05 // Max 20 updates/sec
)
func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentInsetDebouncer.request { [weak self] in
self?.updateContentInsets() // Expensive operation
}
}
}
Result: First scroll update happens immediately (feels responsive), then throttled to prevent UI jank.
Use Case 2: Database Change Notifications (LastOnly mode)
class DatabaseChangeObserver {
// Cross-process database changes - we want to batch them
private lazy var crossProcessDebouncer = DebouncedEvent(
mode: .lastOnly, // ← Batching!
maxFrequencySeconds: 0.2 // Wait 200ms of silence
)
func handleDarwinNotification() {
// Another process (NSE) changed database
crossProcessDebouncer.request { [weak self] in
self?.reloadFromDatabase() // Expensive
}
}
}
Result: If 10 changes happen in 150ms, only the last one triggers reload.
Why This Is Better
Traditional debouncing (one mode):
// Only supports "last only" mode
class SimpleDebouncer {
func debounce(_ action: @escaping () -> Void) {
timer?.invalidate()
timer = Timer.scheduledTimer(...) { action() }
}
}
// Using it for scrolling feels laggy (waits before first update)
debouncer.debounce { updateUI() } // 300ms delay before first update
Signal's approach:
// Choose the right mode for the use case
let searchDebouncer = DebouncedEvent(mode: .lastOnly, maxFrequencySeconds: 0.3)
let scrollDebouncer = DebouncedEvent(mode: .firstLast, maxFrequencySeconds: 0.05)
// Searching: waits for user to stop typing
searchDebouncer.request { performSearch() }
// Scrolling: immediate first update, then throttled
scrollDebouncer.request { updateUI() } // Fires immediately!
What You Can Learn
- Different UX scenarios need different debouncing: One size doesn't fit all
- "FirstLast" mode provides responsive UX: No perceived delay on first action
- "LastOnly" mode is better for batching: Reduces unnecessary work
- Track last fire date for throttling: Simple but effective
- Provide queue parameter: Allows background queue execution
When to Use Each Mode
lastOnly mode - Wait for user to finish action before executing
- Search-as-you-type
- Form validation
- Auto-save drafts
firstLast mode - Immediate feedback but throttle subsequent actions
- Scroll updates
- Gesture tracking
- Live resize
How to Apply This in Your App
class SmartDebouncer {
enum Mode {
case waitForSilence // Traditional debounce
case immediateAndThrottle // Responsive throttle
}
private let mode: Mode
private let delay: TimeInterval
private var timer: Timer?
private var lastExecutionTime: Date?
func trigger(_ action: @escaping () -> Void) {
switch mode {
case .waitForSilence:
// Cancel and restart timer
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
action()
}
case .immediateAndThrottle:
let now = Date()
let shouldExecuteNow = lastExecutionTime == nil ||
now.timeIntervalSince(lastExecutionTime!) >= delay
if shouldExecuteNow {
lastExecutionTime = now
action()
} else {
// Schedule for later
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
self?.lastExecutionTime = Date()
action()
}
}
}
}
}
// Usage
let searchDebouncer = SmartDebouncer(mode: .waitForSilence, delay: 0.3)
let scrollDebouncer = SmartDebouncer(mode: .immediateAndThrottle, delay: 0.05)
4. Sendable-Compatible Atomics
The Problem
With Swift Concurrency (async/await), the compiler enforces Sendable conformance to prevent data races. But many existing thread-safe types don't conform to Sendable:
class Counter {
private let lock = NSLock()
private var _value: Int = 0
var value: Int {
lock.lock()
defer { lock.unlock() }
return _value
}
}
// Error: 'Counter' doesn't conform to 'Sendable'
Task {
let counter = Counter()
await someAsyncFunction(counter)
}
You could mark it @unchecked Sendable, but that disables compiler safety checks.
Signal's Solution
Signal provides a complete suite of atomic types that are properly Sendable and provide clean APIs including property wrappers.
@propertyWrapper
public struct Atomic<Value>: @unchecked Sendable {
private let lock = UnfairLock()
private var _value: Value
public init(wrappedValue: Value) {
self._value = wrappedValue
}
public var wrappedValue: Value {
get { lock.withLock { _value } }
set { lock.withLock { _value = newValue } }
}
// Read and modify atomically
public func update<T>(_ block: (inout Value) -> T) -> T {
lock.withLock {
block(&_value)
}
}
// State machine transitions
public func transition(from: Value, to: Value) throws where Value: Equatable {
try lock.withLock {
guard _value == from else {
throw AtomicError.transitionFailed
}
_value = to
}
}
}
// Specialized atomic types
public final class AtomicBool: @unchecked Sendable {
private let lock = UnfairLock()
private var _value: Bool
public init(_ value: Bool) {
self._value = value
}
// Try to set flag atomically
public func tryToSetFlag() -> Bool {
lock.withLock {
guard !_value else { return false }
_value = true
return true
}
}
public func tryToClearFlag() -> Bool {
lock.withLock {
guard _value else { return false }
_value = false
return true
}
}
}
// Atomic collections
public final class AtomicArray<Element>: @unchecked Sendable {
private let lock = UnfairLock()
private var _value: [Element]
public func append(_ element: Element) {
lock.withLock {
_value.append(element)
}
}
public func removeAll() -> [Element] {
lock.withLock {
let old = _value
_value = []
return old
}
}
}
public final class AtomicDictionary<Key: Hashable, Value>: @unchecked Sendable {
private let lock = UnfairLock()
private var _value: [Key: Value]
public subscript(key: Key) -> Value? {
get { lock.withLock { _value[key] } }
set { lock.withLock { _value[key] = newValue } }
}
}
How It's Used in Signal
Use Case 1: State Machine with Transitions
public final class CancellableContinuation<T>: Sendable {
private enum State {
case initial
case waiting(CheckedContinuation<T, Error>)
case completed(Result<T, Error>)
case consumed
}
@Atomic private var state: State = .initial
public func waitForResult() async throws -> T {
// Use atomic transition to move from .initial → .waiting
try await withCheckedThrowingContinuation { continuation in
do {
try $state.transition(from: .initial, to: .waiting(continuation))
} catch {
// Already completed - resume immediately
if case .completed(let result) = state {
continuation.resume(with: result)
}
}
}
}
public func resume(returning value: T) {
$state.update { state in
switch state {
case .waiting(let continuation):
continuation.resume(returning: value)
state = .consumed
case .initial:
state = .completed(.success(value))
default:
break // Already consumed
}
}
}
}
Use Case 2: Thread-Safe Flag
class MessageProcessor {
@Atomic private var isProcessing: Bool = false
func processMessages() async {
// Try to set flag - prevents concurrent processing
guard $isProcessing.tryToSetFlag() else {
return // Already processing
}
defer {
_ = $isProcessing.tryToClearFlag()
}
// Process messages...
}
}
Use Case 3: Atomic Counter
actor MessageSender {
@Atomic private var messagesSent: UInt = 0
func sendMessage(_ message: Message) async {
// Send message...
// Atomic increment
$messagesSent.update { $0 += 1 }
}
func getStats() -> UInt {
messagesSent // Thread-safe read
}
}
Why This Is Better
Without atomics (race condition):
// Data race - multiple threads can read/write simultaneously
class UnsafeCounter: @unchecked Sendable {
var count = 0 // Not thread-safe!
func increment() {
count += 1 // Race condition
}
}
With manual locking (verbose):
// Works but verbose and error-prone
class VerboseCounter: @unchecked Sendable {
private let lock = NSLock()
private var _count = 0
func increment() {
lock.lock()
_count += 1
lock.unlock() // Easy to forget!
}
}
Signal's approach:
// Clean, safe, and Sendable
class CleanCounter: Sendable {
@Atomic private var count = 0
func increment() {
$count.update { $0 += 1 }
}
func tryToSetFlag() -> Bool {
do {
try $count.transition(from: 0, to: 1)
return true
} catch {
return false
}
}
}
What You Can Learn
- Property wrappers make thread safety ergonomic: Clean syntax without sacrificing safety
- @unchecked Sendable is OK when you validate safety: As long as implementation is correct
- Specialized atomic types are better than generic:
AtomicBool.tryToSetFlag()is clearer than generic update - State transitions prevent invalid states:
transition(from:to:)ensures valid state machine - UnfairLock is faster than NSLock: For simple cases, unfair locks have less overhead
Advanced: UnfairLock Implementation
// Signal's lock primitive (faster than NSLock)
struct UnfairLock {
private var _lock = os_unfair_lock()
mutating func withLock<T>(_ block: () throws -> T) rethrows -> T {
os_unfair_lock_lock(&_lock)
defer { os_unfair_lock_unlock(&_lock) }
return try block()
}
}
How to Apply This in Your App
Start by copying Signal's atomic types into your project, or create simplified versions:
@propertyWrapper
struct Atomic<Value>: @unchecked Sendable {
private var lock = NSLock()
private var value: Value
init(wrappedValue: Value) {
self.value = wrappedValue
}
var wrappedValue: Value {
get {
lock.lock()
defer { lock.unlock() }
return value
}
set {
lock.lock()
defer { lock.unlock() }
value = newValue
}
}
var projectedValue: Atomic<Value> { self }
mutating func update<T>(_ block: (inout Value) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block(&value)
}
}
// Usage
class NetworkManager: Sendable {
@Atomic private var requestCount = 0
@Atomic private var activeRequests: Set<UUID> = []
func performRequest() async {
let requestId = UUID()
$activeRequests.update { $0.insert(requestId) }
$requestCount.update { $0 += 1 }
defer {
$activeRequests.update { $0.remove(requestId) }
}
// Perform request...
}
}
Use cases:
- Shared state in async/await code
- Counters, flags, and statistics
- State machines with validated transitions
- Thread-safe collections
5. Monotonic Clock for Reliable Timing
The Problem
Using Date() for timing and duration measurement has a critical flaw: it's affected by system clock changes.
let start = Date()
// User adjusts clock backward 1 hour
let end = Date()
let duration = end.timeIntervalSince(start) // Negative value!
This causes bugs in:
- Timeout calculations
- Rate limiting
- Performance measurements
- Session duration tracking
Signal's Solution
Signal uses monotonic clock, which is guaranteed to never go backward.
/// A date/time value that is monotonically increasing and immune to system clock changes.
/// Perfect for measuring durations and timeouts within a single app session.
public struct MonotonicDate: Comparable, Sendable, Codable {
/// System uptime in nanoseconds (never decreases)
private let uptimeNanos: UInt64
/// Creates a MonotonicDate representing "now"
public init() {
self.uptimeNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
}
/// Creates a MonotonicDate representing a future point in time
public init(fromNow interval: TimeInterval) {
let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
let intervalNanos = UInt64(interval * 1_000_000_000)
self.uptimeNanos = nowNanos + intervalNanos
}
/// Time interval since another MonotonicDate
/// Always returns a sensible positive value (or zero)
public func timeIntervalSince(_ other: MonotonicDate) -> TimeInterval {
let deltaNanos = self.uptimeNanos - other.uptimeNanos
return TimeInterval(deltaNanos) / 1_000_000_000
}
/// Has this date/time passed yet?
public var isBeforeNow: Bool {
let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
return uptimeNanos < nowNanos
}
/// Comparable conformance - allows sorting
public static func < (lhs: MonotonicDate, rhs: MonotonicDate) -> Bool {
lhs.uptimeNanos < rhs.uptimeNanos
}
}
How It's Used in Signal
Use Case 1: Call Duration Tracking
class IndividualCall {
private let callConnectedTime: MonotonicDate?
var callDuration: TimeInterval {
guard let connectedTime = callConnectedTime else {
return 0
}
return MonotonicDate().timeIntervalSince(connectedTime)
}
func markConnected() {
callConnectedTime = MonotonicDate() // Record connection time
}
}
Result: Call duration is always accurate, even if user adjusts their clock during the call.
Use Case 2: Message Send Timeout
class MessageSender {
func sendMessage(_ message: Message) async throws {
let deadline = MonotonicDate(fromNow: 30.0) // 30-second timeout
while !deadline.isBeforeNow {
// Try to send...
if messageSent {
return
}
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
}
throw MessageSendError.timeout
}
}
Use Case 3: Rate Limiting
class RateLimiter {
private var lastRequestTime: MonotonicDate?
private let minimumInterval: TimeInterval = 1.0
func canMakeRequest() -> Bool {
guard let lastTime = lastRequestTime else {
return true // First request
}
let elapsed = MonotonicDate().timeIntervalSince(lastTime)
return elapsed >= minimumInterval
}
func recordRequest() {
lastRequestTime = MonotonicDate()
}
}
Why This Is Better
With Date (broken):
// Breaks when clock changes
class BrokenTimer {
let startTime = Date()
func checkTimeout() -> Bool {
let elapsed = Date().timeIntervalSince(startTime)
return elapsed > 30 // Can be negative! Can jump hours!
}
}
// Scenario:
// 1. Start timer at 3:00 PM
// 2. User sets clock back to 2:00 PM
// 3. elapsed becomes -3600 seconds (negative!)
With MonotonicDate (reliable):
// Always increases, immune to clock changes
class ReliableTimer {
let startTime = MonotonicDate()
func checkTimeout() -> Bool {
let elapsed = MonotonicDate().timeIntervalSince(startTime)
return elapsed > 30 // Always positive, always accurate
}
}
What You Can Learn
- Never use Date() for durations: Use monotonic clock instead
- Monotonic clock is process-scoped: Doesn't persist across app launches (that's what Date is for)
- CLOCK_MONOTONIC_RAW is available on all platforms: Both iOS and macOS
- Nanosecond precision: More accurate than Date (which uses TimeInterval)
- Perfect for timeouts, debouncing, rate limiting: Any in-app timing
When to Use Each Type
Use MonotonicDate for:
- Measuring duration in-app (immune to clock changes)
- Call duration, timeout, debouncing (reliable timing)
- Performance profiling (accurate measurements)
Use Date for:
- Displaying time to user (user expects their local time)
- Storing timestamp in database (needs to persist across launches)
- Comparing times across devices (synchronized via server time)
How to Apply This in Your App
// Copy Signal's implementation or create simplified version
struct MonotonicTime: Comparable {
private let nanos: UInt64
init() {
self.nanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
}
static var now: MonotonicTime { MonotonicTime() }
func elapsed() -> TimeInterval {
let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)
return TimeInterval(nowNanos - nanos) / 1_000_000_000
}
static func < (lhs: MonotonicTime, rhs: MonotonicTime) -> Bool {
lhs.nanos < rhs.nanos
}
}
// Usage examples
class Examples {
// Timeout
func withTimeout<T>(_ duration: TimeInterval, _ work: () async throws -> T) async throws -> T {
let start = MonotonicTime()
let result = try await work()
guard start.elapsed() < duration else {
throw TimeoutError()
}
return result
}
// Debouncing
class Debouncer {
var lastTriggerTime = MonotonicTime()
let interval: TimeInterval = 0.3
func shouldTrigger() -> Bool {
let elapsed = lastTriggerTime.elapsed()
if elapsed >= interval {
lastTriggerTime = MonotonicTime()
return true
}
return false
}
}
// Performance measurement
func measurePerformance<T>(_ label: String, _ work: () -> T) -> T {
let start = MonotonicTime()
let result = work()
let duration = start.elapsed()
print("\(label): \(duration * 1000)ms")
return result
}
}
Replace all code that looks like this:
// Before
class OldCode {
var startTime: Date?
func startTimer() {
startTime = Date()
}
func checkElapsed() -> TimeInterval {
guard let start = startTime else { return 0 }
return Date().timeIntervalSince(start) // BROKEN
}
}
With this:
// After
class NewCode {
var startTime: MonotonicTime?
func startTimer() {
startTime = MonotonicTime()
}
func checkElapsed() -> TimeInterval {
guard let start = startTime else { return 0 }
return start.elapsed() // RELIABLE
}
}
6. UICollectionView Frame Protection
The Problem
UIKit sometimes temporarily sets view frames to zero during complex layout passes. For UICollectionView, this can cause:
- Invalid layout attributes
- Assertion failures in custom layouts
- Crashes in background threads calculating cell sizes
- Incorrect scroll position restoration
This especially happens during:
- Split view controller transitions
- Rotation
- Keyboard appearance/dismissal
- View controller presentation/dismissal
Signal's Solution
Signal overrides frame and bounds setters to reject invalid zero-size assignments, protecting the collection view's layout integrity.
/// Custom UICollectionView that protects against UIKit's temporary zero-frame assignments
/// which can cause layout calculation crashes in conversation view
class ConversationCollectionView: UICollectionView {
// MARK: - Frame Protection
/// Override frame setter to reject invalid values
/// UIKit sometimes temporarily sets frames to zero during complex transitions,
/// which causes our custom layout to crash when calculating cell sizes
override var frame: CGRect {
get {
super.frame
}
set {
let hasInvalidWidth = newValue.width <= 0 || newValue.width.isNaN
let hasInvalidHeight = newValue.height <= 0 || newValue.height.isNaN
if hasInvalidWidth || hasInvalidHeight {
// Log for debugging but don't crash
Logger.warn("Rejecting invalid frame: \(newValue)")
// Maintain current valid frame
return
}
// Only allow valid frames
super.frame = newValue
}
}
/// Also protect bounds (UIKit can set this independently)
override var bounds: CGRect {
get {
super.bounds
}
set {
let hasInvalidWidth = newValue.width <= 0 || newValue.width.isNaN
let hasInvalidHeight = newValue.height <= 0 || newValue.height.isNaN
if hasInvalidWidth || hasInvalidHeight {
Logger.warn("Rejecting invalid bounds: \(newValue)")
return
}
super.bounds = newValue
}
}
/// Invariant: Collection view should always have a reasonable size
/// This is critical for our custom layout which calculates cell widths as percentages
private func assertValidSize() {
assert(frame.width > 0 && frame.height > 0,
"ConversationCollectionView should always have valid size")
}
}
How It's Used in Signal
The Scenario: Signal's conversation view displays messages in a UICollectionView with a complex custom layout.
The custom layout calculates cell widths based on collection view width:
class MessageCellLayout {
func calculateCellSize(for message: Message,
collectionViewWidth: CGFloat) -> CGSize {
// Calculate width as percentage of collection view
let maxWidth = collectionViewWidth * 0.8 // 80% of screen
// If collectionViewWidth is 0, this crashes!
let textWidth = min(message.textWidth, maxWidth)
return CGSize(width: textWidth, height: message.height)
}
}
Without Protection: During rotation or split view transitions:
- UIKit temporarily sets
collectionView.frame = .zero - Layout pass happens with
collectionViewWidth = 0 - Cell size calculations produce invalid results
- Crash: "Invalid layout attributes"
With Protection:
- UIKit tries to set
collectionView.frame = .zero - Custom setter rejects it, maintains previous valid frame
- Layout calculations use valid width
- No crash, smooth transition
Real Bug This Prevents
From Signal's git history, this prevented a crash that looked like:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException'
*** Reason: 'Invalid layout attributes from -[UICollectionViewLayout layoutAttributesForItemAtIndexPath:]'
Stack trace:
- UICollectionView layoutSubviews
- Custom layout calculating cell size with width=0
- NaN propagation through layout calculations
- Assertion failure
Why This Is Better
Without protection (crashes):
// Crashes during complex transitions
class NormalCollectionView: UICollectionView {
// Uses default frame setter
// UIKit can set frame = .zero temporarily
// Layout crashes
}
With protection (stable):
// Maintains layout integrity
class ProtectedCollectionView: UICollectionView {
override var frame: CGRect {
get { super.frame }
set {
guard newValue.width > 0 && newValue.height > 0 else {
return // Reject invalid frames
}
super.frame = newValue
}
}
}
What You Can Learn
- UIKit can set temporary invalid frames: Especially during transitions
- Override frame/bounds setters to protect: Valid pattern for complex views
- Log rejections for debugging: Helps identify problematic transitions
- Maintain invariants through overrides: Protect your view's contracts
- Check for both <= 0 and isNaN: Edge cases exist with floating point
Advanced: Why This Happens in UIKit
UIKit's layout process sometimes involves:
// UIKit internally during complex transitions:
func performComplexTransition() {
// 1. Save current frame
let savedFrame = view.frame
// 2. Temporarily set to zero for measurement
view.frame = .zero //
// 3. Ask for intrinsic content size
let size = view.intrinsicContentSize
// 4. Calculate new frame
let newFrame = calculateFrame(from: size)
// 5. Set final frame
view.frame = newFrame
// Problem: If a layout pass happens during step 2-3,
// your view has zero size!
}
Signal's protection prevents issues during steps 2-3.
How to Apply This in Your App
Basic Protection:
class ProtectedCollectionView: UICollectionView {
override var frame: CGRect {
get { super.frame }
set {
// Reject zero or negative dimensions
guard newValue.width > 0, newValue.height > 0 else {
print("Rejecting invalid frame: \(newValue)")
return
}
super.frame = newValue
}
}
override var bounds: CGRect {
get { super.bounds }
set {
guard newValue.width > 0, newValue.height > 0 else {
print("Rejecting invalid bounds: \(newValue)")
return
}
super.bounds = newValue
}
}
}
Advanced Protection (with NaN checks):
class RobustCollectionView: UICollectionView {
override var frame: CGRect {
get { super.frame }
set {
guard isValid(rect: newValue) else {
Logger.warn("Rejecting invalid frame: \(newValue)")
return
}
super.frame = newValue
}
}
private func isValid(rect: CGRect) -> Bool {
// Check for zero or negative
guard rect.width > 0, rect.height > 0 else {
return false
}
// Check for NaN (can happen with bad calculations)
guard !rect.width.isNaN, !rect.height.isNaN else {
return false
}
// Check for infinity (another edge case)
guard rect.width.isFinite, rect.height.isFinite else {
return false
}
return true
}
}
When to Use This Pattern:
- UICollectionView with custom layout that depends on collection view size
- UITableView with complex self-sizing cells
- Custom views that calculate layouts based on their own size
- Any view experiencing mysterious layout crashes during transitions
Additional Debugging:
class DebuggableCollectionView: UICollectionView {
override var frame: CGRect {
get { super.frame }
set {
let oldValue = super.frame
guard isValid(rect: newValue) else {
// Capture stack trace to find who's setting invalid frame
let stackTrace = Thread.callStackSymbols
Logger.error("Invalid frame rejected. Old: \(oldValue), New: \(newValue)\n\(stackTrace)")
return
}
super.frame = newValue
// Log significant changes for debugging
if abs(newValue.width - oldValue.width) > 100 {
Logger.debug("Large frame change: \(oldValue) → \(newValue)")
}
}
}
}
Use cases:
- Chat/messaging apps with custom message layouts
- Social media feeds with complex cells
- Split view controllers with collection views
- Any app with custom UICollectionViewLayout
7. Lazy Dependency Injection with Closures
The Problem
Traditional dependency injection requires all dependencies to be created before passing them to a class. This causes problems:
- Initialization order issues: Dependency A needs B, but B needs A (circular)
- Expensive initialization: Creating all dependencies upfront wastes resources
- Testing complexity: Mocking requires creating entire dependency graph
- Tight coupling: Changes to dependencies ripple through initializers
Example of the problem:
// Rigid initialization - all dependencies needed upfront
class MessageProcessor {
private let database: Database
private let networkManager: NetworkManager
private let accountManager: AccountManager
init(database: Database,
networkManager: NetworkManager,
accountManager: AccountManager) {
self.database = database
self.networkManager = networkManager
self.accountManager = accountManager
}
}
// Creating this requires creating everything first
let processor = MessageProcessor(
database: createDatabase(), // Expensive
networkManager: createNetworkManager(), // Expensive
accountManager: createAccountManager() // Expensive
)
Signal's Solution
Signal uses closure-based lazy dependency injection where dependencies are provided as closures that return the dependency when called.
class DatabaseMigratorRunner {
// Dependencies are closures, not concrete instances
private let backgroundMessageFetcherFactory: () -> BackgroundMessageFetcherFactory
private let remoteConfigManager: () -> any RemoteConfigManager
private let tsAccountManager: () -> TSAccountManager
init(
backgroundMessageFetcherFactory: @escaping () -> BackgroundMessageFetcherFactory,
remoteConfigManager: @escaping () -> any RemoteConfigManager,
tsAccountManager: @escaping () -> TSAccountManager
) {
self.backgroundMessageFetcherFactory = backgroundMessageFetcherFactory
self.remoteConfigManager = remoteConfigManager
self.tsAccountManager = tsAccountManager
}
func run() async throws {
// Dependencies are created only when needed
let remoteConfig = remoteConfigManager()
try await remoteConfig.refreshIfNeeded()
// This might not even be called in some code paths
if needsBackgroundFetch {
let fetcher = backgroundMessageFetcherFactory()
await fetcher.fetchMessages()
}
// Each call gets the current instance (could change between calls)
let account = tsAccountManager()
if account.isRegistered {
// ...
}
}
}
// Usage - pass closures instead of instances
let runner = DatabaseMigratorRunner(
backgroundMessageFetcherFactory: { AppEnvironment.shared.backgroundMessageFetcherFactory },
remoteConfigManager: { SSKEnvironment.shared.remoteConfigManager },
tsAccountManager: { DependenciesBridge.shared.tsAccountManager }
)
How It's Used in Signal
Use Case 1: Avoiding Circular Dependencies
Signal has a complex initialization sequence where services depend on each other:
// Database needs account info to decrypt
// Account manager needs database to read settings
// Network manager needs both
class AppSetup {
func initializeServices() {
// Create database first (but don't use account yet)
let database = Database()
// Create account manager with lazy database access
let accountManager = AccountManager(
database: { database } // Closure - can be called later
)
// Create network manager with lazy access to both
let networkManager = NetworkManager(
database: { database },
accountManager: { accountManager }
)
// Now they can all reference each other without circular init issues
}
}
Use Case 2: Conditional Expensive Operations
class MessageProcessor {
// Expensive to create - only create if needed
private let attachmentDownloader: () -> AttachmentDownloader
func processMessage(_ message: Message) async {
if message.hasAttachments {
// Only create downloader when actually needed
let downloader = attachmentDownloader()
await downloader.downloadAttachments(for: message)
}
// If no attachments, downloader never created
}
}
Use Case 3: Getting Current Instance (Mutable Singletons)
Some of Signal's services can be replaced (for testing or feature flags):
class FeatureFlag {
// The implementation can change at runtime
private let accountManager: () -> TSAccountManager
var isEnabled: Bool {
// Always get current instance
let manager = accountManager()
return manager.currentAccount != nil
}
}
// In tests, you can swap out the account manager
func testFeature() {
let mockManager = MockAccountManager()
// Closure returns different instance for testing
let flag = FeatureFlag(
accountManager: { mockManager }
)
XCTAssertTrue(flag.isEnabled)
}
Why This Is Better
Traditional DI (inflexible):
// All dependencies created upfront
class Traditional {
let heavy1: HeavyService
let heavy2: HeavyService
let heavy3: HeavyService
init(heavy1: HeavyService, heavy2: HeavyService, heavy3: HeavyService) {
self.heavy1 = heavy1 // Created even if never used
self.heavy2 = heavy2
self.heavy3 = heavy3
}
}
// Creating this is expensive
let obj = Traditional(
heavy1: HeavyService(), // 100ms
heavy2: HeavyService(), // 100ms
heavy3: HeavyService() // 100ms - might not even be used!
)
Signal's approach (flexible):
// Dependencies created only when needed
class Lazy {
let heavy1: () -> HeavyService
let heavy2: () -> HeavyService
let heavy3: () -> HeavyService
init(heavy1: @escaping () -> HeavyService,
heavy2: @escaping () -> HeavyService,
heavy3: @escaping () -> HeavyService) {
self.heavy1 = heavy1 // Just storing closure - instant
self.heavy2 = heavy2
self.heavy3 = heavy3
}
func doSomething() {
if condition {
heavy1().doWork() // Created only if condition met
}
}
}
// Initialization is instant
let obj = Lazy(
heavy1: { HeavyService() }, // Not created yet
heavy2: { HeavyService() },
heavy3: { HeavyService() }
)
What You Can Learn
- Closures enable lazy initialization: Don't create until needed
- Breaks circular dependencies: A can reference B, B can reference A
- Improves testability: Easy to inject different instances
- Reduces memory footprint: Only create what you use
- Allows runtime dependency changes: Closures can return different instances
Pattern Variations
1. With Default Values:
class Service {
private let dependency: () -> Dependency
init(dependency: @escaping () -> Dependency = { DefaultDependency.shared }) {
self.dependency = dependency
}
}
2. With Memoization:
class Service {
private let dependencyFactory: () -> Dependency
private lazy var dependency: Dependency = dependencyFactory()
init(dependency: @escaping () -> Dependency) {
self.dependencyFactory = dependency
}
func doWork() {
// Only created once, then cached
dependency.performAction()
}
}
3. With Type-Erased Protocol:
class Service {
private let dependency: () -> any DependencyProtocol
init(dependency: @escaping () -> any DependencyProtocol) {
self.dependency = dependency
}
}
How to Apply This in Your App
Basic Pattern:
class MyViewController: UIViewController {
// Instead of: private let database: Database
private let database: () -> Database
// Instead of: private let networkManager: NetworkManager
private let networkManager: () -> NetworkManager
init(
database: @escaping () -> Database,
networkManager: @escaping () -> NetworkManager
) {
self.database = database
self.networkManager = networkManager
super.init(nibName: nil, bundle: nil)
}
func loadData() async {
// Create when needed
let db = database()
let data = await db.fetch()
// Send to server
let network = networkManager()
try await network.upload(data)
}
}
// Usage
let vc = MyViewController(
database: { AppDelegate.shared.database },
networkManager: { AppDelegate.shared.networkManager }
)
Advanced: Solving Circular Dependencies:
class ServiceA {
private let serviceB: () -> ServiceB
init(serviceB: @escaping () -> ServiceB) {
self.serviceB = serviceB
}
func doSomething() {
serviceB().helperMethod()
}
}
class ServiceB {
private let serviceA: () -> ServiceA
init(serviceA: @escaping () -> ServiceA) {
self.serviceA = serviceA
}
func doSomethingElse() {
serviceA().helperMethod()
}
}
// No circular init issue!
var serviceA: ServiceA!
var serviceB: ServiceB!
serviceA = ServiceA(serviceB: { serviceB })
serviceB = ServiceB(serviceA: { serviceA })
Testing:
class MyViewControllerTests: XCTestCase {
func testDataLoading() async {
let mockDatabase = MockDatabase()
let mockNetwork = MockNetworkManager()
let vc = MyViewController(
database: { mockDatabase },
networkManager: { mockNetwork }
)
await vc.loadData()
XCTAssertTrue(mockDatabase.fetchCalled)
XCTAssertTrue(mockNetwork.uploadCalled)
}
}
Use cases:
- Complex dependency graphs with circular references
- Expensive services that aren't always needed
- Services that need to be swappable for testing
- Apps with feature flags that change implementations
8. Context-Aware LRU Caching
The Problem
iOS apps often run in multiple process contexts:
- Main app: Has plenty of memory (hundreds of MB)
- Notification Service Extension (NSE): Limited to ~24MB
- Share Extension: Limited to ~120MB
- Widgets: Very limited memory
Using the same cache sizes across all contexts causes:
- Memory pressure and crashes in extensions
- Wasted memory in main app (cache too small)
- Poor performance tuning
Signal's Solution
Signal's caching layer is context-aware and automatically adjusts cache sizes based on the execution environment.
/// LRU Cache that adjusts size based on app context
/// Main app gets large cache, NSE gets smaller cache
public class LRUCache<KeyType: Hashable & Equatable, ValueType> {
private let cache: NSCache<AnyObject, AnyObject>
private let maxSize: Int
private let nseMaxSize: Int
public init(
maxSize: Int,
nseMaxSize: Int? = nil,
shouldEvacuateInBackground: Bool = true
) {
self.maxSize = maxSize
self.nseMaxSize = nseMaxSize ?? max(4, maxSize / 8) // Default: 1/8th size
self.cache = NSCache()
self.cache.countLimit = CurrentAppContext().isNSE ? self.nseMaxSize : self.maxSize
// Clear cache when app backgrounds to free memory
if shouldEvacuateInBackground {
NotificationCenter.default.addObserver(
forName: .OWSApplicationDidEnterBackground,
object: nil,
queue: nil
) { [weak self] _ in
self?.removeAllObjects()
}
}
}
public func get(key: KeyType) -> ValueType? {
guard let value = cache.object(forKey: key as AnyObject) as? ValueType else {
return nil
}
return value
}
public func set(key: KeyType, value: ValueType) {
cache.setObject(value as AnyObject, forKey: key as AnyObject)
}
public func removeAllObjects() {
cache.removeAllObjects()
}
}
// CurrentAppContext determines execution environment
protocol AppContext {
var isMainApp: Bool { get }
var isNSE: Bool { get }
var isShareExtension: Bool { get }
var reportedMemoryWarnings: Bool { get }
}
How It's Used in Signal
Use Case 1: Profile Image Cache
class ProfileImageCache {
// Main app: Cache 256 profile images (~50MB)
// NSE: Cache 32 profile images (~6MB)
private let imageCache = LRUCache<String, UIImage>(
maxSize: 256,
nseMaxSize: 32
)
func profileImage(for address: SignalServiceAddress) -> UIImage? {
let key = address.stringValue
if let cached = imageCache.get(key: key) {
return cached // Cache hit
}
// Load from disk
guard let image = loadFromDisk(address) else {
return nil
}
// Cache for next time (automatically size-aware)
imageCache.set(key: key, value: image)
return image
}
}
When NSE processes notification:
- Limited to 32 profile images in cache
- Prevents memory pressure
- Still gets performance benefit of caching
When main app shows conversation list:
- Can cache up to 256 profile images
- Smooth scrolling performance
- Better UX with larger cache
Use Case 2: Model Read Cache
class ThreadModelCache {
// Main app: Cache 64 thread models
// NSE: Cache 8 thread models
private let cache = LRUCache<String, TSThread>(
maxSize: 64,
nseMaxSize: 8
)
func thread(for uniqueId: String, tx: DBReadTransaction) -> TSThread? {
if let cached = cache.get(key: uniqueId) {
return cached
}
guard let thread = TSThread.anyFetch(uniqueId: uniqueId, transaction: tx) else {
return nil
}
cache.set(key: uniqueId, value: thread)
return thread
}
}
Use Case 3: Attachment Thumbnail Cache
class ThumbnailCache {
// Main app: Cache 512 thumbnails (~100MB)
// NSE: Cache 16 thumbnails (~3MB)
// Share extension: Cache 64 thumbnails (~12MB)
private let cache: LRUCache<String, UIImage>
init() {
let nseSize = 16
let shareExtensionSize = 64
let mainAppSize = 512
let maxSize: Int
if CurrentAppContext().isNSE {
maxSize = nseSize
} else if CurrentAppContext().isShareExtension {
maxSize = shareExtensionSize
} else {
maxSize = mainAppSize
}
self.cache = LRUCache(maxSize: maxSize, nseMaxSize: nseSize)
}
}
Why This Is Better
Without context awareness (crashes in NSE):
// Same cache size everywhere
class NaiveCache {
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 500 // Too large for NSE!
}
}
// In NSE processing notification:
// - Loads 500 profile images
// - Uses 100MB+ of memory
// - NSE limit is ~24MB
// - Crash: "Extension terminated due to memory pressure"
With context awareness (stable):
// Adapts to environment
class SmartCache {
private let cache = NSCache<NSString, UIImage>()
init() {
if CurrentAppContext().isNSE {
cache.countLimit = 32 // Small for NSE
} else {
cache.countLimit = 500 // Large for main app
}
}
}
Advanced: Memory Pressure Response
Signal also evacuates caches when memory warnings occur:
class LRUCache<K: Hashable, V> {
init(maxSize: Int, shouldEvacuateInBackground: Bool = true) {
// ...
// Clear cache on memory warning
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: nil
) { [weak self] _ in
Logger.warn("Memory warning - evacuating cache")
self?.removeAllObjects()
}
// Clear cache when backgrounding (free memory)
if shouldEvacuateInBackground {
NotificationCenter.default.addObserver(
forName: .OWSApplicationDidEnterBackground,
object: nil,
queue: nil
) { [weak self] _ in
self?.removeAllObjects()
}
}
}
}
What You Can Learn
- Extensions have strict memory limits: NSE ~24MB, Share ~120MB
- NSCache is better than Dictionary: Automatic eviction under memory pressure
- Context detection is essential: Don't treat all processes equally
- Background evacuation helps: Clear caches when app backgrounds
- Memory warnings should clear caches: System is telling you to free memory
Memory Limits by Extension Type
Notification Service Extension
- Memory limit: ~24MB
- Recommended cache size: Very small (8-32 items)
Share Extension
- Memory limit: ~120MB
- Recommended cache size: Small (64-128 items)
Widget
- Memory limit: ~30MB
- Recommended cache size: Very small (16-32 items)
Main App
- Memory limit: ~300MB+
- Recommended cache size: Large (256-1024 items)
How to Apply This in Your App
Basic Context-Aware Cache:
import Foundation
class ContextAwareCache<Key: Hashable, Value> {
private let cache = NSCache<AnyHashable, AnyObject>()
init(mainAppLimit: Int, extensionLimit: Int) {
// Detect if running in extension
let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")
cache.countLimit = isExtension ? extensionLimit : mainAppLimit
// Clear on memory warning
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: nil
) { [weak cache] _ in
cache?.removeAllObjects()
}
}
func get(_ key: Key) -> Value? {
cache.object(forKey: key as AnyHashable) as? Value
}
func set(_ key: Key, value: Value) {
cache.setObject(value as AnyObject, forKey: key as AnyHashable)
}
func clear() {
cache.removeAllObjects()
}
}
// Usage
let imageCache = ContextAwareCache<String, UIImage>(
mainAppLimit: 500,
extensionLimit: 32
)
Advanced: Memory Cost Aware:
class SizeAwareCache<Key: Hashable, Value> {
private let cache = NSCache<AnyHashable, AnyObject>()
init(mainAppMemoryMB: Int, extensionMemoryMB: Int) {
let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")
let memoryLimit = isExtension ? extensionMemoryMB : mainAppMemoryMB
cache.totalCostLimit = memoryLimit * 1024 * 1024 // Convert to bytes
}
func set(_ key: Key, value: Value, costInBytes: Int) {
cache.setObject(value as AnyObject, forKey: key as AnyHashable, cost: costInBytes)
}
}
// Usage
let imageCache = SizeAwareCache<String, UIImage>(
mainAppMemoryMB: 50, // 50MB for main app
extensionMemoryMB: 5 // 5MB for extension
)
// Store with actual memory cost
let image = UIImage(...)
let imageSize = image.pngData()?.count ?? 0
imageCache.set("key", value: image, costInBytes: imageSize)
Production-Ready with Logging:
class ProductionCache<Key: Hashable, Value> {
private let cache = NSCache<AnyHashable, AnyObject>()
private let name: String
init(name: String, mainAppLimit: Int, extensionLimit: Int) {
self.name = name
let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")
let limit = isExtension ? extensionLimit : mainAppLimit
cache.countLimit = limit
cache.delegate = self
print("[\(name)] Initialized with limit: \(limit) (\(isExtension ? "extension" : "main app"))")
}
}
extension ProductionCache: NSCacheDelegate {
func cache(_ cache: NSCache<AnyHashable, AnyObject>, willEvictObject obj: Any) {
print("[\(name)] Evicting object due to memory pressure")
}
}
Use cases:
- Apps with Notification Service Extensions
- Apps with Share Extensions
- Apps with Widgets
- Any app that needs to optimize memory per context
- Image/media caching
- Model/data caching
9. Cross-Process Cache Invalidation
The Problem
Signal iOS has multiple processes sharing the same database:
- Main app: User actively using the app
- Notification Service Extension (NSE): Processing incoming message notifications
- Share Extension: Sharing content to Signal
When the NSE receives a message and writes it to the database, the main app's in-memory caches become stale. Without invalidation:
- Main app shows outdated data
- User sees message delay (cache shows old conversation list)
- Race conditions and data inconsistencies
Standard solutions don't work:
NotificationCenteris process-local only- Database triggers can't notify other processes
- Polling wastes resources
Signal's Solution
Signal uses Darwin notifications (system-level cross-process notifications) to invalidate caches when any process modifies the database.
/// Read cache that automatically invalidates when another process modifies the database
class ModelReadCache<KeyType: Hashable, ValueType> {
private let cache: LRUCache<KeyType, ValueType>
private let cacheIdentifier: String
// Darwin notification name (system-wide)
private var darwinNotificationName: String {
"org.signal.database.didChange.\(cacheIdentifier)"
}
init(cacheIdentifier: String, maxSize: Int, nseMaxSize: Int) {
self.cacheIdentifier = cacheIdentifier
self.cache = LRUCache(maxSize: maxSize, nseMaxSize: nseMaxSize)
// Register for cross-process database change notifications
registerForCrossProcessNotifications()
// Also register for in-process notifications (faster)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInProcessDatabaseChange),
name: .DatabaseDidChange,
object: nil
)
}
private func registerForCrossProcessNotifications() {
// Darwin notifications work across process boundaries
let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterAddObserver(
center,
Unmanaged.passUnretained(self).toOpaque(),
{ (center, observer, name, object, userInfo) in
guard let observer = observer else { return }
let cache = Unmanaged<ModelReadCache>.fromOpaque(observer).takeUnretainedValue()
cache.handleCrossProcessDatabaseChange()
},
darwinNotificationName as CFString,
nil,
.deliverImmediately
)
}
@objc private func handleInProcessDatabaseChange() {
// Same process modified database - invalidate immediately
cache.removeAllObjects()
}
private func handleCrossProcessDatabaseChange() {
// Another process modified database - invalidate cache
DispatchQueue.main.async {
self.cache.removeAllObjects()
// Notify UI to refresh
NotificationCenter.default.post(name: .ModelCacheDidInvalidate, object: nil)
}
}
// Read from cache or database
func read(key: KeyType, transaction: DBReadTransaction) -> ValueType? {
// Check cache first
if let cached = cache.get(key: key) {
return cached
}
// Cache miss - read from database
guard let value = readFromDatabase(key: key, transaction: transaction) else {
return nil
}
// Populate cache for next read
cache.set(key: key, value: value)
return value
}
}
// Write path posts Darwin notification
class Database {
func write(_ block: (DBWriteTransaction) -> Void) {
performWrite(block)
// Notify all processes that database changed
postCrossProcessDatabaseChangeNotification()
}
private func postCrossProcessDatabaseChangeNotification() {
let center = CFNotificationCenterGetDarwinNotifyCenter()
let notificationName = "org.signal.database.didChange" as CFString
CFNotificationCenterPostNotification(
center,
CFNotificationName(notificationName),
nil,
nil,
true // Deliver immediately
)
}
}
How It's Used in Signal
Scenario: Message Arrives While App is in Background
- NSE Process receives push notification
- NSE decrypts message and writes to database
- NSE posts Darwin notification
- Main App (suspended) receives Darwin notification
- Main App invalidates thread list cache
- User opens app → Main App reads fresh data from database
- New message appears immediately
class ThreadModelCache {
private let cache: ModelReadCache<String, TSThread>
init() {
// Main app: cache 64 threads
// NSE: cache 8 threads
self.cache = ModelReadCache(
cacheIdentifier: "threads",
maxSize: 64,
nseMaxSize: 8
)
}
func getThread(uniqueId: String, transaction: DBReadTransaction) -> TSThread? {
// Automatically uses cache if valid
// Automatically invalidates if another process wrote to DB
return cache.read(key: uniqueId, transaction: transaction)
}
}
class SignalRecipientCache {
private let cache: ModelReadCache<SignalServiceAddress, SignalRecipient>
init() {
self.cache = ModelReadCache(
cacheIdentifier: "recipients",
maxSize: 256,
nseMaxSize: 32
)
}
func recipient(for address: SignalServiceAddress, tx: DBReadTransaction) -> SignalRecipient? {
cache.read(key: address, transaction: tx)
}
}
Real-World Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Time: 10:00:00 AM - User viewing conversation list (Main App) │
└─────────────────────────────────────────────────────────────────┘
│
│ Main App caches thread list in memory
▼
┌─────────────────────────────────────────────────────────────────┐
│ Time: 10:00:05 AM - User presses home button (App backgrounds) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Time: 10:00:10 AM - New message arrives (NSE wakes up) │
│ NSE Process: │
│ 1. Decrypt message │
│ 2. Write to database: INSERT INTO messages ... │
│ 3. Update thread: UPDATE threads SET lastMessageDate = ... │
│ 4. Post Darwin notification: "org.signal.database.didChange" │
└─────────────────────────────────────────────────────────────────┘
│
│ Darwin notification crosses process boundary
▼
┌─────────────────────────────────────────────────────────────────┐
│ Time: 10:00:10 AM - Main App receives Darwin notification │
│ Main App (suspended): │
│ 1. Darwin notification handler fires │
│ 2. Invalidate thread cache │
│ 3. Invalidate message cache │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Time: 10:00:15 AM - User opens app again │
│ Main App: │
│ 1. Render conversation list │
│ 2. Check thread cache → MISS (was invalidated) │
│ 3. Read from database → Gets fresh data with new message │
│ 4. Display shows new message immediately ✓ │
└─────────────────────────────────────────────────────────────────┘
Why This Is Better
Without cross-process invalidation (stale data):
// Main app caches become stale when NSE writes
class NaiveThreadCache {
private var cache: [String: TSThread] = [:]
func getThread(_ id: String) -> TSThread? {
if let cached = cache[id] {
return cached // STALE! NSE modified database but cache doesn't know
}
let thread = database.fetch(id)
cache[id] = thread
return thread
}
}
// User experience:
// - Message arrives (NSE processes it)
// - User opens app
// - Conversation list doesn't show new message (stale cache)
// - User has to force refresh or wait for cache timeout
With cross-process invalidation (always fresh):
// Cache automatically invalidates when ANY process writes
class SmartThreadCache {
private let cache: ModelReadCache<String, TSThread>
func getThread(_ id: String, tx: DBReadTransaction) -> TSThread? {
// Cache automatically invalidated by NSE write
return cache.read(key: id, transaction: tx)
}
}
// User experience:
// - Message arrives (NSE processes it)
// - NSE posts Darwin notification
// - Main app cache invalidates
// - User opens app
// - Conversation list immediately shows new message ✓
What You Can Learn
- Darwin notifications are cross-process: Work between app and extensions
- CFNotificationCenter != NotificationCenter: Different APIs, different scopes
- Invalidation is cheaper than polling: Don't poll database for changes
- Cache per-model-type: Separate caches for threads, messages, contacts
- Combine Darwin + local notifications: Fast path for same-process changes
Darwin Notifications Deep Dive
Key characteristics:
- System-level notifications (not limited to your app)
- Work across all processes (app, extensions, background processes)
- No payload (just the notification name)
- Delivered immediately (no queueing)
- Persist even if observer isn't running (delivered when it starts)
API Comparison:
NotificationCenter:
- Scope: Single process
- Payload: Yes (userInfo dictionary)
- Performance: Faster
- Use case: In-process events
Darwin Notifications:
- Scope: System-wide
- Payload: No (name only)
- Performance: Slightly slower
- Use case: Cross-process events
How to Apply This in Your App
Basic Cross-Process Notification:
class CrossProcessNotifier {
static let shared = CrossProcessNotifier()
// Post notification (any process can call this)
func postDatabaseChanged() {
let center = CFNotificationCenterGetDarwinNotifyCenter()
let name = "com.yourapp.database.changed" as CFString
CFNotificationCenterPostNotification(
center,
CFNotificationName(name),
nil,
nil,
true // deliverImmediately
)
}
// Observe notification (any process can observe)
func observeDatabaseChanges(callback: @escaping () -> Void) {
let center = CFNotificationCenterGetDarwinNotifyCenter()
let name = "com.yourapp.database.changed" as CFString
// Create observer context
class ObserverContext {
let callback: () -> Void
init(callback: @escaping () -> Void) {
self.callback = callback
}
}
let context = ObserverContext(callback: callback)
let observer = Unmanaged.passRetained(context).toOpaque()
CFNotificationCenterAddObserver(
center,
observer,
{ (center, observer, name, object, userInfo) in
guard let observer = observer else { return }
let context = Unmanaged<ObserverContext>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async {
context.callback()
}
},
name,
nil,
.deliverImmediately
)
}
}
// Usage in Main App:
class MainAppCache {
init() {
CrossProcessNotifier.shared.observeDatabaseChanges { [weak self] in
print("Database changed by another process!")
self?.clearCache()
}
}
}
// Usage in NSE:
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest) {
// Process message...
database.insert(message)
// Notify main app
CrossProcessNotifier.shared.postDatabaseChanged()
}
}
Production-Ready Cache with Invalidation:
class CrossProcessCache<Key: Hashable, Value> {
private let cache = NSCache<AnyHashable, AnyObject>()
private let darwinNotificationName: String
init(identifier: String, maxSize: Int) {
self.darwinNotificationName = "com.yourapp.cache.\(identifier)"
cache.countLimit = maxSize
// Register for invalidation notifications
registerForInvalidation()
}
private func registerForInvalidation() {
let center = CFNotificationCenterGetDarwinNotifyCenter()
let name = darwinNotificationName as CFString
CFNotificationCenterAddObserver(
center,
Unmanaged.passUnretained(self).toOpaque(),
{ (center, observer, name, object, userInfo) in
guard let observer = observer else { return }
let cache = Unmanaged<CrossProcessCache>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async {
print("[\(cache.darwinNotificationName)] Invalidating due to cross-process change")
cache.cache.removeAllObjects()
}
},
name,
nil,
.deliverImmediately
)
}
func get(_ key: Key) -> Value? {
cache.object(forKey: key as AnyHashable) as? Value
}
func set(_ key: Key, value: Value) {
cache.setObject(value as AnyObject, forKey: key as AnyHashable)
}
func invalidateAllProcesses() {
// Clear local cache
cache.removeAllObjects()
// Notify other processes
let center = CFNotificationCenterGetDarwinNotifyCenter()
let name = darwinNotificationName as CFString
CFNotificationCenterPostNotification(center, CFNotificationName(name), nil, nil, true)
}
deinit {
let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque())
}
}
// Usage:
let threadCache = CrossProcessCache<String, Thread>(identifier: "threads", maxSize: 100)
// In NSE after writing message:
database.write { tx in
tx.insert(message)
}
threadCache.invalidateAllProcesses() // Invalidates main app's cache too!
Use cases:
- Apps with Notification Service Extensions
- Apps with Share Extensions
- Core Data or SQLite shared between processes
- Any cross-process data synchronization
- Cache invalidation between app and extensions
10. AppReadiness Launch Coordination
The Problem
iOS apps have complex initialization requirements:
- Database migrations
- Keychain access
- Network reachability checks
- User settings restoration
- Cloud sync
- Push notification registration
- Analytics setup
- Third-party SDKs
If all of these run immediately on launch:
- Main thread blocks → User sees frozen launch screen
- Watchdog timer kills app → 0x8badf00d crash on older devices
- Poor user experience → App feels unresponsive
- Memory spikes → Simultaneous initialization causes pressure
Signal's Solution
Signal uses an AppReadiness coordination system that:
- Defines multiple readiness levels
- Allows tasks to register for specific readiness stages
- Spreads initialization over time ("polite" async tasks)
- Prevents the dreaded "stampede" of simultaneous work
/// Coordinates app initialization to prevent launch stampede
@objc
public class AppReadiness: NSObject {
// MARK: - Readiness Levels
/// App is ready - database is accessible
private static var isAppReady = false
/// UI is ready - safe to present view controllers
private static var isUIReady = false
/// Registered blocks waiting for readiness
private static var appReadyBlocks: [() -> Void] = []
private static var uiReadyBlocks: [() -> Void] = []
private static var politeAsyncBlocks: [() -> Void] = []
// MARK: - Registration
/// Run block when app becomes ready (or immediately if already ready)
@objc
public class func runNowOrWhenAppDidBecomeReady(_ block: @escaping () -> Void) {
guard !isAppReady else {
// Already ready - run immediately
block()
return
}
// Not ready yet - queue for later
appReadyBlocks.append(block)
}
/// Run block when UI becomes ready (or immediately if already ready)
@objc
public class func runNowOrWhenUIDidBecomeReady(_ block: @escaping () -> Void) {
guard !isUIReady else {
block()
return
}
uiReadyBlocks.append(block)
}
/// Run block asynchronously after app is ready, with delay to be "polite"
/// Use this for non-critical initialization to spread CPU load
@objc
public class func runNowOrWhenAppDidBecomeReadyAsync(_ block: @escaping () -> Void) {
guard !isAppReady else {
// Already ready - run politely (with delay)
runPolitely(block)
return
}
// Not ready yet - queue for polite execution later
politeAsyncBlocks.append(block)
}
// MARK: - Readiness Signaling
/// Mark app as ready - triggers queued blocks
@objc
public class func setAppIsReady() {
assert(!isAppReady, "App is already ready")
isAppReady = true
// Run all queued app-ready blocks
let blocks = appReadyBlocks
appReadyBlocks = []
for block in blocks {
block()
}
// Run polite blocks with delay to spread load
let politeBlocks = politeAsyncBlocks
politeAsyncBlocks = []
for block in politeBlocks {
runPolitely(block)
}
}
/// Mark UI as ready - triggers UI-related blocks
@objc
public class func setUIIsReady() {
assert(isAppReady, "UI ready called before app ready")
assert(!isUIReady, "UI is already ready")
isUIReady = true
let blocks = uiReadyBlocks
uiReadyBlocks = []
for block in blocks {
block()
}
}
// MARK: - Polite Execution
/// Run block asynchronously with delay to be "polite" (spread CPU load)
private class func runPolitely(_ block: @escaping () -> Void) {
// Random delay between 0.1-1.0 seconds
// Spreads initialization over time instead of all at once
let delay = TimeInterval.random(in: 0.1...1.0)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
block()
}
}
}
How It's Used in Signal
Initialization Sequence
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Phase 1: Critical setup (blocks UI)
setupLogging()
setupCrashReporting()
// Phase 2: Database (required for everything else)
setupDatabase()
runDatabaseMigrations()
// Phase 3: Mark app ready
AppReadiness.setAppIsReady() // ← Triggers queued blocks
// Phase 4: UI setup
setupRootViewController()
// Phase 5: Mark UI ready
AppReadiness.setUIIsReady() // ← Triggers UI blocks
return true
}
}
Use Case 1: Critical Services (Run Immediately)
class MessageFetcherJob {
func start() {
// Wait for database before fetching messages
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
self?.fetchMessages()
}
}
private func fetchMessages() {
// Database is ready - safe to query
let messages = database.fetchPendingMessages()
processMessages(messages)
}
}
Use Case 2: Non-Critical Services (Run Politely)
class AnalyticsManager {
func start() {
// Not urgent - run politely to avoid launch stampede
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync { [weak self] in
self?.uploadPendingEvents()
self?.refreshConfiguration()
}
}
}
Use Case 3: UI-Dependent Services
class TooltipManager {
func start() {
// Wait for UI before showing tooltips
AppReadiness.runNowOrWhenUIDidBecomeReady { [weak self] in
self?.showOnboardingTooltipIfNeeded()
}
}
}
Real Initialization Timeline:
App Launch
│
├─ 0ms: didFinishLaunching
│ ├─ Setup logging (10ms)
│ ├─ Setup crash reporting (20ms)
│ └─ Open database (50ms)
│
├─ 80ms: Run database migrations
│ └─ Migration complete (300ms)
│
├─ 380ms: AppReadiness.setAppIsReady() ← TRIGGERS QUEUED BLOCKS
│ ├─ MessageFetcherJob.start() (runs immediately)
│ ├─ ContactSyncJob.start() (runs immediately)
│ ├─ PushRegistration.start() (runs immediately)
│ │
│ └─ Polite blocks (run with random delays):
│ ├─ 480ms (+100ms): AnalyticsManager.uploadEvents()
│ ├─ 730ms (+350ms): BackupCheck.checkIfNeeded()
│ └─ 1130ms (+750ms): CleanupJob.cleanOldData()
│
├─ 400ms: Setup root view controller
│
├─ 450ms: AppReadiness.setUIIsReady() ← TRIGGERS UI BLOCKS
│ ├─ TooltipManager.showTooltips()
│ └─ BadgeManager.updateBadge()
│
└─ 500ms: User sees conversation list
Why This Is Better
Without coordination (stampede):
// Everything runs at once - app freezes
class AppDelegate {
func application(...) -> Bool {
setupDatabase() // 300ms on main thread
fetchMessages() // Network call on main thread
syncContacts() // Heavy operation on main thread
uploadAnalytics() // More network on main thread
checkBackups() // Disk I/O on main thread
cleanupOldData() // Database queries on main thread
// Total: 2000ms+ of blocking work
// Result: Watchdog kills app on older devices
// Crash: 0x8badf00d (app took too long to launch)
}
}
With AppReadiness (coordinated):
// Staged initialization - responsive app
class AppDelegate {
func application(...) -> Bool {
// Critical only (100ms)
setupDatabase()
AppReadiness.setAppIsReady()
// Queue non-critical work
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
uploadAnalytics() // Runs at 500ms
}
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
checkBackups() // Runs at 800ms
}
// Total blocking: 100ms
// Result: Fast launch, no crashes
}
}
What You Can Learn
- Staged initialization prevents watchdog crashes: Don't do everything in didFinishLaunching
- "Polite" tasks spread CPU load: Random delays prevent simultaneous work
- Readiness levels coordinate dependencies: UI blocks wait for app blocks
- Immediate execution when already ready: No queueing if app already initialized
- Simple but effective: Just closures and flags, no complex state machine
The 0x8badf00d Crash
What it is: iOS watchdog timer kills apps that take too long to respond to system events.
Limits:
- Launch: ~20 seconds on old devices, ~5 seconds on new devices
- Resume from background: ~5 seconds
- Suspension: ~5 seconds
How AppReadiness prevents it:
- Keeps initial launch under 1 second
- Defers non-critical work to after launch
- Spreads work over time instead of all at once
How to Apply This in Your App
Basic Readiness System:
class AppReadiness {
static let shared = AppReadiness()
private var isReady = false
private var readyBlocks: [() -> Void] = []
private var politeBlocks: [() -> Void] = []
func runWhenReady(_ block: @escaping () -> Void) {
if isReady {
block()
} else {
readyBlocks.append(block)
}
}
func runWhenReadyAsync(_ block: @escaping () -> Void) {
if isReady {
runPolitely(block)
} else {
politeBlocks.append(block)
}
}
func markReady() {
isReady = true
// Run queued blocks immediately
readyBlocks.forEach { $0() }
readyBlocks = []
// Run polite blocks with delay
politeBlocks.forEach { runPolitely($0) }
politeBlocks = []
}
private func runPolitely(_ block: @escaping () -> Void) {
let delay = TimeInterval.random(in: 0.1...1.0)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)
}
}
// Usage in AppDelegate:
func application(...) -> Bool {
setupCriticalServices() // Fast, synchronous
AppReadiness.shared.markReady()
// Everything else
return true
}
// Usage in other classes:
class AnalyticsManager {
init() {
AppReadiness.shared.runWhenReadyAsync {
self.uploadEvents() // Runs after launch, with delay
}
}
}
Advanced: Multiple Readiness Levels:
class AppReadiness {
enum Level {
case database
case network
case ui
case fullyReady
}
private var currentLevel: Level = .database
private var callbacks: [Level: [() -> Void]] = [:]
func runWhen(level: Level, _ block: @escaping () -> Void) {
if currentLevel >= level {
block()
} else {
callbacks[level, default: []].append(block)
}
}
func markReady(level: Level) {
currentLevel = level
// Run all callbacks for this level
callbacks[level]?.forEach { $0() }
callbacks[level] = []
}
}
// Usage:
AppReadiness.shared.runWhen(level: .database) {
// Database is ready
}
AppReadiness.shared.runWhen(level: .ui) {
// UI is ready
}
Use cases:
- Complex app initialization
- Apps with database migrations
- Apps with many third-party SDKs
- Apps experiencing watchdog crashes
- Apps with slow launch times
Conclusion & Key Takeaways
Signal iOS demonstrates production-grade iOS development with patterns that solve real problems at scale. The most valuable lessons:
Performance
- Adaptive throttling beats fixed timers
- Spread initialization over time to prevent watchdog crashes
- Context-aware caching prevents extension memory crashes
Concurrency
- Monotonic clocks for reliable timing
- Sendable atomics for Swift Concurrency
- Cross-process notifications for cache invalidation
Architecture
- Lazy dependency injection solves circular dependencies
- Multi-window management for complex UI coordination
- Protective overrides for UIKit quirks
Developer Experience
- Dual-mode debouncing for different UX scenarios
- Weak reference containers for protocol delegates
- Job queues with exponential backoff
Written based on analysis of Signal-iOS open source codebase. All code examples are simplified for clarity while maintaining the core concepts.