[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その2:MVVM アーキテクチャ導入)

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

環境&対象

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

  • macOS Catalina 10.15.7
  • Xcode 12.2
  • iOS 14.2

Xcode からテンプレートして作成されるコードは、MVVM とは言い難いので、MVVM アーキテクチャになるよう修正していきます。

MVVM 視点で修正したい箇所

MVVM では、View は、ViewModel のみを知っていることが前提ですが、テンプレートで生成されたコードには、ContentView に、CoreData が直接出てくるので、NG です。

Environment に、ManagedObjectContext を設定できるようにしている時点で、Apple としては、CoreData を Model Layer に留めておく気はないのかもしれません。

修正していく方針

  • View は ViewModel 経由で Model にアクセスする
  • Model は、基本ロジック等の CoreData に依存しない部分と、保存等の CoreData に依存する部分を意識して分離する
  • @Published 等の仕組みで変更を伝播させることとして、@FetchRequest は当面使用しない

特に2つ目の点は、将来的に、モデルレイヤーの入れ替え、例えば、CoreData からRealm に切り替えようとした時に、作り直しになってしまうかどうかに大きな影響を与えます。

モデルを作る(TODOItem)

TODO のアプリなので、以下のプロパティを持つ要素を作ることにします。

  • ID (id:UUID)
  • タイトル (title:String)
  • 詳細 (detail:String)

Model も CoreData に非依存で作成したいので、Swift 的なモデルと CoreData 上のモデルの2つを作成します。
Swift 的な struct を、TODOItem とし、 CoreData 上の Entity を CDTODOItem としました。(CD = CoreData)

TODOItem code

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.title = title
        self.detail = detail
    }
}
CDTODOItem 定義
CDTODOItem 定義

モデルを作る(TODOItemStore)

TODOItem を束ねて保持する要素が欲しくなるので、TODOItemStore とします。

できれば、TODOItemStore も CoreData 非依存としたいのですが、Model を CoreData 非依存とすると、ViewModel で Model と CoreData を管理することになってしまいます。

ViewModel を CoreData 非依存にしたいので、TODOItemStore だけは、CoreData へのリファレンスを持つこととしました。

Model の中で、DB layer 含めて完結する形になります。

TODOItemStore

// depend on CoreData
struct TODOItemStore {
    var items:[TODOItem] = []
    let container: NSPersistentContainer

    let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore")
}

PersistentContainer から要素を読み込むために TODOItem.init と TODOItemStore.init を作ります。

TODOItem.init, TODOItemStore.init

// MARK: CoreData dependent part
extension TODOItem {
    init(_ cdItem: CDTODOItem) {
        self.id = cdItem.id
        self.title = cdItem.title ?? ""
        self.detail = cdItem.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")
        }
    }
}

ここまでで、モデルに対して、コンパイルできる環境は作れたので、Model に対して、基本機能の実装を始めます。

TDD で Model の実装をすすめる

MyTODOApp.swift や ContentView.swift を変更する前に、モデルに機能を実装していきます。

現時点では、モデルの定義のみ存在し、機能する API がありません。

要素作成テストの実装

まずは、要素を作成し Model に保存できるかを確認するテストを作ります。

Model のテスト用に、Model_Tests.swift を、MyTODOTest フォルダ下に作成します。

TODOItem を作成できることを確認する test_createItem_newItem_CorrectValues を以下のように書きました。

test_createItem_newItem_CorrectValues

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

メソッド定義すらしていないので、当然コンパイルエラーです。

TODOItemStore.createTODOItem 実装

TODOItem を作るメソッドを以下のように実装しました。

TODOItemStore.createTODOItem

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()
    }
    func save() {
        if !container.viewContext.hasChanges { return }
        do {
            try container.viewContext.save()
        } catch {
            print(error)
        }
    }
}

上記のコードを追加すると、コンパイルできるようになり、テストもパスします。

現時点では、オンメモリの CoreData を使ってテストしているので、きちんと CoreData に保存されたかをテストする方法がありませんが、後日、追加することを検討します。

要素削除テストの実装

削除のテストも作ってみます。

test_removeItem_existingItem_shouldBeVanished

    func test_removeItem_existingItem_shouldBeVanished() {
        var 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)
    }

要素削除の実装

削除するメソッドは、以下のように実装しました。

TODOItemStore.removeTODOItem

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

上記の実装で、テストはパスします。

ここまでの実装で、プログラム的には、TODOItem を作成し、削除することができるようになりました。

次回は、UI を使ったテストとその実装を行っていきます。

まとめ:Xcode12.2 で CoreData プロジェクト:CoreData は、M(Model) に入れてしまうのが良い

モデルと CoreData の組み合わせ方がポイント
  • テンプレートは、MVVM になっていないので、修正が必要
  • CoreData は、Model 内に留めておくと、綺麗な MVVM を作りやすい
  • CoreData に依存する Model を限定的にしておくと、再利用しやすい

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

コメントを残す

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