iOS idioms and anti-patterns

design · memo

In one line: Cocoa gives you a small set of communication idioms — delegate, data source, target–action, closure, NotificationCenter, KVO, responder chain — and choosing one is a question of how many listeners, whether the sender needs an answer, and who owns whom. Most iOS anti-patterns are one mistake: responsibilities and dependencies that nobody can see.

Download PDF Print view LaTeX source

iOS idioms and anti-patterns — figure 1

The idioms

IdiomShapeMechanism · rule
Delegate1:1protocol : AnyObject, weak var delegate; can answer (shouldSelect), did/will/should names, sender first
Data source1:1delegate that supplies data (numberOfRows); UIKit holds both weak
Target–action1:NaddTarget(_:action:for:) + @objc selector; the control does not retain the target; UIAction closure (iOS 14)
Closure1:1onSelect: (Item) -> Void; stored → [weak self]; best for one event
NotificationCenter1:Nbroadcast by name, untyped userInfo; synchronous, on the posting thread
KVO1:Nobserve(\.x, options:) → NSKeyValueObservation; needs NSObject + @objc dynamic
Responder chain1:?sendAction(_:to: nil, …): first responder up via next (see gof-behavioural)
Combine / @Observable1:Ntyped streams / per-property tracking (iOS 17); the modern default in-app

protocol PickerDelegate: AnyObject {        // so it can be weak
  func picker(_ p: Picker, didPick item: String)
  func pickerShouldClose(_ p: Picker) -> Bool   // answer back
}
final class Picker {
  weak var delegate: PickerDelegate?        // Picker never owns it
  func pick(_ item: String) {
    delegate?.picker(self, didPick: item)
    if delegate?.pickerShouldClose(self) ?? true { /* close */ }
  }
}
// load(true, false) -- what? Name the choice instead:
enum CachePolicy { case useCache, reload }
func load(_ policy: CachePolicy) {}         // load(.reload)

Massive VC — the recipe

Characterisation/snapshot tests first, then steps 1–6 as drawn, one safe move at a time (child VCs: addChild + didMove(toParent:)). It is an SRP problem, not an MVC one — moving the mass into the model or a Manager only relocates it.

Interview traps

  • “Singletons are always bad” — no (FileManager.default). The damage is hidden lookup (.shared inside logic) + global mutable state: keep one instance, inject it.
  • A service locator still hides dependencies — a missing registration fails at run time, not compile time (Mark Seemann).
  • NotificationCenter for a 1:1 flow = invisible coupling: no contract, no “who listens?”.
  • “Duplication is far cheaper than the wrong abstraction” (Sandi Metz).
  • UIAction/sink/timer closures are stored: [weak self]; unowned only when the lifetime is guaranteed.

Remember

Answer back → delegate · one → closure · many → Combine/Notification · unknown → responder chain. Make responsibilities and dependencies visible.

Likely questions

  1. Delegate vs closure? — many related callbacks + answers vs one event.
  2. Why weak delegate? — the delegate usually owns the delegator.
  3. Callback pyramid? — async/await, async let, task groups.

Anti-patternSymptomDamageFix
Massive VC1 000+ lines, everything in viewDidLoaduntestable, merge conflictsthe recipe above; VC becomes glue
God objectAppManager, Utils; everyone imports itevery change ripplessplit by responsibility; inject small collaborators
Singleton / locator.shared / resolve() inside logichidden deps, order-dependent testsinit injection; .shared only at the composition root
Stringly-typedsegue ids, userInfo["k"], [String: Any]typos crash at run timeenums, Notification.Name constants, Codable
Boolean flagsload(true, false)unreadable calls, 2n branchestwo functions, an enum, or an OptionSet
Deep inheritanceBaseViewController with 30 hooksfragile base class, LSP breaksprotocols + extensions, composition, child VCs
Copy-paste reusethe same fix needed in 5 placesshotgun surgery, driftextract once it repeats 3×
Premature abstractiona protocol with one conformerindirection, harder to readYAGNI; abstract at the 2nd–3rd case (a test seam counts)
Callback pyramidnested completions; a path never calls backlost errors, hangsasync/await, structured concurrency
UIKit in modelsUIColor/UIImage in domain typesno widget/test reuse, main-thread tiesFoundation-only domain; map in the ViewModel
Over-modularization60 modules, one file eachbuild graph + API churn costmodule by feature/team, measure build times