Taskによる強参照がもたらした予期せぬ挙動とその解決方法
はじめに
こんにちは、サービス機能開発チーム・iOSエンジニアの深来です!
2025年3月に入社し、iOSアプリの開発やテックブログの運営をしています。
WealthNaviではUIKit × RxSwiftのコードからSwiftUI × Swift Concurrencyのコードに段階的にリファクタリングしています。
今回は強参照が解消されないことにより、UIKitの画面で予期せぬ挙動が起きた問題についての説明とその解決方法を紹介します。
また、iOSDC 2025 のチャレンジトークンが記事内に含まれています。ぜひ探してみてください👀
問題の概要
下記の動画のようにホーム画面からポートフォリオの詳細画面に遷移し、ポートフォリオの詳細の読み込みが完了する前にホーム画面に戻ると、ホーム画面でもローディングが残ってしまうという問題が発生していました。
※画面はイメージです。
原因
WealthNaviではTCAに近い独自のStatefulアーキテクチャを採用しています。
ViewStoreではStateでの画面の状態保持やActionでのStateの状態を更新するイベントやEffectでのローディングの表示・非表示を行なっています。dispatchCommandを介してViewのアクションがViewStoreに伝わります。
下記のコードのようにポートフォリオの詳細画面表示時にdispatchCommand
を介してAPI通信が行われています。
final class PortfolioViewStoreImpl {
.......
private var task: Task<Void, Never>?
.......
deinit { task?.cancel() }
}
extension PortfolioViewStoreImpl: PortfolioViewStore {
func dispatchCommand(_ command: PortfolioViewCommand) {
task = Task {
do {
try await handleCommand(command)
} catch {
if Task.isCancelled { return }
/* Viewにエラーを通知 */
}
}
}
func handleCommand(_ command: PortfolioViewCommand) async throws {
switch command {
case .fetch:
try await fetch()
.......
}
}
}
private extension PortfolioViewStoreImpl {
func fetch() async throws {
/* ローディングを表示 */
defer { /* ローディングを非表示 */ }
...
}
}
上記のように書いていましたが、API通信完了後にはdeinit
が呼ばれるが、API通信の途中でホーム画面に戻ってしまうと、deinit
が呼ばれていませんでした。そのためtask
がキャンセルされない状態になっていました。
deinit
が呼ばれない理由
「Taskが実行中の間はselfへの強参照が残っている」ことが原因でした。
Taskについて詳しく知りたい方はこちら
Taskが提案されたSE-304 Structured concurrencyのプロポーザルには@escaping
属性がありますが、Taskに渡されたクロージャは即時実行されるため明示的にself参照を考慮しなくても良いと記載があります。
ただし、そのTaskをキャンセルする必要があるときは注意が必要です。Task内の処理を待っている間はselfへの強参照は残ります。
下記のTaskクロージャ内では暗黙的にself
(ViewStore)をキャプチャしており、そのためTaskクロージャが終了するまではself
への強参照を持ち続けます。
extension PortfolioViewStoreImpl: PortfolioViewStore {
func dispatchCommand(_ command: PortfolioViewCommand) {
task = Task {
do {
try await handleCommand(command)
} catch {
if Task.isCancelled { return }
/* Viewにエラーを通知 */
}
}
}
.......
そのためAPI通信中(Task内の処理が終了していない状態)はTaskからViewStoreへの参照カウントは解放されず、参照カウントはゼロにならないので、インスタンスはARCによって割り当て解除されません。そのため下記の図のような状態になっていました。
deinit
の呼び出しのタイミングは、インスタンスの解放が行われる直前に自動的に呼び出されます。
しかし、今回の場合は強参照が残っているため、ARCによってインスタンスの解放が行われないので、そもそもdeinit
が呼ばれず、TaskがキャンセルされないままPortfolioViewControllerが破棄されていたので、ローディングが残ったままになっていました。
解決方法
deinit
でタスクをキャンセルするのではなく、下記のようにviewWillDisappear
でタスクをキャンセルするdispatchCommand(.cancelTask)
コマンドを送る実装にしました。
しかし、dispatchCommand(.cancelTask)
コマンドで新しいtask
が生成されると、ポートフォリオ画面が表示された直後に呼ばれるdispatchCommand(.fetch)
コマンドで生成されていた古いtaskと入れ替わってしまい、本来キャンセルしたかった古いtask
がキャンセルできない問題が発生してしまいます。
そのためdidSet
を使用し、古いtask
をキャンセルする実装を追加することで、task
の入れ替え時に古いtask
をキャンセルできるため、TaskからViewStoreへの強参照を解消することができました。
final class PortfolioViewStoreImpl {
.......
- private var task: Task<Void, Never>?
+ private var task: Task<Void, Never>? {
+ didSet {
+ oldValue?.cancel()
+ }
+ }
.......
}
extension PortfolioViewStoreImpl: PortfolioViewStore {
func dispatchCommand(_ command: PortfolioViewCommand) {
task = Task {
do {
try await handleCommand(command)
} catch {
if Task.isCancelled { return }
/* Viewにエラーを通知 */
}
}
}
func handleCommand(_ command: PortfolioViewCommand) async throws {
switch command {
case .fetch:
try await fetch()
+ case .cancelTask:
+ task?.cancel()
.......
}
}
}
private extension PortfolioViewStoreImpl {
func fetch() async throws {
/* ローディングを表示 */
defer { /* ローディングを非表示 */ }
...
}
}
final class PortfolioViewController: UIViewController {
........
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
func bind() {
........
rx.viewDidAppear.mapVoid()
.bind(with: self, onNext: { $0.dispatchCommand(.fetch) })
+ rx.viewWillDisappear.mapVoid()
+ .bind(with: self, onNext: { $0.dispatchCommand(.cancelTask) })
........
}
........
}
下記の動画のように、ポートフォリオの詳細の読み込みが完了する前にホーム画面に戻った場合でもローディングも消えるように改善することができました。
※画面はイメージです。
SwiftUI化による今後
SwiftUI × Swift Concurrencyのコードに段階的にリファクタリングすることによって、今回のようなローディングが残ってしまうという問題を防ぐことができます。
下記のようにViewStoreのEffect内でenumでViewの状態を管理します。
final class PortfolioViewStoreImpl: PortfolioViewStore {
func dispatchCommand(_ command: Command) async {
do {
try await handleCommand(command)
} catch {
guard !Task.isCancelled else { return }
dispatchEffect(.presentError(error))
}
}
func handleCommand(_ command: Command) async throws {
switch command {
case .fetch:
try await fetch()
.......
}
}
}
PortfolioViewのSwiftUIのtask内でfetchのコマンドを送り、API通信を行います。
struct PortfolioView<ViewStore: PortfolioViewStore>: View {
var body: some View {
........
.task {
await viewStore.dispatchCommand(.fetch)
}
........
}
}
SwiftUIのtaskにはタスクが完了する前にビューが非表示になると、自動的にタスクをキャンセルしてくれる機能があるので、今回のUIKitのようなViewControllerが削除されてもタスクがキャンセルされず、ローディングが残ることを防ぐことができます。
発展: ポートフォリオ画面の速度改善
また、今回対象となったポートフォリオ画面では、複数のAPIを呼んでいることによりバックエンドの負荷がかかり、一部のユーザーにおいて、画面のローディング時間が長くなるという課題がありました。そこで、ユーザー体験の向上を目的として、バックエンドチームと連携し、APIエンドポイントの統合を実施しました。
その結果、バックエンドの負荷が改善され、iOSアプリの当該画面におけるローディング時間が約86%改善されるという成果を得ることができました。
改善前 | 改善後 |
---|---|
![]() |
![]() |
※画面はイメージです。
お知らせ
ウェルスナビは #ものづくりする金融機関 というビジョンのもと、iOS開発を盛り上げていくために iOSDC 2025 にゴールドスポンサーとして参加します。
当日はブース出展やセッションも行いますのでお楽しみに!
セッションの詳細については以下をご覧ください。
有明でぜひお話ししましょう!
Discussion