⏱️

"SyncUps" で SwiftUI アプリ開発におけるベストプラクティスを探る

2024/12/22に公開

はじめに

この記事はフラー株式会社 Advent Calendar 2024の22日目の記事です。

PointFree といえば、Flux ベースのアプリが構築できるTCA(swift-composable-architecture) や、依存注入ツール swift-dependencies など、iOS 開発で活用できる便利ライブラリを多数制作している方々です。
その PointFree が作っているにも関わらず、あまり注目されていない SyncUps というアプリがあります。
SyncUps はリリースされているアプリではなく、あくまで実装例を示しているリポジトリですが、個人的にこの SyncUps の実装がとても興味深いと感じたので、この記事では SyncUps の中身を深掘りしてみます。

この記事の対象者

  • SwiftUI アプリ開発のベストプラクティスやモダンな技術について人
  • SwiftUI で MVVM を採用した実装例を知りたい人
  • PointFree のファンな人

SyncUps とは

Apple が出している SwiftUI チュートリアルの1つである Scrumdinger というアプリを、なるべくバニラな SwiftUI を採用しつつ、 PointFree なりに再構築したのが SyncUps[1] です。
About によると、

A rebuild of Apple's "Scrumdinger" application using modern, best practices for SwiftUI development.

とのことです。この記事では、どのあたりがモダンでベストプラクティスなのか?というのを探っていきます。

SyncUps が作られた背景 〜 TCA はベストプラクティスではない?

なぜ PointFree が、TCA を使わずバニラに寄せて SwiftUI アプリの実装例を示しているかは、PointFree が出している動画 Modern SwiftUI: Introduction で語られています。

We want to spend a little more time with vanilla SwiftUI because we feel there aren’t enough examples out there of applications written with best, modern practices. By this we mean an application that is decently complex in order to show off real world problems, built in a way that can be tested, built in a way that is modular, and using all of Swift’s powerful domain modeling tools.

ベストで最新のプラクティスに従って作成されたアプリケーションの例が十分にないと感じているため、バニラ SwiftUI にもう少し時間を費やしたいと考えています。ここで言うアプリケーションとは、現実世界の問題を示すために適度に複雑で、テスト可能な方法で構築され、モジュール化され、Swift の強力なドメイン モデリング ツールをすべて使用したアプリケーションを意味します。

引用元: Modern SwiftUI: Introduction

PointFree が作っている TCA は、 SwiftUI におけるベストプラクティスを示しているわけではないのか?と思ったのですが、PointFree は以下のような考えを示しています。

By this we mean an application that is decently complex in order to show off real world problems, built in a way that can be tested, built in a way that is modular, and using all of Swift’s powerful domain modeling tools.
Now of course we feel that the Composable Architecture is one of the best ways to create such applications, but we also know that many people do not want to use our library or possibly just can’t. So, we still think it’s worthwhile exploring how modern SwiftUI applications can be built.

もちろん、Composable Architecture はそのようなアプリケーションを作成するための最良の方法の 1 つであると私たちは考えていますが、多くの人が私たちのライブラリを使いたくない、あるいは単に使えないということもわかっています。そのため、最新の SwiftUI アプリケーションを構築する方法を探ることは依然として価値があると考えています。

引用元: Modern SwiftUI: Introduction

全ての個人・企業に TCA がマッチするわけではないことを、PointFree は理解しているようです。

Scrumdinger について

Scrumdinger は、先述した通り Apple が出している SwiftUI チュートリアルの1つになります。
スクラムの機能を支援するためのアプリであり、主に以下の画面で構成されています。

  • デイリースクラム一覧画面
  • デイリースクラム追加画面
  • デイリースクラム詳細画面
  • デイリースクラム録音画面(会話書き起こし機能付き)
  • 書き起こされた会話を表示する画面

ただ SwiftUI を書くだけでなく、ミーティングの内容を録音しつつ並行でタイマーを動かしたり、録音内容を書き起こす機能があったりと、チュートリアルとしてはやや高度で複雑な機能を持ったアプリのようです。
それゆえ、チュートリアルアプリでも様々な欠陥があることを、PointFree は動画内で指摘しています。

SyncUps を構成している技術 〜 2023 年 1 月から 2024 年 12 月までの変遷

SyncUps の最初のバージョンは 2023 年 1 月にコミットされていますが、現在でも新しい技術が出るたびに内容が更新されています。

今後も継続してアップデートされると思われるので、この項目は時間が経つにつれ古くなっていく可能性があることをご承知おきください。

