Closed12

A Tour of isowords: Part 2 を読む

アイカワアイカワ

オンボーディング機能は重要だが、アプリにアクセスした時一番最初に表示されるものでもあるため、アプリのコア機能にオンボーディング機能を含めたくなる

そうなってくると、オンボーディングに特化した余分な State を追加したり、View に追加のロジックをちりばめたりなどアプリのコア機能に関係ないものを追加していく羽目になってしまう

しかし isowords ではオンボーディング機能をゲームのコア機能に一行もコードを追加することなく実装することができている

実際にそのオンボーディングがどのように実現されているかについてみていく

アイカワアイカワ

Onboarding design

まずオンボーディング機能のコードの大半が存在する OnboardingView.swift に移動し、ハイパーモジュール化を利用してアクティブターゲットを OnboardingFeature に切り替えることによって、オンボーディングに必要なものだけをビルドすることができる(アプリターゲット全体をビルドする必要はもうない)

まず最初に目に入るのは State

public struct OnboardingState: Equatable {
  // プレーヤーがオンボーディングをスキップしようとした時念のために出すアラートのための変数
  public var alert: AlertState<OnboardingAction.AlertAction>?
  public var game: GameState
  // オンボーディングをどのように表示するかを決める変数
  public var presentationStyle: PresentationStyle
  // フローの全てのステップを記述した大きな enum
  public var step: Step
}

public enum PresentationStyle {
  // App Clip からオンボーディングを表示する時に使用する
  case demo
  // メインアプリを初めて起動した時にオンボーディングを表示する時に使用する
  case firstLaunch
  // ホーム画面からクエスチョンマークのアイコンをタップした時に使用する
  case help
}

OnboardingStateGameState を利用しつつ、少しだけ独自の State を追加していることを明確に示している
これらの追加の State が GameState に感染しないようにしたことが重要で、これによってゲームのコア機能にオンボーディング独自の機能が含まれることを防ぐことができる

アイカワアイカワ

OnboardingActionOnboardingEnvironment は以下のようになっている

public enum OnboardingAction: Equatable {
  case alert(AlertAction)
  case delayedNextStep
  case delegate(DelegateAction)
  case game(GameAction)
  case getStartedButtonTapped
  case onAppear
  case nextButtonTapped
  case skipButtonTapped

  public enum AlertAction: Equatable {
    case confirmSkipButtonTapped
    case dismiss
    case resumeButtonTapped
    case skipButtonTapped
  }

  public enum DelegateAction {
    case getStarted
  }
}
public struct OnboardingEnvironment {
  var audioPlayer: AudioPlayerClient
  var backgroundQueue: AnySchedulerOf<DispatchQueue>
  var dictionary: DictionaryClient
  var feedbackGenerator: FeedbackGeneratorClient
  var lowPowerMode: LowPowerModeClient
  var mainQueue: AnySchedulerOf<DispatchQueue>
  var mainRunLoop: AnySchedulerOf<RunLoop>
  var userDefaults: UserDefaultsClient

  // ...
  var gameEnvironment: GameEnvironment {
    GameEnvironment(
      apiClient: .noop,
      applicationClient: .noop,
      audioPlayer: self.audioPlayer,
      backgroundQueue: self.backgroundQueue,
      build: .noop,
      database: .noop,
      dictionary: self.dictionary,
      feedbackGenerator: self.feedbackGenerator,
      fileClient: .noop,
      gameCenter: .noop,
      lowPowerMode: self.lowPowerMode,
      mainQueue: self.mainQueue,
      mainRunLoop: self.mainRunLoop,
      remoteNotifications: .noop,
      serverConfig: .noop,
      setUserInterfaceStyle: { _ in .none },
      storeKit: .noop,
      userDefaults: self.userDefaults,
      userNotifications: .noop
    )
  }
}

OnboardingEnvironment には興味深い computed property がある
これは OnboardingEnvironment から完全な GameEnvironment を派生させるもので、Game は本当は多くの依存を必要とするが Onboarding の場合はそんなに多くの依存は必要ないため、Onboarding 用の GameEnvironment を派生させている

例えば、オンボーディングはサーバーとの通信を必要としないため、apiClient は必要ないし、gameCenter やその他諸々も必要ない
Point-Free では、最近検討した依存関係の軽量な「no-op」を作成することによって、不要な依存関係を全て埋める仕組みを実現している

(例えば APIClient の noop は以下のように定義されている)

