Sponsor Link
環境&対象
- macOS Catalina 10.15.7
- Xcode 12.2
- iOS 14.2
いろいろと、コードを書いてきたので、一度 リファクタリング します。
ここまでの Model コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
// // MyTODOModel.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import Foundation import CoreData import os struct TODOItem { var id: UUID? = nil var title: String = "" var detail: String = "" let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem") init(_ title: String,_ detail: String = "") { self.id = UUID() self.title = title self.detail = detail } } // depend on CoreData struct TODOItemStore { var items:[TODOItem] = [] let container: NSPersistentContainer let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore") init(_ inMemory:Bool ) { container = NSPersistentContainer(name: "MyTODO") if inMemory { container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") } container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) fetchFromCoreData() } mutating func fetchFromCoreData() { let request:NSFetchRequest<CDTODOItem> = CDTODOItem.fetchRequest() do { items = try container.viewContext.fetch(request).map(TODOItem.init) } catch { logger.error("error in fetching data from coredata") } } } // MARK: CoreData dependent part extension TODOItem { init(_ cdItem: CDTODOItem) { self.id = cdItem.id self.title = cdItem.title ?? "" self.detail = cdItem.detail ?? "" } } extension TODOItemStore { // create Item , then put into coredata mutating func createTODOItem(_ title: String, _ detail: String = "") { let newItem = TODOItem(title, detail) let newCDItem = CDTODOItem(context: container.viewContext) newCDItem.id = newItem.id newCDItem.title = newItem.title newCDItem.detail = newItem.detail items.append(newItem) save() } mutating func removeTODOItem(_ item: TODOItem) { guard let id = item.id else { return } guard let index = items.firstIndex(where: {$0.id == item.id}) else { return } let request:NSFetchRequest<NSFetchRequestResult> = CDTODOItem.fetchRequest() request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg) let deleteRequest = NSBatchDeleteRequest.init(fetchRequest: request) do { try self.container.viewContext.execute(deleteRequest) } catch { print(error) } items.remove(at: index) save() } func save() { if !container.viewContext.hasChanges { return } do { try container.viewContext.save() } catch { print(error) } } } |
気になる点は、「TODOItemStore で TODOItem の配列を保持しておく必要はあるか?」です。
パフォーマンス上の問題が出るまでは、必要な時に、CoreData から fetch するようにしてみます。
こうすることで、TODOItemStore が保持している [TODOItem] と CoreData の保持している CDTODOItem の整合性を考える必要がなくなります。
なんとなく [TODOItem] を保持しておいて、@Published 指定すると、変更に対して 自動更新が起こりそうですが、Model 内部で持っている配列が変更されても、更新はされないです。
ということで、TODOItemStore は TODOItem を保持せず、リクエストに応じて、CoreData から fetch したものをベースに、TODOItem の配列を返すとしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
// // MyTODOModel.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import Foundation import CoreData import os struct TODOItem { var id: UUID? = nil var title: String = "" var detail: String = "" let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem") init(_ title: String,_ detail: String = "") { self.id = UUID() self.title = title self.detail = detail } } // depend on CoreData struct TODOItemStore { let container: NSPersistentContainer let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore") init(_ inMemory:Bool ) { container = NSPersistentContainer(name: "MyTODO") if inMemory { container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") } container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) } func fetchFromCoreData() -> [TODOItem] { var items:[TODOItem] = [] let request:NSFetchRequest<CDTODOItem> = CDTODOItem.fetchRequest() do { items = try container.viewContext.fetch(request).map(TODOItem.init) } catch { logger.error("error in fetching data from coredata") } return items } } // MARK: CoreData dependent part extension TODOItem { init(_ cdItem: CDTODOItem) { self.id = cdItem.id self.title = cdItem.title ?? "" self.detail = cdItem.detail ?? "" } } extension TODOItemStore { // create Item , then put into coredata func createTODOItem(_ title: String, _ detail: String = "") -> TODOItem { let newItem = TODOItem(title, detail) let description = NSEntityDescription.entity(forEntityName: "CDTODOItem", in: container.viewContext)! let newCDItem = CDTODOItem(entity: description, insertInto: container.viewContext) newCDItem.id = newItem.id newCDItem.title = newItem.title newCDItem.detail = newItem.detail save() return newItem } func removeTODOItem(_ item: TODOItem) { guard let id = item.id else { return } let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "CDTODOItem") request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg) let deleteRequest = NSBatchDeleteRequest.init(fetchRequest: request) do { try self.container.viewContext.execute(deleteRequest) } catch { print(error) } save() } func save() { if !container.viewContext.hasChanges { return } do { try container.viewContext.save() } catch { print(error) } } } |
ViewModel の作成
ViewModel は、ObseravableObject に準拠し、Model である TODOItemStore を保持するようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// // MyTODOViewModel.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import Foundation import SwiftUI import CoreData import os import Combine class MyTODOViewModel : ObservableObject { @Published var todoItemStore: TODOItemStore let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOViewModel", category: "MyTODOViewModel") init() { self.todoItemStore = TODOItemStore(false) // not in-memory CoreData } } |
TODOItem の配列も、@Published 指定で保持するようになると思いますが、必要になったら実装することにします。
App, View 実装前のクリーンアップ
App と View には、テンプレートコードが残っているので、ViewModel や View の実装を進める前にきれいにしておきます。
App のクリーンアップ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// // MyTODOApp.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import SwiftUI @main struct MyTODOApp: App { // (1) @StateObject var viewModel: MyTODOViewModel = MyTODOViewModel() var body: some Scene { WindowGroup { ContentView() // (2) .environmentObject(viewModel) } } } |
- App と同じライフサイクルを持つように、ViewModel を生成します。
- EnvironmentObject に登録し、すべての下位ビューから参照できるようにします
Persistence は使用しませんので、ファイルを削除して構いません。
View のクリーンアップ
ContentView は、以下のように修正しました。
- EnvironmentObject として、 ViewModel を参照します
- fetch 等の CoreData を直接操作する箇所を削除しました
- テンプレートで用意されているリストの追加・削除は、そのまま使えそうですので、ダミーのデータ(String) を用意して残しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
// // ContentView.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import SwiftUI import CoreData struct ContentView: View { @EnvironmentObject var viewModel: MyTODOViewModel @State private var items:[String] = ["test1", "test2"] var body: some View { NavigationView { List { ForEach(items, id:\.self) { item in Text("\(item)") } .onDelete(perform: deleteItems) } .toolbar { #if os(iOS) ToolbarItem(placement: .navigationBarLeading) { EditButton() } #endif ToolbarItem(placement: .navigationBarTrailing) { Button(action: addItem) { Label("Add Item", systemImage: "plus") } } } } } private func addItem() { withAnimation { print("addItem called") } } private func deleteItems(offsets: IndexSet) { withAnimation { print("deleteItems called") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView().environmentObject(MyTODOViewModel()) } } |
View, ViewModel 共に、実装を開始できる状態になったので、次回は、UITest, View, ViewModel を実装していきます。
テストを忘れずに
コードを変更したら、テストが通ることを確認します。
Model のコードを変えたのでそこに合わせるためにテストコードの修正も必要となりました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
// // Model_Tests.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import XCTest @testable import MyTODO class Model_Tests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func test_createItem_newItem_withCorrectValues() { // prep model with coredata let model = TODOItemStore(true) XCTAssertEqual(model.items.count, 0) // create new item _ = model.createTODOItem("Item Title", "item detail") XCTAssertEqual(model.items.count, 1) // get item from model let item = model.items.first! // test : compare properties XCTAssertEqual(item.title, "Item Title") XCTAssertEqual(item.detail, "item detail") } func test_removeItem_existingItem_shouldBeVanished() { let model = TODOItemStore(true) XCTAssertEqual(model.items.count, 0) // create new item _ = model.createTODOItem("Item Title", "item detail") XCTAssertEqual(model.items.count, 1) // get item from model let item = model.items.first! model.removeTODOItem(item) XCTAssertEqual(model.items.count, 0) } } |
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link