클라우드킷 레코드의 로컬 캐시 유지하기 CloudKit


Maintaining a Local Cache of CloudKit Records

https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitQuickStart/MaintainingaLocalCacheofCloudKitRecords/MaintainingaLocalCacheofCloudKitRecords.html

앱이 오프라인에서 실행되거나 성능을 향상시키기 위해 클라우드킷 레코드의 로컬 캐시를 추가 할 수 있다. 또는 자체적인 데이터 저장소를 두고 클라우드킷에서 데이터를 유지하기위한 지원을 추가하기를 원할 수도 있다.

일반 작업흐름

앱을 로컬 캐시를 유지한 후 앱이 따른 일반 흐름은 다음과 같다.
1. 새로운 장치에서 처음으로 앱이 실행되면 사용자의 프라이비트 그리고 공유된 데이터베이스에 변화에 대한 구독된다.
2. 사용자가 자체데이터를 장치 A에서 수정하고 앱은 이런 변화를 클라우드킷에 보낸다.
3. 앱은 같은 사용자의 장치 B에서 푸시 알림을 받아 서버에서 변화가 있음을 감지한다.
4. 장치B는 최종적으로 변화가 있음을 서버에게 묻고 이런 변화로 로컬 캐시를 업데이트 한다.

컨테이너 초기화하기

앱의 초기화 로직은 앱이 실행될 때 언제나 실행되어야 한다. 앱은 이미 생성한 존과 구독에 상관없이 지역적으로 캐시되어 모든 실행에서 불필요한 요청이 없게 한다.

먼저 다음 코드는 이 예시를 따르는 아이템을 정의한다.

let container = CKContainer.default()
let privateDB = container.privateCloudDatabase
let sharedDB = container.sharedCloudDatabase

// 사용자의 장치사이에 일관된 존 ID를 사용한다.
// CKCurrentUserDefaultName은 존ID를 생성할 때의 현재 사용자의 ID를 지정한다.
let zoneID = CKRecordZoneID(zoneName: "Todos", ownerName: CKCurrentUserDefaultName)

// 실행 사이에서 유지되도록 디스크에 저장한다.
var createdCustomZone = false
var subscribedToPrivateChanges = false
var subscribedToSharedChanges = false

let privateSubscriptionId = "private-changes"
let sharedSubscriptionId = "shared-changes"

사용자 존 생성하기

클라우드킷의 기능성추적 변화를 사용하려면 사용자의 프라이비트 데이터베이스에 사용자 존 내의 데이터를 저장해야 한다. 사용자 존은 아래에 보여진 바와 같이 CKModifyRecordZonesOperation 을 사용해 생성할 수 있다.

let createZoneGroup = DispatchGroup()
if !self.createdCustomZone {
  createZoneGroup.enter()
  let customZone = CKRecordZone(zoneID: zoneID)
  let createZoneOperation = CKModifyRecordZonesOperation(recordZonesToSave: [customZone], recordZoneIDsToDelete: [])
  createZoneOperation.modifyRecordZonesCompletionBlock = ( (saved, deleted, error) in
    if (error == nil) {
      self.createdCustomZone = true
    }
    // else custom error handling
    createZoneGroup.leave()
  }
  createZoneOperation.qualityOfService = .userInitiated
  self.privateDB.add(createZoneOperation)
}

변화 알림에 구독하기

다른 장치로부터의 변화에 구독할 필요가 있다. 구독은 클라우드킷에서 관심있는 데이터가 무엇인지를 알려 데이터가 변화될 때 푸시알림을 보낼 수 있게 한다.

앱은 데이터베이스 변화 (CKDatabaseSubscription)에 두 개의 구독을 생성할 필요가 있는데, 하나는 프라이비트 데이터베이스 그리고 하나는 공유된 데이터베이스이다.

