😸

非同期処理をラクにする!SwiftUIでの設計パターンの紹介

2024/12/06に公開

これは株式会社TimeTree Advent Calendar 2024の6日目の記事です。

こんにちはこんにちは。TimeTreeのiOSエンジニアのmasaichiです。

多くのアプリケーションは何らかのデータを整形して表示するということをしており、そのデータの多くは手段は様々あるにせよ「非同期」に取得していることと思います。
TimeTreeでも予定をはじめとしたデータはTimeTreeのサーバ上にあり、多くの画面はサーバと通信をすることでそのデータを取得・表示しています。
このフローは単純ですが、複数の状態を管理しようとした場合の複雑さ、ボイラープレート的なコードの多さ、一部の状態への対応を見落とす、などなど難しいところも多いです。
TimeTreeでは AsyncValue, AsyncValueViewという2つの構造を作ることでこれを簡素化しました。
こういうものは各社でそれぞれあると思いますが、TimeTreeではこうしているぞ、というのものを紹介いたします。参考になれば幸いです。

ネットワークからとってきたデータを表示するのって簡単なようで大変

ネットワーク等から値を取得する場合、その値は処理の経過によって変わってきます。
単純にはあるかないかの2値 T?型 であらわせますが、読み込み中やエラーもハンドリングするのがほとんどでしょう。

こんなコードになるでしょうか。


struct ContentView: View {
    @State var isLoading: Bool = false
    @State var error: Error?
    @State var data: Model?

    var body: some View {
        ZStack {
            if let error {
                Text(error.localizedDescription)
            } else if let data {
                Text(data.text)
            } else if isLoading {
                ProgressView()
            } else {
                EmptyView()
            }
        }
        .task {
            isLoading = true
            error = nil
            do {
                data = try await fetchValue()
            } catch {
                self.error = error
            }
            isLoading = false
        }
    }
}

値が1つならこれで良いですが、複数の非同期にとる値があった場合は状態の管理がもう少し複雑になるでしょう。
こんな感じになるでしょうか(そうはせんやろとは思いますが。。)


struct ContentView: View {
    @State var isData1Loading: Bool = false
    @State var data1Error: Error?
    @State var data1: Model?
    @State var isData2Loading: Bool = false
    @State var data2Error: Error?
    @State var data2: Model?

    // bodyは省略
}

状態をenumにまとめよう

上記のdata1とdata2は両方とも、loading, data, errorの3つで1つの非同期の値の状態を表しています。なので、この3つを1つのAsyncValueという型にまとめました。

enum AsyncValue<T, E: Error> {
    case initial /// 読み込み開始前
    case loading(T?) /// 読み込み中 or リフレッシュ中
    case loaded(T) /// 読み込み成功
    case error(E, T?) ///エラー
}

これを前項のContentViewにあてて書き換えるとこうなります。(簡単にするために表示はvalue1だけ)

struct ContentView: View {
    @State var value1: AsyncValue<Model, Error> = .initial
    @State var value2: AsyncValue<Model, Error> = .initial

 .   var body: some View {
        ZStack {
            switch value1 {
            case .initial:
                EmptyView()
            case .loading:
                ProgressView()
            case let .loaded(data1):
                Text(data1.text)
            case let .error(error, _):
                Text(error.localizedDescription)
            }
        }
        .task {
            value1 = .loading(nil)
            do {
                value1 = .loaded(try await fetchValue())
            } catch {
                value1 = .error(error, nil)
            }
        }
    }
}

非同期に関わる値がenumにまとまってスッキリしたと思います。
また、enumにまとめたことでviewのbodyをswitch文で書けるようになり、コンパイラによるcase漏れの検査が行われるので、ある状態に対するviewの実装漏れも防げます。

参考までにですが、非同期の状態をloading, data, errorの3つの値の組み合わせにしているのは、reactのswr https://swr.vercel.app/ja TanStack-Query https://tanstack.com/query/v3/ を参考にしています。

正常系に集中したい

値は1つの型にまとめられました。しかし、毎回全状態に対してswitch文を書くのも大変です。諸々を後回しにしたりエラーケースをText(error.localizedDescription)で済ませたのを忘れるかもしれません。それにやはり正常系の実装に集中したいものです。
そこで、AsyncValueを受け取って、正常系以外のデフォルト表示を実装したAsyncValueViewというViewも用意しました。


struct AsyncValueView<T, E: Error, DataView: View>: View {

    var value: AsyncValue<T, E>
    var dataView: (T) -> DataView

    public var body: some View {
        ZStack(alignment: .center) {
            switch value {
            case .initial:
                ProgressView()
            case let .loading(t):
                if let t {
                    dataView(t)
                } else {
                    ProgressView()
                }
            case let .loaded(t):
                dataView(t)
            case let .error(e, _):
                CommonErrorView(error: e)
            }
        }
    }
}

これを使うとContentViewが以下のように書き換えられます。

struct ContentView: View {
    @State var value1: AsyncValue<Model, Error> = .initial
    @State var value2: AsyncValue<Model, Error> = .initial

