SwiftUIアプリ設計をReduxを使って開発する(コードを使っての説明)
#Redux #SwiftUI #Swift #プログラミング
SwiftUIアプリ設計をReduxを使って開発する(Reduxの特徴をおさらいする)
の続きの記事となります。Reduxについてご存じの方はこのままお読みください。もし慣れ親しんでいなければ先に上の記事を読んでください。
本記事では解説のためにソースコードの抜粋を貼っていきますが、全文確認されたい方は
こちらからDL可能です。なお今回のSwiftUI+Reduxの仕組みはこちらのサイトのものを改造したものになります。合わせてお読みください。
出来上がるものは、任意のキーワードにマッチしたGithubのユーザー一覧が表示されます。ここでは人の顔写真が載せないようにAppleの1件のみの表示ですが、実際は部分一致したものが複数行表示されます。
Viewのインプットとアウトプット
前の記事に以下のように書きましたが、
View (UIKitのViewController、SwiftUIのView) は画面を表示するための情報としてStateツリー、アクション(ボタンタップなど)としてActionのディスパッチを行いますが、その裏にあるActionの実行+Reducerの存在は意識しません。
「Actionのディスパッチ」と表現しておりますが、Actionを実行という意味ではなく、StoreへActionを送るイメージで書いております。
これを実現させるためにReduxをどのように活用するかにフォーカスして解説していきます。
struct UserListView: View {
@EnvironmentObject var store: Store<AppState, AppAction>
@ObservedObject var imageFetcher = ImageFetcher()
@State private var query: String = "Apple"
var body: some View {
UserSearchView(
query: $query,
imageFetcher: imageFetcher,
users: store.state.userState.searchResult, // 画面表示のための情報
onCommit: fetchUser
// 画面表示時に、画面表示情報のリクエスト
).onAppear(perform: fetchUser)
}
private func fetchUser() {
// アクション(画面表示情報のリクエスト)
store.dispatch(.user(action: .fetchList(query: query)))
}
}
画面を表示するための情報としてStoreから以下の情報をもらい
store.state.userState.searchResult
もらいの部分を深堀りするとSwiftUIの機能で解決しており、
@EnvironmentObject と宣言しているの変数はアプリケーションの中で参照されるすべてのViewで共有される情報であり+この変数(今回はStore)の中の値に変化がある場合にそれを参照しているViewが自動的に更新される特徴があります。
青枠が各画面でEnvironmentを参照していることがわかります。
RxSwiftのデータバインドやNotificationCenterによる更新に変わるものとなります。
または、画面情報取得のリクエストは、以下の1行であり
store.dispatch(.user(action: .fetchList(query: query)))
ユーザー情報をActionとしてStoreへDispatchすることにより取得します。
このAction部分はただのデータ部分となりまして、下記のようにenumとして宣言しております。
enum AppAction: Action {
...
case user(action: UserAction)
....
}
enum UserAction: Action {
case fetchList(query: String)
...
}
上で紹介した、INとOUTは共にただのデータであり、API通信や実際の処理は存在しません。
では続いてstore.dispatchの dispatch関数の中身を見ていきます。
今回のReduxの肝となる部分になります。
func dispatch(_ action: AppAction) {
action
// Actionを実行可能な形に変化させます。 ActionCreaterの役割みたいなもの
.mapToMutation(dependencies: self.dependencies)
// 画面更新はメインスレッドで行う必要があるため、メインスレッドで結果をもらいます。
.receive(on: DispatchQueue.main)
// reduceを実行
.sink { self.appReducer(&self.state, $0) }
.store(in: &cancellables)
}
一行ずつ読み解いて行きたいと思います。
dispatch部分を画面から呼び出している部分で型推論を使わないと以下のように書かれ、AppActionの中のUserActionを渡していることがわかります。
store.dispatch(AppAction.user(action: UserAction.fetchList(query: query)))
何度も書きますが、このネストがポイントです!
mapToMutation (Actionを実行可能な形に変形)
mapToMutationには、AppActionの型で引数を受け取ります。
Action自体がStateと同様にツリー構造(enumのネスト構造)となっており、
enum AppMutation {
...
case user(mutation: UserMutation) // ・・・1-2
}
enum AppAction: Action {
...
case user(action: UserAction)
func mapToMutation(dependencies: Dependencies) -> AnyPublisher<AppMutation, Never> {
switch self {
...
case let .user(action): // 1-1
return action // ・・・1-2
.mapToMutation(dependencies: dependencies) // ・・・1-3. UserActionのmapToMutation呼び出す
.map { AppMutation.user(mutation: $0) } // ・・・1-4
.eraseToAnyPublisher()
}
}
}
enum UserMutation {
case searchResults(users: [User], error: Error?)
}
enum UserAction: Action {
case fetchList(query: String)
// 1-3. Actionを実行可能な形に変換
func mapToMutation(dependencies: Dependencies) -> AnyPublisher<UserMutation, Never> {
switch self {
case let .fetchList(query):
return dependencies.searchUserService
.searchPublisher(matching: query)
.map { UserMutation.searchResults(users: $0, error: nil) }
.catch { Just(UserMutation.searchResults(users: [], error: $0)) } // handle error
.eraseToAnyPublisher()
}
}
}
・AppAction → UserActionを呼び出す (1-1 & 1-2)
・UserActionのmapToMutationによりActionを実行可能なAnyPublisher<UserMutation, Never> の形に変換(1-3)
・1-3で変換されたAnyPublisherを更にAnyPublisher<AppMutation, Never>の形にラップする (1-4)
SwiftのEnumのAssociated Valueと呼ばれるものがすごくて、
Enumの中にEnumを値として格納できる
イメージとしては、AppAction(UserAction)
この外(AppAction)→内(UserAction)→実行可能な形にActionを変換→外(AppAction)の処理の流れがポインとなります。
AnyPublisher<AppMutation, Never> の形に最終的にしているのは、Actionが増えても、その親のActionは一つであるためどこからでも同じ処理を記述できるようにするためです。
こうすることで、機能が増えても各画面の各アクションから、store.dispatchの形で同様に呼び出せるようになります。
// ユーザー一覧を取得
store.dispatch(.user(action: .fetchList(query: query)))
// ユーザー情報を更新
store.dispatch(.user(action: .update(user: user)))
// githubリポジトリー取得
store.dispatch(.repo(action: .fetchList(query: query)))
// スターを付ける
store.dispatch(.star(action: .add(repoId: repoId)))
このデータをラップする手法はとても効果的な方法なので是非活用してみてください。
.receive(on: DispatchQueue.main) (受け取りはメインスレッドで行う)
画面の更新処理などを扱うもののため、受け取るのはメインスレッドで行う必要があり、このようなオペレーションを記述します。
.sink { self.appReducer(&self.state, $0) } (reduce実行)
今までdispatchとしていたものはあくまで実行できる状態にしていたもので、sinkオペレーションを追加する(購読を開始すると言われたりする)ことで実行されます。
これがreduxの花形処理のreducer部分になります。
mapToMutationで下処理(お皿に調理したものを並べる程度のもの)が済んでいるためreducerで行うことは実際は少なくなっております。
let userReducer: Reducer<UserState, UserMutation> = { state, mutation in
switch mutation {
case let .searchResults(_, error) where error != nil:
state = UserState(searchResult: state.searchResult, errorMessage: "It occured a some error.")
case let .searchResults(users, _):
state = UserState(searchResult: users, errorMessage: "")
}
}
ここでは、reducerの特徴である、stateとaction(ここでは実行可能なmutation)を受け取って stateを返却しております。
errorありとなしで処理を分岐したいため、caseが2つで表現していますが実にシンプルな部分となります。
redux部分は純粋関数で記述する必要がある。
条件を満たしていることがわかります。
純粋関数ではないAPI通信を担っている処理
ここまでは、副作用のない処理となりましたが、ここからはAPI通信を行っている部分の解説をしていきます。
もうすでに実は上の処理でコードは書いておりますが、
func mapToMutation(dependencies: Dependencies) -> AnyPublisher<UserMutation, Never> {
switch self {
case let .fetchList(query):
return dependencies.searchUserService // ここでAPI通信を行っている
.searchPublisher(matching: query)
.map { UserMutation.searchResults(users: $0, error: nil) }
.catch { Just(UserMutation.searchResults(users: [], error: $0)) } // handle error
.eraseToAnyPublisher()
}
}
dependencies.searchUserService の1文で書かれており、実際の処理はService側で担っております。
dependencies.searchUserServiceのdependenciesは
アプリ起動時に呼び出されるSceneDelegate.swift内のsceneメソッドの中で設定しております。ここでは、dependenciesだけではなく、stateやreducerを格納しているStoreのインスタンスも生成して、先に説明した、 environmentObject に格納しています。
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let scene = scene as? UIWindowScene else {
return
}
let appState: AppState = AppState(repoState: RepoState(), userState: UserState())
let store = Store<AppState, AppAction>(initialState: appState, appReducer: appReducer, dependencies: fetchApi)
let window = UIWindow(windowScene: scene)
window.rootViewController = UIHostingController(
rootView: UserListView()
.environmentObject(store)
)
self.window = window
window.makeKeyAndVisible()
}
struct Dependencies {
var repoService: RepoService
var searchUserService: SearchUserService
}
let fetchApi = Dependencies(repoService: RepoServiceImpl(),
searchUserService: SearchUserServiceImpl())
API通信部分
ここはSwiftUIの機能は一切使っていないSwiftであり、SwiftUIを使わない画面やサービスと共存できるところになります。
Publisherとして返却しているのは、上で説明したようにCombine Frameworkのオペレータ機能を使うためです。
protocol SearchUserService {
func searchPublisher(matching query: String) -> AnyPublisher<[User], Error>
}
class SearchUserServiceImpl : SearchUserService {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
self.session = session
self.decoder = decoder
}
func searchPublisher(matching query: String) -> AnyPublisher<[User], Error> {
guard
var urlComponents = URLComponents(string: "https://api.github.com/search/users")
else { preconditionFailure("Can't create url components...") }
urlComponents.queryItems = [
URLQueryItem(name: "q", value: query)
]
guard
let url = urlComponents.url
else { preconditionFailure("Can't create url from url components...") }
return session
.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: UserResponse.self, decoder: decoder)
.map { $0.items }
.eraseToAnyPublisher()
}
}
まとめ
実践のアプリへ昇華するにはこの仕組だけでは足りない部分も多いと思いますが、Reduxで綺麗にSwiftが書けるイメージが湧いたら幸いです。
記事にまとめることで自分の中でも更に理解が深まりました。次はプロダクトに適用してみてより昇華して改善点を記事に出来れば良いなと考えております。
ここまで読んで頂きましてありがとうございます。