Open11

pointfreeco/isowords のコードリーディング

yimajoyimajo

pointfreeさんのTCAを利用したisowordsアプリのコードが公開されているのでそれのコードについて書いておきます。

https://github.com/pointfreeco/isowords

追記

2023/5/3現在、isowordsはisowords.workspaceを取り除いてApp/isowords.xcodeprojからXcodeを利用するようにしています。(そのせいか私の環境だとXcode 14でも13でもビルドできなくなってる)

https://github.com/pointfreeco/isowords/pull/122

以下の記事はかなり古い内容なので現状と変わっています。

yimajoyimajo

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で利用する場合のデメリットでもあります。

他にもいくつものデメリットがあり公式で説明されています。

https://www.pointfree.co/episodes/ep142-a-tour-of-isowords-part-1

yimajoyimajo

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から分離して、他で使えるようにしている印象。

yimajoyimajo

ユーザ情報

APIClient.currentPlayerプロパティでアクセスできるようにしてる

https://github.com/pointfreeco/isowords/blob/a107f2b8611e4bf6f0b6cabe16b77ac25a6767f9/Sources/ApiClientLive/Live.swift#L35-L44

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
  }
}

yimajoyimajo

WithViewStoreを使わずにinitでViewStoreに変換する

SwiftUIでもWithViewStoreを使わずにinitでViewStoreに変換している箇所がある。

https://github.com/pointfreeco/isowords/blob/1b0c5441a8cc32a03cdc7d29983b1267f2ba9fd4/Sources/OnboardingFeature/OnboardingStepView.swift#L12

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
yimajoyimajo

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)) }
    )
yimajoyimajo

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()
  }
}
yimajoyimajo

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:),
                )
yimajoyimajo

UserDefaultsから取得するのも副作用としたらEffect使うの?

UserDefaultsでは同期的に即取得できるものは副作用であってもそのまま返しています。戻り値を必要としない保存に対してはEffect<Never, Never>

Effectを使わない例

Effectを使わず即返している例を示す。

UserDefaultsClient

hasShownFirstLaunchOnboarding: BoolというプロパティがUserDefultsClientにあり、これはEffectではないです。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/UserDefaultsClient/Interface.swift#L15-L17

使っているコードとしてはReducerで即時取得しています。

https://github.com/pointfreeco/isowords/blob/244925184babddd477d637bdc216fb34d1d8f88d/Sources/AppFeature/AppView.swift#L218

これのsetterとしてはEffect<Never, Never>を使っています

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/UserDefaultsClient/Interface.swift#L19-L21

まあ、副作用の実行した戻り値をVoidにしたくないからEffectにした、という気持ちはわかります。

DBから取得するものはEffectを使ってるの?

結論から言うとDBから取得するものはすべてEffectにしている。

Effectを使ってる例

即返せるからと言って即返さずEffectを使っている例を示す。

LocalDatabaseClient

public var playedGamesCount: (GameContext) -> Effect<Int, Error>プロパティはクロージャでEffectを返している

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/LocalDatabaseClient/Interface.swift#L9

実装としては内部で即返せるにも関わらずEffectにしている。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/LocalDatabaseClient/Live.swift#L23-L25

即返せるのは下記playedGamesCountを見ればわかる。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/LocalDatabaseClient/Live.swift#L228-L248

DBに関するものは即返せるがあえてEffectにしているのだと考えられる。

yimajoyimajo

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にする

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/LocalDatabaseClient/Interface.swift#L5

ここでは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メソッドの利用箇所を見てみます。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/VocabFeature/Vocab.swift#L136-L142

...
    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.gamesResponsecase 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を例にあげます。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/LocalDatabaseClient/Interface.swift#L10

...

public struct LocalDatabaseClient {
...
  public var saveGame: (CompletedGame) -> Effect<Void, Error>
...

先程の説明でErrorはNSErrorにすればいいのでどうでもいいですが、Equatableに準拠することができないVoidを使ってるのはどうするんでしょうか。

利用箇所を見るとfireAndForgetしてるだけでmapErrorもしません。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/GameCore/GameCore.swift#L741-L743

...
    let saveGameEffect: Effect<GameAction, Never> = environment.database
      .saveGame(.init(gameState: self))
      .fireAndForget()
...

これの意図としては

  • Voidなんだったらその結果を気にしない
  • そもそも成功したあとになにかするActionが必要ない
  • 失敗してもなにかするActionがない

たしかにリリースしてるアプリでsaveに失敗したらっていうことを考えても復帰方法はないですわな。だから割り切って何もしてない。やるとしたらhandleEventsでクラッシュログに送るとかかな。

Effect<Error?, Never>で成功したらnil, 失敗したらErrorを返す

失敗をハンドリングしたいが成功した場合の戻り値が必要ないようなときの例がTurnBasedMatchClientにあった。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/ComposableGameCenter/Interface.swift#L75-L85

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 {}

これでもいけるような気がする。

yimajoyimajo

Helperを使う

Reducerから~Clientを使わずHelperを使って処理してるコードがありました。

https://github.com/pointfreeco/isowords/blob/fcff52ccf176f40db55951d5897def7cd9b879cf/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift

やってることは

  • 引数
    • TurnBasedMatchとGameCenterClientを引数に
  • 処理
    • Clientからplayer取り出し
    • matchから比較
    • Clientの処理を切り替える

Reducerでできそうな処理ですが、Stateを変更するわけではないのでReducerで行わず副作用を切り替えています。