    var body: some View {
        AsyncValueView(value: value1) { value1 in
            Text(value1.text)
        }
        .task {
            value1 = .loading(nil)
            do {
                value1 = .loaded(try await fetchValue())
            } catch {
                value1 = .error(error, nil)
            }
        }
    }
}

さらに短くなり、正常系の実装に集中できるようになりました。
(CommonErrorViewは何らかの共通のエラー用のViewです)
しかしこの実装のままだと読み込み中やエラー時の表示をカスタムしたい場合にswitch caseに戻さざるを得ません。

AsyncValueViewを普段は正常系だけを実装し、必要な場合に必要なケースの表示だけを変えられるようにしたいです。

型制約付きのエクステンションで必要な場合だけ特定のケースのViewを実装できるようにする

  • 普段は正常系だけを実装したい
  • 必要な場合に必要なケースの表示を変えたい

これを行うために、まずAsyncValueViewに全てのケースのViewの実装を渡せるようにします。


struct AsyncValueView<T, E: Error, DataView: View, InitialView: View, LoadingView: View, ErrorView: View>: View {

    var value: AsyncValue<T, E>
    var dataView: (T) -> DataView
    var initialView: InitialView
    var loadingView: LoadingView
    var errorView: (E) -> ErrorView

    var body: some View {
        ZStack(alignment: .center) {
            switch value {
            case .initial:
                initialView
            case let .loading(t):
                if let t {
                    dataView(t)
                } else {
                    loadingView
                }
            case let .loaded(t):
                dataView(t)
            case let .error(e, _):
                errorView(e)
            }
        }
    }
}

ここに型を指定したコンストラクタを追加していき必要なケースのViewだけ実装できるようにします。


extension AsyncValueView {
    /// dataViewだけ
    init(value: AsyncValue<T, E>,
         @ViewBuilder dataView: @escaping (T) -> DataView)
        where InitialView == LoadingView,
        LoadingView == ProgressView<EmptyView, EmptyView>,
        ErrorView == CommonErrorView {
        self.value = value
        self.dataView = dataView
        initialView = ProgressView()
        loadingView = ProgressView()
        errorView = { error in CommonErrorView(error: error) }
    }

    /// dataView, loadingView
    /// 省略
    /// dataView, errorView
    /// 省略

    /// dataView, loadingView, errorView
    init(value: AsyncValue<T, E>,
         @ViewBuilder dataView: @escaping (T) -> DataView,
         @ViewBuilder loadingView: @escaping () -> LoadingView,
         @ViewBuilder errorView: @escaping (E) -> ErrorView)
        where InitialView == LoadingView {
        self.value = value
        self.dataView = dataView
        initialView = loadingView()
        self.loadingView = loadingView()
        self.errorView = errorView
    }
}

このようにデフォルトの挙動に任せたいケースの具体的なViewの型を制約として指定することで、その型の引数を省略したコンストラクタを作ることができます。
これで以下のように必要な場合だけ必要なケースの表示を作れるようになりました。


struct ContentView: View {
    @State var value1: AsyncValue<Model, Error> = .initial
    @State var value2: AsyncValue<Model, Error> = .initial

    var body: some View {
        AsyncValueView(value: value1) { value1 in
            Text(value1.text)
            AsyncValueView(value: value2) { value2 in
                Text(value2.text)
            } loadingView: {
                Text("読み込み中")
            } errorView: { _ in
                Text("error")
            }
        }
        .task { // 省略 }
    }
}

余談ですが、この辺りのGenerics付きのViewに制約をつけたinitを生やしていく方法はSwiftUIの標準のViewのインターフェースが参考になります。(気になるViewの型をCmd+Clickすることで該当の定義に飛べます)

本実装を作っていくにあたってはAsyncImageのインターフェースを参考にしています。

public struct AsyncImage<Content> : View where Content : View {
    public init<I, P>(url: URL?, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View
}

他にもForEachは学ぶところが多くありいくつかのviewの実装に活用しています。
ビルドの合間のコンテンツとして大変おすすめです。

まとめ

TimeTreeで採用した非同期データ管理の仕組みについて解説しました。非同期に取得するデータの状態をAsyncValueという型で統一的に表現し、それを簡潔に表示するためのAsyncValueViewを導入することで、以下のような利点を得られました。

  • 非同期データの状態を統一的に管理
    • AsyncValue型を用いることで、データの状態(初期化、読み込み中、成功、エラー)を一つの型にまとめ、コードの明確さと一貫性を向上させました。
  • 状態管理の複雑さを軽減
    • 非同期データが複数ある場合でも、コードが煩雑になるのを防ぎ、状態管理をシンプルにしました。
  • 正常系の実装に集中できるViewの設計
    • AsyncValueViewにより、状態ごとの表示を簡潔に記述可能にし、特に正常系に集中して開発できる環境を整えました。
  • 必要な場合に柔軟な表示を実現
    • 型制約付きの拡張を利用し、特定のケースでの表示をカスタマイズできるようにしました。これにより、通常はデフォルトのViewを使用しつつ、必要に応じて柔軟に対応可能です。

この記事で紹介した仕組みは、ReactのSWRやTanStack Queryからインスピレーションを受けていますが、Swift, SwiftUIに最適化しています。
参考になれば幸いです。

TimeTree Tech Blog

Discussion