Core DataをConcurrency(バックグランド)で使いこなす(SwiftUI)
Core DataをConcurrency(バックグランド)で使いこなす(SwiftUI)
Core DataをConcurrency(バックグランド)で使うということは、View(UI)のメインスレッド以外のバックグランドスレッドでCore Dataを扱うことで、View(UI)をロックしないことを目指します。
この記事での注意事項
- Appleのサンプル(Earthquake)の解説のようなものです。(少し改良しています。)
- SwiftUIの特性を使う関係で、謎のアーキテクチャ?の実装になります。
- うまくいっていない問題もあります。
実現したいこと
- Viewのデータは、SwiftUIの
@FetchRequest
を使用 - NSBatchInsertRequestでCore Dataに保存する(バックグランド)
- NSBatchDeleteRequestでCore Dataのデータを削除する(バックグランド)
実装の問題点・解決方法
実際に上のような設計で実装すると、CoreDataのデータに更新をかけても、View側に反映されないという問題が出てきます。
上記の問題は、viewContext(メインスレッド)以外でCore Dataを更新した場合、UIに変更が反映されないというものです。
Core Dataの変更をviewContextに伝える(mergeする)ことで、Viewの更新ができるようになります。
viewContextとは
自分はviewContextというものの実態が掴めず、うまく進められませんでした。
viewContextとはその名の通り、Viewに関するContextです。
Viewに関わるものなので、メインスレッドで動作します。
バックグランドスレッドで動作させるためには、newBackgroundContext()が必要です。
実装
Appleのサンプル(Earthquake)の地震アプリを作っていきます。
載せていないコードはサンプルを大体そのまま使用しています。
ContentView.swift
View(UI)面は、@FetchRequest
を使って普通に実装するだけです。
struct ContentView: View {
let quakesProvider: QuakesProvider = .init()
@FetchRequest(sortDescriptors: [SortDescriptor(\.time, order: .reverse)])
private var quakes: FetchedResults<Quake>
var body: some View {
NavigationView {
List {
ForEach(quakes, id: \.code) { quake in
NavigationLink(destination: QuakeDetail(quake: quake)) {
QuakeRow(quake: quake)
}
}
}
.navigationTitle("Earthquakes")
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
RefreshButton {
Task {
try! await quakesProvider.fetchQuakes()
}
}
Spacer()
DeleteButton {
Task {
try! await quakesProvider.deleteQuakes(identifiedBy: quakes.map(\.objectID))
}
}
}
}
}
}
}
QuakesProvider.swift
notificationToken
でCore Dataを監視して、NSPersistentHistoryChangeRequestを使って、viewContext側に、backgroundContextからマージさせます。
class QuakesProvider {
let url = URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson")!
private var notificationToken: NSObjectProtocol?
let container: NSPersistentContainer
init() {
container = PersistentController.shared.container
// Core Dataの変更を監視
notificationToken = NotificationCenter.default.addObserver(forName: .NSPersistentStoreRemoteChange, object: nil, queue: nil) { note in
Task {
try! await self.fetchPersistentHistoryTransactionsAndChanges()
}
}
}
deinit {
if let observer = notificationToken {
NotificationCenter.default.removeObserver(observer)
}
}
/// A persistent history token used for fetching transactions from the store.
private var lastToken: NSPersistentHistoryToken?
/// Fetches the earthquake feed from the remote server, and imports it into Core Data.
func fetchQuakes() async throws {
let (data, response) = try await URLSession.shared.data(from: url)
let httpResponse = response as! HTTPURLResponse
guard httpResponse.statusCode == 200 else { throw QuakeError.missingData }
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .secondsSince1970
let geoJSON = try jsonDecoder.decode(GeoJSON.self, from: data)
let quakePropertiesList = geoJSON.quakePropertiesList
try await importQuakes(from: quakePropertiesList)
}
private func importQuakes(from propertiesList: [QuakeProperties]) async throws {
guard !propertiesList.isEmpty else { return }
let backgroundContext = container.newBackgroundContext()
try await backgroundContext.perform {
let batchInsertRequest = NSBatchInsertRequest(entity: Quake.entity(), objects: propertiesList.map(\.dictionaryValue))
let fetchResult = try backgroundContext.execute(batchInsertRequest)
let batchInsertResult = fetchResult as? NSBatchInsertResult
if let success = batchInsertResult?.result as? Bool, success {
return
}
else {
throw QuakeError.batchInsertError
}
}
}
func deleteQuakes(identifiedBy objectIDs: [NSManagedObjectID]) async throws {
guard !objectIDs.isEmpty else { return }
let taskContext = container.newBackgroundContext()
try await taskContext.perform {
let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
let fetchResult = try taskContext.execute(batchDeleteRequest)
guard let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
let success = batchDeleteResult.result as? Bool, success
else {
throw QuakeError.batchDeleteError
}
}
}
func fetchPersistentHistoryTransactionsAndChanges() async throws {
let backgroundContext = container.newBackgroundContext()
let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
try await backgroundContext.perform {
let historyResult = try backgroundContext.execute(changeRequest) as? NSPersistentHistoryResult
guard let history = historyResult?.result as? [NSPersistentHistoryTransaction] else { throw QuakeError.persistentHistoryChangeError }
guard !history.isEmpty else { return }
// MainThreadのContextを使わないといけない
let viewContext = self.container.viewContext
viewContext.perform {
for transaction in history {
viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
self.lastToken = transaction.token
}
}
}
}
}
PersistentController.swift
struct PersistentController {
static let shared = PersistentController()
let container: NSPersistentContainer
init() {
self.container = NSPersistentContainer(name: "Earthquakes")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
// CoreDataの変更を検知するためのオプション(NSPersistentStoreRemoteChange)
// Enable persistent store remote change notifications
/// - Tag: persistentStoreRemoteChange
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
)
// Enable persistent history tracking
/// - Tag: persistentHistoryTracking
description.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
// This sample refreshes UI by consuming store changes via persistent history tracking.
/// - Tag: viewContextMergeParentChanges
container.viewContext.automaticallyMergesChangesFromParent = false
container.viewContext.name = "viewContext"
/// - Tag: viewContextMergePolicy
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
}
}
問題点(初回の反映が遅い)
初回の反映が遅い
2回目以降の、Batch処理は比較的すぐ反映されるのですが、1回目が遅いです。
mergeChanges()
が初回に150以上走っているのが、原因だと思うのですが対象方法はわかりませんでした。
2回目以降は、データ数がいくら多くても1回で済んでいます。
Discussion