iOS面接の質問:ARC・画面の寿命・非同期タスクをコードで追う
Quick Overview
iOS面接のARC・画面の寿命・非同期タスクを、画像一覧の再利用ケースで練習。Swiftで実行した24項目から、weak capture、協調的キャンセル、写真IDと要求IDの照合を説明します。
画像一覧を閉じたのにオブジェクトが解放されない。スクロールで表示先が変わると、別の写真が一瞬現れる。iOS面接でこの二つを説明するとき、ARCの定義に加えて、コード上の保持と更新の条件を説明します。誰がオブジェクトを保持しているかと、どの要求に表示を更新する権限があるかを、別々に確かめる必要があります。
本稿では、写真P17を表示していた枠をP23に再利用し、その後一覧を閉じるケースを追います。証拠の区分: SwiftとAppleの文書を公式仕様の根拠とし、写真ID、要求ID、表示方針はPracHubが作った練習上の仮定として扱います。特定企業の候補者報告や出題頻度は根拠にしていません。Swift 6.3.3のCLIで実行した24項目の確認と、UIKitや実機でまだ行っていない検証を分けます。最初に Explain iOS ARC and avoid retain cycles で保持関係を描き、以下の結果と照合してください。

まず、画面・表示枠・要求の寿命を分ける
練習の要求は三つです。一覧を離れた後は、表示枠が結果を採用しない。表示枠が別の写真に再利用されたら、その写真の結果だけを採用する。同じ写真を再試行した場合も、最新の要求だけを採用する。今回は新しい要求の開始時に古い画像を消し、空の表示から読み直す方針にします。画像を残す製品なら、別の受け入れ条件を書いてください。
表示枠は、実際のセルを単純化した ImageSlot です。representedAsset は今その枠が表す写真、currentRequest はその要求のID、active は結果を受け入れる状態を表します。画面が表示中でも、スクロールで同じ枠が別の写真を表すことがあります。「まだ画面がある」だけでは、P17の結果をP23の枠に入れてよいとは判断できません。
ここでいう要求IDは、テストが重複しない整数として渡します。実アプリでは所有者が一意なIDやトークンを発行し、古い仕事が残る間に使い回さない設計が必要です。配列の行番号は、並べ替えや挿入で変わるため写真の識別子とは分けます。この例のP17とP23は行番号ではなく、写真を表す固定IDです。APIの写真IDも、同じ写真の再試行を区別する要求IDの代わりにはなりません。
面接の最初には、「画面が閉じたこと」と「セルが別のデータを表すこと」の両方で、更新条件が変わると説明すると話が進みます。先にこの契約を決めれば、メモリの修正で表示の不具合まで直ったと早合点せずに済みます。
ARCは、残っている強い参照の経路から説明する
公式の説明: SwiftのARCでは、classのインスタンスへの強い参照が寿命に関わります。weak参照は対象を保持せず、解放後は nil になります。unownedは対象の寿命について別の前提を置くため、解放後に参照すれば実行時エラーになります。詳細は Automatic Reference Counting を参照してください。
本稿の推論では、最初に「外側の所有者→表示枠」「表示枠→task handle」「実行中のclosure→表示枠」という経路を描きます。外側の所有者が参照を手放しても、closureが表示枠を強く保持していれば解放されません。保持元が一つ減った事実と、最後の強い参照がなくなった事実は違います。
次のコードは、保持を観察するための誤った例です。Loader はテスト側から結果を返すまで待ち、キャンセルには自動で応答しません。この関数を呼び、開始を確認してから外部の参照を nil にします。
func load(_ request: Int) -> Task<Void, Never> {
let next = Task { [weak self] in
guard let self else { return }
let result = await self.loader.fetch(request)
self.image = result
}
task = next
return next
}
この実験では、待機中のweak probeは nil になりません。guard let self で得た強い参照を、待機の後でも使っているからです。結果を返してtaskの終了を待つと、probeは nil になりました。「最初にweakでcaptureした」と「待機中にも強く保持していない」は同じ意味ではありません。
ただし、この観察だけで永久的なメモリリークとは呼びません。今回のtaskは、結果を返せば終わり、その時点で保持も解消しました。終わらない仕事や蓄積する仕事なら影響は変わります。面接では、保持が続いた区間と、解放を確認した点を両方示します。実行中のclosureは完了後に解放されるという Taskの公式文書 とも区別して読めます。
cancelしても、任意の待機処理は自動で終わらない
公式仕様: Swiftのキャンセルは協調的です。Task.cancel() はキャンセルを通知しますが、任意の関数を強制終了する仕組みではありません。処理側が状態を確認したり、キャンセルに対応するAPIを呼んだりする必要があります。
実験では、保持が続いているtaskに cancel() を呼びました。キャンセルフラグは立ちましたが、loaderの待機件数は1のままでした。テストが明示的に結果を返して初めて待機が終わります。この順序を決めているのは、sleepの秒数ではなく、開始通知とcontinuationです。全ての待機を最後に解消してから実験を終えています。
このloaderは意図的にキャンセルを無視します。したがって、この結果をURLSession全体の挙動や、通信量の測定結果には広げません。確認したのは、キャンセル要求と任意の継続処理の終了が別の事象だという点です。実装するloaderごとに、どの待機が終了し、どのリソースが片付くかを確認してください。
deinit にキャンセルを書くだけで解決したと考えるのも危険です。taskが所有者を保持している間は、その所有者の deinit 自体がまだ来ないからです。解放時の後始末と、ユーザーが機能を離れた時の明示的な無効化を分けて設計します。task handleを捨てるだけでも、実行中の仕事は自動終了しません。これは Task文書 に記載された挙動です。
表示枠には、写真IDと要求IDの両方を持たせる
次は実行済みモデルの中心部分です。class全体を @MainActor とし、loaderを別にcaptureします。self は結果が戻るまで強い参照に変えません。写真を結び付け直すたび、前のtaskにキャンセルを要求し、新しい写真と要求IDを記録します。
func bind(_ asset: String, request: Int) -> Task<Void, Never> {
task?.cancel()
currentRequest = request
representedAsset = asset
image = nil
active = true
let loader = loader
let next = Task { [weak self, loader] in
let result = await loader.fetch(request)
guard !Task.isCancelled else { return }
self?.accept(result, asset: asset, request: request)
}
task = next
return next
}
func accept(_ result: String, asset: String, request: Int) {
MainActor.assertIsolated()
guard active,
representedAsset == asset,
currentRequest == request else { return }
image = result
accepted.append(result)
}
練習上の設計判断: 三条件を、書き込み直前の accept に集めました。キャンセル確認はtaskの経路を止めます。写真IDと要求IDの確認は、別経路のcallbackが accept に来た場合にも、古い結果を拒否します。実験では条件を一つずつ食い違わせ、キャンセル確認だけに頼っていないことを確かめました。
loaderを別にcaptureする版では、loaderが待機中でも外側が所有権を手放すとweak probeは nil になりました。deinit がキャンセルを要求したことも確認しました。ただし、loaderの待機はその後も残っています。所有者の解放、表示の拒否、仕事の終了は、それぞれ別の観察です。
このコードは完成した画像ライブラリではありません。結果は文字列であり、画像デコード、キャッシュ、失敗表示、通信の共有、メモリ上限の扱いはありません。同じ画像のダウンロードを複数セルで共有するなら、一つのセルが離れた時に共有通信まで止めるのか、購読だけ外すのかも契約になります。ここでは一枠の表示権限に絞っています。
順序を逆転させ、同じ写真の再試行も確かめる
まずP17のrequest 10を開始し、その待機を確認してから同じ枠にP23のrequest 11を結び付けます。P23を先に返し、P17を後に返してください。両方のtaskの終了を待った後、採用記録は [P23-v1] だけでした。一覧が表示中という条件だけなら、古い結果を排除する説明にはなりません。
さらに、写真IDが一致するだけでは不十分なケースを作ります。P23をrequest 12で再試行した後、request 11のcallbackを直接渡します。写真は同じP23でも、現在の要求ではないので拒否されます。キャッシュ由来のcallbackなど、taskのキャンセル分岐を通らない経路を想定する練習です。実際のキャッシュ製品の不具合を報告しているわけではありません。
| 入力または操作 | 現在の表示枠 | 実行した確認の結果 |
|---|---|---|
| P23/request 11を先に返す | P23/request 11、active | P23-v1を採用 |
| P17/request 10を後に返す | P23/request 11、active | 採用記録を増やさない |
| P17/request 11を直接渡す | P23/request 11、active | 要求IDが同じでも写真が違うため拒否 |
| P23/request 10を直接渡す | P23/request 11、active | 写真が同じでも要求が違うため拒否 |
| request 12の開始後にP23/request 11を渡す | P23/request 12、active | 古い再試行結果として拒否 |
| 一覧を離れた後にP23/request 12を渡す | inactive、写真IDなし | 表示を更新しない |