extension ApiClient {
  public static let noop = Self(
    apiRequest: { _ in .none },
    authenticate: { _ in .none },
    baseUrl: { URL(string: "/")! },
    currentPlayer: { nil },
    logout: { .none },
    refreshCurrentPlayer: { .none },
    request: { _ in .none },
    setBaseUrl: { _ in .none }
  )
}

これらの .noop は基本的に何もしない
何の値も出さない Effect を返却するだけですぐに完了するものになっている
.noop は何かを提供しなければならない場面で、不必要な依存性を注入するのに最適である

アイカワアイカワ

次に Reducer を少しずつ見ていく

public let onboardingReducer = Reducer<
  OnboardingState,
  OnboardingAction,
  OnboardingEnvironment
> { state, action, environment in
  ...
}

この Reducer が処理する Action の多くは非常に単純なものである
例えば、オンボーディングをスキップしたいことをアラートで確認した際に、オンボーディングを最後のステップに移動させるなどの処理を行う

case .alert(.confirmSkipButtonTapped):
  state.step = OnboardingState.Step.allCases.last!
  return .none

また Next ボタンがタップされると、次のステップに移動しつつちょっとした効果音が流れるようにしている

case .nextButtonTapped:
  state.step.next()
  return environment.audioPlayer.play(.uiSfxTap)
    .fireAndForget()

Reducer の中には .game の Action を利用している部分もある
これにより、ゲームドメインの内部を覗き、何が起きているかを全てオンボーディングから把握することができるようになる

これにより、入ってくる Action を監視し、通過させるかフィルタリングするかを決めたり、コアとなる GameReducer の実行前後に追加のロジックを重ねたりすることができる

例えば、GameAction が最初にキャッチされるのは「congrats」ステップにいるかどうかをチェックする部分である

case .game where state.step.isCongratsStep:
  return .none

「congrats」ステップは isowords のゲーム中でプレーヤーが単語を見つけるなどのタスクを達成した直後に起こるものである
数秒間メッセージが表示された後、自動的に次のステップに進むものになっている

↑ では全てのゲームロジックを完全にショートさせ、「congrats」ステップにいるときは何の Effect も偏客しないことでゲームキューブ全体を事実上不活性化している
キューブをいくらタップやスワイプしても何も起こらないようになっている

ゲームのロジックを丸ごとスキップするのではなく、オンボーディングの時は少しだけ補強したいようなことがある
例えば、投稿された単語が「GAME」「CUBES」「REMOVE」などの単語であれば次のステップに進めるようにしている

