Closed7

A Tour of isowords: Part 1 を読む

アイカワアイカワ

まずは https://github.com/pointfreeco/isowords をクローンし、xcworkspace を開く

workspace を開いて一番最初に気づくことはルートディレクトリには README 以外に二つのディレクトリしか含まれていないということ(isowords Xcode Project と isowords Swift Package)

isowords Xcode Project にはメインの iOS クライアントターゲット、AppClip ターゲット、Preview ターゲット(Xcode Previews ではないっぽい)が格納されている

しかし、これらのターゲットには「実際の」コードが含まれていないのが isowords のプロジェクト構成の特徴である
これらのターゲットは、ターゲットのエントリーポイントとして機能し、アプリケーションを起動するための View を構築するだけの役割である

実際に、Xcode Project 全体では10個のアプリターゲットに700行の Swift コードが含まれているだけである

では、「実際の」コードはどこに入っているかというと、全て isowords SPM Package に含まれている

現在時点でこの Package には iOS クライアントとサーバーの両方に 91 のモジュールを持ち、約5万行のコードを含んでいる

Package.swift はコード量が多く、少し奇妙な構造になっているが、iOS 用にのみビルドされるライブラリ、Mac や Linux 用にのみビルドされるライブラリ、両方にビルドされるライブラリを一つの Package.swift でサポートしようとしているためにこのような構造になっている

極端に見えるが、この構成には以下のような利点がある

  • モジュールのほとんどは依存関係が非常に少ないため、非常に素早くビルドすることができる
  • モジュールが小さければ小さいほど、SwiftUI のプレビューやシンタックスハイライトなどのビルドツールの動作が良くなる
  • AppClip Target からは必要なモジュールのみを import する形で利用できるため、簡単に App Clip を実現できるようになる(App Clip には非圧縮で 10MB のサイズ制限があるため)
  • XcodeGen などのツールを利用せずにプロジェクトファイルのコンフリクトから逃れることができる
    • わかりやすい Package.swift ファイルの変更を確認したり、共有されている Xcode Scheme の変更を確認するだけで済むようになる

自分のプロジェクトや将来のプロジェクトでこのようなことを試したい場合、アプリのディレクトリのどこかで SPM Package を初期化し、Xcode のワークスペースを作成して、SPM Package と Xcode プロジェクトをワークスペースにドラッグするだけで準備を始めることができる

アイカワアイカワ

しかし、SPM を利用した構成のすべてが完璧というわけではなく、もちろんいくつか注意点が存在する

  • Xcode が各 SPM モジュールの Scheme を自動的に生成するためには、Package.swift のトップレベルには library として記述し、その下では target として記述しなければならない(この重複には理由があると思うが、ちょっと残念と述べられている)
