CloudKit

CloudKitは,iOS8から追加されたBaaS?のようなサービスフレームワークである. ユーザは,Appleが提供するAPIを使って,データをクラウド上のストレージに保存したり,取り出したりすることができる. Appleの説明によると,CloudKitは,Appleも写真共有やCoreData on iCloudで利用しているiCloudのバックエンドとして使われている. 額面通りに説明を受け取ると,つまり,従来あるiCloudのファイル共有の枠組みやCoreDataよりもレイヤーの低いフレームワークということになる.

Container

まず,CloudKitを使うには,Certification Centerで,iCloud Container Identifierを登録する必要がある. ここで登録したContainer Identifierを各アプリケーションのiCloudの設定に追加する. これをしておかなければ,アプリケーションからCloudKitを利用することはできない. Certification Centerでの作業後,XcodeのCapabilitiesで,iCloudの項目のCloudKitにチェックを入れ,Containerを設定する. これで準備は完了である.

image

CKRecord

基本的にクラウドにはどんなデータでも保存できる. 保存には,CKRecordというクラスを使う. CKRcordには,キーとオブジェクトをセットすてデータを保存できるが,データの大きさが数キロバイトを超える場合は,CKAssetの使用する. このCKAssetひとつに保存できるデータは,250MBまでのようだ.

The files or data you save using an asset object must be no larger than 250 MB.

データの保存コードは,以下のようになる.

CKDatabase *database = [[CKContainer defaultContainer] publicCloudDatabase];

double refTime = [NSDate timeIntervalSinceReferenceDate];
CKRecord *newRecord = [[CKRecord alloc] initWithRecordType:@"comment"];

[newRecord setObject:_textField.text forKey:@"text"];
[newRecord setObject:[NSNumber numberWithDouble:refTime] forKey:@"time"];

[database saveRecord:newRecord
   completionHandler:^(CKRecord *saved, NSError *error) {
			 dispatch_async(dispatch_get_main_queue(), ^{
				 if (error) {
					 NSLog(@"%@", [error localizedDescription]);
				 }
				 else {
					 NSLog(@"Added - %@", saved);
					 [self.navigationController popViewControllerAnimated:YES];
				 }
			 });
		 }];

非常に便利に見えるCloudKitだが,キャッシュ機能を持たない. このため,アプリケーションは,オフライン環境下ではデータの追加・編集はおろか,データのフェッチも出来ない. もし,オフラインでも不自由なく操作可能にするためには,自前でキャッシュの仕組みを実装しなければならない. 何のためのクラウドのAPIだと思われる人もいるだろうが,AppleがCloudKitがバックエンドのためのフレームワークであると言い切っているように,最初からそういった仕組みが欲しい人は,iCloudのファイル共有やCoreData on iCloudを使うべきなのである.

そうまでしてCloudKitを使う理由は何なのか. 私も,そこにあまり明確な答えを持ち合わせていないのだが,いくつか, CloudKitには,いくつかの特色のある機能がある. これらの概要をさらっと解説してみる.

PrivateとPublic

CloudKitのレコードには,iCloudのアカウントを持つユーザなら誰でも見られるデータと,データを追加したユーザ自身からしか見えないデータの2種類がある. publicにすると誰からも見られるので扱いに注意する必要がある. Privateは,個人的なデータを保持するために使うと容易に想像できるが,publicの応用先が難しい・・・・と思う.

2種類のデータは,データベースのインスタンスを取得するときに明示的に指定することで使い分けられる.

CKDatabase *database = nil;

if (isPrivate) {
    database = [[CKContainer defaultContainer] privateCloudDatabase];
}
else {
    database = [[CKContainer defaultContainer] publicCloudDatabase];
}

Subscription

CloudKitには,データの追加等を検出し,Push Notificationを発行してくれる仕組みが実装されている. このSubscriptionを利用する場合は,CapabilitiesのBackground ModesのRemote notificationsにチェックを入れておく.

image

次にアプリケーションに通知を登録する. Subscriptionは,Push Notificationを使うのでiOS Simulatorでテストできない.

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
	//
	// You have to enable "Remote notification" in "Background mode", Capability of Xcode project.
	//
	[application registerForRemoteNotifications];
	return YES;
}

それでは,実際にSubscriptionをdatabaseに登録してみる. 登録は,CKSubscriptionクラスで行う. 通知されるデータは,NSPredicateオブジェクトをCKSubscriptionオブジェクトにセットして指定する. また,通知時に受け取る情報は,CKNotificationInfoオブジェクトをCKSubscriptionオブジェクトにセットして指定する. 簡単とデバッグ目的のため,以下の例では,どんなレコードでも追加されると通知が来るようにしている.

NSPredicate *truePredicate = [NSPredicate predicateWithValue:YES];
CKSubscription *itemSubscription
    = [[CKSubscription alloc] initWithRecordType:@"comment"
                                       predicate:truePredicate
                                         options:CKSubscriptionOptionsFiresOnRecordCreation];


CKNotificationInfo *notification = [[CKNotificationInfo alloc] init];
notification.alertBody = @"New comment has been added.";
itemSubscription.notificationInfo = notification;

