[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その3:1回目のリファクタリング と ViewModel の作成)

SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。

環境&対象

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

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

いろいろと、コードを書いてきたので、一度 リファクタリング します。

ここまでの Model コード

MyTODOModel.swift

//
//  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.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 = 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 の配列を返すとしました。

MyTODOModel

//
//  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.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 = 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 を保持するようにします。

MyTODOViewModel

//
//  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 のクリーンアップ

MyTODOApp.swift

//
//  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)
        }
    }
}
コード解説
  1. App と同じライフサイクルを持つように、ViewModel を生成します。
  2. EnvironmentObject に登録し、すべての下位ビューから参照できるようにします

Persistence は使用しませんので、ファイルを削除して構いません。

View のクリーンアップ

ContentView は、以下のように修正しました。

  • EnvironmentObject として、 ViewModel を参照します
  • fetch 等の CoreData を直接操作する箇所を削除しました
  • テンプレートで用意されているリストの追加・削除は、そのまま使えそうですので、ダミーのデータ(String) を用意して残しています。
ContentView.swift code

//
//  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 のコードを変えたのでそこに合わせるためにテストコードの修正も必要となりました。

Model_Tests code

//
//  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)
    }
}

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

コメントを残す

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