🦔

Core DataをConcurrency(バックグランド)で使いこなす(SwiftUI)

2023/03/26に公開

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