pointfreeco/isowords のコードリーディング
pointfreeさんのTCAを利用したisowordsアプリのコードが公開されているのでそれのコードについて書いておきます。
追記
2023/5/3現在、isowordsはisowords.workspaceを取り除いてApp/isowords.xcodeprojからXcodeを利用するようにしています。(そのせいか私の環境だとXcode 14でも13でもビルドできなくなってる)
以下の記事はかなり古い内容なので現状と変わっています。
SwiftPMでフレームワークとして自分のコードを入れる
プロジェクトを開くとSwiftPMで自前のコードを読み込んでいるのがわかります。
isowordsターゲットのターゲットはApp.swift
まずiOSアプリ起動時のターゲットisowordsはApp.swiftのみ。
これはKickstarter-iosプロジェクトでも見られたマルチモジュール戦略ですね。
マルチモジュールの読み込み方がSwiftPM
App.swiftからPackage.swiftで各種コードを読み込んでいます。
上記スクリーンショットでは自前のコードをPackage.swiftで読んでいます。サードパーティの外部ライブラリもそこで呼んでるようです。
これはXcode上でSwift Packageを決めてるのではありません。一般的にはXcode上でSwift Package使ってサードパーティのライブラリを入れると思いますが、そうではありません。次のスクリーンショットでは何も設定されていないのがわかります。
このやり方はライブラリ開発のやり方と似ています(というか細かいことを気にしなければ同じでしょう)。また、あまり知られてる方法ではありませんが、アプリ開発プロジェクトでCLIでSwiftPMを使って開発ツールを読み込む方法と同じです。
デメリット
このやり方は良くない側面もあります。それはXcodeの履歴として同名のワークスペースが出ずに同名のSwift Package扱いされてしまうことです。
Xcodeの履歴として下記スクリーンショットではフォルダのように表示されてます。
フォルダとして表示されているのはSwiftPackageとして認識されていることを示しています。これワークスペースとして認識してワークスペースのアイコンを表示して欲しい場面です。こうなるとワークスペースを開きたい場合でもSwift Packageのみが開かれます。
このデメリットはアプリ開発プロジェクトでSwiftLintやSwiftFormatなどをCLIのSwiftPMで利用する場合のデメリットでもあります。
他にもいくつものデメリットがあり公式で説明されています。
isowordsの自前コードフレームワーク分離の分類
自前コードをSwift Packageで入れていて、それを下記のようにさらに分類しています
- Feature
- Core
- Client
- Models
- Helper
- Live
- 利用するサードパーティライブラリ
- その他
Feature
- FooState, FooAction, FooReduceerをセットでさらにFooViewがある
- 別ファイルにFooViewを分けることもある(例: GameFeature)
- さらに細かいViewが別ファイルにある
- 例
- ActiveGamesFeature
- HomeFeature
- GameFeature
ActiveGamesFeatureを例に
- ActiveGameCard.swift
- TCAに依存しない部品でSwiftUI的なView
- ActiveGamesView.swift
- ActiveGamesState, ActiveGamesAction, ActiveGamesView(reducerはない)
- ActiveTurnBasedMatch.swift
-
public struct ActiveTurnBasedMatch: Equtable, Identifiable
な部品
-
Core
- Featureとほぼ同じ
- Coreが別のFeatureを持つこともある
基本的にCoreというものはあまり作らずにFeatureから分離して、他で使えるようにしている印象。
ユーザ情報
APIClient.currentPlayerプロパティでアクセスできるようにしてる
Envelope
CurrentPlayerEnvelope型がPlayerとレシートを保有している。
public struct CurrentPlayerEnvelope: Codable, Equatable {
public let appleReceipt: AppleVerifyReceiptResponse?
public let player: Player
public init(
appleReceipt: AppleVerifyReceiptResponse?,
player: Player
) {
self.appleReceipt = appleReceipt
self.player = player
}
}
WithViewStoreを使わずにinitでViewStoreに変換する
SwiftUIでもWithViewStoreを使わずにinitでViewStoreに変換している箇所がある。
struct OnboardingStepView: View {
let store: Store<OnboardingState, OnboardingAction>
@ObservedObject var viewStore: ViewStore<ViewState, OnboardingAction>
@Environment(\.colorScheme) var colorScheme
init(store: Store<OnboardingState, OnboardingAction>) {
self.store = store
self.viewStore = ViewStore(self.store.scope(state: ViewState.init))
}
...
これはbodyの大外でGeometryReaderを使うからだろう。
...
var body: some View {
GeometryReader { proxy in
let height = proxy.size.height / 4
AccessTokenをどうやって使っているか
- ApiClientを.live()時にCurrentPlayerEnvelopeで取得
- CurrentPlayerEnvelopeはUserDefaultsから復帰できる
extension ApiClient {
private static let baseUrlKey = "co.pointfree.isowords.apiClient.baseUrl"
private static let currentUserEnvelopeKey = "co.pointfree.isowords.apiClient.currentUserEnvelope"
public static func live(
accessToken defaultAccessToken: AccessToken? = nil,
baseUrl defaultBaseUrl: URL = URL(string: "http://localhost:9876")!,
sha256: @escaping (Data) -> Data
) -> Self {
let router = ServerRouter.router(
date: Date.init,
decoder: decoder,
encoder: encoder,
secrets: secrets.split(separator: ",").map(String.init),
sha256: sha256
)
#if DEBUG
var baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl {
didSet {
UserDefaults.standard.set(baseUrl, forKey: baseUrlKey)
}
}
#else
var baseUrl = URL(string: "https://www.isowords.xyz")!
#endif
var currentPlayer = UserDefaults.standard.data(forKey: currentUserEnvelopeKey)
.flatMap({ try? decoder.decode(CurrentPlayerEnvelope.self, from: $0) })
{
didSet {
UserDefaults.standard.set(
currentPlayer.flatMap { try? encoder.encode($0) },
forKey: currentUserEnvelopeKey
)
}
}
この.live()を利用する場合は下記(defaultAccessTokenとdefaultBaseURLは省略してる)
extension AppEnvironment {
static var live: Self {
let apiClient = ApiClient.live(
sha256: { Data(SHA256.hash(data: $0)) }
)
APIClientのAPIリクエスト
実際のリクエストはliveのプロパティとextension側にありfuncで実装している2パターン。
live側にあるプロパティ
これはプロパティでクロージャとして定義しているので置き換え可能
public static func live(
accessToken defaultAccessToken: AccessToken? = nil,
baseUrl defaultBaseUrl: URL = URL(string: "http://localhost:9876")!,
sha256: @escaping (Data) -> Data
) -> Self {
色々省略
return Self(
apiRequest: { route in
ApiClientLive.apiRequest(
accessToken: currentPlayer?.player.accessToken,
baseUrl: baseUrl,
route: route,
router: router
)
},
authenticate: { request in
return ApiClientLive.request(
baseUrl: baseUrl,
route: .authenticate(
.init(
deviceId: request.deviceId,
displayName: request.displayName,
gameCenterLocalPlayerId: request.gameCenterLocalPlayerId,
timeZone: request.timeZone
)
),
router: router
)
.map { data, _ in data }
.apiDecode(as: CurrentPlayerEnvelope.self)
.handleEvents(receiveOutput: { currentPlayer = $0 })
.eraseToEffect()
},
baseUrl: { baseUrl },
currentPlayer: { currentPlayer },
logout: {
.fireAndForget { currentPlayer = nil }
},
refreshCurrentPlayer: {
ApiClientLive.apiRequest(
accessToken: currentPlayer?.player.accessToken,
baseUrl: baseUrl,
route: .currentPlayer,
router: router
)
extension側にありfuncで実装している
funcで内部でapiRequestメソッドを呼び出す。
これは置き換えられない。APIClient自体を置き換えればいいとは思うがそうやっていない。
extension ApiClient {
func loadSoloResults(
gameMode: GameMode,
timeScope: TimeScope
) -> Effect<ResultEnvelope, ApiError> {
self.apiRequest(
route: .leaderboard(
.fetch(
gameMode: gameMode,
language: .en,
timeScope: timeScope
)
),
as: FetchLeaderboardResponse.self
)
.compactMap { response in
response.entries.first.map { firstEntry in
ResultEnvelope(
outOf: firstEntry.outOf,
results: response.entries.map { entry in
ResultEnvelope.Result(
denseRank: entry.rank,
id: entry.id.rawValue,
isYourScore: entry.isYourScore,
rank: entry.rank,
score: entry.score,
subtitle: nil,
title: entry.playerDisplayName ?? (entry.isYourScore ? "You" : "Someone")
)
}
)
}
}
.eraseToEffect()
}
}
APIClient初期化時のliveメソッド
-
static let live
を使うのではなくstatic let live()
メソッドにしている
これは下記のようにbaseURLなどを初期化時にセットしたいため
public struct APIClientSample {
let baseURL: URL
}
extension APIClientSample {
static func live(baseURL: URL) -> APIClientSample {
APIClientSample(baseURL: baseURL)
}
func 何かのリクエスト(accessToken: String) -> Effect<Response, Error> {
let request = UsersMeRequest(baseURL: baseURL, accessToken: accessToken)
return Effect.future { callback in
...
}
}
}
これによってリクエストはfunction typeとして表現できるので利用するCoreで下記リクエストを使えばいい。
何かのリクエスト(accessToken: String) -> Effect<Response, Error>
何かのCore.Environment(
constants: $0.constants,
mainQueue: $0.mainQueue,
updateQueue: DispatchQueue.global().eraseToAnyScheduler(),
何かのリクエスト: $0.apiClient.何かのリクエスト(accessToken:),
)
UserDefaultsから取得するのも副作用としたらEffect使うの?
UserDefaultsでは同期的に即取得できるものは副作用であってもそのまま返しています。戻り値を必要としない保存に対してはEffect<Never, Never>
。
Effectを使わない例
Effectを使わず即返している例を示す。
UserDefaultsClient
hasShownFirstLaunchOnboarding: Bool
というプロパティがUserDefultsClientにあり、これはEffectではないです。
使っているコードとしてはReducerで即時取得しています。
これのsetterとしてはEffect<Never, Never>
を使っています
まあ、副作用の実行した戻り値をVoidにしたくないからEffectにした、という気持ちはわかります。
DBから取得するものはEffectを使ってるの?
結論から言うとDBから取得するものはすべてEffectにしている。
Effectを使ってる例
即返せるからと言って即返さずEffectを使っている例を示す。
LocalDatabaseClient
public var playedGamesCount: (GameContext) -> Effect<Int, Error>
プロパティはクロージャでEffectを返している
実装としては内部で即返せるにも関わらずEffectにしている。
即返せるのは下記playedGamesCount
を見ればわかる。
DBに関するものは即返せるがあえてEffectにしているのだと考えられる。
ReducerのエラーはSwift.ErrorではなくNSErrorを使えばEqutableでも面倒じゃない
はじめに
isowordsではReducer ActionのEquatable準拠のため、Swift.ErrorではなくNSErrorを利用し、自前で==
の実装を書かなくて済むようにしている。ということを書いておきます。
つまり下記のようにVoidやErrorを使ってません。
...
public enum ExampleAction: Equatable {
case onAppear
case request
case response(Result<Void, Error>) // ここでのErrorはSwift.Error
// VoidとErrorがEqutableに準拠してないためここで==を実装してる
public static func == (lhs: ExampleAction, rhs: ExampleAction) -> Bool {
switch (lhs, rhs) {
case (.onAppear, .onAppear),
(.request, .request),
(.response, .response):
return true
case (.request, _),
(.onAppear, _),
(.response, _):
return false
}
}
}
...
はじめに結論を書いておく
- ClientのSwift.ErrorはReducerでは
mapError { $0 as NSError }
でNSErrorに変換する -
Result<Void, Error>
の場合に結果を気にしないならfireAndForget
してる- もし成功かどうか知りたければVoidを避ける(サンプルにはない)
-
Effect<Error?, Never>
というやり方もある
fetchGamesForWord
メソッドの例
アプリ内のDBをいじってそうなLocalDatabaseClientから fetchGamesForWord
メソッドを例にあげます。
Swift.Errorを使わずNSErrorにする
ここではfetchGamesForWord
メソッド、これは成功したら配列を、失敗したらSwift.Errorを返してる。
...
public struct LocalDatabaseClient {
public var fetchGamesForWord: (String) -> Effect<[LocalDatabaseClient.Game], Error>
...
まあ順当ですよね。
細かく実装を見てみるとdb.fetchGames(for:)
という例外を出せるメソッドに対してEffect.catchしてEffectに変換しつつインタフェースをあわせています。
...
extension LocalDatabaseClient {
public static func live(path: URL) -> Self {
var _db: Sqlite!
func db() throws -> Sqlite {
if _db == nil {
try! FileManager.default.createDirectory(
at: path.deletingLastPathComponent(), withIntermediateDirectories: true
)
_db = try Sqlite(path: path.absoluteString)
}
return _db
}
return Self(
fetchGamesForWord: { word in .catching { try db().fetchGames(for: word) } },
...
DBにアクセスするオブジェクトがあり、それはClient利用時にEffectに変換されます。
次にfetchGamesForWord
メソッドの利用箇所を見てみます。
...
case let .wordTapped(word):
return environment.database.fetchGamesForWord(word.letters)
.map { .init(games: $0, word: word.letters) }
.mapError { $0 as NSError }
.catchToEffect()
.map(VocabAction.gamesResponse)
}
...
なんとmapErrorでSwift.ErrorをNSErrorに変換しているのです。
つまりこの次に繋がるActionのVocabAction.gamesResponse
はcase gamesResponse(Result<VocabState.GamesResponse, NSError>)
となっておるわけです。
...
public enum VocabAction: Equatable {
case dismissCubePreview
case gamesResponse(Result<VocabState.GamesResponse, NSError>)
case onAppear
case preview(CubePreviewAction)
case vocabResponse(Result<LocalDatabaseClient.Vocab, NSError>)
case wordTapped(LocalDatabaseClient.Vocab.Word)
}
...
Swift.ErrorにしてしまうとEquatableに準拠してないので、enumのVocabActionではstatic func == (lhs: Action, rhs: Action) -> Bool
を自前で実装しないといけなくなり面倒だからですね。
saveGame
の例
同じくLocalDatabaseClientから、saveGames
を例にあげます。
...
public struct LocalDatabaseClient {
...
public var saveGame: (CompletedGame) -> Effect<Void, Error>
...
先程の説明でErrorはNSErrorにすればいいのでどうでもいいですが、Equatableに準拠することができないVoidを使ってるのはどうするんでしょうか。
利用箇所を見るとfireAndForgetしてるだけでmapErrorもしません。
...
let saveGameEffect: Effect<GameAction, Never> = environment.database
.saveGame(.init(gameState: self))
.fireAndForget()
...
これの意図としては
- Voidなんだったらその結果を気にしない
- そもそも成功したあとになにかするActionが必要ない
- 失敗してもなにかするActionがない
たしかにリリースしてるアプリでsaveに失敗したらっていうことを考えても復帰方法はないですわな。だから割り切って何もしてない。やるとしたらhandleEvents
でクラッシュログに送るとかかな。
Effect<Error?, Never>
で成功したらnil, 失敗したらErrorを返す
失敗をハンドリングしたいが成功した場合の戻り値が必要ないようなときの例がTurnBasedMatchClientにあった。
public struct TurnBasedMatchClient {
public var endMatchInTurn: (EndMatchInTurnRequest) -> Effect<Void, Error>
public var endTurn: (EndTurnRequest) -> Effect<Void, Error>
public var load: (TurnBasedMatch.Id) -> Effect<TurnBasedMatch, Error>
public var loadMatches: () -> Effect<[TurnBasedMatch], Error>
public var participantQuitInTurn:
(TurnBasedMatch.Id, Data)
-> Effect<Error?, Never>
public var participantQuitOutOfTurn:
(TurnBasedMatch.Id)
-> Effect<Error?, Never>
たしかに、これならActionには成功時にErrorがnilで、失敗時にErrorがあるというコードになる。ただ、success側にErrorがくるのでReducerのコードを見たときの驚きは結構あるでしょうね。
(実際にこのメソッドをReducerで使ってる部分はない)
考察
もし次のActionにつなげるときはVoidではなく成功したというSuccess型を考える
もし、次のActionにつなげたいときかつErrorが必要なら、VoidではなくBoolにして、失敗したらfaslseにするってことでしょうかね。そうするとErrorを送れないのがやっかい。Boolよりも成功を示す専用の型をつくるのが良さそうです。というかそもそもVoidがカラのタプルであるからEqutableに準拠できないのが一番厄介のように思えます。
なのでSuccess型を考えてみる。
// 成功したらSuccess、失敗したらError
public var foo: ()-> Effect<Success, Error>
// Successは内容がない
struct Success: Hashable {}
これでもいけるような気がする。
Helperを使う
Reducerから~Clientを使わずHelperを使って処理してるコードがありました。
やってることは
- 引数
- TurnBasedMatchとGameCenterClientを引数に
- 処理
- Clientからplayer取り出し
- matchから比較
- Clientの処理を切り替える
Reducerでできそうな処理ですが、Stateを変更するわけではないのでReducerで行わず副作用を切り替えています。