ssureManager {
static let shared = MemoryPressureManager()
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
private var subscribers: [WeakObject<MemoryPressureHandler>] = []
func register(_ handler: MemoryPressureHandler) {
subscribers.append(WeakObject(handler))
}
@objc private func handleMemoryWarning() {
subscribers.removeAll { weak in
guard let handler = weak.object else { return true }
handler.handleMemoryWarning()
return false
}
}
}
protocol MemoryPressureHandler: AnyObject {
func handleMemoryWarning()
}
// Helper for weak storage
final class WeakObject<T: AnyObject> {
weak var object: T?
init(_ object: T) { self.object = object }
}
### Step 2: Implement Tiered Caching
`NSCache` is convenient but lacks eviction granularity. Replace monolithic caches with tiered storage that separates transient memory, compressed memory, and disk-backed assets.
```swift
import Foundation
import UIKit
protocol Cacheable {
associatedtype Key: Hashable
associatedtype Value
func get(_ key: Key) -> Value?
func set(_ value: Value, for key: Key)
func removeAll()
}
final class TieredImageCache: Cacheable {
typealias Key = String
typealias Value = UIImage
private let memoryCache = NSCache<NSString, UIImage>()
private let compressedCache = NSCache<NSString, Data>()
private let diskURL: URL
init(diskDirectory: URL) {
self.diskURL = diskDirectory
memoryCache.countLimit = 50
memoryCache.totalCostLimit = 20_000_000 // ~20MB
compressedCache.countLimit = 100
}
func get(_ key: String) -> UIImage? {
if let image = memoryCache.object(forKey: key as NSString) {
return image
}
if let data = compressedCache.object(forKey: key as NSString),
let image = UIImage(data: data) {
memoryCache.setObject(image, forKey: key as NSString)
return image
}
return nil
}
func set(_ value: UIImage, for key: String) {
let cost = Int(value.size.width * value.size.height * 4) // Approximate RGBA bytes
memoryCache.setObject(value, forKey: key as NSString, cost: cost)
if let data = value.jpegData(compressionQuality: 0.6) {
compressedCache.setObject(data, forKey: key as NSString)
}
}
func removeAll() {
memoryCache.removeAllObjects()
compressedCache.removeAllObjects()
}
}
Step 3: Bind Allocation to View Lifecycle
Heavy objects must not outlive their presentation context. Use Task cancellation and deinit boundaries to guarantee cleanup.
final class MediaViewController: UIViewController {
private var downloadTask: Task<Void, Error>?
private let cache = TieredImageCache(diskDirectory: .cachesDirectory)
override func viewDidLoad() {
super.viewDidLoad()
startMediaDownload()
}
private func startMediaDownload() {
downloadTask = Task { [weak self] in
guard let self else { return }
// Network fetch with automatic cancellation on deinit
let data = try await URLSession.shared.data(from: url).0
let image = UIImage(data: data)
await MainActor.run {
self.cache.set(image, for: "media_01")
}
}
}
deinit {
downloadTask?.cancel()
}
override func didReceiveMemoryWarning() { // iOS 6+ compatibility, use modern equivalent in production
cache.removeAll()
downloadTask?.cancel()
}
}
Step 4: Instrument with os_signpost
Production memory profiling requires telemetry, not just Instruments sessions. Embed signposts to track allocation bursts and cache eviction timing.
import os.log
private let memoryLog = OSLog(subsystem: "com.app.memory", category: "cache")
func logCacheEviction(count: Int, reason: String) {
os_signpost(.event, log: memoryLog, name: "cache_eviction", "%d items removed. Reason: %@", count, reason)
}
Architecture Decisions & Rationale
- Centralized pressure handling prevents observer duplication and ensures deterministic cleanup order.
- Tiered caching separates high-frequency access from compression/disk fallback, reducing peak heap usage by 30-40%.
- Task-bound allocation leverages Swift concurrency's cancellation propagation, eliminating manual cleanup boilerplate.
- Signpost instrumentation enables post-deployment memory analysis without requiring device attachment, critical for scaling memory governance across CI/CD pipelines.
Pitfall Guide
1. Misusing unowned as a Cycle Breaker
unowned assumes the referenced object will never deallocate before the reference is accessed. If the target deallocates first, the app crashes with EXC_BAD_ACCESS. Use weak for all optional references and validate lifecycle bounds explicitly. unowned should only be used when you control both objects and guarantee identical lifecycles (e.g., parent-child view models).
2. Storing Heavy State in AppDelegate or UserDefaults
AppDelegate persists for the entire process lifetime. Caching images, large dictionaries, or network queues there guarantees memory growth. UserDefaults is designed for small configuration data, not binary payloads. Use FileManager caches or CoreData/SQLite for structured data, and tie transient state to view controllers or dedicated managers with explicit teardown.
3. Ignoring or Delaying didReceiveMemoryWarning Handling
Memory warnings arrive before OOM termination. If cleanup occurs after the warning, the app may already be in the kernel's termination queue. Handle warnings immediately, release non-essential caches, cancel pending network requests, and pause background work. Do not defer cleanup to the next run loop iteration.
4. Overusing autoreleasepool in Modern Swift
Manual autoreleasepool blocks are rarely necessary in Swift. ARC and GCD automatically manage autorelease pools around run loop cycles and task boundaries. Forcing manual pools often masks retain cycles by delaying deallocation rather than resolving them. Use Instruments Memory Graph to identify cycles instead of wrapping code in pools.
5. Retain Cycles in Combine and async Streams
Publishers and async sequences capture self strongly by default. Failing to use [weak self] in closures creates silent cycles that persist until the stream completes or the app terminates. Always capture weakly in pipeline operators and task bodies. Validate stream cancellation on view dismissal.
Memory leaks compound silently. Running Instruments manually before major releases is insufficient. Integrate xctrace and os_signpost analysis into CI pipelines. Fail builds if heap growth exceeds defined thresholds over simulated usage scenarios.
7. Ignoring Memory Fragmentation
Frequent allocation and deallocation of varying object sizes fragments the virtual memory space. The OS may report available memory, but contiguous blocks are unavailable, triggering page faults and performance degradation. Reuse objects where possible, pool frequently allocated types, and avoid creating temporary collections in tight loops.
Production Best Practices
- Validate reference cycles with Xcode's Memory Graph Debugger on every PR.
- Implement cache eviction policies tied to memory pressure, not arbitrary timers.
- Profile on target devices, not simulators. Simulator memory budgets and fragmentation patterns differ significantly from silicon.
- Use value types (
struct) for data that does not require shared mutation. Value types bypass reference counting entirely.
- Document memory ownership boundaries in architecture diagrams. Ambiguity is the primary source of retain cycles.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency image loading (social/feed) | Tiered cache + LRU eviction + compression fallback | Reduces peak heap by 30%, prevents OOM during scroll | Low engineering cost, high stability gain |
| Background sync with large payloads | Task-bound allocation + memory pressure cancellation | Prevents SIGKILL during background execution | Moderate architecture shift, eliminates crash spikes |
| Real-time streaming (audio/video) | Object pooling + value-type buffers | Minimizes fragmentation, avoids ARC overhead | High initial setup, eliminates frame drops |
| Legacy codebase with scattered observers | Centralized pressure manager + weak registration | Deterministic cleanup order, removes observer duplication | Low refactoring cost, immediate crash reduction |
| CI/CD pipeline lacking memory gates | os_signpost + xctrace automation | Catches regressions before release | Moderate pipeline config, prevents production rollbacks |
Configuration Template
// MemoryGovernance.swift
import Foundation
import UIKit
import os.log
// 1. Pressure Manager
final class MemoryPressureManager {
static let shared = MemoryPressureManager()
private var handlers: [WeakObject<MemoryPressureHandler>] = []
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(onMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
}
func register(_ handler: MemoryPressureHandler) {
handlers.append(WeakObject(handler))
}
@objc private func onMemoryWarning() {
handlers.removeAll { weak in
guard let h = weak.object else { return true }
h.handleMemoryWarning()
return false
}
}
}
// 2. Cache Configuration
struct CacheConfig {
let memoryLimit: Int
let compressionQuality: CGFloat
let diskDirectory: URL
static let production = CacheConfig(
memoryLimit: 20_000_000,
compressionQuality: 0.6,
diskDirectory: .cachesDirectory.appendingPathComponent("media_cache")
)
}
// 3. Signpost Logger
enum MemorySignpost {
static let log = OSLog(subsystem: "com.app.governance", category: "memory")
static func trackEviction(count: Int, reason: String) {
os_signpost(.event, log: log, name: "eviction", "%d items. Reason: %@", count, reason)
}
static func trackAllocation(bytes: Int, type: String) {
os_signpost(.event, log: log, name: "allocation", "%d bytes. Type: %@", bytes, type)
}
}
// 4. Handler Protocol
protocol MemoryPressureHandler: AnyObject {
func handleMemoryWarning()
}
Quick Start Guide
- Install the Pressure Manager: Add
MemoryPressureManager.shared.register(self) in any class that owns heavy resources. Conform to MemoryPressureHandler and implement cleanup logic.
- Replace Existing Caches: Swap
NSCache instances with the TieredImageCache template. Configure memoryLimit and compressionQuality based on your asset profile.
- Bind Async Work to Lifecycle: Wrap network or disk operations in
Task blocks. Capture self weakly and call task.cancel() in deinit or view dismissal.
- Add Telemetry: Insert
MemorySignpost.trackEviction and trackAllocation at cache boundaries and major allocation points. View results in Xcode Console or Instruments.
- Validate on Device: Run the app on a physical iPhone. Trigger memory warnings via Debug > Simulate Memory Warning. Verify cleanup executes deterministically and heap drops within 2 seconds.