[ING] - SwiftUIとReduxで作るサンプルアプリ
SwiftUIを利用した表現&Reduxを利用した画面状態管理を組み合わせたUI実装サンプルになります。
※ 2023.03.07に開催された「YUMEMI.grow Mobile #1」での登壇資料はこちらになります。
※ 記事として登壇内容をまとめたものはこちらになります。
基本的には、APIから画面表示に必要なデータを取得した後に画面表示をする機能を中心として、一部の画面では「お気に入り機能」の様な形でアプリ内部にデータを永続化して保持しておく機能や、表示一覧データをキーワードやカテゴリーに合致するものだけをフィルタリングする「絞り込み検索」の様な形で表示する画面も実装しています。
【その1】
【その2】
以前に、UIKitベースのiOSアプリ開発やReactNativeに触れた経験の中でReduxに触れる機会があったので、SwiftUIでも自分で試してみたいと思ったことがきっかけで組んだ次第です。
※また裏テーマとして、TCA(The Composable Architecture)を理解するための布石としたり、類似点や相違点等の違いを比較したい意図もあったりします。
このサンプルを作り始めた際はSwiftUIでの実装経験があまりなかった事もあり、Udemy講座を受講した後に基本事項や少し複雑めな構成を取る際のポイントをある程度押さえる用に取り組んでいました。下記に受講した講座とその中で重要と感じた点をまとめたノートやそのた参考リンクを掲載しております。
【活用したUdemy講座】
- Composable SwiftUI Architecture Using Redux
- SwiftUI 2 - Build Netflix Clone - SwiftUI Best Practices
【SwiftUIとReduxを組み合わせた場合の事例】
【UIKitとReduxを組み合わせた場合の事例】
【TCA(The Composable Architecture)とRedux比較した際の所感等】
今回のサンプルでは、下記のような形でReduxの処理を実現するために必要な要素を役割ごとのファイルに分割した上でまとめています。さらに命名によって画面ごとにそれぞれのStateが対応するようにしています。
- Store: 👉 アプリケーション全体の状態(複数の画面表示用State)を一枚岩の様な形で保持する。
- Action:
👉 Storeが保持している状態(対象の画面表示用State)を更新するための唯一の手段でstructで定義する。
(重要) Actionの発行はStoreが提供しているstore.dispatch()
を実行する形となります。 - Reducer: 👉 現在の状態(対象の画面表示用State)とActionの内容から新しい状態を作成する部分で純粋関数として定義する。
- Middleware:
👉 Reducerの実行前後で処理を差し込むための部分で純粋関数として定義する。
(重要) 画面表示に必要なMiddleware内部で、API非同期通信処理や内部データ登録処理等を実施する形となります。
この様な形にすることで、画面を構成しているView要素については、主に下記の処理に限定する事が可能になります。
- Storeから受け取った画面用State値を反映する
- ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する
画面用State変化とUI変化をうまく結びつけるためには、できるだけ「Stateの値 = アプリのUI要素の状態」
という形となる様に、State構造やUI関連処理に関する設計をする点がポイントになると考えております。すなわち、「各状態におけるデータとUIのあるべき姿を整理する」
点が重要になると思います。
※ React.jsでも利用されている様なReduxの処理機構を、SwiftUIで表現した様なイメージで作成しています。
【このUIサンプル実装におけるStore部分の実装】
各画面に対応するStateを集約しているAppState(ReduxStateプロトコル準拠)
の部分については、@Published
で定義しています。
Store.swiftの実装コード
import Foundation
// MEMO: Store部分はasync/awaitで書くなら、MainActorで良いんじゃないかという仮説
// https://developer.apple.com/forums/thread/690957
// FYI: 他にも全体的にCombineを利用した書き方も可能 (※他にも事例は探してみると面白そう)
// https://wojciechkulik.pl/ios/redux-architecture-and-mind-blowing-features
// https://kazaimazai.com/redux-in-ios/
// https://www.raywenderlich.com/22096649-getting-a-redux-vibe-into-swiftui
// MARK: - Typealias
// 👉 Dispatcher・Reducer・Middlewareのtypealiasを定義する
// ※おそらくエッセンスとしてはReact等の感じに近くなるイメージとなる
typealias Dispatcher = (Action) -> Void
typealias Reducer<State: ReduxState> = (_ state: State, _ action: Action) -> State
typealias Middleware<StoreState: ReduxState> = (StoreState, Action, @escaping Dispatcher) -> Void
// MARK: - Protocol
protocol ReduxState {}
protocol Action {}
// MARK: - Store
final class Store<StoreState: ReduxState>: ObservableObject {
// MARK: - Property
@Published private(set) var state: StoreState
private var reducer: Reducer<StoreState>
private var middlewares: [Middleware<StoreState>]
// MARK: - Initialzer
init(
reducer: @escaping Reducer<StoreState>,
state: StoreState,
middlewares: [Middleware<StoreState>] = []
) {
self.reducer = reducer
self.state = state
self.middlewares = middlewares
}
// MARK: - Function
func dispatch(action: Action) {
// MEMO: Actionを発行するDispatcherの定義
// 👉 新しいstateに差し替える処理については、メインスレッドで操作したいのでMainActor内で実行する
Task { @MainActor in
self.state = reducer(
self.state,
action
)
}
// MEMO: 利用する全てのMiddlewareを適用
// 補足: MiddlewareにAPI通信処理等を全て寄せずに実装したい場合には別途ActionCreatorの様なStructを用意する方法もある
// https://qiita.com/fumiyasac@github/items/f25465a955afdcb795a2
middlewares.forEach { middleware in
middleware(state, action, dispatch)
}
}
}
// 👉 アプリの一番おおもと部分でStoreを定義する
let store = Store(
reducer: appReducer,
state: AppState(),
middlewares: [
// OnBoarding処理用Middleware
onboardingMiddleware(),
onboardingCloseMiddleware(),
// Home処理用Middleware
homeMiddleware(),
// Archive処理用Middleware
archiveMiddleware(),
addArchiveObjectMiddleware(),
deleteArchiveObjectMiddleware(),
// Favorite処理用Middleware
favoriteMiddleware(),
// Profile処理用Middleware
profileMiddleware(),
]
)
// 👉 ContentView(ScreenView)に対してenvironmentObjectを経由してstoreを渡す
WindowGroup {
ContentView()
.environmentObject(store)
}
// 👉 渡されたView(ScreenView)では下記の様な形でstoreを利用する
@EnvironmentObject var store: Store<AppState>
本サンプルにおけるUI実装に関しては、一部DragGesture
の処理を活用したCarousel表現や局所的にGeometryReader
を利用した表現を画面のSection要素内に取り入れて組み合わせた様な形となっています。
DragGesture
を活用した奥行きのある無限Carouselの実装や、PinterestのようなGrid表示については、UIKit + UICollectionView
を利用した実装を選択した場合でも、UICollectionViewDelegateFlowLayout
クラスを継承した独自のレイアウト定義等を活用したカスタマイズが必要になるので、結果的にはなかなか一筋縄ではいかないUI実装になる事は多いかと思います。
※ Home画面及びFavorite画面で利用されているUI表現に関する解説の詳細は、下記のQiita記事でまとめていますので、ご一読頂けますと幸いです。
【UI表現例: その1】
【UI表現例: その2】
【UI表現例: その3】
【UI表現例: その4】
【UI表現例: その5】
【UI表現例: その6】
サンプルアプリ内では、APIモックサーバーから受け取ったJSON形式のレスポンスを画面に表示する処理を実現するために、node.js製の 「json-server」 を利用して実現しています。(※こちらはTypeScript製のものを利用しています。)
このリポジトリをClone後に下記コマンドを実行することで、自分のローカル環境で動作させる事ができます。
サンプルアプリ内にAPIモックサーバーから受け取ったJSON形式のレスポンスを画面に表示する処理を実現するために、Node.js製の「JSONServer」というものを利用して実現しています。JSONServerに関する概要や基本的な活用方法につきましては下記のリンク等を参考にすると良いかと思います。
※ 自分のLocal環境にnode.js
とyarn
がインストールされていない場合は、まずはその準備をする必要があります
【Local環境で再現する手順】
# まずはMockサーバーの場所まで移動する
$ cd SwiftUIAndReduxExample/mock_server
# 必要なpackageのインストール
$ yarn install
# Mockサーバーの実行
$ yarn start
※ 自分の手元でまっさらな状態から準備する場合は下記コマンドを順次実行するイメージになります。
【Local環境で新規作成する場合の手順】
# ⭐️ 必要な実行コマンド
# ① package.jsonの新規作成
$ yarn init -y
# ② 必要なライブラリのインストール
$ yarn add typescript
$ yarn add json-server
$ yarn add @types/json-server -D
※ こちらはMockサーバーを実行するために最低限必要な設定を記載したpackage.json
になります。
【package.json設定例】
{
"name": "mock_server",
"version": "1.0.0",
"main": "server.ts",
"license": "MIT",
"dependencies": {
"json-server": "^0.17.0",
"typescript": "^4.7.4"
},
"scripts": {
"start": "npx ts-node server.ts"
},
"devDependencies": {
"@types/json-server": "^0.14.4"
}
}
- json-serverの実装に関する参考資料
- TypeScriptで始めるNode.js入門
- JSON ServerをCLIコマンドを使わずTypescript&node.jsからサーバーを立てるやり方
余談にはなりますが、最近作っていたUI実装サンプルのアイデアや盛り込みたい機能イメージを雑に書いたものになります。 今回は1つの画面内に複数Sectionが入るものやUI実装イメージが湧きにくいものに加えて、API関連処理部分でasync/awaitを利用することもあったので、自分が 「ここはハマりそうかも...?」 や 「UIの形や表現を自分の言葉でまとめておこう」 と感じた部分を中心にメモとして残しています。
Quick / Nimble / CombineExpectations を利用し、「初期State → Action発行 → API処理が伴う部分ではMiddleware処理実行時に準ずるActionを発行 → 新規State」 とすることで、Reducerでの処理が正しく実行されているかを見る方針としました。
また、各画面に対応するStateをまとめて管理しているAppStateは@Published
で定義されているため、下記の様な形で値変化をキャッチする点がポイントになるかと思います。
【Case1: Home画面でのテスト例】
HomeStateTest.swiftの実装コード
final class HomeStateTest: QuickSpec {
// MARK: - Override
override func spec() {
// MEMO: Quick+NimbleをベースにしたUnitTestを実行する
// ※注意: Middlewareを直接適用するのではなく、Middlewareで起こるActionに近い形を作ることにしています。
describe("#Home画面表示が成功する場合のテストケース") {
// 👉 storeをインスタンス化する際に、想定するMiddlewareのMockを適用する
let store = Store(
reducer: appReducer,
state: AppState(),
middlewares: []
)
// CombineExpectationを利用してAppStateの変化を記録するようにしたい
// 👉 このサンプルではAppStateで`@Published`を利用しているので、AppStateを記録対象とする
var homeStateRecorder: Recorder<AppState, Never>!
context("表示するデータ取得処理が成功する場合") {
beforeEach {
homeStateRecorder = store.$state.record()
}
afterEach {
homeStateRecorder = nil
}
// 👉 Middlewareで実行するAPIリクエストが成功した際に想定されるActionを発行する
store.dispatch(
action: SuccessHomeAction(
campaignBannerEntities: getCampaignBannerEntities(),
recentNewsEntities: getRecentNewsRecentNewsEntities(),
featuredTopicEntities: getFeaturedTopicEntities(),
trendArticleEntities: getTrendArticleEntities(),
pickupPhotoEntities: getPickupPhotoEntities()
)
)
// 対象のState値が変化することを確認する
// ※ homeStateはImmutable / Recorderで対象秒間における値変化を全て保持している
it("homeStateに想定している値が格納された状態であること") {
// timeout部分で0.16秒後の変化を見る(※async/await処理の場合は0.16秒ぐらいを見る)
let homeStateRecorderResult = try! self.wait(for: homeStateRecorder.availableElements, timeout: 0.16)
// 0.16秒間の変化を見て、最後の値が変化していることを確認する
let targetResult = homeStateRecorderResult.last!
// 👉 特徴的なテストケースをいくつか準備する(このテストコードで返却されるのは仮のデータではあるものの該当Stateにマッピングされる想定)
let homeState = targetResult.homeState
// (1) CampaignBannerCarouselViewObject
let campaignBannerCarouselViewObjects = homeState.campaignBannerCarouselViewObjects
let firstCampaignBannerCarouselViewObject = campaignBannerCarouselViewObjects.first
// 季節の特集コンテンツ一覧は合計6件取得できること
expect(campaignBannerCarouselViewObjects.count).to(equal(6))
// 1番目のidが正しい値であること
expect(firstCampaignBannerCarouselViewObject?.id).to(equal(1))
// 1番目のbannerContentsIdが正しい値であること
expect(firstCampaignBannerCarouselViewObject?.bannerContentsId).to(equal(1001))
// (2) RecentNewsCarouselViewObject
let recentNewsCarouselViewObjects = homeState.recentNewsCarouselViewObjects
let lastCampaignBannerCarouselViewObject = recentNewsCarouselViewObjects.last
// 最新のお知らせは合計12件取得できること
expect(recentNewsCarouselViewObjects.count).to(equal(12))
// 最後のidが正しい値であること
expect(lastCampaignBannerCarouselViewObject?.id).to(equal(12))
// 最後のtitleが正しい値であること
expect(lastCampaignBannerCarouselViewObject?.title).to(equal("美味しいみかんの年末年始の対応について"))
}
}
}
describe("#Home画面表示が失敗する場合のテストケース") {
let store = Store(
reducer: appReducer,
state: AppState(),
middlewares: []
)
var homeStateRecorder: Recorder<AppState, Never>!
context("#Home画面で表示するデータ取得処理が失敗した場合") {
beforeEach {
homeStateRecorder = store.$state.record()
}
afterEach {
homeStateRecorder = nil
}
store.dispatch(action: FailureHomeAction())
it("homeStateのisErrorがtrueとなること") {
let homeStateRecorderResult = try! self.wait(for: homeStateRecorder.availableElements, timeout: 0.16)
let targetResult = homeStateRecorderResult.last!
let homeState = targetResult.homeState
let isError = homeState.isError
expect(isError).to(equal(true))
}
}
}
}
// MARK: - Private Function
private func getCampaignBannerEntities() -> [CampaignBannerEntity] {
guard let path = Bundle.main.path(forResource: "campaign_banners", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([CampaignBannerEntity].self, from: data) else {
fatalError()
}
return result
}
private func getRecentNewsRecentNewsEntities() -> [RecentNewsEntity] {
guard let path = Bundle.main.path(forResource: "recent_news", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([RecentNewsEntity].self, from: data) else {
fatalError()
}
return result
}
private func getFeaturedTopicEntities() -> [FeaturedTopicEntity] {
guard let path = Bundle.main.path(forResource: "featured_topics", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([FeaturedTopicEntity].self, from: data) else {
fatalError()
}
return result
}
private func getTrendArticleEntities() -> [TrendArticleEntity] {
guard let path = Bundle.main.path(forResource: "trend_articles", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([TrendArticleEntity].self, from: data) else {
fatalError()
}
return result
}
private func getPickupPhotoEntities() -> [PickupPhotoEntity] {
guard let path = Bundle.main.path(forResource: "pickup_photos", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([PickupPhotoEntity].self, from: data) else {
fatalError()
}
return result
}
}
【Case2: Archive画面でのテスト例】
ArchiveStateTest.swiftの実装コード
final class ArchiveStateTest: QuickSpec {
// MARK: - Override
override func spec() {
// MEMO: Quick+NimbleをベースにしたUnitTestを実行する
// ※注意: Middlewareを直接適用するのではなく、Middlewareで起こるActionに近い形を作ることにしています。
describe("#Archive画面表示が成功する場合のテストケース") {
// 👉 storeをインスタンス化する際に、想定するMiddlewareのMockを適用する
let store = Store(
reducer: appReducer,
state: AppState(),
middlewares: []
)
// CombineExpectationを利用してAppStateの変化を記録するようにしたい
// 👉 このサンプルではAppStateで`@Published`を利用しているので、AppStateを記録対象とする
var archiveStateRecorder: Recorder<AppState, Never>!
context("表示するデータ取得処理が成功する場合") {
beforeEach {
archiveStateRecorder = store.$state.record()
}
afterEach {
archiveStateRecorder = nil
}
// 👉 Middlewareで実行するAPIリクエストが成功した際に想定されるActionを発行する
// 手順1: 検索キーワードとカテゴリーを選択する
let keyword = "チーズ"
let category = "洋食"
store.dispatch(
action: RequestArchiveWithInputTextAction(inputText: keyword)
)
store.dispatch(
action: RequestArchiveWithSelectedCategoryAction(selectedCategory: category)
)
var archiveSceneEntities = getArchiveSceneEntities()
archiveSceneEntities = archiveSceneEntities.filter {
$0.category == category
}
archiveSceneEntities = archiveSceneEntities.filter {
$0.dishName.contains(keyword) || $0.shopName.contains(keyword) || $0.introduction.contains(keyword)
}
// 手順2: 登録されているIDの一覧を設定する
let storedIds = [17, 33]
store.dispatch(
action: SuccessArchiveAction(
archiveSceneEntities: archiveSceneEntities,
storedIds: storedIds
)
)
// 対象のState値が変化することを確認する
// ※ archiveStateはImmutable / Recorderで対象秒間における値変化を全て保持している
it("archiveStateに想定している値が格納された状態であること") {
// timeout部分で0.16秒後の変化を見る
let archiveStateRecorderResult = try! self.wait(for: archiveStateRecorder.availableElements, timeout: 0.16)
// 0.16秒間の変化を見て、最後の値が変化していることを確認する
let targetResult = archiveStateRecorderResult.last!
// 👉 特徴的なテストケースをいくつか準備する(このテストコードで返却されるのは仮のデータではあるものの該当Stateにマッピングされる想定)
let archiveState = targetResult.archiveState
// archiveCellViewObjects / inputText / selectedCategory
let archiveCellViewObjects = archiveState.archiveCellViewObjects
let inputText = archiveState.inputText
let selectedCategory = archiveState.selectedCategory
// archiveStateのPropertyへ入力値&選択値が反映されていること
expect(inputText).to(equal("チーズ"))
expect(selectedCategory).to(equal("洋食"))
// Archive用コンテンツ一覧は合計2件取得できること
expect(archiveCellViewObjects.count).to(equal(2))
let firstArchiveCellViewObject = archiveCellViewObjects[0]
let secondArchiveCellViewObject = archiveCellViewObjects[1]
// (1) firstArchiveCellViewObject
expect(firstArchiveCellViewObject.id).to(equal(17))
expect(firstArchiveCellViewObject.dishName).to(equal("熱々が嬉しいマカロニグラタン"))
expect(firstArchiveCellViewObject.isStored).to(equal(true))
// (2) secondArchiveCellViewObject
expect(secondArchiveCellViewObject.id).to(equal(33))
expect(secondArchiveCellViewObject.dishName).to(equal("シーフードミックスピザ"))
expect(secondArchiveCellViewObject.isStored).to(equal(true))
}
}
}
describe("#Archive画面表示が失敗する場合のテストケース") {
let store = Store(
reducer: appReducer,
state: AppState(),
middlewares: []
)
var archiveStateRecorder: Recorder<AppState, Never>!
context("画面で表示するデータ取得処理が失敗した場合") {
beforeEach {
archiveStateRecorder = store.$state.record()
}
afterEach {
archiveStateRecorder = nil
}
store.dispatch(action: FailureArchiveAction())
it("archiveStateのisErrorがtrueとなること") {
let archiveStateRecorderResult = try! self.wait(for: archiveStateRecorder.availableElements, timeout: 0.16)
let targetResult = archiveStateRecorderResult.last!
let archiveState = targetResult.archiveState
let isError = archiveState.isError
expect(isError).to(equal(true))
}
}
}
}
// MARK: - Private Function
private func getArchiveSceneEntities() -> [ArchiveSceneEntity] {
guard let path = Bundle.main.path(forResource: "achive_images", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([ArchiveSceneEntity].self, from: data) else {
fatalError()
}
return result
}
}
Preview画面では、API通信部分やデータ永続化が関係するMiddleware層(あるいはRepository層)の処理については、実際の振る舞いを模したMock用のクラスを適用しています。
実際にAPI処理を実行させる形でもこのサンプル実装においては差し支えはありませんが、各種UI要素におけるPreview画面や実機検証の際に利用するビルドターゲットSwiftUIAndReduxExampleMockApi
では下記の様な形でMock処理を使う様にしています。
【Case1: API通信部分のMock例】
Mock込みのRequestArchiveRepository.swift実装コード
// MARK: - Protocol
protocol RequestArchiveRepository {
func getArchiveResponse(keyword: String, category: String) async throws -> ArchiveResponse
}
final class RequestArchiveRepositoryImpl: RequestArchiveRepository {
// MARK: - Function
// 👉 検索キーワードと選択カテゴリーに合致する一覧データ取得APIリクエスト処理を実行する
func getArchiveResponse(keyword: String, category: String) async throws -> ArchiveResponse {
return try await ApiClientManager.shared.getAchiveImages(keyword: keyword, category: category)
}
}
// MARK: - MockSuccessRequestArchiveRepositoryImpl
final class MockSuccessRequestArchiveRepositoryImpl: RequestArchiveRepository {
// MARK: - Function
// 👉 実際にAPIリクエスト処理で実行される処理に相当するものをMockで再現する
func getArchiveResponse(keyword: String, category: String) async throws -> ArchiveResponse {
// 第2引数で与えられるcategoryと全く同じ値であるものだけを取り出す
// 第1引数で与えられるkeywordが(dishName / shopName / introduction)いずれかに含まれるものだけを取り出す
var filteredResult = getArchiveSceneResponse().result
if !category.isEmpty {
filteredResult = filteredResult.filter { $0.category == category }
}
if !keyword.isEmpty {
filteredResult = filteredResult.filter { $0.dishName.contains(keyword) || $0.shopName.contains(keyword) || $0.introduction.contains(keyword) }
}
return ArchiveSceneResponse(result: filteredResult)
}
// MARK: - Private Function
private func getArchiveSceneResponse() -> ArchiveSceneResponse {
guard let path = Bundle.main.path(forResource: "achive_images", ofType: "json") else {
fatalError()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
fatalError()
}
guard let result = try? JSONDecoder().decode([ArchiveSceneEntity].self, from: data) else {
fatalError()
}
return ArchiveSceneResponse(result: result)
}
}
【Case2: データ永続化部分のMock例】
Mock込みのStoredArchiveDataRepository.swift実装コード
// MARK: - Protocol
protocol StoredArchiveDataRepository {
func getAllObjectsFromRealm() -> [StockArchiveRealmEntity]
func createToRealm(archiveCellViewObject: ArchiveCellViewObject)
func deleteFromRealm(archiveCellViewObject: ArchiveCellViewObject)
}
final class StoredArchiveDataRepositoryImpl: StoredArchiveDataRepository {
// MARK: - Function
// 👉 Realmから全件取得処理・Realmへの1件追加・1件削除処理を実行する
func getAllObjectsFromRealm() -> [StockArchiveRealmEntity] {
if let stockArchiveRealmEntities = RealmAccessManager.shared.getAllObjects(StockArchiveRealmEntity.self) {
return stockArchiveRealmEntities.map { $0 }
} else {
return []
}
}
func createToRealm(archiveCellViewObject: ArchiveCellViewObject) {
let stockArchiveRealmEntity = convertToRealmObject(archiveCellViewObject: archiveCellViewObject)
RealmAccessManager.shared.saveStockArchiveRealmEntity(stockArchiveRealmEntity)
}
func deleteFromRealm(archiveCellViewObject: ArchiveCellViewObject) {
if let stockArchiveRealmEntities = RealmAccessManager.shared.getAllObjects(StockArchiveRealmEntity.self),
let stockArchiveRealmEntity = stockArchiveRealmEntities.map({ $0 }).filter({ $0.id == archiveCellViewObject.id }).first
{
RealmAccessManager.shared.deleteStockArchiveRealmEntity(stockArchiveRealmEntity)
} else {
fatalError("削除対象のデータは登録されていませんでした。")
}
}
// MARK: - Private Function
private func convertToRealmObject(archiveCellViewObject: ArchiveCellViewObject) -> StockArchiveRealmEntity {
let realmObject = StockArchiveRealmEntity()
realmObject.id = archiveCellViewObject.id
realmObject.photoUrl = archiveCellViewObject.photoUrl?.absoluteString ?? ""
realmObject.category = archiveCellViewObject.category
realmObject.dishName = archiveCellViewObject.dishName
realmObject.shopName = archiveCellViewObject.shopName
realmObject.introduction = archiveCellViewObject.introduction
return realmObject
}
}
final class MockStoredArchiveDataRepositoryImpl: StoredArchiveDataRepository {
// MARK: - Function
// 👉 実際にデータ永続化処理で実行される処理に相当するものをMockで再現する
func getAllObjectsFromRealm() -> [StockArchiveRealmEntity] {
return RealmMockAccessManager.shared.mockDataStore.values.map({ $0 })
}
func createToRealm(archiveCellViewObject: ArchiveCellViewObject) {
RealmMockAccessManager.shared.mockDataStore[archiveCellViewObject.id] = convertToRealmObject(archiveCellViewObject: archiveCellViewObject)
}
func deleteFromRealm(archiveCellViewObject: ArchiveCellViewObject) {
RealmMockAccessManager.shared.mockDataStore.removeValue(forKey: archiveCellViewObject.id)
}
// MARK: - Private Function
private func convertToRealmObject(archiveCellViewObject: ArchiveCellViewObject) -> StockArchiveRealmEntity {
let realmObject = StockArchiveRealmEntity()
realmObject.id = archiveCellViewObject.id
realmObject.photoUrl = archiveCellViewObject.photoUrl?.absoluteString ?? ""
realmObject.category = archiveCellViewObject.category
realmObject.dishName = archiveCellViewObject.dishName
realmObject.shopName = archiveCellViewObject.shopName
realmObject.introduction = archiveCellViewObject.introduction
return realmObject
}
}
Middlewareはメソッドとして提供されるので、Repositoryの本実装を適用したメソッドとRepositoryのMock実装を適用したメソッドの2種類を用意する形になります。
ArchiveMiddleware.swiftに定義した本実装時のメソッド
// MARK: - Function (Production)
// APIリクエスト結果に応じたActionを発行する
// ※テストコードの場合は検証用のarchiveMiddlewareのものに差し替える想定
func archiveMiddleware() -> Middleware<AppState> {
return { state, action, dispatch in
switch action {
// 👉 選択カテゴリー・入力テキスト値の変更を受け取ったらその後にAPIリクエスト処理を実行する
// 複合条件の処理をするために現在Stateに格納されている値も利用する
case let action as RequestArchiveWithInputTextAction:
let selectedCategory = state.archiveState.selectedCategory
requestArchiveScenes(
inputText: action.inputText,
selectedCategory: selectedCategory,
dispatch: dispatch
)
case let action as RequestArchiveWithSelectedCategoryAction:
let inputText = state.archiveState.inputText
requestArchiveScenes(
inputText: inputText,
selectedCategory: action.selectedCategory,
dispatch: dispatch
)
case _ as RequestArchiveWithNoConditionsAction:
requestArchiveScenes(
inputText: "",
selectedCategory: "",
dispatch: dispatch
)
default:
break
}
}
}
// MARK: - Private Function (Production)
// 👉 APIリクエスト処理を実行するためのメソッド
// ※テストコードの場合は想定するStubデータを返すものに差し替える想定
private func requestArchiveScenes(inputText: String, selectedCategory: String, dispatch: @escaping Dispatcher) {
Task { @MainActor in
do {
// 👉 Realm内に登録されているデータのIDだけを詰め込んだ配列に変換する
let storedIds = StoredArchiveDataRepositoryFactory.create().getAllObjectsFromRealm()
.map { $0.id }
// 👉 Realm内に登録されているデータのIDだけを詰め込んだ配列に変換する
// 🌟 最終的にViewObjectに変換をするのはArchiveReducerで実行する
let archiveResponse = try await RequestArchiveRepositoryFactory.create().getArchiveResponse(keyword: inputText, category: selectedCategory)
if let archiveSceneResponse = archiveResponse as? ArchiveSceneResponse {
// お望みのレスポンスが取得できた場合は成功時のActionを発行する
dispatch(
SuccessArchiveAction(
archiveSceneEntities: archiveSceneResponse.result,
storedIds: storedIds
)
)
} else {
// お望みのレスポンスが取得できなかった場合はErrorをthrowして失敗時のActionを発行する
throw APIError.error(message: "No FavoriteSceneResponse exists.")
}
dump(archiveResponse)
} catch APIError.error(let message) {
// 通信エラーないしはお望みのレスポンスが取得できなかった場合は成功時のActionを発行する
dispatch(FailureArchiveAction())
print(message)
}
}
}
ArchiveMiddleware.swiftに定義したMock時のメソッド
// MARK: - Function (Mock for Success)
// テストコードで利用するAPIリクエスト結果に応じたActionを発行する(Success時)
func archiveMockSuccessMiddleware() -> Middleware<AppState> {
return { state, action, dispatch in
// 👉 本来はAPIリクエスト処理やRealmからのデータ取得処理をMockに置き換えたもので代用する関数を実行する
switch action {
case let action as RequestArchiveWithInputTextAction:
let selectedCategory = state.archiveState.selectedCategory
mockSuccessRequestArchiveScenes(
inputText: action.inputText,
selectedCategory: selectedCategory,
dispatch: dispatch
)
case let action as RequestArchiveWithSelectedCategoryAction:
let inputText = state.archiveState.inputText
mockSuccessRequestArchiveScenes(
inputText: inputText,
selectedCategory: action.selectedCategory,
dispatch: dispatch
)
case _ as RequestArchiveWithNoConditionsAction:
mockSuccessRequestArchiveScenes(
inputText: "",
selectedCategory: "",
dispatch: dispatch
)
default:
break
}
}
}
// MARK: - Function (Mock for Failure)
// テストコードで利用するAPIリクエスト結果に応じたActionを発行する(Failure時)
func archiveMockFailureMiddleware() -> Middleware<AppState> {
return { state, action, dispatch in
switch action {
// 👉 処理失敗を想定したmock用関数を実行する
case _ as RequestArchiveWithInputTextAction:
mockFailureRequestArchiveScenes(dispatch: dispatch)
case _ as RequestArchiveWithSelectedCategoryAction:
mockFailureRequestArchiveScenes(dispatch: dispatch)
case _ as RequestArchiveWithNoConditionsAction:
mockFailureRequestArchiveScenes(dispatch: dispatch)
default:
break
}
}
}
// MARK: - Private Function (Dispatch Action Success/Failure)
// 👉 成功時のAPIリクエストを想定した処理を実行するためのメソッド
private func mockSuccessRequestArchiveScenes(inputText: String, selectedCategory: String, dispatch: @escaping Dispatcher) {
Task { @MainActor in
let _ = try await Task.sleep(for: .seconds(0.64))
// 👉 実際はRealmへの処理ではあるが、MockはDictionaryを利用する処理としている
let storedIds = MockStoredArchiveDataRepositoryFactory.create().getAllObjectsFromRealm()
.map { $0.id }
let archiveResponse = try await MockSuccessRequestArchiveRepositoryFactory.create().getArchiveResponse(keyword: inputText, category: selectedCategory)
if let archiveSceneResponse = archiveResponse as? ArchiveSceneResponse {
dispatch(
SuccessArchiveAction(
archiveSceneEntities: archiveSceneResponse.result,
storedIds: storedIds
)
)
} else {
throw APIError.error(message: "No favoriteSceneResponse exists.")
}
}
}
// 👉 失敗時のAPIリクエストを想定した処理を実行するためのメソッド
private func mockFailureRequestArchiveScenes(dispatch: @escaping Dispatcher) {
Task { @MainActor in
let _ = try await Task.sleep(for: .seconds(0.64))
dispatch(FailureArchiveAction())
}
}