LoginSignup
13
13

More than 1 year has passed since last update.

MVVMを壊す悪魔、@FetchRequestを駆除する(Core Data)

Last updated at Posted at 2021-04-19
1 / 2

注意(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にあたります。)

DiaryListViewModel.Swift
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の方はこんな感じです↓

DiaryListView.Swift
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を使います。

DiaryListViewModel.Swift
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

13
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
13