[Swift][SwiftUI] actor を使って、LifeGame を作る(その 2: ViewModel/View を作る)

SwiftUI2021

     
LifeGame を作っていく過程で、SwiftUI / Swift を理解していきます。actor と MVVM の組み合わせを確認していきます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.2 Beta
  • Xcode 14.2

全てを作成してから記事を書いていないので、途中でバージョンアップがあるかもしれません。

LifeGame

LifeGame とは以下のようなルールのもと 世代を進めていき、進化を眺める(?)ゲームです。

・セルは、「生きている」「死んでいる」の2つの状態のいずれかを持つ。
・セルは、時間が進むにつれて、「死んだり」「生きたり」する
・セルは、周囲の8つのセルの状態に応じて、「死んだり」「生きたり」する

・セルの誕生死滅ルール
・生きているセルの周りに、2つ もしくは 3つの生きたセルがあると、そのセルは 次の世代でも生きている
・生きているセルの周りに、1つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代で死滅する
・死んでいるセルの周りにちょうど3つの生きたセルがあると、そのセルに次の世代で誕生する
・死んでいるセルの周りに、2つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代でも死滅したまま

# セルの”周りのセル”とは、周囲8個のセルのことです。
詳細は、Wikipediaで。

前回まで

前回までに行ったことは以下のことです。

・ボード全体を表現する LGBoard を actor で 作成した
・各セルを表現する LGCell を class で作成した
・LGBoard 内での隣接セルの状態を確認する準備をした
[Swift][SwiftUI] actor を使って、LifeGame を作る(その1: actor でモデルを作る)

今回

モデルの実装を進めていくと、モデルの情報を表示して確認してみたくなります。

ということで、今回は、LGBoard の情報を View に表示していきます。

おおよそ以下の項目です。

・View に表示する情報を保持する ViewModel を作成する
・LGBoard を表示する LGBoardView を作成する

ViewModel を作る

今回のモデル LGBoard は、actor で作成しています。

actor の情報を取得するときには、非同期で取得する必要があります。(一部、nonisolated 指定されているものを除きます)

しかし、View は、表示する情報を非同期で取得する時間的余裕はありませんので、同期的に情報が取得できる必要があります。そこで ViewModel が登場する余地が発生します。(ViewModel の存在意義は、他にもあります。)

前回作成した LGBoard では サイズの情報(numX, numY) は、nonisolated 指定していますので、同期的に取得できますが、各セルの情報は、非同期にしか取得できません。
ですので、LGBoard 自体の情報を保持するだけでなく、各セルの情報を ViewModel で独自に持ち 同期的に取得できるようにしてみます。

LGViewModel

まずは、テストコードを書いてみます。

Iteration1: initializer

まずは、LGBoard の初期化に必要な情報と合わせて ViewModel (LGViewModel と言う名称にしました) を初期化するテストです。

    func test_viewModel_initialize() async throws {
        let sut = LGViewModel(numX: 10, numY: 10)
        XCTAssertNotNil(sut)

        XCTAssertEqual(sut.numX, 10)
        XCTAssertEqual(sut.numY, 10)
    }

モデルの numX, numY は、同期的に取得できるハズなので、ViewModel 経由でも同期的に取得できることを確認しています。

以下のように ViewModel(LGViewModel) を作成しました。

class LGViewModel: ObservableObject {
    var board: LGBoard

    init(numX: Int, numY: Int) {
        board = LGBoard(numX: numX, numY: numY)
    }

    var numX: Int {
        board.numX
    }
    var numY: Int {
        board.numY
    }
}

上記コードで、先のテストは通ります。

LGViewModel は、与えられた numX, numY を使って、LGBoard を初期化して保持しています。初期化したままでセルの値は設定していません。
つまり、LGBoard の各セルを設定する/取得する方法はまだ用意していません。設定/取得できるようにするのが次のステップです。

Iteration2: LGBoard/LGCell の設定

各セルの値を設定/取得できることを確認するテストするコードを作成します。

LGBoard と同様に、セルの設定はclosure を与えて設定するようにします。

LGBoard が actor であることから、LGViewModel 側から LGBoard 内の LGCell の状態を同期的に取得することはできません。

そこで、ViewModel に表示用データを保持し、View 表示の際に参照するようにします。

cells という Dictionary(key: CellIndex, value: LGCellState) を準備して、LGBoard の状態に合わせるようにします。

ここでは、LGCell そのものを持つ必要はないので、表示に必要な LGCellState のみを持つようにしています。