if !self.subscribedToPrivateChanges {
  let createSubscriptionOperation = self.createDatabaseSubscriptionOperation(subscriptionId: privateSubscriptionId)
  createSubscriptionOperation.modifySubscriptionCompletionBlock = { (subscriptions, deletedIds, error) in
    if error == nil {
      self.subscribedToPrivateChanges = true
    }
    // else custom error handling
  }
  self.privateDB.add(createSubscriptionOperation)
}

if !self.subscribedToSharedChanges {
  let createSubscriptionOperation = self.createDatabaseSubscriptionOperation(subscriptionId: sharedSubscriptionId)
  createSubscriptionOperation.modifySubscriptionsCompletionBlock = { (subscriptions, deletedIds, error) in
    if error == nil {
      self.subscribedToSharedChanges = true
    }
  }
  self.sharedDB.add(createSubscriptionOperation)
}

// 앱이 실행중이 아닐 때 발생한 변화를 얻는다.
createZoneGroup.notify(queue: DispatchQueue.global()) {
  if self.createdCustomZone {
    self.fetchChanges(in: .private) {}
    self.fetchChanges(in: .shared) {}
  }
}

이들 구독은 클라우드킷에게 데이터베이스내의 레코드나 존이 추가, 수정, 삭제되면 이 장치상의 앱에게 푸시알림을 보내게 한다.

사일런트 푸시 알림을 보내도록 구독을 설정할 수도 있다. 이들 알림은 앱을 깨워 변화를 페치하지만 앱은 사용자에게 엘럿을 보여주지 않는다.

func createDatabaseSubscriptionOperation(subscriptionId: String) -> CKModifySubscriptionsOperation {
  let subscription = CKDatabaseSubscription.init(subscriptionID: subscriptionId)
  let notificationInfo = CKNotificationInfo()
  // 사일런트 알림 보내기
  notificationInfo.shouldSendContentAvailable = true
  subscription.notificationInfo = notificationInfo

  let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: [])
  operation.qualityOfService = .utility
  return operation
}

푸시알림 리스닝

클라우드킷을 사용하는 앱을 설정하는 부분으로서 원격 알림을 위해 앱을 리슨하게 해야 한다.
CKSubscription으로 실행되는 리모트 알림으로 알 수 있는 userInfo 딕셔너리의 CKNotification(fromRemoteNotificationDictionary: dict) 를 사용한다.

func application(_ application: UIApplication, didFinishLaunchingWithOperations launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
  application.registerForRemoteNotification()
  return true
}

func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
  print("Received notification!")
  let viewController = self.window?.rootViewController as? ViewController
  guard let viewController = self.window?.rootViewController as? ViewController else {return}
  let dict = userInfo as! [String: NSObject]
  guard let notification: CKDatabaseNotification = CKNotification(fromRemoteNotificationDictionary:dict) as? CKDatabaseNotification else { return }
  viewController!.fetchChanges(in: notification.databaseScope) {
    completionHandler(.newData)
  }
}


변화 페칭

앱이 실행하거나 푸시를 받으면 앱은 CKFetchDatabaseChangesOperation 을 사용하고 CKFetchRecordZoneChangesOperation으로 마지막으로 업데이트한 것에서 변화된 것만 서버에게 질의한다.

이 연산의 핵심은 previousServerChangeToken 객체로서 서버에게 앱이 마지막으로 서버에 물어본 때를 알려주도록 한다. 서버는 그 시간 이후에 변화를 반환하도록 한다.

먼저 CKFetchDatabaseChangesOperation 을 사용해 어떤 존이 변경되었는지를 찾고
1. 새롭거나 업데이트된 존을 위한 ID들을 취합한다.
2. 존에서 삭제된 로컬 데이터를 제거한다.

데이터베이스 변화를 얻기 위한 예시코드가 여기에 있다.

