pointfreeco/isowords のコードリーディング
pointfreeさんのTCAを利用したisowordsアプリのコードが公開されているのでそれのコードについて書いておきます。
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:),
)