SwiftUIのデータフェッチを簡潔に書く
概要
私はSwiftUIはViewとデータ層は密接な関係にあると思います。
例えばCore Dataからデータを取得するためのFetchRequestや、Viewに格納されている値を取得するためのEnvironmentなどのProperty Wrapperが用意されています。
そのためSwiftUIでは、Property Wrapperを活用してViewからデータの取得処理を隠蔽しつつ、Viewで状態を保持することが基本的な思想なのかなと考えています。
そこでRest APIでのデータの取得処理も、FetchRequestやEnvironmentなどと同様に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
FetchRequestやEnvironmentなどと同様に、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 {
// 任意の処理
}
}
課題
上記の実装だと、エンドポイントに対応するリクエストの処理を強制できないことです。
例えば以下のようにusers
でrequestSongs
を実行できてしまいます。これをコンパイルエラーとできるようにしたいです。
@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()
}
}
}
Discussion