Open
8

pointfreeco/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プロパティでアクセスできるようにしてる

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

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

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:),
                )
ログインするとコメントできます