注意(2022/6/19追記)
まずViewModelの機能を併せ持ったView
があるのに、やたらとMVVMにこだわるのはお勧めできません。
https://qiita.com/karamage/items/8a9c76caff187d3eb838 を参照してください。
@FetchRequestが駆除されなければならない理由
(釣りタイトルですいません。)
SwiftUIとCoreDataを組み合わせたアプリの例では、ほとんど全てと言っていいほど@FetchRequestが使われています。しかしこのプロパティラッパーは現時点でViewの内部でしか使えないため、必然的にViewがUIに表示するデータを持ってしまい、MVVMが成り立たなくなります。よってこの記事では、このプロパティラッパーを使わずにViewModel側からデータをフェッチし、なおかつViewを自動で更新する方法を紹介します。
(注意:初学者なので間違いがあるかもしれません。その場合コメントで指摘をお願いします。)
2021年7月23日 追記
ここまでMVVMにこだわる意味をあまり感じなくなってきました。Appleさんも、完全にMVVMでアプリを構築できるフレームワークにするつもりはないのでしょう。
方法
1. NSFetchedResultsControllerDelegate
https://developer.apple.com/documentation/coredata/nsfetchedresultscontrollerdelegate
公式ドキュメントには「フェッチ結果が変化したときに、関連するfetched result controllerによって呼び出されるメソッドを記述するデリゲート」とあります。簡単に言うと、データが変わったときにメソッドが呼ばれるよ、ということです。下記のコードは、DiaryというEntityをフェッチし、Viewに表示するViewModelです。
(DiaryDataModelというのはcontextを取得するためのクラスで、MVVMのModelにあたります。)
import Foundation
import CoreData
final class DiaryListViewModel: NSObject, NSFetchedResultsControllerDelegate, ObservableObject {
private let fetchController: NSFetchedResultsController<Diary>
var diaries: [Diary] {
return fetchController.fetchedObjects ?? []
}
override init() {
fetchController = {
let fetchRequest = NSFetchRequest<Diary>(entityName: "Diary")
fetchRequest.sortDescriptors = [.init(keyPath: \Diary.createdDate, ascending: true)]
return NSFetchedResultsController<Diary>(
fetchRequest: fetchRequest,
managedObjectContext: DiaryDataModel.context,
sectionNameKeyPath: nil,
cacheName: nil
)
}()
super.init()
fetchController.delegate = self
try? fetchController.performFetch()
}
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
print("❗️controllerWillChangeContent is called.")
objectWillChange.send() //更新をSwiftUIへ知らせる
}
......
}
fetchController
の初期化の書き方はわかりにくかもしれませんが、クロージャに最後()をつけて実行し、戻り値をセットしているだけです。
try? fetchController.performFetch()
で実際にフェッチさせて、fetchController
にデータを読ませます。どうやらデータベースの変更があっても再度フェッチする必要はないようです。
NSFetchedResultsControllerDelegate
に定義されているcontrollerWillChangeContent
メソッドでは、変更をSwiftUIに知らせるためにobjectWillChange.send()
を呼びます。
diaries
はget-onlyの計算型プロパティで、実際にView側からアクセスするところです。objectWillChange.send()
が呼ばれるとSwiftUIによって再度読み込まれ、Viewに反映されます。
Viewの方はこんな感じです↓
import SwiftUI
struct SidebarView: View {
@StateObject private var viewModel = DiaryListViewModel()
//ViewがViewModel以外にプロパティを持っていない...美しぃ...?
var body: some View {
List {
if !viewModel.diaries.isEmpty {
ForEach(viewModel.diaries) { diary in
Label(diary.name, systemImage: "book")
}
} else {
Text("ページがありません。")
}
}
}
}
NSFetchedResultsControllerDelegate
の使い方に関してはこの記事をかなり参考にさせていただきました。
2.NotificationCenter & Combine(失敗)
NSFetchedResultsControllerDelegate
を使う方法とほぼ同じですが、肝となる更新の方法は異なり、NSManagedObjectContextObjectsDidChange
というクソ長い名前のNotificationを使います。
import Foundation
import CoreData
import Combine
final class DiaryListViewModel: ObservableObject {
private var cancellables: Set<AnyCancellable> = []
private let fetchController: NSFetchedResultsController<Diary>
var diaries: [Diary] {
return fetchController.fetchedObjects ?? []
}
init() {
fetchController = {
let fetchRequest = NSFetchRequest<Diary>(entityName: "Diary")
fetchRequest.sortDescriptors = [.init(keyPath: \Diary.createdDate, ascending: true)]
return .init(fetchRequest: fetchRequest, managedObjectContext: DiaryDataModel.context, sectionNameKeyPath: nil, cacheName: nil)
}()
try? fetchController.performFetch()
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.sink { _ in
print("objecgt was changed.")
self.objectWillChange.send()
}
.store(in: &cancellables)
}
......
}
この場合、sink
のクロージャは呼ばれ、diaries
プロパティも再読み込みされていることは確認できたのですが、なぜかUIは更新されませんでした。クロージャ内でobjectWillChange.send()
を呼ぶことがいけないのでしょうか......(Combine使ったらカッコいいと思ったのに、残念)。問題点がわかった方、コメントで教えていただけると助かります🙇🏻♂️
あとがき
@AppStorage、@SceneStorageとかも「駆除」できそうですね。というか、ViewModelでも機能するようなプロパティラッパーを作るのもできそうです。作ったらまた記事にするかもしれません。
その他
参考記事
Using CoreData with SwiftUI : NSFetchedResultsControllerDelegateの使用法など
Core Data Notifications With Swift : Core Dataの様々なNotification
環境
OS : macOS Big Sur 11.1
Xcode: 12.4
Swift : 5.3.2