4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

OSSに見るローカルキャッシュの実装

Last updated at Posted at 2017-09-27

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];
    }
}
4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?