Package.swift
var package = Package(
  name: "isowords",
  platforms: [
    .macOS(.v10_15),
    .iOS(.v14),
  ],
  products: [ // ここに library として記述した上で、
    .library(name: "Build", targets: ["Build"]),
    .library(name: "DictionaryClient", targets: ["DictionaryClient"]),
    // ...
  ],
  dependencies: [
    // ...
  ],
  targets: [ // ここに target として記述しなければならない
    .target(
      name: "Build",
      dependencies: [
        // ...
      ]
    ),
    // ...
    .target(
      name: "DictionaryClient",
      dependencies: [
        // ...
      ]
    ),
    // ...
  • Test ターゲットを作成する際には、手動での管理が必要になる
    • Test ターゲットの作成自体は簡単で、↓ のように Package.swift に新しい .testTarget 行を追加するだけで SPM がそのターゲットを作成してくれる
Package.swift
    .target(
      name: "ServerRouter",
      dependencies: [
        "SharedModels",
        .product(name: "ApplicativeRouter", package: "Web"),
        .product(name: "Tagged", package: "swift-tagged"),
        .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
      ]
    ),
    .testTarget(
      name: "ServerRouterTests",
      dependencies: [
        "FirstPartyMocks",
        "ServerRouter",
        "TestHelpers",
        .product(name: "Overture", package: "Overture"),
      ]
    ),

しかし、例えばメインのターゲットで作業を行っていて、cmd+U を押した時に対応するテストを実行できるようにしたい場合は個別に Scheme を開き、明示的にテストターゲットを追加する必要がある(SPM は必要な情報を全て持っているようなので、これを自動的に将来行ってくれるようになるかもしれない)

  • iOS専用モジュール, Mac/Linux 専用モジュール、共有モジュールを開発するためには技術的には少なくとも3つの Package に分割する必要がある
    • SPM はビルド可能なプラットフォームをターゲットレベルではなくパッケージレベルで指定するため
    • 3つの Package を管理するとなると、ディレクトリ構造が増えてしまうため少し面倒である
    • さらに、isowords の場合、iOS とサーバーのコードの両方を同時に実行する統合テストを行っているため、統合テストのためだけに4つ目のパッケージが必要になってしまう
    • isowords では、そのような手間をかける価値はないと判断し、SPM の流れに逆らって全てを1つの Package に収め、環境変数を使って Package にターゲットを選択的に追加する方法を選択している
Package.swift
if ProcessInfo.processInfo.environment["TEST_SERVER"] == nil {
  // ...
}
  • 上記の続き
    • 全てを1つの Package に収め、プラットフォームを Package レベルで指定しているため、技術的にはサポートされていないプラットフォームでもターゲットをビルドすることができてしまう
  • SPM の Package をプロジェクトのルートに置いておくと、Xcode で予期しないものが露出してしまう危険性がある
    • 例えば、ルートに単純にディレクトリを作成すると Xcode のサイドバーに表示されてしまう
    • これは望んでいないことで、isowords パッケージに表示させたいものは Sources と Tests だけである
    • しかし、ディレクトリ自体には App, Assets, Bootstrap のディレクトリがあるが、これらのディレクトリは Xcode のサイドバーに表示されていない
    • これを実現するには、Package.swift のスタブをサブディレクトリにドロップすることで、Xcode にそのディレクトリを表示しないように指示することができる


Xcode のサイドバーに表示されてしまう現象


Package.swift のスタブが格納されている

Package.swift のスタブの中身
// swift-tools-version:5.2

// Leave blank. This is only here so that Xcode doesn't display it.

import PackageDescription

let package = Package(
  name: "client",
  products: [],
  targets: []
)
  • 何らかの理由で、おそらく Xcode が SPM Package と Xcode Project をどのように検出するかに関連して、isowords workspace は「最近の」プロジェクトとしてピックアップされないため、毎回手動で開く必要がある
  • (SPM で管理できないものを利用する場合は考える必要もありそう -> https://twitter.com/d_date/status/1374680205327966213?s=20)

将来的にはもっと良くなるだろうし、SPM でのセットアップには非常に満足しているとのこと

アイカワアイカワ

isowords を simulator 用にビルドしようとするとエラーが発生する

🛑 Type ‘Bundle’ has no member ‘module’

これはオーディオなどのように Repository に保存するには大きすぎるリソースや、フォントなどライセンスの関係で非公開にしなければならないリソースがあるために起こっている

この問題を解消するための bootstrap が Makefile で用意されているので実行する(Makefile は結構膨大なので参考になりそう)

$ make bootstrap-client

  ⚠️ Checking for Git LFS...
  ✅ Git LFS is good to go!

これでアプリ自体はビルドできる

アイカワアイカワ

ここからはコードを見ていく

App.swift には AppDelegate が存在しており、そこにはアプリケーション全体を動かすための Root Store が存在している

final class AppDelegate: NSObject, UIApplicationDelegate {
  let store = Store(
    initialState: .init(),
    reducer: appReducer,
    environment: .live
  )

  ...
}

Delegate の各メソッドは以下のようになっている

func application(
  _ application: UIApplication,
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
  self.viewStore.send(.appDelegate(.didFinishLaunching))
  return true
}

func application(
  _ application: UIApplication,
  didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
  self.viewStore.send(.appDelegate(.didRegisterForRemoteNotifications(.success(deviceToken))))
}

func application(
  _ application: UIApplication,
  didFailToRegisterForRemoteNotificationsWithError error: Error
) {
  self.viewStore.send(
    .appDelegate(.didRegisterForRemoteNotifications(.failure(error as NSError)))
  )
}

一貫して viewStore を利用していることがわかる

そして、以下はアプリの実際のエントリーポイントである

@main
struct IsowordsApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
  @Environment(\.scenePhase) private var scenePhase

  init() {
    Styleguide.registerFonts()
  }

  var body: some Scene {
    WindowGroup {
      AppView(store: self.appDelegate.store)
    }
    .onChange(of: self.scenePhase) {
      self.appDelegate.viewStore.send(.didChangeScenePhase($0))
    }
  }
}

複雑なことは一切しておらず、AppView を呼び出しているだけである

AppEnvironment の live 実装も同ファイルに定義されている

extension AppEnvironment {
  static var live: Self {
    ...
  }
}
アイカワアイカワ

AppView について見ていく

まずは State

public struct AppState: Equatable {
  public var game: GameState?
  public var onboarding: OnboardingState?
  public var home: HomeState

  ...
}

Root には三つの主な仕事しかないため、主要な State 三つが存在している

  • GameState: RootV で Game Modal の起動と終了を担当するために必要としている
  • OnboardingState: 最初の起動時にオンボーディングを表示するために必要としている
  • HomeState: Root で常に表示する必要があるため必要としている

AppAction も同じファイルに定義されている

public enum AppAction: Equatable {
  case appDelegate(AppDelegateAction)
  case currentGame(GameFeatureAction)
  case didChangeScenePhase(ScenePhase)
  case gameCenter(GameCenterAction)
  case home(HomeAction)
  case onboarding(OnboardingAction)
  case paymentTransaction(StoreKitClient.PaymentTransactionObserverEvent)
  case savedGamesLoaded(Result<SavedGamesState, NSError>)
  case verifyReceiptResponse(Result<ReceiptFinalizationEnvelope, NSError>)
}

↑ の通り、いくつかの Action をネストする形で定義されている

TCA の重要な要素である Environment は非常に長いため別ファイルで定義されている

public struct AppEnvironment {
  public var apiClient: ApiClient
  public var applicationClient: UIApplicationClient
  public var audioPlayer: AudioPlayerClient
  public var backgroundQueue: AnySchedulerOf<DispatchQueue>
  public var build: Build
  public var database: LocalDatabaseClient
  public var deviceId: DeviceIdentifier
  public var dictionary: DictionaryClient
  public var feedbackGenerator: FeedbackGeneratorClient
  public var fileClient: FileClient
  public var gameCenter: GameCenterClient
  public var lowPowerMode: LowPowerModeClient
  public var mainQueue: AnySchedulerOf<DispatchQueue>
  public var mainRunLoop: AnySchedulerOf<RunLoop>
  public var remoteNotifications: RemoteNotificationsClient
  public var serverConfig: ServerConfigClient
  public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect<Never, Never>
  public var storeKit: StoreKitClient
  public var timeZone: () -> TimeZone
  public var userDefaults: UserDefaultsClient
  public var userNotifications: UserNotificationClient
}

AppEnvironment はアプリに必要な依存関係を全て保持するだけではなく、.failingnoop インスタンスを保持している

public static let failing = Self(
  apiClient: .failing,
  applicationClient: .failing,
  audioPlayer: .failing,
  backgroundQueue: .failing("backgroundQueue"),
  build: .failing,
  database: .failing,
  deviceId: .failing,
  dictionary: .failing,
  feedbackGenerator: .failing,
  fileClient: .failing,
  gameCenter: .failing,
  lowPowerMode: .failing,
  mainQueue: .failing("mainQueue"),
  mainRunLoop: .failing("mainRunLoop"),
  remoteNotifications: .failing,
  serverConfig: .failing,
  setUserInterfaceStyle: { _ in
    .failing("\(Self.self).setUserInterfaceStyle is unimplemented")
  },
  storeKit: .failing,
  timeZone: {
    XCTFail("\(Self.self).timeZone is unimplemented")
    return TimeZone(secondsFromGMT: 0)!
  },
  userDefaults: .failing,
  userNotifications: .failing
)

public static let noop = Self(
  apiClient: .noop,
  applicationClient: .noop,
  audioPlayer: .noop,
  backgroundQueue: DispatchQueue.main.eraseToAnyScheduler(),
  build: .noop,
  database: .noop,
  deviceId: .noop,
  dictionary: .everyString,
  feedbackGenerator: .noop,
  fileClient: .noop,
  gameCenter: .noop,
  lowPowerMode: .false,
  mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
  mainRunLoop: RunLoop.main.eraseToAnyScheduler(),
  remoteNotifications: .noop,
  serverConfig: .noop,
  setUserInterfaceStyle: { _ in .none },
  storeKit: .noop,
  timeZone: { .autoupdatingCurrent },
  userDefaults: .noop,
  userNotifications: .noop
)

これらはより簡潔なテストを書くために使われたり、SwiftUI のプレビューや実際にアプリケーションロジックのためにも使われている

appReducer も定義されているが、appReducer 自体に大きなロジックがあるわけではなく、いくつかの Reducer を combine によって結合する形で作られている

public let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  ...
)

View の表示は viewStore の状態によって決められている

if !self.viewStore.isOnboardingPresented && !self.viewStore.isGameActive {
  NavigationView {
    HomeView(store: self.store.scope(state: \.home, action: AppAction.home))
  }
  .navigationViewStyle(StackNavigationViewStyle())
  .zIndex(0)
}

続く else 文では、IfLetStore を利用して State が Optional かどうかに応じて表示する View を決定している

} else {
  IfLetStore(
    self.store.scope(
      state: { appState in
        appState.game.map {
          (
            game: $0,
            nub: nil,
            settings: .init(
              enableCubeShadow: appState.home.settings.enableCubeShadow,
              enableGyroMotion: appState.home.settings.userSettings.enableGyroMotion,
              showSceneStatistics: appState.home.settings.showSceneStatistics
            )
          )
        }
      }
    ),
    then: { gameAndSettingsStore in
      GameFeatureView(
        content: CubeView(
          store: gameAndSettingsStore.scope(
            state: CubeSceneView.ViewState.init(game:nub:settings:),
            action: { .currentGame(.game(CubeSceneView.ViewAction.to(gameAction: $0))) }
          )
        ),
        store: self.store.scope(state: \.currentGame, action: AppAction.currentGame)
      )
    }
  )
  .transition(.game)
  .zIndex(1)

  IfLetStore(
    self.store.scope(state: \.onboarding, action: AppAction.onboarding),
    then: OnboardingView.init(store:)
  )
  .zIndex(2)
}

今後の Point-Free のエピソードでは画面遷移(Navigation)についても多く話す予定らしい

アイカワアイカワ

これ以降は TCA を利用した画面の作り方の例やテストの書き方が書かれているくらいで、_pullback (異なるエピソードで説明される予定らしい)以外は目立ったものがなかったので、ここで終了

このスクラップは2021/05/03にクローズされました