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 の一文で書かれており、実際の処理は 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 が書けるイメージが湧いたら幸いです。
記事にまとめることで自分の中でもさらに理解が深まりました。 次はプロダクトに適用してみてより昇華して改善点を記事にできればよいなと考えています。
ここまで読んでいただきましてありがとうございます。