一覧を離れる操作では active = false にし、写真IDと画像を消し、taskをキャンセルしてhandleを外します。待機件数はまだ1ですが、callbackを渡しても画像は空のままです。待機を解消した後も採用記録は変わりません。次にP31/request 13を結び付けると、その新しい結果は採用され、最終記録は [P23-v1, P31-v1] になりました。
この表は受け入れ条件の反例集として使えます。「weakで直した」「cancelで直した」だけでは、どの行が直ったのか分かりません。修正ごとに、保持、キャンセル、写真の一致、要求の一致、画面の有効性のどれを検証したかを述べてください。たとえば要求IDの照合を外すと、同じP23の古いcallbackを拒否する根拠がなくなります。
MainActorは、最新の要求を選んではくれない
公式の説明: MainActor はメインdispatch queueに相当するexecutorを持つactorです。今回の状態変更は @MainActor のclassにまとめ、accept で MainActor.assertIsolated() を実行しています。これを、UI描画が全て正しいことの証明には使いません。
隔離によって同時に書き換える問題を扱っても、await の前後で業務上の条件が同じとは限りません。待っている間に表示枠がP17からP23へ変わることも、一覧を離れることもあります。だから戻った後にIDとactiveを照合します。この照合は、データ競合の防止とは別に必要な論理上の検証です。
また、Task と書けば重い同期処理まで自動で別の場所へ移るとは考えません。今回は実際の画像を処理しておらず、デコード時間やフレーム落ちを測定していません。大きな画像処理を追加するなら、その実行場所とUIへの反映を分け、使用するSwift言語モードと隔離設定の下で確認する必要があります。
面接では「MainActorだから安全です」で止めず、「状態へのアクセスは隔離します。ただし待機中に表示対象が変わるため、戻った結果の写真IDと要求IDを確認します」と続けると、何を防ぐ設計かが伝わります。最新性を決めるのは、本稿では三条件の契約です。
viewDidDisappearとdeinitを同じイベントにしない
UIKitの公式説明: viewDidDisappear(_:) はviewがview hierarchyから取り除かれたことを通知します。overrideする場合は super を呼びます。これはclassのインスタンスが解放されたことを通知するメソッドではありません。
本稿の適用上の推論: 一覧が別の画面で覆われても、navigationやcontainerがcontrollerを保持している可能性があります。一方、同じ画面内のセル再利用は、画面の消失を待たずに起きます。controllerの表示イベントだけに全てのキャンセルを寄せると、表示中の古いサムネイルを説明しきれません。
製品として、見えなくなるたびに画像の読み込みを止めるのか、戻った時のために共有キャッシュの仕事は続けるのかを決めます。今回の leave() は「この枠は結果をもう受け取らない」という明示的な操作です。どのUIKitイベントから呼ぶかは、実際の画面構成に合わせて検証する対象であり、CLIの結果から一律には決めません。
UIViewControllerの文書 も、表示遷移を単純な一方向の列として扱わないよう説明しています。実機確認では、一覧から詳細へ進んで戻る、モーダルで覆って戻る、対話的な戻り操作を途中で取り消す、といった経路を分けて記録します。どの経路で新しい要求を出すか、古い要求を継続するかも観察してください。
実行した24項目と、実機で補う証拠
実行環境はmacOS上のSwift 6.3.3、Swift 6言語モードです。制御できるloaderを用意し、開始確認、結果の返却、task.value の待機の順序を明示しました。24項目は、待機中の保持と完了後の解放、キャンセル後の待機、表示枠再利用、ID不一致、同じ写真の再試行、一覧を離れた後の拒否、新しい写真での再開、全continuationの解消を確認しています。
これらは実行されたモデルの証拠です。UIKitのセルやcontrollerは生成していません。実通信、画像デコード、アプリのバックグラウンド動作、Instrumentsでのメモリ計測、実機での描画も未実施です。24項目はこの入力と順序でのモデル検証です。アプリ全体のリークがない保証や、性能の測定値ではありません。
実機で追加するなら、まずP17の要求を遅らせてP23へスクロールし、表示対象IDと要求IDをログに残します。次に一覧を繰り返し開閉し、controllerと表示枠の生存をmemory graphで調べます。まだ生きているオブジェクトがあれば、保持元を記録します。画像のメモリが増えた場合も、キャッシュの設計上の保持と、終わらないtaskによる保持を分けて調べます。
説明の例はこうです。「先に所有経路を描き、待機前にselfを強くした版で保持を確認しました。修正版ではloaderだけを保持し、結果の反映時に表示枠と要求を照合します。キャンセルは処理側の協力が必要なので、古い結果の拒否も独立して検証しました。UIKitの画面遷移と実画像のメモリ計測は、別の統合確認として残っています」。この順序なら、確認済みの結果と未確認の事項が混ざりません。
関連問題では、保持関係から表示までをつなげる
次の問題は、全てが同じARCケースを出題しているわけではありません。前半二問で所有関係と調査方法を練習し、後半では画面や表示要素にその考え方を適用します。演出の問題をメモリ管理の公式仕様の代わりには使わず、自分が追加した非同期処理の所有者を説明する練習にしてください。
| PracHubの問題 | 今回のケースから持ち込む確認 |
|---|---|
| Explain iOS ARC and avoid retain cycles | closureが待機中に保持する対象と、解放した参照を描く |
| Investigate an iOS Memory Failure | 一度の保持と、繰り返しで増える保持を証拠で分ける |
| Implement an iOS scrollable grid with navigation | 画面の移動と、各表示枠が表すデータを別々に管理する |
| Implement tap-to-infect color grid on iOS | 表示要素のIDを決め、遅れて届く変更の受け入れ条件を説明する |
| Build an emoji blaster animation on iOS | 演出の仕事を誰が所有し、画面を離れた後どう片付けるかを考える |
まず Implement an iOS scrollable grid with navigation を開き、画像を非同期に取得すると仮定して、P17→P23→P23再試行の順序を説明してみてください。最後に「誰が残るか」「どの結果を採用するか」「どこまで実行して確かめたか」の三点を答えてください。未実施の画面遷移や描画も示すことで、説明と検証の範囲が明確になります。
Sources and Further Reading
- Swift: Automatic Reference Counting — 公式の保持・weak・unownedの説明。
- Apple: Task — task handleと実行、closureの寿命に関する公式文書。
- Apple: Task.cancel() — 協調的キャンセルの公式仕様。
- Apple: MainActor — 状態の隔離を説明する公式文書。
- Apple: UIViewController — view controllerと表示遷移の公式文書。
- Apple: viewDidDisappear(_:) — viewの表示イベントを解放と混同しないための公式文書。
Comments (0)