func fetchChanges(in databaseScope: CKDatabaseScope, completion: @escaping() -> Void) {
  switch databaseScope {
  case .private:
    fetchDatabaseChanges(database: self.privateDB, databaseTokenKey: "private", completion: completion)
  case .shared:
    fetchDatabaseChanges(database: self.sharedDB, databaseTokenKey: "shared", completion: completion)
  case .public:
    fatalError()
  }
}

func fetchDatabaseChanges(database: CKDatabase, databaseTokenKey: String, completion: @escaping() -> Void) {
  var changedZoneIDs: [CKRecordZoneID] = []
  let changeToken = ... // Read Change token from disk
  let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: changeToken)

  operation.recordZoneWithIDChangedBlock = { (zoneID) in
    changedZoneIDs.append(zoneID)
  }

  operation.recordZoneWithIDWasDeletedBlock = { (zoneID) in
    // Write this zone deletion to memory
  }

  operation.changeTokenUpdatedBlock = {(token) in
    // 이 데이터베이스의 존 삭제를 디스크 플러쉬한다.
    // 이 새로운 데이터베이스 변경 토큰을 메모리에 적는다.
  }

  operation.fetchDatabaseChangesCompletionBlock = {(token, moreComing, error) in
    if let error = error {
      print("Error during fetch shared database changes operation", error)
      completion()
      return
    }
    // 이 데이터베이스의 존 삭제를 디스크 플러쉬한다.
    // 이 새로운 데이터베이스 변화 토큰을 메모리에 적는다.
    self.fetchZoneChanges(database: database, databaseTokenKey: databaseTokenKey, zoneIDs: changedZoneIDs) {
      // 인메모리 데이터베이스 토큰을 디스크로 플러쉬한다.
      completion()
    }
  }
  operation.qualityOfService = .userInitiated
  database.add(operation)
}

다음으로 앱은 CKFetchRecordZoneChangesOperation 객체를 사용해 취합한 존 IDs집합으로 다음을 수행한다.
1. 모든 변화된 레코드에 대해 생성과 업데이트를 수행
2. 더이상 존재하지 않는 레코드를 삭제한다.
3. 존 변화 토큰을 업데이트 한다.

여기에 존 변화를 페치하는 예시코드가 있다.

func fetchZoneChanges(database: CKDatabase, databaseTokenKey: String, zoneIDs: [CKRecordZoneID], completion: @escaping() -> Void) {
  // 각 존에 대해 이전 변화 토큰을 살펴본다.
  var optionsByRecordZoneID = [CKRecordZoneID: CKFetchRecordZoneChangesOptions]()
  for zoneID in zoneIDs {
    let options = CKFetchRecordZoneChangesOption()
    options.previousServerChangeToken = ... // Read change token from disk
      optionsByRecordZoneID[zoneID] = options
  }
  let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIDs, optionsByRecordZoneID: optionsByRecordZoneID)

  operation.recordChangedBlock = { (record) in
    print("Record changed: ", record)
    // write this record change to memory
  }
  operation.recordWithIDWasDeletedBlock = ( (recordId) in
    print("Record deleted: ", recordId)
    // write this record deletion to memory
  }
  operation.recordZoneChageTokensUpdatedBlock = ( (zoneId, token, data) in
    // 이 존 내의 레코드 변화와 삭제를 디스크에서 플러시 한다.
    // 이 새로운 존 변경 토큰을 디스크에 적는다.
  }

  operation.recordZoneFetchCompletionBlock = { (zoneId, changeToken, _, _, error) in
    if let error = error {
      print("Error fetching zone changes for \(databaseTokenKey) database: ", error)
      return
    }
    // 이 존 내의 레코드 변화와 삭제를 디스크에서 플러시 한다.
    // 새로운 존 변경 토큰을 디스크에 적는다.
  }
  
  operation.fetchRecordZoneChangesCompletionBlock = { (error) in
    if let error = error {
      print("Error fetching zone changes for \(databaseTokenKey) database: ", error)
    }
    completion()
  }
  database.add(operation)
}

