Appleの公式ドキュメントを見て見たところ、前回記事ではローカルからCloudKitへの変更差分の送信という点を扱っていなかったので、今回はOSSを見ながら様々なアプローチを探っていきます。
作りかけで放置されているものや、出来上がっても数年メンテナンスされていないものもあるので、あくまでこんなアプローチがあるのか。的な感じで思っておいてください。
基本的な方針
WWDCのAdvanced CloudKitから
あなたのアプリがすべきこと
- 変更を追跡する(変更管理のテーブルにて、と言及があった。)
- 変更をサーバーに送る
- コンフリクトの解消
- サーバーから変更のフェッチ
- サーバーの変更の適応
Seam
環境
- CoreData
- swift2。swift3.verは別の開発者によって別レポジトリで進められている。
特徴
・変更管理用のエンティティを持っている。
Change.swift
class Change: NSManagedObject {
@NSManaged var entityName: String?
@NSManaged var type: NSNumber?
@NSManaged var properties: String?
@NSManaged var queued: NSNumber?
@NSManaged var creationDate: NSDate
中略...
struct Entity {
static let name = "Seam_Change"
static var entityDescription: NSEntityDescription {
let entityDescription = NSEntityDescription()
entityDescription.name = name
entityDescription.properties.append(UniqueID.attributeDescription)
entityDescription.properties.append(Properties.ChangeType.attributeDescription)
entityDescription.properties.append(Properties.EntityName.attributeDescription)
entityDescription.properties.append(Properties.ChangedProperties.attributeDescription)
entityDescription.properties.append(Properties.CreationDate.attributeDescription)
entityDescription.managedObjectClassName = "Seam.Change"
return entityDescription
}
}
中略...
struct Properties {
struct ChangeType {
static let name = "type"
static var attributeDescription: NSAttributeDescription {
let attributeDescription = NSAttributeDescription()
attributeDescription.name = name
attributeDescription.attributeType = .Integer16AttributeType
attributeDescription.optional = false
attributeDescription.indexed = true
return attributeDescription
}
}
以下略...
・メタデータ保存用のエンティティを持っている。
詳細
CKRecordをエンコードして保存するのに使う
Metadata.swift
class Metadata: NSManagedObject {
@NSManaged var entityName: String?
@NSManaged var data: NSData?
/**
* Entity information for Metadata.
*/
struct Entity {
static let name = "Seam_Metadata"
static var entityDescription: NSEntityDescription {
let entityDescription = NSEntityDescription()
entityDescription.name = name
entityDescription.properties.append(UniqueID.attributeDescription)
entityDescription.properties.append(Properties.Data.attributeDescription)
entityDescription.managedObjectClassName = "Seam.Metadata"
return entityDescription
}
}
// MARK: Properties
/**
* Properties information belonging to Metadata entity.
*/
struct Properties {
struct Data {
static let name = "data"
static var attributeDescription: NSAttributeDescription {
let attributeDescription = NSAttributeDescription()
attributeDescription.name = name
attributeDescription.attributeType = .BinaryDataAttributeType
attributeDescription.optional = false
return attributeDescription
}
}
}
・NSIncrementalStoreを継承したサブクラスを使うことで、自動的な変更管理を実現している。
Store.swift
override public func executeRequest(request: NSPersistentStoreRequest, withContext context: NSManagedObjectContext?) throws -> AnyObject {
var result: AnyObject = []
try backingMOC.performBlockAndWait {
if let context = context {
if let fetchRequest = request as? NSFetchRequest {
result = try self.executeFetchRequest(fetchRequest, context: context)
} else if let saveChangesRequest = request as? NSSaveChangesRequest {
result = try self.executeSaveChangesRequest(saveChangesRequest, context: context)
} else {
throw Error.InvalidRequest
}
}
}
return result
}
中略...
// MARK: SaveChangesRequest
private func executeSaveChangesRequest(request: NSSaveChangesRequest, context: NSManagedObjectContext) throws -> [AnyObject] {
if let deletedObjects = request.deletedObjects {
try deleteObjectsFromBackingStore(objectsToDelete: deletedObjects, context: context)
}
if let insertedObjects = request.insertedObjects {
try insertObjectsInBackingStore(objectsToInsert: insertedObjects, context: context)
}
if let updatedObjects = request.updatedObjects {
try updateObjectsInBackingStore(objectsToUpdate: updatedObjects, context: context)
}
try backingMOC.performBlockAndWait {
try self.backingMOC.saveIfHasChanges()
}
return []
}
func insertObjectsInBackingStore(objectsToInsert objects:Set<NSManagedObject>, context: NSManagedObjectContext) throws {
try backingMOC.performBlockAndWait {
try objects.forEach { sourceObject in
let backingObject = NSEntityDescription.insertNewObjectForEntityForName((sourceObject.entity.name)!, inManagedObjectContext: self.backingMOC) as NSManagedObject
if let uniqueID = self.backingMOC.uniqueIDForInsertedObject(sourceObject) {
backingObject.uniqueID = uniqueID
} else {
let uniqueID = self.uniqueIDForObjectID(sourceObject.objectID)
backingObject.uniqueID = uniqueID
}
self.setAttributeValuesForBackingObject(backingObject, sourceObject: sourceObject)
try self.setRelationshipValuesForBackingObject(backingObject, fromSourceObject: sourceObject)
if context.doesNotAllowChangeRecording == false {
self.changeManager.new(backingObject.uniqueID, changedObject: sourceObject)
}
}
}
}
以下略...
ちょっと解説
NSManagedObjectContextでの変更・フェッチ要求は、NSPersistentStoreCoordinatorを通じてNSIncrementalStoreのサブクラスのexecuteRequest()を呼び出すので、その部分をoverrideして変更のあった場合は変更管理用のオブジェクトを追加している。
・ローカル送信の流れ
Sync.swift
func applyLocalChanges() throws {
1. 別メソッドから変更管理オブジェクトの一覧を取得
let changes = try localChanges()
var conflictedRecords = [CKRecord]()
2. 追加/変更されたオブジェクトのCKRecordと削除されたオブジェクトのCKRecordIDを取得
var insertedOrUpdatedCKRecordsAndChangesWithIDs = [CKRecordID: (record: CKRecord, change: Change)]()
changes.insertedOrUpdatedRecordsAndChanges.forEach {
insertedOrUpdatedCKRecordsAndChangesWithIDs[$0.record.recordID] = $0
}
let insertedOrUpdatedCKRecords = changes.insertedOrUpdatedRecordsAndChanges.map { $0.record }
let deletedCKRecordIDs = changes.deletedCKRecordIDs
3. CustomZone内のレコードを修正
let modifyRecordsOperation = CKModifyRecordsOperation(recordsToSave: insertedOrUpdatedCKRecords, recordIDsToDelete: deletedCKRecordIDs)
modifyRecordsOperation.perRecordCompletionBlock = { (record, error) in
if let record = record, let error = error where error.code == CKErrorCode.ServerRecordChanged.rawValue {
conflictedRecords.append(record)
}
}
operationQueue.addOperation(modifyRecordsOperation)
operationQueue.waitUntilAllOperationsAreFinished()
4. 更新されたCKRecordから、ローカルのメタデータを修正
try changes.insertedOrUpdatedRecordsAndChanges.forEach {
try metadataManager.setMetadata(forRecord: $0.record)
}
5. コンフリクトの処理
guard conflictedRecords.count == 0 else {
var conflictedRecordsWithChanges = [(record: CKRecord, change: Change)]()
conflictedRecords.forEach { record in
guard let recordWithChange = insertedOrUpdatedCKRecordsAndChangesWithIDs[record.recordID] else {
return
}
conflictedRecordsWithChanges.append(recordWithChange)
}
throw Error.ConflictsDetected(conflictedRecordsWithChanges: conflictedRecordsWithChanges)
}
6. 更新完了した更新管理用のオブジェクトを削除
let changeObjects = changes.insertedOrUpdatedRecordsAndChanges.map { $0.change }
changeManager.remove(changeObjects)
}
MMRealmCloud
環境
- Realm
特徴
・プロトコルを使ってユーザーの定義したモデルにフィールドを追加している。
Models.swift
/**
Declare a protocol to be implemented by Realm `Object`'s to be uploaded to CloudKit.
*/
public protocol RealmCloudObject {
var id: String { get set }
static func primaryKey() -> String?
var isLocallyModified: Bool { get set }
var isLocallyDeleted: Bool { get set }
var ckSystemFields: NSData? { get set }
func toRecord() -> CKRecord
}
Realmのモデルでは「フィールドでは基本的にdynamic var をつけなさい。」となっているので、「プロトコルを宣言すると、実装されるモデルのフィールドはdynamic var になっている。」という契約主義的な発想だと思われる。
・変更管理は自分でしてくれスタイル
ViewController.swift
// MARK: - Data
func addNote() {
do {
// throw RealmSwift.Error.Fail
let realm = try Realm()
try realm.write {
let note = Note()
note.text = "Note \(String(format: "%04X", arc4random_uniform(UInt32(UInt16.max))))"
# フラグをオンにしている。
note.isLocallyModified = true
print("add: \(note.id)")
realm.add(note)
}
} catch let realmError as NSError {
produceAlert(realmError)
}
}
SyncKit
環境
- CoreData, Realm 両方使える
特徴(Realm側の実装)
・変更管理用のモデルがある。
QSSyncedEntity.h
# import <Realm/Realm.h>
@class QSRecord;
@interface QSSyncedEntity : RLMObject
@property (nullable, nonatomic, copy) NSString *changedKeys;
@property (nullable, nonatomic, copy) NSString *entityType;
@property (nullable, nonatomic, copy) NSString *identifier;
@property (nullable, nonatomic, copy) NSNumber<RLMInt> *state;
@property (nullable, nonatomic, copy) NSDate *updated;
@property (nullable, nonatomic, strong) QSRecord *record;
@end
・メタデータ用のモデルがある。
QSRecord.h
# import <Realm/Realm.h>
@class QSSyncedEntity;
@interface QSRecord : RLMObject
@property (nullable, nonatomic, strong) NSData *encodedRecord;
@end
・すべてのObjectに対してaddNotificationBlockして、オブジェクト変更通知を受け取ることによって変更通知の自動追加を実現している。
詳細
(メモリ的に問題ないのかな?)
QCRealmChangeManager.m
- (void)setup
{
self.mainRealmProvider = [[QSRealmProvider alloc] initWithPersistenceConfiguration:self.persistenceConfiguration targetConfiguration:self.targetConfiguration];
BOOL needsInitialSetup = [[QSSyncedEntity allObjectsInRealm:self.mainRealmProvider.persistenceRealm] count] == 0;
__weak QSRealmChangeManager *weakSelf = self;
for (RLMObjectSchema *objectSchema in self.mainRealmProvider.targetRealm.schema.objectSchema) {
Class objectClass = NSClassFromString(objectSchema.className);
NSString *primaryKey = [objectClass primaryKey];
RLMResults *results = [objectClass allObjectsInRealm:self.mainRealmProvider.targetRealm];
//Register for insertions
RLMNotificationToken *token = [results addNotificationBlock:^(RLMResults * _Nullable results, RLMCollectionChange * _Nullable change, NSError * _Nullable error) {
for (NSNumber *index in change.insertions) {
RLMObject *object = [results objectAtIndex:[index integerValue]];
NSString *identifier = [object valueForKey:primaryKey];
/* This can be called during a transaction, and it's illegal to add a notification block during a transaction,
* so we keep all the insertions in a list to be processed as soon as the realm finishes the current transaction
*/
if ([object.realm inWriteTransaction]) {
[self.pendingTrackingUpdates addObject:[[QSObjectUpdate alloc] initWithObject:object identifier:identifier entityType:objectSchema.className updateType:QSObjectUpdateTypeInsertion]];
} else {
[self updateTrackingForInsertedObject:object withIdentifier:identifier entityName:objectSchema.className provider:self.mainRealmProvider];
}
}
}];
[self.collectionNotificationTokens addObject:token];
for (RLMObject *object in results) {
NSString *identifier = [object valueForKey:primaryKey];
RLMNotificationToken *token = [object addNotificationBlock:^(BOOL deleted, NSArray<RLMPropertyChange *> * _Nullable changes, NSError * _Nullable error) {
if ([self.mainRealmProvider.persistenceRealm inWriteTransaction]) {
[self.pendingTrackingUpdates addObject:[[QSObjectUpdate alloc] initWithObject:nil identifier:identifier entityType:objectSchema.className updateType:(deleted ? QSObjectUpdateTypeDeletion : QSObjectUpdateTypeUpdate) changes:changes]];
} else {
[weakSelf updateTrackingForObjectIdentifier:identifier entityName:objectSchema.className inserted:NO deleted:deleted changes:changes realmProvider:self.mainRealmProvider];
}
}];
if (needsInitialSetup) {
[self createSyncedEntityForObjectOfType:objectSchema.className identifier:identifier inRealm:self.mainRealmProvider.persistenceRealm];
}
[self.objectNotificationTokens setObject:token forKey:identifier];
}
}
RLMNotificationToken *token = [self.mainRealmProvider.targetRealm addNotificationBlock:^(RLMNotification _Nonnull notification, RLMRealm * _Nonnull realm) {
[weakSelf enqueueObjectUpdates];
}];
[self.collectionNotificationTokens addObject:token];
[self updateHasChangesWithRealm:self.mainRealmProvider.persistenceRealm];
if (self.hasChanges) {
[[NSNotificationCenter defaultCenter] postNotificationName:QSChangeManagerHasChangesNotification object:self];
}
}