[SwiftUI] 15Puzzle の作り方(4: ViewModel 設計・実装・テスト)

SwiftUI2021

     
⌛️ 4 min.
SwiftUI を使って、15パズルを作っていきます。

環境&対象

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

  • macOS Sonoma Beta 7
  • Xcode 15 Beta 8
  • iOS 17 Beta 8
  • Swift 5.9

# 特に ベータ版の機能を使用する予定はありません。

全体の方針

以下のような方針で、作っていきます。

– 全体のアーキテクチャは、MVVM(Model-View-ViewModel) です
– TDD で作っていきます。
– Model は、actor で作ります
– スライドパズルは、アニメーションして動作させます
– macOS/iOS それぞれの UI を考慮しつつ、最大限コードを共通化します

全体のステップは、以下です。

15Puzzle とは

これから作っていくのは 15パズル です。スライドパズルと呼ばれることもあるようです。

Wikipedia での説明は、こちら

15puzzle

# 上記の画像も、Wikipedia から引用しています。

いわゆる ソリティア と呼ばれる 一人で遊ぶタイプのゲームの1つです。

ViewModel 構想

View と Model がある程度できてきたので、その仲介をする ViewModel に要求されることを考えてみます。

View からの要求
・Grid で表示するために、GridRow 単位で 表示するパネルの情報を渡すこと
・パネルごとに、スライド可能かどうかを判断できること
・パネルがスライドされた時には、Model に変更を伝え、更新された 配置情報を渡すこと

Model からの要求
・パネル配置情報の Copy を渡すので、View へ適宜変換して渡すこと
・配置情報が更新されたときは通知するので、View へ適宜 更新要求を出すこと

上記を考えて設計していきます

ViewModel 設計

・配置情報については、必要な情報をまとめたものを Model から取得し、ViewModel に保持することにします
・View には、ViewModel 内で加工した情報を GridRow 単位で渡せるようにします
・スライド可能かどうかについては、Model から取得した情報に含まれるようにします
・Model 側に変更を通知する Publisher を用意し、ViewModel はその Publisher を subscribe して、変更を検知するようにします
・Model の変更を検知したときには、Model から取得して ViewModel 内部に保持している情報を更新します
・Model から取得した情報を @Published 設定することで、更新された時に View が更新されるようにします

Model 手直し

Model の変更を検知できるように、Model 側に Publisher を設定します。
そして、Model 変更時に、PanelLayout を Publisher 経由で send するようなメソッドを作成します。

モデルを変更する swap 関数からはモデル変更後に そのメソッドを呼ぶようにしました。

actor Puzzle15Model {
   private var panels: Dictionary<Index2D, Int>
    
    public typealias PanelLayout = Dictionary<Index2D,(Int,Bool)>
    nonisolated
    let modelDidChange: PassthroughSubject<PanelLayout,Never> = PassthroughSubject()

    // ... snip ...

    func swap(_ index1: Index2D,_ index2: Index2D) {
        let value1 = panels[index1]
        panels[index1] = panels[index2]
        panels[index2] = value1
        
        publishChange()
    }
    
    func publishChange() {
        var change = PanelLayout()
        for row in 0...3 {
            for column in 0...3 {
                let index = Index2D(row, column)
                let canMove = movable(at: index)
                change[index] = PanelDetail(panels[index]!, canMove)
            }
        }
        modelDidChange.send(change)
    }
}

当初、パネルのレイアウト情報 panels が外部から直接読めた方が便利と考えました。
しかし View/ViewModel からすると 個々のパネルがスライドできるかという 付加的な情報 も同時に必要となるため、panels 自体を public にしていることの意味はなさそうです。(ということで、 private にしました)

そのような付加情報も付与したデータが PanelLayout です。

以下が、最終的な Puzzle15Model の コードです。

//
//  Puzzle15Model.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/08
//  © 2023  SmallDeskSoftware
//

import Foundation
import Combine

struct Index2D: Hashable {
    var x: Int
    var y: Int
    init(_ x: Int,_ y: Int) {
        self.x = x
        self.y = y
    }
}

extension Index2D {
    var dir4: [Index2D] {
        return [self.n, self.e, self.s, self.w]
    }
    var n: Index2D { return Index2D(self.x    , self.y - 1) }
    var e: Index2D { return Index2D(self.x + 1, self.y    ) }
    var s: Index2D { return Index2D(self.x    , self.y + 1) }
    var w: Index2D { return Index2D(self.x - 1, self.y    ) }
}