アーキテクチャ: MVVM

SyncUps のアーキテクチャは、いわゆる MVVM 形式に近いものとなっています。

画面名 View ViewModel ファイル名
デイリースクラム一覧画面 SyncUpsListView SyncUpsListModel SyncUpsList.swift
デイリースクラム詳細画面 SyncUpsDetailView SyncUpsDetailModel SyncUpsDetail.swift
デイリースクラム追加・編集画面 SyncUpsFormView SyncUpsFormModel SyncUpsForm.swift
デイリースクラム録音画面 RecordMeetingView RecordMeetingModel RecordMeeing.swift

View と ViewModel は、1つのファイルにまとめて記述されています。
TCA でも同様に View と Reducer はまとめて1つのファイルに記述されていたので、PointFree 的にはそちらが好みなのでしょう。

副作用系は、Dependencies ディレクトリにまとめられています。

依存名 ファイル名
録音機能 SpeechClient.swift
OSの設定画面を開く機能 OpenSettings.swift
音声を再生する機能 SoundEffectClient.swift

監視ツール: Observation (旧版: Combine)

ViewModel から View への値の伝播は、 Observation が使われています。こちらも 2023 年に Apple から発表された新しい技術です。
リポジトリ作成当初は Combine が使われていましたが、Observation の登場に伴い、2023 年 11 月頃に移行されたようです。
Observation は iOS 17 から使用可能ですが、SyncUps のプロジェクトは Deployment Target が 18 なので使用可能、ということでしょう。

ここで、ViewModel の中身を少し見てみます。
@Observable が付いているので、addSyncUp の変更が View に通知されます。
@ObservationIgnored が付いているプロパティは監視対象には入りません。

SyncUpsList.swift
@MainActor
@Observable
final class SyncUpsListModel {
  var addSyncUp: SyncUpFormModel?
  @ObservationIgnored @Shared(.syncUps) var syncUps

  @ObservationIgnored @Dependency(\.uuid) var uuid

