Sponsor Link
環境&対象
- macOS Big Sur 11.2.2
- Xcode 12.4
- iOS 14.4
value-type で作るモデル
WWDC のビデオとサンプルを確認して、value-type のモデルを使うことで、UNDO/REDO の実装がシンプルになりそうなことが見えてきたので、実際に 作っていきます。
value-type ??
Swift の基本型である Int や Double だけでなく、Array 等も すでに value-semantics の型になっています。つまり、代入時にコピーされるということです。
Swift で使える全ての型が value-type ではないので、モデルを作る時に、気をつけなくてはいけません。
例えば、NSArray は reference-semantics な型です。
value-type でモデルを作る(TODOItem)
例によって、TODO アプリを作る予定なので、以下のような struct を要素 TODOItem として定義します。
struct TODOItem: Hashable, Equatable {
enum Priority: Int, Equatable, CaseIterable, Identifiable, CustomStringConvertible {
case lowestPriority = 0, lowerPriority, middlePriority, higherPriorily, highestPriority
var id: Priority { self }
var description: String {
switch self {
case .lowestPriority:
return "Lowerst"
case .lowerPriority:
return "Lower"
case .middlePriority:
return "Middle"
case .higherPriorily:
return "Higher"
case .highestPriority:
return "Highest"
}
}
}
var id: UUID
var title: String
var priority: Priority
init(_ title: String, _ priority: TODOItem.Priority) {
self.id = UUID()
self.title = title
self.priority = priority
}
}
TODOItem としては、「タイトル」と「優先度」を持ちます。内部管理用として、id を持っています。
いずれも struct な型であり、value-type です。
モデルを作る(TODOItems)
アプリでは、TODOItem を複数もつ予定なので、TODOItem を複数持つ struct を定義します。
struct TODOItems {
var itemsDic: [UUID: TODOItem]
init(_ itemsDic: [UUID: TODOItem] = Dictionary()) {
self.itemsDic = itemsDic
}
}
TODOItem を そのまま配列 でもつのではなく、[UUID : TODOITem] という Dictionary 型で保持することにしてみます。
配列で持つ方法も考えられますが、以下を考慮し、Dictionary にしています。
value-type の モデルで気をつけるべき点 と Dictionary を採用した理由
Value 型を配列で持つときに、気をつけるべき点があります。それは、
「配列の中の要素を変更したい時に、配列から要素を取り出してしまうと 取り出した瞬間にコピーになってしまう。つまり、以降の変更は取り出した要素の変更となり、配列中の要素の変更にはならない」
ということです。
value 型は、受け渡しの時にコピーが行われ、意図しない変更が行われないという点は良い点でもあるのですが、変更を意図するときには、コピーが発生しているという点に気をつけなければいけません。
var targetItem = items[index]
targetItem.title = newTitle
つまり、上記のようなコードでは、コピーした targetItem を変更しているのであり、items[index] にある要素については、変更されないということです。
配列中の要素を変更したい時には、以下のようにしないと変更されません。
items[index].title = newTitle
つまり 配列に保存されている要素を変更する時には、改めて、配列中の index を算出し、その index を使用して変更を行うことが必要となります。
このことは、該当要素の index を探す回数が増えることにつながると考え、index 検索のコストを安くするという目的で、Dictionary を採用しました。
# Dictionary は key を使っての value 取得は、(Dictionary のもつ) 要素数に影響されず、固定時間で取得できるという特徴を持ちます。
# 対象が 100要素程度では、いずれにしても誤差かもしれません・・・
ここまでで TODOItems は、TODOItem 含め すべて value-type で構成されています。
以降では、テストを作りながら、CRUD の API 詳細を決めていきます。CRUD 以外のAPIは 必要となった時に 追加していく方針です。
テスト設計/実装
TODOItems をテストするということで、TODOItemsTests というクラスを作って テストを記述していきます。
作るテストは以下です。
- TODOItems モデルの作成をテスト(作成でき、要素数 0 であること)
- TODOItems から [TODOItem] を取得する(配列として取得できること)
- TODOItems から TODOItem を取得する(id 指定で、TODOItem を取得できること)
- TODOItem を追加する(追加でき、保存されている TODOItem が作成時に指定したものと一致すること)
- TODOItem を削除する(削除できること)
TODOItem の取得は、追加・削除のテストで使われるので、暗にテストされるかたちになります。
CRUD それぞれについて、以下のような メソッドを想定してます
- 配列で取得 → items:[TODOItem]
- UUID を使って取得 → item(withID:)
- TODOItem を追加 → add(_ TODOItem)
- TODOItem を削除 → remove(_ TODOItem)
以下が、TODOItems モデル作成と TODOItem 追加のテストコードです。
final class TODOItemsTests: XCTestCase {
func test_modelInitialize_initialize_shouldBeSuccess() throws {
// (1) TODOItems を作成
let sut = TODOItems()
// (2) 作成できていることをテスト
XCTAssertNotNil(sut)
// (3) 作成直後は要素数が0であることをテスト
XCTAssertEqual(sut.items.count, 0)
}
func test_createItem_fromScratch_shouldBeCreated() throws {
// (1) TODOItems を作成
var sut = try XCTUnwrap(TODOItems())
XCTAssertEqual(sut.items.count, 0)
// (2) TODOItem を作成
let newItem1 = TODOItem("title1", .lowestPriority)
// (3) TODOItem を TODOItems に追加
sut.add(newItem1)
// (4) TODOItems の持つ TODOItem の総数が1となったことをテスト
XCTAssertEqual(sut.items.count, 1)
// (2') TODOItem を作成
let newItem2 = TODOItem("title2", .higherPriorily)
// (3') TODOItem を TODOItems に追加
sut.add(newItem2)
// (4') TODOItems の持つ TODOItem の総数が2となったことをテスト
XCTAssertEqual(sut.items.count, 2)
// (5) TODOItems から id 指定で要素を取得
let retrievedItem1 = try XCTUnwrap(sut.item(withID: newItem1.id))
// (6) TODOModels から取得した TODOItem の中身が一致することを確認
XCTAssertEqual(retrievedItem1, newItem1)
// (5') TODOItems から id 指定で要素を取得
let retrievedItem2 = try XCTUnwrap(sut.item(withID: newItem2.id))
// (6') TODOModels から取得した TODOItem の中身が一致することを確認
XCTAssertEqual(retrievedItem2, newItem2)
}
}
まだ add や items, item(withID:) を実装していないので、コンパイルエラーになってしまいますが、テストコードは上記のような感じです。
# 実装中に困った時には、修正していきます。
モデル実装
TODOItems に items, add, item(withID:) を以下のように実装しました。
struct TODOItems {
var itemsDic: [UUID: TODOItem]
// var tags: [UUID: Tag]
init(_ itemsDic: [UUID: TODOItem] = Dictionary()) {
self.itemsDic = itemsDic
}
var items: [TODOItem] {
itemsDic.map { $0.value }
}
func item(withID id: UUID) -> TODOItem? {
itemsDic[id]
}
mutating func add(_ item: TODOItem) {
itemsDic[item.id] = item
}
}
上記実装で、テスト結果が緑になります。
テストをパスしたタイミングで、テストコード中の重複コードをまとめたくなりますが、まとめるのは少し大変です。
モデルが value-type で構成されているので 関数の引数で受け渡しすると作られたコピーが渡されてしまうため、渡した先での変更となり、テストできなくなってしまいます。
テスト設計/実装(続)
以下は、remove のテストコードです。
func test_removeItem_addThenRemove_shouldBecomeZeroItems() throws {
// (1) TODOItems を作成
var sut = try XCTUnwrap(TODOItems())
// (2) TODOItem を追加
let newItem1 = TODOItem("title1", .lowestPriority)
sut.add(newItem1)
XCTAssertEqual(sut.items.count, 1)
// (3) 追加した TODOItem を削除
sut.remove(newItem1)
// (4) TODOItems から削除できたことを確認
XCTAssertEqual(sut.items.count, 0)
}
モデル実装(続)
remove を実装します。
struct TODOItems {
var itemsDic: [UUID: TODOItem]
// var tags: [UUID: Tag]
init(_ itemsDic: [UUID: TODOItem] = Dictionary()/*, _ tags: [UUID: Tag] = Dictionary() */) {
self.itemsDic = itemsDic
// self.tags = tags
}
var items: [TODOItem] {
itemsDic.map { $0.value }
}
func item(withID id: UUID) -> TODOItem? {
itemsDic[id]
}
mutating func add(_ item: TODOItem) {
itemsDic[item.id] = item
}
mutating func remove(_ item: TODOItem) {
// (1) id が一致する最初の要素の index を探し
guard let index = itemsDic.firstIndex(where: { key, _ -> Bool in
key == item.id
}) else { return }
// (2) itemsDic から削除
itemsDic.remove(at: index)
}
}
上記の実装で、先ほど追加した remove のテストもパスします。
まとめ:value-type でモデルを作る時のポイント
- モデルを構成する要素に reference-type を入れ込まないように気をつける
- 受け渡しするとコピーが発生していることに気をつける。
次回は、ViewModel を作成し、UNDO/REDO ができる ベースを作ります。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link