😊

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通信が行われています。

PortfolioViewStore.swift
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への強参照を持ち続けます。

PortfolioViewStore.swift
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への強参照を解消することができました。

PortfolioViewStore.swift
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 { /* ローディングを非表示 */ }
        ...
    }
}
PortfolioViewController.swift
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の状態を管理します。

PortfolioViewStore
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 にゴールドスポンサーとして参加します。
当日はブース出展やセッションも行いますのでお楽しみに!
セッションの詳細については以下をご覧ください。

https://fortee.jp/iosdc-japan-2025/proposal/c8dd7bcb-454d-40bf-9915-1144126296b8

有明でぜひお話ししましょう!

WealthNavi Engineering Blog

Discussion