  init(
    addSyncUp: SyncUpFormModel? = nil
  ) {
    self.addSyncUp = addSyncUp
  }
  ...

ViewModel には、処理ごとにメソッドが定義されています。

SyncUpsList.swift
func addSyncUpButtonTapped() {
  addSyncUp = withDependencies(from: self) {
    SyncUpFormModel(syncUp: SyncUp(id: SyncUp.ID(uuid())))
  }
}

func dismissAddSyncUpButtonTapped() {
  ...

これらのメソッドが View から呼ばれることによって、処理が実行されます。

SyncUpsList.swift
struct SyncUpsList: View {
  @State var model = SyncUpsListModel()

  var body: some View {
    List {
      ...
    }
    .toolbar {
      Button {
        model.addSyncUpButtonTapped()
      } label: {
        ...

addSyncUp は監視対象なので、変更が入り次第、View にも反映されます。

SyncUpsList.swift
...
.sheet(item: $model.addSyncUp) { syncUpFormModel in // 値が入り次第、シートが表示される
  NavigationStack {
    SyncUpFormView(model: syncUpFormModel)
      .navigationTitle("New sync-up")
      ...

データモデル: swift-tagged, swift-identified-collections

swift-tagged は、データの ID に型を与えて型安全にするライブラリです。

Model.swift
struct Attendee: Hashable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  var name = ""
}

struct Meeting: Hashable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  let date: Date
  var transcript: String
}

swift-tagged を使わず、素の UUID を id の型として採用すると、Attendee.idMeeting.id を同値比較した場合、そもそも親のモデルが違うので false を返してほしいところですが、そうはならずに true が返ってきてしまいます。
swift-tagged を使用することで、このような事故を防ぐことができます。

swift-identified-collections は、Identified な配列を定義することができるライブラリです。
IdentifiedArrayOf<T>で型定義をすることで、Identified な配列を作ることができます。これにより、インデックス指定ではなく ID で対象を探すことができるようになります。

Model.swift
struct SyncUp: Hashable, Identifiable, Codable {
  let id: Tagged<Self, UUID>
  var attendees: IdentifiedArrayOf<Attendee> = []
  var duration = Duration.seconds(60 * 5)
  var meetings: IdentifiedArrayOf<Meeting> = []
  var theme: Theme = .bubblegum
  var title = ""
}
SyncUpDetail.swift
struct MeetingView: View {
...
  init?(id: Meeting.ID, syncUpID: SyncUp.ID) {
    @Shared(.syncUps) var syncUps
    guard
      let syncUp = syncUps[id: syncUpID],
      let meeting = syncUp.meetings[id: id] // id で探すことが可能
    else {
      return nil
    }
    self.syncUp = syncUp
    self.meeting = meeting
  }
  ...
}

swift-identified-collections の README にある通り、IdentifiedArrayswift-collectionsOrderedDictionary の軽量なラッパーのようです。

また、swift-tagged と swift-identified-collections いずれも、PointFree 作のライブラリとなります。

画面遷移: swift-navigation (swiftui-navigation)

swift-navigation[2] は、シンプルで強力なナビゲーションツールです。
SwiftUI には、通常のプッシュ遷移だけでなく、アラートやシート、ポップオーバーなど、様々な遷移の種類が存在しています。しかしながら、標準の SwiftUI APIでは 遷移の状態管理や値の Binding が困難です。これらの問題を解決したツールが、swift-navigation です。

swift-navigation の特徴としては、遷移先を以下のように Destination として定義する点です。
ここでは、各種アラート画面とモーダル形式で表示される編集画面(つまり、プッシュ通知で遷移するタイプの画面ではなく、画面の上に乗っかって表示されるタイプの画面)が、遷移先として定義されています。

SyncUpDetail.swift
final class SyncUpDetailModel {
  var destination: Destination?

  @CasePathable
  @dynamicMemberLookup
  enum Destination {
    case alert(AlertState<AlertAction>)
    case edit(SyncUpFormModel)
  }
  enum AlertAction {
    case confirmDeletion
    case continueWithoutRecording
    case openSettings
  }
  ...
}

extension AlertState where Action == SyncUpDetailModel.AlertAction {
  static let deleteSyncUp = Self {
    TextState("Delete?")
  } actions: {
    ButtonState(role: .destructive, action: .confirmDeletion) {
      TextState("Yes")
    }
    ButtonState(role: .cancel) {
      TextState("Nevermind")
    }
  } message: {
    TextState("Are you sure you want to delete this sync-up?")
  }
  ...
}

destination に任意の遷移先を入れることで、遷移が発生します。これにより、遷移の状態が常に1つであることが保証されます。

SyncUpDetail.swift
func deleteButtonTapped() {
  destination = .alert(.deleteSyncUp) // deleteSyncUp アラートが表示される
}

DI: swift-dependencies

依存注入には、こちらも PointFree 作である swift-dependencies が使われています。
TCA でも同様の用途で導入されています。
swift-dependencies は、依存の利用が簡単かつ安全で、テスト時やプレビュー時の値の差し替えを細かく行えるのが特徴です。

swift-dependencies を使用して追加された依存名には基本的に ~Client が付きますが、意味合い的には「APIクライアント」の「クライアント」と同じようなものでしょう。

SoundEffectClient.swift
@DependencyClient
struct SoundEffectClient {
  var load: @Sendable (_ fileName: String) -> Void
  var play: @Sendable () -> Void
}

extension SoundEffectClient: DependencyKey {
  static var liveValue: Self {
    let player = LockIsolated(AVPlayer())
    return Self(
      load: { fileName in
        player.withValue {
          guard let url = Bundle.main.url(forResource: fileName, withExtension: "")
          else { return }
          $0.replaceCurrentItem(with: AVPlayerItem(url: url))
        }
      },
      ...
}

extension DependencyValues {
  var soundEffectClient: SoundEffectClient {
    get { self[SoundEffectClient.self] }
    set { self[SoundEffectClient.self] = newValue }
  }
}

@Dependency を付けることで、外部から呼び出せるようになります。
SyncUps では、基本的には View から呼ぶことはなく、ViewModel から呼ぶように統一されています。
また、Dependency を呼ぶ際は @ObservationIgnored を付ける必要があります。

RecordMeeting.swift
@MainActor
@Observable
final class RecordMeetingModel {
  var alert: AlertState<AlertAction>?
  @ObservationIgnored @Shared(.path) var path
  var secondsElapsed = 0
  var speakerIndex = 0
  @ObservationIgnored @Shared var syncUp: SyncUp
  private var transcript = ""

  @ObservationIgnored @Dependency(\.continuousClock) var clock
  @ObservationIgnored @Dependency(\.date.now) var now
  @ObservationIgnored @Dependency(\.soundEffectClient) var soundEffectClient
  @ObservationIgnored @Dependency(\.speechClient) var speechClient
  @ObservationIgnored @Dependency(\.uuid) var uuid
  ...
}

テストでは、 withDependencies を利用することで、テスト用に簡単に値の差し替えを行うことができます。

RecordMeetingTests.swift
let model = withDependencies {
  $0.continuousClock = clock
  $0.date.now = Date(timeIntervalSince1970: 1234567890)
  $0.soundEffectClient = .noop
  $0.soundEffectClient.play = { soundEffectPlayCount.withValue { $0 += 1 } }
  $0.speechClient.authorizationStatus = { .denied }
  $0.uuid = .incrementing
} operation: {
  RecordMeetingModel(
    syncUp: Shared(
      value: syncUp
    )
  )
}

この Dependencies とよく似た機能として、EnvironmentObject が SwiftUI には存在します。
しかし EnvironmentObject はテストが困難だったり値の差し替えが困難だったりと、依存の管理には少し足りていない印象があります。
Dependencies は、それらを克服した依存注入ツールと言えそうです。

データの永続化: swift-sharing (旧版: swift-dependencies)

swift-sharing は、アプリ全体でデータを共有できるようにするためのライブラリです。

SwiftUI には、UserDefaults にアクセスできる @AppStorage が備わっていますが、View からしか扱うことができなかったり、保存できる型が限られていたり等、機能として少し物足りない部分がありました。swift-sharing は、@AppStorage の機能を補完するようなライブラリといえるでしょう。

swift-sharing も PointFree 作で、元々 TCA 用に作られていた機能でしたが、2024 年 12 月の頭に単体でライブラリとしてリリースされました。SyncUps でも、Sharing が早速採用されています。

SyncUps リポジトリ作成当初は swift-sharing が存在していなかったため、UserDefaults へのアクセスを担う Dependencies がありましたが、現在では swift-sharing を使用する方針に置き換わっているようです。

SyncUps では、少し特徴的な使い方がなされています。

App.swift
struct AppView: View {
  @Shared(.path) var path

  var body: some View {
    NavigationStack(path: Binding($path)) {
      SyncUpsList()
        .navigationDestination(for: AppPath.self) { path in
          switch path {
          case let .detail(id: syncUpID):
            SyncUpDetailView(id: syncUpID)
          case let .meeting(id: meetingID, syncUpID: syncUpID):
            MeetingView(id: meetingID, syncUpID: syncUpID)
          case let .record(id: syncUpID):
            RecordMeetingView(id: syncUpID)
          }
        }
    }
  }
}

@Shared(.path) とあるので、アプリのパスが swift-sharing によってアプリ全体に共有されています。
それが NavigationStack に Binding されています。

AppPath の定義は、以下のようになっています。

AppPath.swift
enum AppPath: Codable, Hashable {
  case detail(id: SyncUp.ID)
  case meeting(id: Meeting.ID, syncUpID: SyncUp.ID)
  case record(id: SyncUp.ID)
}

上からそれぞれ、デイリースクラム詳細画面・書き起こされた会話を表示する画面・デイリースクラム録音画面を指しています。
つまり、上記の画面には、アプリのどこからでも遷移することが可能となっています。

// ViewModel での 使用例
_ = $path.withLock { $0.removeLast() } // 1つ前の画面に戻る
$path.withLock { $0.append(.record(id: syncUp.id)) } // 録音画面に遷移

// View での使用例
List {
  ForEach(model.syncUp) { syncUp in
    NavigationLink(value: AppPath.detail(id: syncUp.id)) { // CardView タップで詳細画面に遷移
      CardView(syncUp: syncUp)
    }
  }
}

このように遷移先を管理しているメリットは、ディープリンクによる遷移が容易になることでしょう。

テストツール: swift-testing (旧版: XCTest)

SyncUps にはテストも備わっています。
swift-testing は、2023 年に Apple から新しく発表されたテストライブラリです。
リポジトリが作られた当初は、XCTest を使って記述されていましたが、2024年夏に swift-testing に移行しています。[3]

SyncUpsDetailTests.swift
@Test func openSettings() async {
  let settingsOpened = LockIsolated(false)
  let model = withDependencies {
    $0.openSettings = { settingsOpened.setValue(true) }
  } operation: {
    SyncUpDetailModel(
      destination: .alert(.speechRecognitionDenied),
      syncUp: Shared(value: .mock)
    )
  }

  await model.alertButtonTapped(.openSettings)

  #expect(settingsOpened.value == true)
}

ちなみに、単体テストだけでなく、UI テストも記述されています。
しかしながら、UI テストのファイルには以下のようなコメントが残されています。

SyncUpsListUITests.swift
// This test case demonstrates how one can write UI tests using the swift-dependencies library. We
// do not really recommend writing UI tests in general as they are slow and flakey, but if you must
// then this shows how.

このテストケースでは、swift-dependencies ライブラリを使用して UI テストを記述する方法を示します。UIテストは一般的に時間がかかり、信頼性に欠けるため、私たちは UI テストの記述を推奨していませんが、どうしても記述する必要がある場合は、この方法で記述できます。

PointFree 的には、UI テストの記述は推奨していないようです。

比較ツール: swift-custom-dump

swift-custom-dump は、データのデバッグ・比較・テストをより容易に行えるライブラリです。
そもそも SyncUps には、PointFree 製以外のライブラリは使われてないのでは?と思ったそこの貴方、お見事な考察です。SyncUps で採用されているサードパーティー製ライブラリは、すべて PointFree 作です。

SyncUps では主にテスト部分で使われており、テストが落ちた際の差分の確認がしやすくなります。


落ちた箇所の diff を表示してくれる

プロジェクト: Group構成

ここは意外な点でしたが、プロジェクトはマルチモジュール構成ではないようです。
また、ビルド対象は Folder ではなく Group となっているので、新しくファイルを追加すると xcodeproj にも更新が入ってしまいます。
これにはおそらく、以下のような考えがあると思われます。

  • このアプリはこれ以上の機能追加は予定されてないため、このようなミニマム構成でも問題ない
  • マルチモジュールを採用するかどうかはアーキテクチャの話とはあまり関係ない

また、ファイルのディレクトリ構成はそこまで細かく分かれておらず、SyncUps 直下にざっくりと置かれています。
Dependencies 関連のみ、Dependencies ディレクトリ配下に置かれています。

SyncUps のディレクトリ構成
SyncUps のディレクトリ構成

考察と感想

SyncUps は、Swift のモダンな技術が取り入れられた実装例としては、かなりよくできていると感じました。
TCA を使わずバニラに寄せているとはいえ、PointFree 節が炸裂しているサンプルアプリではありますが、それらはただ便利なライブラリを使用しているというよりは、 Apple が出している SwiftUI の欠陥や欠点を補完している部分も多く見受けられました。

TCA は、今回紹介したライブラリ群を使用することが前提になっているフレームワークです。SyncUps はそれとは異なり、ライブラリごとに使用する・しないの判断が可能なつくりになっているため、少しずつ導入することができそうなのもよい点です。

また、SyncUps 作成当初から今までの約 2 年の間で使われていた技術の変遷も追ってみましたが、2 年前と比較すると実装が大きく変わっていることが見受けられます。
もちろん、PointFree が便利なライブラリを作り続けているのも変化の要因の1つですが、特に Apple 公式から Observation が登場したことにより、それまで主流だった Combine の書き方から記述が大きく変化した印象があります。

ディレクトリ構成やプロジェクトの造りについては、少なくともベストプラクティスではないだろう、と感じました。
SyncUps の目的はあくまで「モダンなベストプラクティスを用いて Scrumdinger を再構築し、実装例を示すこと」なので、ディレクトリ構成の話はそこから外れているからなのかもしれませんが…
マルチモジュールにするか、Folder をビルド対象にして xcodeproj へのコンフリクトをなるべく少なくするのがよいと思います。

おわりに

SwiftUI アプリの開発手法にはデファクトスタンダードと呼べるものが無く、どのようなライブラリを使用するか、またはどのようなアーキテクチャを採用するかで悩んでいる方も少なくないはずです。
そんな中、PointFree が TCA を使用せず MVVM の形で、なるべくバニラ SwiftUI を使用しつつ、モダンな技術を取り入れた実装例を示していることは、アーキテクチャ選定・技術選定における 1 つの基準になるだろうと思いました。
この記事が誰かの参考になれば幸いです。

脚注
  1. 元々は StandUps という名前で作られていましたが、2023年10月頃に SyncUps に変わったようです。 ↩︎

  2. 当初は swiftui-navigation という名前で SwiftUI 専用のライブラリでしたが、現在は swift-navigation のリポジトリに統合されています。 ↩︎

  3. 発表から導入が少し遅いように見えるのは、swift-testing が Xcode 16 以降でないと動作しなかったためであると考えられます。 ↩︎

Discussion