actor Puzzle15Model {
    public private(set) var panels: Dictionary
    
    public struct PanelDetail: Equatable, Hashable {
        var value: Int
        var movable: Index2D?
        init(_ value: Int,_ movable: Index2D?) {
            self.value = value
            self.movable = movable
        }
    }
    public typealias PanelLayout = Dictionary
    
    nonisolated
    let modelDidChange: PassthroughSubject = PassthroughSubject()
    
    init() {
        panels = Self.initialPanels
    }    
    
    init(dic: Dictionary) {
        self.panels = dic
    }
    
    func panel(at index: Index2D) -> Int? {
        return panels[index]
    }
    
    func isOpenPanel(at index: Index2D) -> Bool {
        return (panels[index] == 0)
    }
    
    func movable(at index: Index2D) -> Bool {
        for nbr in index.dir4 {
            if isOpenPanel(at: nbr) { return true }
        }
        return false
    }
    
    func swap(_ index1: Index2D,_ index2: Index2D) {
        let value1 = panels[index1]
        panels[index1] = panels[index2]
        panels[index2] = value1
        
        publishChange()
    }
    
    func publishChange() {
        var change = PanelLayout()
        for row in 0...3 {
            for column in 0...3 {
                let index = Index2D(row, column)
                let canMove = movable(at: index)
                change[index] = PanelDetail(panels[index]!, canMove)
            }
        }
        modelDidChange.send(change)
    }
}

Model の時と同様に PanelLayout の初期配置情報を static で用意しておくと 便利です。

以下のように、extension で定義しています。

extension Puzzle15Model {
    // ... snip ...
    nonisolated static var initialLayout: PanelLayout {
        [Index2D(0, 0): PanelDetail( 1, nil), Index2D(0, 1): PanelDetail( 2, nil),
         Index2D(0, 2): PanelDetail( 3, nil), Index2D(0, 3): PanelDetail( 4, nil),
         Index2D(1, 0): PanelDetail( 5, nil), Index2D(1, 1): PanelDetail( 6, nil),
         Index2D(1, 2): PanelDetail( 7, nil), Index2D(1, 3): PanelDetail( 8, nil),
         Index2D(2, 0): PanelDetail( 9, nil), Index2D(2, 1): PanelDetail(10, nil), 
         Index2D(2, 2): PanelDetail(11, nil), Index2D(2, 3): PanelDetail(12, Index2D(3,3)),
         Index2D(3, 0): PanelDetail(13, nil), Index2D(3, 1): PanelDetail(14, nil),
         Index2D(3, 2): PanelDetail(15, Index2D(3,3)), Index2D(3, 3): PanelDetail( 0, nil)]
    }
}

ViewModel テスト

ViewModel 向けの Model の準備ができたので、ViewModel のテストを書いていきます。

・初期化
  内部に初期配置のモデルを作成できているかをテストします
・View 向けの情報取得
・GridRow で使うであろう、特定行の Panel 情報を正しく渡してくれるかテストします
・各パネルが スライド可能であるかの情報が正しく取得できるかテストします
・View – ViewModel – Model の連携
・ViewModel から Model を更新した時に、(Model からの更新通知をうけて、) ViewModel が View 向けの@Published 設定された情報を更新するかをテストします

初期化テスト

Model の時と同様ですが、Model が actor であるため、少し(?) 工夫が必要です。

Model の initializeModel を await で待ってから、テストすることが必要です。

//
//  Puzzle15ViewModel_Tests.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/09
//  © 2023  SmallDeskSoftware
//

import XCTest
@testable import Puzzle15

typealias PanelDetail = Puzzle15Model.PanelDetail

final class Puzzle15ViewModel_Tests: XCTestCase {
    func test_init() async throws {
        let sut = Puzzle15ViewModel()
        XCTAssertNotNil(sut)
        
        XCTAssertEqual(sut.layout, Puzzle15Model.initialLayout)
    }
}    

View 向け情報のテスト

次に Grid 向けに GridRow 単位で正しい情報を提供することのテストです。

