😸

SwiftUIのデータフェッチを簡潔に書く

2023/07/15に公開

概要

私はSwiftUIはViewとデータ層は密接な関係にあると思います。
例えばCore Dataからデータを取得するためのFetchRequestや、Viewに格納されている値を取得するためのEnvironmentなどのProperty Wrapperが用意されています。
そのためSwiftUIでは、Property Wrapperを活用してViewからデータの取得処理を隠蔽しつつ、Viewで状態を保持することが基本的な思想なのかなと考えています。

そこでRest APIでのデータの取得処理も、FetchRequestEnvironmentなどと同様にProperty Wrapperを活用して実装することはできないかと考えました。

TL;DR

@Get(\.users) var users

と宣言し、

_users.requestUsers()

を任意のタイミングで実行することでユーザー一覧の取得が完了する。

サンプルコード:https://github.com/ueshun109/get-request-sample

モチベーション

私はSwiftUIでアプリケーションを実装していますが、そのアプリはいわゆるJson色付け係の側面を大きく持っています。そのため基本的には、データの取得UIの作成の実装をすることがほとんどです。以下はイメージです。

import SwiftUI

struct ContentView: View {
  @State private var uiState = UiState()
  private let dataSource: UserDataSource = .mock
  
  var body: some View {
    List {
      ForEach(uiState.users) { user in
        Text(user.firstName)
      }
    }
    .task {
      await fetchUsers()
    }
  }
}

extension ContentView {
  struct UiState {
    var users: [User] = []
  }
}

extension ContentView {
  func fetchUsers() async {
    do {
      uiState.users = try await dataSource.fetchUsers()
    } catch {
      // error handling
    }
  }
}

enum UserDataSource {
    case live

    func fetchUsers() async throws -> [User] {
        // APIクライアントからデータを取得する処理
        return try await apiClient.requestUsers()
    }
}

上記はシンプルなのでまだ見通しは良いですが、私が関わっているプロジェクトでは画面がもう少し複雑になってくると見通しが悪くなってくることが多々ありました。その一因がViewの責務からはみ出ているものが存在していることがあるためのでは無いかと考えました。

そこで気になったのがContentViewのfetchUsersです。
この処理はViewの責務の範囲外のように見えました。Viewでなるべくデータを表示することだけに集中させたいです。

そこでfetchUsersで行っている処理をFetchRequestなどのSwiftUIのAPIに習ってProperty Wrapperで行うことができれば、データを表示することにより集中できるのではないかと考えました。

I/F

Property Wrapper

FetchRequestEnvironmentなどと同様に、Property Wrapperを使って状態の取得と状態の保持を隠蔽したいと考えています。
そのためI/Fとしては、@Getをプロパティにマークするすることで、状態の保持と取得処理を行わせようとします。

KeyPath

I/Fを@Getとすることは決定しましたが、引数にどのエンドポイントから取得するかの情報も渡したいです。そして渡したエンドポイント情報と、そこから取得するデータの型は対応するようにしておきたいです。
これを考える上で注目したのがEnvironmentのI/Fです。

Environmentでは引数にKeyPathを渡すI/Fになっています。KeyPathを渡すことで、以下のようにマークされたプロパティの型が決定されます。

// var colorScheme: ColorSchemeと書かなくても決定されている
@Environment(\.colorScheme) var colorScheme

今回はEnvironmentと同様なI/Fにすればよいのではないかと思いました。

APIリクエスト

Property Wrapperで状態の取得と保持を行いますが、取得のタイミングは任意にしたいためリクエスト処理のI/Fも用意します。

extension Get {
  /// /v1/songs
  func requestSongs(songDataSource: SongDataSource = .mock) async {}
  
  /// /v1/users
  func requestUsers(userDataSource: UserDataSource = .mock) async {}
}

実装

最終的に以下のような実装になりました。

import SwiftUI

@propertyWrapper
struct Get<Value>: DynamicProperty where Value: Decodable, Value: Equatable {
  var wrappedValue: LoadState<Value> { loadState }

  @State private var loadState: LoadState<Value> = .loading()
  private let keyPath: KeyPath<EndpointValues, Value>
  private let defaultValue: Value?

  init(
    _ keyPath: KeyPath<EndpointValues, Value>,
    _ defaultValue: Value? = nil
  ) {
    self.keyPath = keyPath
    self.defaultValue = defaultValue
    if let defaultValue = defaultValue {
      _loadState = .init(initialValue: .loading(skelton: defaultValue))
    }
  }
}

extension Get {
  struct EndpointValues {
    /// /v1/users
    @Lateinit var users: [User]
    /// /v1/songs
    @Lateinit var songs: [Song]
  }
}

extension Get {
  /// /v1/songs
  func requestSongs(songDataSource: SongDataSource = .live) async {
    // 任意の処理
  }
  
  /// /v1/users
  func requestUsers(userDataSource: UserDataSource = .live) async {
    // 任意の処理
  }
}

課題

上記の実装だと、エンドポイントに対応するリクエストの処理を強制できないことです。
例えば以下のようにusersrequestSongsを実行できてしまいます。これをコンパイルエラーとできるようにしたいです。

@Get(\.users) private var users
_users.requestSongs()

後から気づいたこと

@MainActor
final class UserState: ObservableObject {
  @Published private(set) var users: LoadState<[User]>

  func fetchUsers() async { ... }
}

struct ContentView: View {
  @StateObject private var userState: UserState = .init()

  var body: some View {
    Group {
      switch userState.users {
      case .success(let users):
        // 成功時のView
      case .loading(let users):
        // ローディング中のView 
      case .failure(let error):
        // 失敗時のView
      }
    }
    .task {
      await userState.fetchUsers()
    }
  }
}
GitHubで編集を提案

Discussion