[database saveSubscription:itemSubscription
         completionHandler:^(CKSubscription *subscription, NSError *error) {
    if (error) {
        NSLog(@"%@", [error localizedDescription]);
    }
    else {
        NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
        [defaults setBool:YES forKey:@"subscribed"];
        [defaults setObject:subscription.subscriptionID forKey:@"subscriptionID"];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
}];

このコードを実行した状態で,publicデータベースに他のデバイスからレコードを追加してみよう. CKSubscriptionを設定したデバイスに通知が来るはずだ.

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)info {
    DNSLog(@"Push received");
    CKNotification *cloudKitNotification = [CKNotification notificationFromRemoteNotificationDictionary:info];
    NSString *alertBody = cloudKitNotification.alertBody;
    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:NSLocalizedString(@"Subscription", nil)
                                                        message:alertBody
                                                       delegate:nil
                                              cancelButtonTitle:nil
                                              otherButtonTitles:NSLocalizedString(@"OK", nil), nil];
    [alertView show];
}

通知の処理仕方はアプリケーションによって変わってくると思うが,受け取ったデータの取り出し方のサンプルとして,上のソースコードを例示した. CloudKitで送られるデータは,CKNotificationクラスのクラスメソッドnotificationFromRemoteNotificationDictionary:で,CKNotificationオブジェクトに変換して利用する.

CKDatabaseOperation

データベースへの一連操作をバッチ的に実行するためのNSOperationのサブクラスも用意される. このクラスでは,データベース操作,操作後のコールバック,データ毎の操作後のコールバック,エラーハンドリング,データ毎のエラーハンドリングなどをblocksを使って記述できる. たとえば,以下のようなメソッドをカテゴリ等で実装できる.

@implementation CKModifyRecordsOperation(test)

+ (instancetype)testModifyRecordsOperationWithRecordsToSave:(NSArray /* CKRecord */ *)records
                                          recordIDsToDelete:(NSArray /* CKRecordID */ *)recordIDs {
    //
    // You have to set appropriate role at iCloud Dashboard in order to edit a property of your record.
    //
    CKModifyRecordsOperation *operation
         = [[CKModifyRecordsOperation alloc] initWithRecordsToSave:records recordIDsToDelete:recordIDs];
    
    //
    // Save policy
    //
    operation.savePolicy = CKRecordSaveIfServerRecordUnchanged;
    // operation.savePolicy = CKRecordSaveAllKeys;
    //  operation.savePolicy = CKRecordSaveChangedKeys;
    
    operation.completionBlock = ^(void) {
    };
    operation.modifyRecordsCompletionBlock
        = ^(NSArray *savedRecords, NSArray *deletedRecordIDs, NSError *error) {
        if (error) {
            DNSLog(@"%@", error);
        }
        if ([savedRecords count]) {
            DNSLog(@"savedRecords = %@", savedRecords);
        }
        if ([deletedRecordIDs count]) {
            DNSLog(@"deletedRecordIDs = %@", deletedRecordIDs);
        }
    };
    operation.perRecordCompletionBlock = ^(CKRecord *record, NSError *error) {
        DNSLog(@"%@", error);
    };
    operation.perRecordProgressBlock = ^(CKRecord *record, double progress) {
    };
    
    return operation;
}

@end

タスクを実行する場合は,NSOperationと同じようにNSOperationQueueにオブジェクトを突っ込めばよい.

_queue = [[NSOperationQueue alloc] init];
CKModifyRecordsOperation *operation
    = [CKModifyRecordsOperation testModifyRecordsOperationWithRecordsToSave:@[record] recordIDsToDelete:@[]];
operation.database = database;
[_queue addOperation:operation];

NSOperationクラスのdepedencesを使って,まとめて,かつ順番に処理を実行することもできる. しかしながら,ロールバック機能がないので,エラーを起こした場合の処理については熟慮する必要がある.

CKAsset

CKAssetは,サイズの大きいデータをデータベースに登録するときに利用する. CKAssetを使ったデータの登録は,ファイル経由で行うことになる. CKASsetのオブジェクトは,initWithFileURL:メソッドで生成,初期化する. UIImageViewにセットされた画像をCKAssetを使ってレコードに登録する場合は,以下のようなコードになる.

// バイナリデータ
NSData *data = UIImageJPEGRepresentation(_imageView.image, 0.5);

// ファイルの保存
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *file = [documentsDirectory stringByAppendingPathComponent:@"data.jpg"];
[data writeToFile:file atomically:YES];

// CKAssetオブジェクトを生成し,CKRecordオブジェクトにセットする
CKAsset *asset = [[CKAsset alloc] initWithFileURL:[NSURL fileURLWithPath:file]];
[newRecord setObject:asset forKey:@"image"];

ゆえにCKAssetでデータを登録する場合は,URLで指定する必要があるため一度ファイルをローカルに保存する必要がある. ドキュメントパスにデータを補完するとデータが補完されてしまうので,キャッシュフォルダに適当なhashをファイル名として保存するのがよいのかもしれない.

登録されたCKAssetは,非同期で自動的にiCloudにアップロード,あるいは別の端末にダウンロードされる.