위의 코드는 메모리에 변화를 적는데에대한 몇가지 코멘트가 있고 디스크에 변화를 플러싱한다. 일반적인 흐름은 다음과 같다.

데이터베이스에서는:
- zone 삭제에 대해 메모리에 적는다.
- 데이터베이스에 대해 새로운 변화가 있으면 메모리에 토큰을 적는다. 그리고 다음을 수행한다.
  . 디스크에 인메모리 존을 유지 (삭제된 존을 삭제하고 페치하기를 원하는 콘의 리스트 기록) 하거나 또는
  . 디스크에 존 삭제를 유지하고 모든 수정된 레코드 존에 대한 변화를 페치한다.

일러두기: 데이터베이스 변경을 페치할 때 데이터베이스 변화토컨을 얻기전에 모든 존 당 콜백을 유지하여야 한다.
- 최종적으로, 업데이트된 데이터베이스 변경 토큰을 디스크에 플러시한다.

비슷하게, 존에 대해서는
- 존 내의 레코드 변경에 대해 메모리에 적는다.
- 존에 대해 새로운 변화 토큰은 해당 존 내의 모든 인메모리 레코드 변경과 해당 존의 업데이트된 변화 토큰까지 커밋한다.

레코드 메타데이터 저장하기

서버상의 레코드와 로컬 데이터 저장소내의 레코드를 연관시키려면 레코드(레코드 이름, 존ID, 변화태그, 생성 날짜 등)를 위한 메타데이터를 저장해야 한다. 이는 CKRecord, encodeSystemFieldsWithCoder라는 편리한 메소드가 시스템 필드를 위해 도와준다. 자체적인 사용자 필드는 구별해서 처리해야 한다.

어떻게 지역적으로 저장하기 위해 메타데이터를 읽어들이는지 보여준다.
// CKRecord 로부터 메타데이터를 얻는다.
let data = NSMutableData()
let coder = NSKeyedArchiver.init(forWritingWith: data)
coder.requiresSecureCoding = true
record.encodeSystemFields(with: coder)
coder.finishEncoding()

// 이 메타데이터를 로컬 객체에 저장한다.
yourLocalObject.encodeSystemFields = data

로컬 데이터에 기반해 클라우드킷에 변화를 보낼 때 로컬 캐시 객체를 클라우드킷 객체로 읽을 수 있으며 이들을 처리해 클라우드킷내의 저장소에 맞게 처리할 수 있다.

// CKRecord를 그 메타데이터로 설정한다.
let coder = NSKeyedUnarchiver(forReadingWith: yourLocalObject.encodedSystemFields!)
coder.requiresSecureCoding = true
let record = CKRecord(coder: coder)
coder.finishDecoding()
// 커스텀 필드 작성하기

고급 로컬 캐싱

사용자는 클라우드킷 서버의 데이터를 아이클라우드 설정 -> 저장소 관리 메뉴로 앱 데이터를 삭제할 수 있다. 이에 대한 처리를 섬세하게 해야 하며 이들이 존재하지 않으면 존과 구독을 다시 생성해야 한다. 이 상황에서의 에러는 userDeletedZone이다.

연산 의존 시스템은 WWDC2015의 고급 NSOperations 에서 클라우드킷 연산을 관리하는 훌륭한 방법을 설명하는데 계정과 네트워크 상태 그리고 존과 구독은 적절한 시간에 생성되어야 한다는 것이다.

언제든 네트워크 연결이 사라질 수 있으니 모든 연산에 대해 networkUnavailable 에러를 적절히 처리해야 한다.

네트워크 접근성을 확인하고 네트워크가 다시 가용하면 연산을 다시 시도한다.

함께 보기

WWDC 2016 클라우드킷 최고 연습 비디오 에서 로컬 캐싱에 대해 더 살펴본다.
https://developer.apple.com/videos/play/wwdc2016/231/








덧글

댓글 입력 영역