case .game(.submitButtonTapped):
  switch state.step {
  case .step5_Submit where state.game.selectedWordString == "GAME",
    .step8_FindCubes where state.game.selectedWordString == "CUBES",
    .step12_CubeIsShaking where state.game.selectedWordString.isRemove,
    .step16_FindAnyWord where environment.dictionary.contains(state.game.selectedWordString, .en):

    state.step.next()

    return onboardingGameReducer.run(
      &state,
      .game(.submitButtonTapped(nil)),
      environment
    )

↑ では最初にちょっとしたロジックを実行してステップを進め、その後通常通り GameReducer を実行して、キューブの状態を更新したりサウンドエフェクトを再生したりしている

このテクニックで特定の GameAction をリッスンし、追加のロジックを実行するという ↑ のようなシンプルなこともできるが、より高度なテクニックについて見ていく
Game ドメインの状態の変化をリッスンして、新しいロジックのトリガーにすることもできる例を見る

例えば、オンボーディングのフローでは「GAME」という文字を選択すると自動的に1ステップ進む
そして、文字を選択解除すると自動的に1ステップ後ろに移動する

これを実現する方法は、選択された単語の状態変化をリッスンし、特定のステップにいて特定の単語を選択した場合には前進させ、特定の単語を解除した場合には後退させるというものになる

Point-Free はこれを実現するために Reducer の実験的な演算子である .onChange を使用している
.onChange は状態のある部分が変化した時にいくつかの追加ロジックや Effect を実行することができるものであり、isowords では以下のように利用されている

.onChange(of: \.game.selectedWordString) { selectedWord, state, _, _ in
  switch state.step {
  case .step4_FindGame where selectedWord == "GAME",
    .step11_FindRemove where selectedWord.isRemove:
    state.step.next()
    return .none
  case .step5_Submit where selectedWord != "GAME",
    .step12_CubeIsShaking where !selectedWord.isRemove:
    state.step.previous()
    return .none
  default:
    return .none
  }
}

現在は .onChange は isowords のコードベースのみに存在しているが、いずれは誰もが使えるように TCA でサポートしたいと考えているとのこと(それまでの間もし便利だったらコピペで利用してくださいとのこと)

アイカワアイカワ

オンボーディングの最後は View だが、これはとても短い
CubeView を ZStack するだけで、各ステップの説明が表示され、右上にはスキップボタンが表示されるだけのものになる

public var body: some View {
  ZStack(alignment: .topTrailing) {
    CubeView(
      store: self.store.scope(
        state: cubeSceneViewState(onboardingState:),
        action: { .game(CubeSceneView.ViewAction.to(gameAction: $0)) }
      )
    )
    .opacity(viewStore.step.isFullscreen ? 0 : 1)

    OnboardingStepView(store: self.store)

    if viewStore.isSkipButtonVisible {
      Button("Skip") { viewStore.send(.skipButtonTapped, animation: .default) }
        .adaptiveFont(.matterMedium, size: 18)
        .buttonStyle(PlainButtonStyle())
        .padding([.leading, .trailing])
        .foregroundColor(
          self.colorScheme == .dark
            ? viewStore.step.color
            : Color.isowordsBlack
        )
    }
  }
}

オンボーディングには基本的にこれだけの機能しかない

しかしたった500行のコードでこの機能全体を構築することができているし、ゲームのコアコードには何も手を加えることなく機能を実現することができている(素晴らしい)

アイカワアイカワ

Onboarding tests

記事では次に OnboardingFeatureTests について説明されている
オンボーディングのコードをコア機能に感染させることなく実現しただけでなく、OnboardingFeature の全体を OnboardingFeatureTests でテストできていることについてある程度詳しく解説されている

とは言え、TCA の通常のテストコードと大きく変わる部分はなく以下のようにテストが記述されている

  • 実現したい状況に合わせて Environment を設定
  • テスト用の TestStore を作成
  • TestStore に Action を send しつつ、期待の状態になっているかどうかをテスト

ただ、「noop」と似たような failing が扱われていることは特徴であると感じた

extension OnboardingEnvironment {
  static let failing = Self(
    audioPlayer: .failing,
    backgroundQueue: .failing("backgroundQueue"),
    dictionary: .failing,
    feedbackGenerator: .failing,
    lowPowerMode: .failing,
    mainQueue: .failing("mainQueue"),
    mainRunLoop: .failing,
    userDefaults: .failing
  )
}

#if DEBUG
  import XCTestDynamicOverlay

  extension AudioPlayerClient {
    public static let failing = Self(
      load: { _ in .failing("\(Self.self).load is unimplemented") },
      loop: { _ in .failing("\(Self.self).loop is unimplemented") },
      play: { _ in .failing("\(Self.self).play is unimplemented") },
      secondaryAudioShouldBeSilencedHint: {
        XCTFail("\(Self.self).secondaryAudioShouldBeSilencedHint is unimplemented")
        return false
      },
      setGlobalVolumeForMusic: { _ in
        .failing("\(Self.self).setGlobalVolumeForMusic is unimplemented")
      },
      setGlobalVolumeForSoundEffects: { _ in
        .failing("\(Self.self).setGlobalVolumeForSoundEffects is unimplemented")
      },
      setVolume: { _, _ in .failing("\(Self.self).setVolume is unimplemented") },
      stop: { _ in .failing("\(Self.self).stop is unimplemented") }
    )
  }
#endif

.failing は ↑ のように定義されていて、OnboardingFeatureTests では以下のように扱われている

    var environment = OnboardingEnvironment.failing
    environment.audioPlayer = .noop
    environment.backgroundQueue = .immediate
    environment.dictionary.load = { _ in true }
    environment.dictionary.contains = { word, _ in
      ["GAME", "CUBES", "REMOVE", "WORD"].contains(word)
    }
    environment.feedbackGenerator = .noop
    environment.mainRunLoop = .immediate
    environment.mainQueue = self.mainQueue.eraseToAnyScheduler()
    environment.userDefaults.setBool = { value, key in
      .fireAndForget {
        XCTAssertEqual(key, "hasShownFirstLaunchOnboardingKey")
        XCTAssertEqual(value, true)
        isFirstLaunchOnboardingKeySet = true
      }
    }

一旦、failing を設定しておいてテストで関心がある部分のみ依存を設定し直している形になっている

.failing は実装を見るに XCTFail が実行されているため、テストで意図しない依存関係(Environment)が利用されることを防ぐ仕組みにしていると思われる

意図しない依存関係を呼び出していないかどうかのチェックにもなるので .failing は TCA のテストコードを書く上で役立ちそうだと感じた

アイカワアイカワ

App Clip modularization

オンボーディングと密接な関係である App Clip の実現方法について見ていく
これはアプリケーションをモジュール化し、依存性のある軽量なインターフェースを重量のある実装から分離することの重要性を示す機会となっている

isowords の App Clip はフルアプリケーションをインストールすることなく、3分間のデモパズルを遊べるようにしている

App Clip の作成は、コンパイルされたバイナリ全体が非圧縮で 10MB 以下でなければならないという事実を除いてはとても簡単である
そのため、デモに必要な最低限のコードだけをコンパイルし、不要なリソースを省くことに細心の注意を払う必要がある
isowords ではサウンドだけで 8MB ものリソースが扱われているため、これに少し苦労したとのこと

どうやって App Clip を実現したか、少しずつコードを実際に見ていく

AppClipApp.swift(AppClip のエントリーポイント)を見てみると以下のようになっている

@main
struct AppClipApp: App {
  init() {
    Styleguide.registerFonts()
  }

  var body: some Scene {
    WindowGroup {
      DemoView(
        store: Store(
          initialState: DemoState(),
          reducer: demoReducer,
          environment: .live
        )
      )
    }
  }
}

↑ はメインアプリのエントリーポイントとほとんど同じであることがわかる
AppClip の場合は DemoView と呼ばれるものを起動し、DemoState, demoReducer, DemoEnvironment を Store に与えることで作成している

live environment の実装は以下のようになっている

extension DemoEnvironment {
  static var live: Self {
    Self(
      // こちらについては後ほど見ていく
      apiClient: .appClip,
      applicationClient: .live,
      audioPlayer: .live(bundles: [AppClipAudioLibrary.bundle]),
      backgroundQueue: DispatchQueue(label: "background-queue").eraseToAnyScheduler(),
      build: .live,
      dictionary: .file(),
      feedbackGenerator: .live,
      lowPowerMode: .live,
      mainQueue: .main,
      mainRunLoop: .main,
      userDefaults: .live()
    )
  }
}

DemoView は DemoFeature モジュール内の View で、App Clip の全ての機能を提供するモジュールである
Package.swift 内の DemoFeature の定義を見てみると必要最低限のものだけに依存していることがわかる

Pakcage.swift
    .target(
      name: "DemoFeature",
      dependencies: [
        "ApiClient",
        "Build",
        "GameCore",
        "DictionaryClient",
        "FeedbackGeneratorClient",
        "LowPowerModeClient",
        "OnboardingFeature",
        "SharedModels",
        "UserDefaultsClient",
        .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
      ]
    ),

それぞれ依存しているモジュール(一部)の利用方法は以下のようになっている

  • ApiClient
    • ゲーム終了時にスコアをサーバーに送信する
  • DictionaryClient
    • 有効な単語と無効な単語を教えてくれる、辞書と対話するためのインターフェースを持っている。ただ実際には辞書を含んでいない
  • FeedbackGeneratorClient
    • Haptics を実行する
  • LowPowerModeClient
    • デバイスの低電力モードの変化を観察できる。低電力モードでは、ジャイロスコープやシャドウを無効にし、デバイスの寿命を少しでも延ばせるようにしている
  • UserDefaultsClient
    • UserDefautls のラッパー。様々な値を取得できる

これらの依存関係がどのように指定されているかの最も重要な部分は、依存関係のインターフェースのみを使用し、実際の実装は使用していないということである(Point-Free の "Designing Dependencies" と題した一連のエピソードで何度か話してきたこと)

isowords では、実際にそれを二つの方法で活用している

まず ApiClient モジュールには API クライアントのインターフェースのみが含まれており、ネットワークリクエストを実行するための実際のコードは含まれていない

通常ネットワークリクエストは URLSession を使用するが、isowords では実験的に invertible parser printer というライブラリを利用して、iOS アプリで使用する API クライアントと API を動かす server router を同時に動かしている

残念なことにこれらのコードはモジュール化されておらず、整然としていないため、必要以上にビルドされてしまいコードサイズを肥大化させてしまうらしい

そこで isowords では重いものは ApiClientLive モジュールに分離して、どうしても必要な時以外はビルドする必要がないようにしている

幸い、App Clip にはアクセスできるエンドポイントが一つだけ、つまりゲーム送信エンドポイントだけなので、重い実装は必要ない
これによって、App Clip の容量を 1MB 近く削ることができ、これが 10MB という制限を下回るための重要なポイントとなった

次に DictionaryClient には辞書にアクセスするためのインターフェースのみが含まれており、実際の辞書そのものは含まれていない
通常のアプリケーションでは、特定の機能のために、ある文字のセットが単語の接頭辞に一致するかどうかをチェックする効率的な方法が必要であるため、辞書に SQLite DB を使用している

しかし、この辞書用の SQLite ファイルはかなり大きく、5MB 以上ある
SQLite にはインデックスやその他のメタデータを詰め込む必要があるからであるが、これだと App Clip の容量のほとんどを食い尽くしてしまう

そこで isowords では SQLite 実装部分を DictionarySqliteClient という別のモジュールに格納し、DictionaryFileClient という別の辞書のライブ実装を用意している
DictionaryFileClient は単純な単語のフラットテキストファイルをベースにしており、圧縮すると 700KB ほどしかない

もし依存関係を適切にモデル化し、コードベースをモジュール化するための事前作業がなければ App Clip を作るのはさらにさらに難しかっただろうと Point-Free は確信している

アイカワアイカワ

Preview apps vs. Xcode previews

isowords ではアプリをモジュール化し、重いものと軽いものを分けたことで得られたメリットが App Clip 以外にもまだ存在している
それは Simulator やデバイス上で実装できるように、isowords の全ての機能のサブセットを構築した小さな「Preview」アプリを作成できていることである

全ての Preview アプリは App ディレクトリ内の Previews ディレクトリに格納されており、そこに全ての Target が格納されている
これらのターゲットには実際のコードは含まれておらず、アプリケーションを設定して起動するためのコードだけが含まれている

例として CubeCorePreview を見る
これは Cube を単独でレンダリングするために作ったプレビューで、ゲームを作った初期段階でデザインを試したり、描画コードを最適化しようとした時に重要だったらしい

実際に CubeCorePreviewApp.swift ファイルを覗いてみる

CubeCorePreviewApp.swift
@main
struct CubeCorePreviewApp: App {
  var body: some Scene {
    WindowGroup {
      CubeView(
        store: .init(
          initialState: .init(
            cubes: .mock,
            isOnLowPowerMode: false,
            nub: nil,
            playedWords: [],
            selectedFaceCount: 0,
            selectedWordIsValid: false,
            selectedWordString: "",
            settings: .init(showSceneStatistics: true)
          ),
          reducer: .empty,
          environment: ()
        )
      )
    }
  }
}

これはスタブのデータを Store に与えて CubeView を作成するだけの App になっている

cubes に与えられている .mock パズルは 3x3x3 のパズルの構築を処理し、影がどのように投影されるかを確認するためにいくつかのキューブを削除してある状態になっている
これをデバイスで実行するとゲームのジャイロスコープ機能を試すこともでき、端末の向きに応じてキューブがわずかに回転することが確認できる

では、Xcode のプレビューも使っているのに、何故わざわざこのようなプレビューアプリを作るのかという疑問が浮かんでくる
それに対する答えは、Xcode のプレビューは画面のスタイル変更や単純な動作をテストするには最適だが、いくつかの特定の状況では不十分であるためである

Xcode のプレビューは以下の点で不十分である

  • Simulator が持っている全ての機能がない
    • Slow Animation を有効にすることができない
    • アプリのバックグラウンド、フォアグラウンドなどのライフサイクルイベントをシミュレートできない
    • タッチ圧、ボリュームコントロール、キーボードなどのハードウェア機能もシミュレートできない
  • コードを編集したりファイルをナビゲートしたりするとプレビューが無効になってしまう
    • Simulator やデバイス上での実行は別プロセスなので長時間開いておくことができるし、永井時間がかかるアプリの部分をテストすることに適している
  • CoreMotion, CoreLocation, StoreKit などプレビューでは動作しないものがある
    • キューブのレンダリングに使用している SceneKit はプレビューでは重すぎてタイムアウトしてしまう
    • Preview, Simulator にはジャイロスコープがないため簡単にテストを実行したい場合は、デバイス上で実行するしかない
  • プレビューはデバッガーとの相性も良くない
    • 開発中の機能の小さな部分だけをデバッグする場合、ミニアプリの中で分離して実行したほうが良いのが現状

いつか Xcode のプレビュー版がデバイス上でしっかり動作するようになるまでは、このような小さいなミニアプリを作ることは非常に有効である

アイカワアイカワ

このような小さなミニアプリはなるべく早くビルドできるようにして、すぐに確認できるようにすることが重要である

isowords のフルビルドは1分近くかかるが、CubeCorePreview アプリはわずか8秒ほどでコンパイルできるようになっている

実際に今回は全く新しい Preview アプリ(Leaderboard)を一から追加して、Preview アプリをどのように構築できるかについて体験していく

以下の手順を実施していくと Preview アプリが作成できる

  • LeaderboardPreview という新しいターゲットを追加する
  • Previews ディレクトリに LeaderboardPreview ディレクトリを移動する
  • preview content ディレクトリが見つからないというエラーが起きるが、必要ないのでディレクトリ自体・ビルド設定を削除
  • Info.plist が見つからないというエラーも起きるが、これはビルド設定の前に Previews/ をつければ解決する
  • 以上でターゲットがビルドできる状態になるので、LeaderboardFeature を作成したターゲットとリンクさせる
  • 今後、Preview で必要になるのはアプリのエントーポイントである LeaderboardView を構築することだけなので、ターゲットから不要な ContentView を削除する
  • 実際に LeaderboardView を作成していく
import LeaderboardFeature
...
LeaderboardView(
  store: .init(
    initialState: .init(isHapticsEnabled: false, settings: .init()),
    reducer: leaderboardReducer,
    // environment は noop によって構築することができる
    environment: .init(
      apiClient: .noop,
      audioPlayer: .noop,
      feedbackGenerator: .noop,
      lowPowerMode: .false,
      mainQueue: DispatchQueue.main.eraseToAnyScheduler()
    )
  )
)

この状態でアプリをシミュレータで実行すると、Leaderboard のようなものが表示されるが、Loading Indicator が表示されるだけで終了しているようには見えない
これは、何も出力しない noop を提供していることによって、Leaderboard が Indicator を停止することを知らないために起きている

これを解決する方法の一つとして、Leaderboard を動作させるためにある程度現実的な Environment を作るというものがある
これは noop APIClient をベースにしつつ、実際のデータを返すように APIClient の実装をいくつかオーバーライドすることで実現することができる(既に isowords の APIClient では .override ヘルパーが定義されている)

実際には以下のように override できる(細かい説明は本質ではないため、元記事を参照)

var apiClient = ApiClient.noop

apiClient.override(
  route: .leaderboard(
    .fetch(
      gameMode: .timed,
      language: .en,
      timeScope: .lastWeek
    )
  ),
  withResponse: .ok(
    FetchLeaderboardResponse(
      entries: [
        .init(
          id: .init(rawValue: UUID()),
          isSupporter: false,
          isYourScore: false,
          outOf: 1_000,
          playerDisplayName: "Blob",
          rank: 5,
          score: 3_000
        )
      ]
    )
  )
)

あとは作成した apiClient を environment に当て込めばミニ Preview アプリが機能するようになる

environment: .init(
  apiClient: apiClient,
  ...
)

これで高速に起動するミニ Preview アプリが出来上がったので、あとは適当にデータなどをいじりながら簡単に開発を行う・確認するのプロセスを踏むことができるようになる

(isowords の apiClient の実装については気になるので、別スクラップで勉強してみたい)

アイカワアイカワ

App Store assets

App Store 用の Assets の準備でも Point-Free はある工夫を行っているので、それについて見ていく

最近ではアプリをリリースする際に3本のビデオ・9枚のスクリーンショットなどを iPhone mini, max, iPad などの様々なデバイスサイズでアップロードすることができる
しかし、これらの Assets を作成するにはかなりの労力が必要となる

例えばスクショを用意する際は、デバイス上でアプリを実行してスクリーンショットを撮ったりする必要がある
しかしこれにはいくつかデメリットがある

  • アプリに表示されているライブデータに翻弄されてしまう
    • アプリに表示されている数字やアバターなどを調整したいと思っても実際には難しい
  • 撮影が難しい状態も存在する
    • 例えば特定の時間帯にしかアクセスできない画面・ユーザーが何かの操作をした時にしかアクセスできない画面などがある

異なる方法として、Figma や Sketch のような環境でデザイナーがスクリーンショットをモックアップすることも考えられる
しかし、同じくこれにもデメリットがいくつかある

  • アプリの外観を忠実に再現するものではない
    • どんなに完璧なピクセルデザインが好きでもモックアップと実際にデバイス上でレンダリングされたものとの間には常に多少のズレが生じてしまう
  • 画面が大幅に更新されてスクリーンショットを更新するたびにデザイナーさんと相談して更新してもらう必要がある

これらのどちらの方法も理想的ではないと考えられる
しかし、もっと良い方法としてスナップショットテストを利用する方法を Point-Free は提案している

スナップショットテストはアプリの外観を正確にキャプチャし、実行環境のコントロールも可能なのであらゆるデータをモックアウトすることができ、ちょっとした工夫で画像にスタイリングやブランディングを追加することもできる

アイカワアイカワ

Tests/AppStoreSnapshotTests には App Store のスクリーンショットを生成するためのテストターゲットが用意されている
このディレクトリには15枚の画像を格納した Snapshots ディレクトリがある
Snapshots ディレクトリには3つの異なるデバイスサイズと、1つのデバイスにつき5つの画像を含む15枚の画像が格納されている

これらのスクリーンショットは全てスタイリングされている
アプリの UI は iPhone のフレームの中に埋め込まれており、上部にはコンテキストのためのタイトルすらも追加されている

これらのスクリーンショットに表示されているものは全て SwiftUI でレイアウトされ、スナップショットテストライブラリでスナップショットされたものらしい
これらのスクリーンショットでは、標準的なスクリーンキャプチャを行うことなくスタイリングされたスクショを入手することが可能である

スクショはいけるかもしれないが(今のところ自分は全然実現の方法の想像はついていないが)、短い動画は流石に厳しいだろうと思うかもしれない

しかし、TCA なら動画を作ることすらも可能であるらしい

実際に動画を作成するための TrailerPreviewApp というエントリーポイントを見ていく

@main
struct TrailerPreviewApp: App {
  init() {
    Styleguide.registerFonts()
  }

  var body: some Scene {
    WindowGroup {
      TrailerView(
        store: .init(
          initialState: .init(),
          reducer: trailerReducer,
          environment: .init(
            audioPlayer: .live(
              bundles: [
                AppAudioLibrary.bundle,
                AppClipAudioLibrary.bundle,
              ]
            ),
            backgroundQueue: .main,
            dictionary: .init(
              // トレーラーのために認識する必要がある単語のセットは限られているためこのような実装になっている
              contains: { string, _ in
                [
                  "SAY", "HELLO", "TO", "ISOWORDS",
                  "A", "NEW", "WORD", "SEARCH", "GAME",
                  "FOR", "YOUR", "PHONE",
                  "COMING", "NEXT", "YEAR",
                ]
                .contains(string.uppercased())
              },
              load: { _ in true },
              lookup: { _, _ in nil },
              randomCubes: { _ in .mock },
              unload: { _ in }
            ),
            mainQueue: .main,
            mainRunLoop: .main
          )
        )
      )
      .statusBar(hidden: true)
    }
  }
}

↑ を見てみると他のターゲットと同様にあまりコードが入っていないことがわかる

Environment だけは微妙に手間はかかるが、dictionary 以外の依存関係については最もシンプルな方法が採用されている

Trailer のコードの大部分は TrailerView にある
Trailer.swift を見ると TCA で構築されている他の機能とほぼ同じパターン(State, Action, Environment などが用意されている)で書かれていることがわかる

中でも興味深いのは State である

public struct TrailerState: Equatable {
  var game: GameState
  var nub: CubeSceneView.ViewState.NubState
  var opacity: Double

  ...
}

↑ を見るとコアとなる GameState を保持しつつ、Trailer の目的のためだけに状態を重ねていることがわかる
NubState は画面上を動き回る小さな仮想の指をコントロールするもので、opacity は Trailer をフェードイン/フェードアウトするために使用される
重要なのはコアのゲームロジックに手を加えていないことで、もしゲームがコアロジックに加えてオンボーディング・トレイラーまで気にし始めた場合どんどん混沌として行ってしまう

下に辿っていくと機能のロジックを実装するための trailerReducer があり、これには gameReducer のロジックも組み込まれている

https://github.com/pointfreeco/isowords/blob/61855898078d0c848f5f9c2a0925e9ee88e3191a/Sources/TrailerFeature/Trailer.swift#L71

このファイル全体の中で最も興味深い部分は Reducer である
TrailerView が最初に表示された時に送信される .onAppear Action から Reducer の動作の全てが始まる

case .onAppear:
  return .merge(
    environment.audioPlayer.load(AudioPlayerClient.Sound.allCases)
      .fireAndForget(),

    Effect(value: .delayedOnAppear)
      .delay(
        for: 1,
        scheduler: environment.mainQueue.animation(.easeInOut(duration: fadeInDuration))
      )
      .eraseToEffect()
  )

onAppear によって幾つかの Effect が始まる
まず、Trailer で再生される全てのサウンドをロードしている
また、Action を1秒遅らせてシステムに送り返す Effect も実行される
これはキューブの最初のレンダリングに時間がかかることがあるため、少し時間を置くようにしている

また animation Scheduler を使って Action を送信していることにも注目したい
delayedOnAppear Action を見ればなぜこんなことをしているかがわかる

case .delayedOnAppear:
  state.opacity = 1

  ...

最初は View を透明にしておいて、Trailer を開始する準備ができたらフェードインさせるためにこのようにしている

この state mutation の後、この Action の残りのロジックは膨大な数の Effect の構築を担っている

https://github.com/pointfreeco/isowords/blob/61855898078d0c848f5f9c2a0925e9ee88e3191a/Sources/TrailerFeature/Trailer.swift#L106-L232

元記事に細かい説明はあるためある程度省略するが、個人的に重要だと感じた部分をピックアップする

// Move the nub to the face being played
effects.append(
  Effect(value: .binding(.set(\.nub.location, .face(face))))
    .delay(
      for: moveNubDelay(wordIndex: wordIndex, characterIndex: characterIndex),
      scheduler: environment.mainQueue
        .animate(withDuration: moveNubToFaceDuration, options: .curveEaseInOut)
    )
    .eraseToEffect()
)

↑ では animation scheduler を使って、アニメーションを実現している

animate 自体の実装は以下のようになっている

public func animate(
  withDuration duration: TimeInterval,
  delay: TimeInterval = 0,
  options animationOptions: UIView.AnimationOptions = []
) -> AnySchedulerOf<Self> {
  AnyScheduler(
    minimumTolerance: { self.minimumTolerance },
    now: { self.now },
    scheduleImmediately: { options, action in
      self.schedule(options: options) {
        UIView.animate(
          withDuration: duration,
          delay: delay,
          options: animationOptions,
          animations: action
        )
      }
    },
    delayed: { date, tolerance, options, action in
      self.schedule(after: date, tolerance: tolerance, options: options) {
        UIView.animate(
          withDuration: duration,
          delay: delay,
          options: animationOptions,
          animations: action
        )
      }
    },
    interval: { date, interval, tolerance, options, action in
      self.schedule(after: date, interval: interval, tolerance: tolerance, options: options) {
        UIView.animate(
          withDuration: duration,
          delay: delay,
          options: animationOptions,
          animations: action
        )
      }
    }
  )
}

以前 Point-Free では SwiftUI のための animate を作っていたが、↑ は UIView 用の animate である
NubView が SwiftUI で作られた View ではないため、UIView.animate を利用している

他にも記事では細かい実装が紹介されているが、animation scheduler を利用して工夫した処理を行い、Effect を append して行っている

巨大な Effect によって自動的にゲームが進行されていくため、何の操作をすることもなくゲームが進行してくれる => あとはシミュレータを動かして QuickTime 等でアプリを録画するだけで Trailer 用の動画が完成する

プログラムで作っていることの利点として、様々な長さなどをプログラムで制御できることがある
↓ のような数値をいじれば、動画の長さなどを調節することが可能である

private let firstCharacterDelay: DispatchQueue.SchedulerTimeType.Stride = 0.3
private let firstWordDelay: DispatchQueue.SchedulerTimeType.Stride = 1.5
private let moveNubToFaceDuration = 0.45
private let moveNubToSubmitButtonDuration = 0.4
private let moveNubOffScreenDuration = 0.5
private let fadeInDuration = 0.3
private let fadeOutDuration = 0.3
private let submitPressDuration = 0.05
private let submitHesitationDuration = 0.15

TCA を利用することによって、実際にゲームをプレイしているようにエミュレートすることもできた
しかも Trailer の作り方は他の機能の作り方と何ら変わりがない

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