LGBoard の情報からアップデートする関数は、updateCells とする予定です。

新しいテストではなく、先の initializer のテストコードに追記する形でテストしています。

    func test_viewModel_initialize() async throws {
        let sut = LGViewModel(numX: 10, numY: 10) { cellIndex in
            return ((cellIndex.x + cellIndex.y) % 2 == 0) ? .alive : .dead
        }
        XCTAssertNotNil(sut)

        XCTAssertEqual(sut.numX, 10)
        XCTAssertEqual(sut.numY, 10)

        await sut.updateCells()

        for index in [CellIndex(x: 0, y: 0), CellIndex(x: 7, y: 1), CellIndex(x: 7, y: 7)] {
            XCTAssertEqual(sut.cells[index], .alive)
        }
        for index in [CellIndex(x: 0, y: 1), CellIndex(x: 0, y: 7), CellIndex(x: 7, y: 0), CellIndex(x: 7, y: 6)] {
            XCTAssertEqual(sut.cells[index], .dead)
        }
    }

テスト内容は、LGBoard のテストコードを流用して作りました。
異なるのは、LGViewModel からは、同期的に情報が取得できなければいけないので、同期的に取得して、意図通りの値になっているかをテストしています。

LGViewModel にメソッド等を追加したコードが以下です。

typealias BoardSetupClosure = (CellIndex) -> LGCellState

class LGViewModel: ObservableObject {
    var board: LGBoard
    @Published var cells: [CellIndex:LGCellState] = [:]

    init(numX: Int, numY: Int,_ closure: @escaping BoardSetupClosure) {
        board = LGBoard(numX: numX, numY: numY, closure)
    }

    @MainActor
    func updateCells() async {
        cells = await board.boardStatus()
    }

    var numX: Int {
        board.numX
    }
    var numY: Int {
        board.numY
    }
}

LGViewModel に合わせて、LGBoard も変更しています。

前回、LGBoard の初期化と、LGCell への設定は別々に実装されていましたが、個別に設定されることは稀であると考えて、1つにまとめました。
また、LGBoard から、セルの情報を個々に取得しようとするとそれぞれ非同期になってしまうので、まとめて取得するメソッドを LGBoard に追加しています。

そしてそのメソッドをつかって、ViewModel の cells を更新しています。

actor LGBoard: ObservableObject {
    nonisolated let numX: Int
    nonisolated let numY: Int

    var cells:Dictionary<CellIndex, LGCell> = [:]
    init(numX: Int, numY: Int,_ closure: BoardSetupClosure = { index in .alive }) {
        self.numX = numX
        self.numY = numY
        for x in 0..<numX {
            for y in 0..<numY {
                let index = CellIndex(x: x, y: y)
                let state = closure(index)
                let cell = LGCell(state: state)
                cells[index] = cell
            }
        }
    }
    func boardStatus() -> [CellIndex: LGCellState] {
        var newDic: [CellIndex: LGCellState] = [:]
        for key in cells.keys {
            if let cell = cells[key] {
                newDic[key] = cell.state
            }
        }
        return newDic
    }
   // ... 以降 省略
}

上記のコードで、ViewModel のテストコードはパスします。

Note: actor の初期化

actor と class を組み合わせて使うときの初期化については、少しクセがあります。

initializer の制約として、「init は、すべてのプロパティを初期化する必要がある」と言う点があります。
actor ではそれに加えて「init は、actor の context で実行されていない」という点があります。(actor 生成途中なので、当然と言えば当然ですが)

class の init で actor の init を呼ぶことは問題ありません。ですので、class の プロパティに actor を持つことも可能です。
上にも書いたように、actor の init は、 class の init の context で実行されます。ですので、async ではなく、sync な init の中で完了することができます。

ですが、class の init で 生成した actor にアクセスしようとすると、非同期アクセスが必要となります。actor なので、当然なのですが、sync な init の中ではアクセスできません。
非同期アクセスが必要となると言うことは、class の init も async で定義するか、Task等を使用し 別スレッドで actor にアクセスすることになります。

このような実装を行うときに困るのが、class の init 中から Task等の別スレッドで行った actor へのアクセス処理の終了を確認することが難しい点です。
情報を確認するだけであれば問題ありませんが、actor の一部を変更することや actor から取得した情報で自身を変更することが必要なときには、その変更が終了したことを確認したくなります。

class の init 内で行われた actor の 生成については生成完了を確認することができますが、Task 等を使用した別スレッドでの actor へのアクセス終了は、知るすべがありません。