init のテストとほとんど同じですが、Row 毎に取得することができているかのテストです。

    func test_infoForGridRow() async throws {
        let sut = Puzzle15ViewModel()
        
        XCTAssertEqual(sut.panelRows(for: 0),
                       [PanelDetail( 1, nil ), PanelDetail( 2, nil ),
                        PanelDetail( 3, nil ), PanelDetail( 4, nil ) ])
        XCTAssertEqual(sut.panelRows(for: 1),
                       [PanelDetail( 5, nil ), PanelDetail( 6, nil ),
                        PanelDetail( 7, nil ), PanelDetail( 8, nil ) ])
        XCTAssertEqual(sut.panelRows(for: 2),
                       [PanelDetail( 9, nil ), PanelDetail(10, nil ),
                        PanelDetail(11, nil ), PanelDetail(12, Index2D(3,3) ) ])
        XCTAssertEqual(sut.panelRows(for: 3),
                       [PanelDetail(13, nil ), PanelDetail(14, nil ),
                        PanelDetail(15, Index2D(3,3) ), PanelDetail( 0, nil ) ])
    }

swap した時、ViewModel が更新されるかのテスト

ViewModel 経由で Model を変更した時に、最終的に ViewModel の View 向けの情報が更新され、適切な情報が取得できるかをテストします。

(3,2) にある パネル”15″ を (3,3) 空きパネル の位置に移動してテストしています。

    func test_vvmm_notification() async throws {
        let sut = Puzzle15ViewModel()

        let index32 = Index2D(3,2)
        let index33 = Index2D(3,3)
        
        let expectation = expectation(description: "ViewModel is updated")
        let cancellable = sut.$layout
            .dropFirst()
            .sink(receiveValue: { newModel in
            expectation.fulfill()
        })
        
        sut.swap(index32, index33)
        await fulfillment(of: [expectation], timeout: 10)
        
        XCTAssertEqual(sut.panelRows(for: 0),
                       [PanelDetail( 1, nil ), PanelDetail( 2, nil ),
                        PanelDetail( 3, nil ), PanelDetail( 4, nil ) ])
        XCTAssertEqual(sut.panelRows(for: 1),
                       [PanelDetail( 5, nil ), PanelDetail( 6, nil ), 
                        PanelDetail( 7, nil ), PanelDetail( 8, nil ) ])
        XCTAssertEqual(sut.panelRows(for: 2),
                       [PanelDetail( 9, nil ), PanelDetail(10, nil ),
                        PanelDetail(11, Index2D(3,2) ), PanelDetail(12, nil ) ])
        XCTAssertEqual(sut.panelRows(for: 3),
                       [PanelDetail(13, nil ), PanelDetail(14, Index2D(3,2) ),
                        PanelDetail( 0, nil ), PanelDetail(15, Index2D(3,2) )  ])
    }

ViewModel 実装

Model は、非同期に更新されるはずなので、ViewModel が更新されたという通知を待って、ViewModel の情報をテストするようにしています。

ViewModel の layout というプロパティには、@Published を付与するつもりなので、$ をつけることで Publisher として参照することができる想定です。

テストが書けたので実装していきます。

//
//  Puzzle15ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2023/09/09
//  © 2023  SmallDeskSoftware
//

import Foundation
import Combine

class Puzzle15ViewModel: ObservableObject {
    var model: Puzzle15Model
    @Published var layout: Puzzle15Model.PanelLayout
    var cancellable: AnyCancellable? = nil
    
    init() {
        model = Puzzle15Model()
        layout = Puzzle15Model.initialLayout
        
        cancellable = model.modelDidChange
            .sink { newModel in
                self.layout = newModel
            }
    }
    
    func panelRows(for row: Int) -> [Puzzle15Model.PanelDetail] {
        return (0...3).map({ Index2D(row, $0) }).compactMap({ layout[$0] })
    }

    func swap(_ index1: Index2D,_ index2: Index2D) {
        Task {
            await model.swap(index1, index2)
        }
    }
}

上記の ViewModel が、先ほどのテストコードをパスします。

init では、Model の initialize, Model の Publisher の subscribe をしています。

# Model を actor で実装したので、ViewModel の init() 内では、model の panels 情報等にはアクセスできません。
# そのため、初期配置を直接代入しています。

まとめ

15Puzzle 向けの ViewModel を実装しました。

15Puzzle 向けの ViewModel を実装
  • Model の変更は、取得するのではなく、通知として受ける
  • Model を actor で実装していると Model.init 直後に Model の内部情報は取得できない
  • Model へのアクセスは async なので、View に必要な情報は、ViewModel にキャッシュする

次回には、View と ViewModel を接続します。

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

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版が最新版です。

コメントを残す

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