今回の例では、LGViewModel 中の board: LGBoard の initialize は 問題ありませんが、init 内から、board の情報を使用して、LGViewModel のプロパティである cells をアップデートしようとすると Taskを使って、別スレッドで実行する必要が発生します。そして、その終了タイミングを知ることはできません。

class の init を async にするのも方法の1つですが、すこし採用しずらいです。(ViewModel を class としているのは、View 側の sync な世界と、Model 側の async な世界の境界部分に使いたいためで、View 側はできるだけ、sync な世界としておきたいのです)

もちろん、何らかのフラグを使って処理が終了したことを検知することはできるでしょうが、スマートな解決策には見えません。

今回の実装では、明示的に (async な)別メソッドに切り出すことで、その処理の終了を明確化できるようにしています。それが、updateCells メソッドです。

この実装方針が妥当であるかは、自信がありません・・・識者の方 教えてください_o_

View を作る

ViewModel がある程度できてきたので、View を作っていきます。

View に対しては、SnapshotTesting を使って、テストしていくのが良いと思いますが、この記事では画面のスナップショットだけ載せて省略します。

LGCellView

まずは、セルを表示するビューを作ります。LGCellView という名前にします。

LGCellView 概要
・セルは、状態(LGCellState)を持っているので、その状態を受け取って表示することとします。
・.alive な状態では、○ を表示し、.dead な状態のセルは、× を表示する

SF Symbols でそれっぽい(?)ものを探して、次のようにしました。

.alive には、 “circle” という symbol を .dead には、”xmark” という symbol を使うことにしました。

CircleCross
struct LGCellView: View {
    let cellState: LGCellState
    var body: some View {
        Image(systemName: symbolName)
            .resizable().aspectRatio(contentMode: .fit)
            .frame(width: 50, height: 50)
    }

    var symbolName: String {
        switch cellState {
        case .alive:
            return "circle"
        case .dead:
            return "xmark"
        }
    }
}

1つのセルを大きく表示することも考えられるので、.resizable/aspectRatio を指定しています。

.frame でのサイズは、確認用に設定したサイズです。LGBoard を作成した後に最終調整することを想定してます。

LGBoardView

LGBoard を表示する View として、LGBoardView を作ります。

LGViewModel を参照して、必要な数の LGCellView を表示する役割です。

LGBoardView 概要
・LGCellView を必要数表示して、LGBoard の状態を表示する
・LGCellView は、矩形に配置するので、Grid/GridLayout を使用する
・x方向は水平方向、y方向は、垂直方向とする

LGViewModel を使うことで、LGBoard のサイズ、各セルの状態 いずれも 同期的に取得できるようになっていますので、
Grid/GridRow と ForEach を使うことで、シンプルに表示できるはずです。

なお、LGViewModel は、EnvironmentObject 経由で取得しています。

struct LGBoardView: View {
    @EnvironmentObject var viewModel: LGViewModel

    var body: some View {
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            ForEach(0..

また、表示のタイミングで、LGViewModel の情報をアップデートしています。

App

以下は、実行に必要となる MainView/App です。(ContentView を MainView に rename しています)

@main
struct LifeGame2022App: App {
    @StateObject var viewModel: LGViewModel = LGViewModel(numX: 10, numY: 10) { index in
        return ((index.x + index.y) % 2 == 0) ? .alive : .dead
    }
    var body: some Scene {
        WindowGroup {
            MainView()
                .environmentObject(viewModel)
        }
    }
}
struct MainView: View {
    var body: some View {
        VStack {
            Text("Life Game").font(.title)
            LGBoardView()
        }
        .padding()
    }
}

App レベルで ViewModel を初期化し、EnvironmentObject に設定していますので、配下の View では、LGViewModel を @EnvironmentObject 経由でアクセスできることになります。
MainView では、LGBoardView の上に、”Life Game” というタイトル表示をしているくらいで、新しいことはしていません。

以下のような表示になりました。

LifeGameApp

Refactoring

シンプルな View を書いてきたこともあり、特に Refactoring は必要なさそうです。

次回以降

モデルとその表示ができてきたので、次回は、世代が進む部分を作っていく予定です。

まとめ

LifeGame のモデルを 表示するための ViewModel/View を作成しました。

LifeGame の ViewModel/View を作成
  • actor へのアクセスは非同期なので、View 向けには 同期アクセスできるデータが必要となる
  • 盤状の要素配置は、 Grid/GridLayout が便利

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です