LoginSignup
65
47

More than 3 years have passed since last update.

Core Data に入門しつつ CloudKit 連携を試す

Last updated at Posted at 2020-12-20

この記事は、ドワンゴ Advent Calendar 2020 の 20 日目の記事です。

こんにちは、@tasuwo です。ドワンゴではニコニコ動画 iOS アプリの開発をしています。今回は業務は関係なく、個人開発で触れた技術についての話を書こうかなと思います。

これまではローカルデータの保存には主に Realm を採用していたのですが、今年新しく個人開発を始めたアプリでは Core Data を採用することにしました。さらに、WWDC 2019 で発表もあった、Core Data と iCloud 同期の連携により、複数の端末間で Core Data 上のデータを同期可能な実装を行いアプリをリリースしました。Core Data も CloudKit も iCloud も何もわからない... という状態から開発を始めたのですが、調べていたときに日本語情報が少なめだったのと、あまり Core Data の人気がないようだったので、これを機に得た知見を公開してみることにしました :pencil:

  • Core Data をなぜ採用したか?
  • Core Data の基本
  • Core Data + CloudKit 連携で利用する Core Data の機能
    • Faulting
    • Query Generation
    • Persistent History
  • Core Data + CloudKit 連携方法
  • Tips

はじめに

Core Data とは?

Core Data は、iOS に古くから存在しているデータ永続化のための Framework です。Xcode 上の Core Data Model Editor を通してデータモデルを定義/編集し、そこから自動生成された Swift コードやその他複数のオブジェクトを利用してデータの読み書きを行います。Core Data は永続化の抽象レイヤのような役割も担っており、実際にディスク上で永続化する形式はいくつか選べるようですが 1、iOS では SQLite を利用するのが一般的なようです。単なるデータベースとしての機能のみでなく、UITableView/UICollectionView と連携して容易にデータの表示、編集(redo/undo)、削除を行うための API が用意されていたり、効率よくデータを UI に反映するための種々の仕組みが用意されていたりなど、多くの機能があります。

ただし、古くから存在しているが故に API が扱いづらい点や、種々の概念の学習コストが高い点、不適切な利用方法によりランタイムのクラッシュを引き起こしてしまいがちな点などから、ローカルにデータを保存する場合は Realm などのサードパーティ製ライブラリが採用されるケースも多いように思います。

自分が個人開発しようとしていたアプリは、簡単な画像の管理アプリでした。こちらは元々 Realm を利用して開発を進めていたのですが、途中から Core Data に切り替えて開発することにしました。

なぜ Core Data を採用したか?

iCloudの利用コストが実質ゼロだったため

元々端末間のデータ同期は視野に入れてなかったのですが、開発を進めていく中でやはり欲しいなという気持ちが湧いてきてしまい、実現方法を考えることになりました。
端末間のデータ同期自体は、Realm でも Realm Sync を利用すればできますが、オンプレの場合は自身でサーバを管理する必要があり、一方でクラウドだと2020/12現在で月 6000 円ほどかかります。その他有名所だと Cloud Firestore があり、コスト的にはこちらの方が低めですが、開発しようとしていたアプリが画像管理系のアプリで、ユーザ毎にそれなりの容量のデータが継続的に保存される可能性があり、かつアプリの収益化などは考えていなかったため、イマイチ踏み出せなかった部分があります。
iCloud にはデータの格納先のスコープとして、アプリ利用者全員がアクセス可能な Public、AppleIDに紐づいた特定ユーザのみがアクセス可能な Private、アプリ利用者のうち特定グループのみがアクセス可能な Shared があり、これらのうち Private スコープは、ユーザ固有の iCloud ストレージ容量を消費 します。そのため、クラウドにデータを保管するために開発者が負担するコストは実質ゼロになります。
従って、今回は iCloud の Private スコープを Core Data + CloudKit から利用することにしてみました。

CloudKitとの連携が容易であったため

端末のローカルデータを iCloud で同期したい場合、まず CloudKit を利用するのが基本になるはずです。CloudKit は、ユーザが iCloud を通して端末間でデータを同期するための API を提供する Framework です。
さらに、この CloudKit と Core Data を容易に連携させるための機能が iOS 13 から提供されるようになりました 2。こちらを利用すれば、ほぼ通常通り Core Data を利用してローカルデータを操作するだけで、自動的に iCloud 経由で端末間のデータ連携を実現することができます。そのため、CloudKit を直接利用するのではなく、こちらの Core Data + CloudKit 連携機能を採用することにしました。

Core Data が継続的に改善されてそうで、触っても良いかなと思えたため

Core Data の API は以前より簡潔になっており、型安全性も増してきているように思えます。例えば、あるエンティティ Tag の読み書きは、以前は下記のように記載する必要がありましたが、

// Read
let request = NSFetchRequest<Tag>(entityName: "Tag")
let tags = try context.fetch(request)
print(tags)

// Write
let tag = NSEntityDescription.insertNewObject(forEntityName: "Tag", into: context) as! Tag
tag.id = UUID()
tag.name = "hoge"
try context.save()

iOS 10 以降では以下のように記載できるようになりました。iOS 10 より前は Entity 名をわざわざ指定する必要がありましたが、これが不要になりました。また、冗長だった API も簡潔になり、forced unwrap も不要になっています。

// Read
let request: NSFetchRequest<Tag> = Tag.fetchRequest()
let tags = try context.fetch(request)
print(tags)

// Write
let tag = Tag(context: context)
tag.id = UUID()
tag.name = "hoge"
try context.save()

他にも、ランタイムのクラッシュの原因となっていた機能に対してそれを防ぐための設定値が新たに用意されていたり、iOS 13 から導入された Diffable Data Sources との連携用の API が用意されていたり 3、継続的に改善されていそうだったので、一度学習してみても良いのでは?という気持ちになりました。

Core Data の基本

Core Data についてしっかりとした入門記事を書こうと思うとそれだけで記事が複数できてしまいそうなので、CloudKit との連携についてお話しする前提知識として必要な最低限の知識について記述します。

モデルの定義

Core Data を利用したい場合、まず Xcode 上で .xcdatamodeld ファイルを新規作成し、データモデルを定義する必要があります。Core Data におけるデータ構造の基本単位は Entity です。モデルエディター上の「Add Entity」から複数の Entity を追加したり、Entity 同士を関連づけたりすることができます。モデルエディターの使い方について解説すると記事がとても長くなってしまうので、詳しい利用方法についての解説は割愛します。重要なことは、ここで Entity を定義すると、Xcode のビルド時に対応する Swift コードが自動生成され、ソースコード上から各 Entity 定義にアクセスできるようになる、という点です。

例えば、上記の Post という Entity 定義から自動生成されるファイルは、下記の 2 つになります。前者はクラス定義、後者はプロパティのアクセサ定義です。全ての Entity は NSManagedObject を継承します。これらの自動生成ファイルは、デフォルトでは Xcode プロジェクトには含まれることはないため直接手を加えることはできませんが、各 Entity の設定を変更することで Xcode プロジェクトに含め、直接編集できるようになります 4 (が、今回は特に利用しません。直接編集する用途としては、Entity 自体にロジックを実装したい場合や、複数の property を組み合わせた computed property を定義したい場合などが挙げられます)。

Post+CoreDataClass.swift
@objc(Post)
public class Post: NSManagedObject {}
Post+CoreDataProperties.swift
extension Post {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Post> {
        return NSFetchRequest<Post>(entityName: "Post")
    }
    @NSManaged public var createdAt: Date?
    @NSManaged public var id: UUID?
    @NSManaged public var title: String?
    @NSManaged public var updatedAt: Date?
    @NSManaged public var attachments: NSSet?
    @NSManaged public var tags: NSSet?
    @NSManaged public var user: User?
}

// 以下略

Core Data Stack のセットアップ

モデル定義だけでは Core Data は利用可能にはなりません。内部的には、例えば .xcdatamodeld に定義したデータモデルをロードしたり、永続化のバックエンドである SQLite をセットアップしたりして、最終的にデータの読み書きができるようにならなくてはいけません。このような Core Data のためのセットアップを完了するためには、最低限、下記の 3 つのオブジェクトの生成が必要となっています 5

  • NSManagedObjectModel
    • .xcdatamodeld 内に定義されたモデル定義をロードしたもの
    • アクセスすることで、定義されている Entity 群やその関連等の情報を知ることができる
    • 開発者が直接利用することはあまりなさそう
  • NSManagedObjectContext
    • データモデルの読み書きを行うための、Persistent Store の手前の一時保存領域
    • 詳細は後述
  • NSPersistentStoreCoordinator
    • データ永続化の抽象化レイヤー
    • バックエンド (SQLite等) からのデータの取得や保存を行う

iOS 10 未満だと、これらのセットアップだけでも一苦労だったようですが、iOS 10 以降ではこれらを一度の初期化できる NSPersistentContainer が導入され、Core Data Stack のセットアップが非常に楽になったようです 6。開発者が指定すべきは、ロードすべき .xcdatamodeld の名前のみです。下記のサンプルコードでいうと、"DataModel" が指定されているため、DataModel.xcdatamodeld がロードされます。

class AppDelegate: UIResponder, UIApplicationDelegate {
    lazy var persistentContainer: NSPersistentContainer = {        
        let container = NSPersistentContainer(name: "DataModel")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Unable to load persistent stores: \(error)")
            }
        }
        return container
    }()
}

初期化された NSManagedObjectModel, NSManagedObjectContext, NSPersistentStoreCoordinator には、各 managedObjectModelviewContextpersistentStoreCoordinator でアクセスできます。

NSManagedObjectContext

Core Data Stack として初期化したオブジェクトの中に NSManagedObjectContext がありました。個人的には Core Data を利用する上で一番難しいのがこの Context の扱いについて理解することだと思っています... 今回は、簡単な概要を紹介します。
Context は、モデルのライフサイクルを管理する責務を持つオブジェクトです。ここでいうモデルのライフサイクルとは、モデルの生成/削除や、それらの Persistent Store への反映(永続化)、変更内容の redo/undo 等の編集行為を含んでいます。
Core Data で永続化する/されているモデルデータは、基本的にはこの Context を経由してアプリ内で利用します。例えば、新しいデータを永続化したい場合には、まず Context 内に新しくモデルを作成します。その後、必要な編集を Context 上のモデルに加えた後、改めて Context に永続化を指示することで、初めてデータが Persistent Store を通して永続化されます (Cotnext は各々対応する Persistent Store のインスタンスに紐づけられています)。

lazy var persistentContainer: NSPersistentContainer = { /* ... */ }()

func createAndSaveNewPost() {
    // Context 上に新しく `Post` モデルのインスタンスを作成する (この時点では永続化はされない)
    let post = Post(context: self.persistentContainer.viewContext)

    // Context 上の `Post` モデルを書き換える (この時点でも永続化はされない)
    post.id = UUID()
    post.title = "hoge"

    // Context 上の全モデルを永続化する (これが成功して初めて永続化される)
    do {
        try self.persistentContainer.viewContext.save()
    } catch { /* ... */ }
}

var hasChanges: Bool {
    // Contextに永続化前の変更が含まれているかどうかは、`hasChanges` でチェックできる
    return self.persistentContainer.viewContext.hasChanges
}

func undoChange() {
    // 変更の undo により、最新の変更を無かったことにできる
    self.persistentContainer.viewContext.undo()
}

func rollbackChanges() {
    // rollback により、全ての変更を無かったことにできる
    self.persistentContainer.viewContext.rollback()
}

逆に、既存のデータを Persistent Store から読み込む場合、まずは Context に対して Fetch Request を要求します。その後、Context が Persistent Store からデータを読み取り、Context 上に展開した上で、取得結果を返します。

lazy var persistentContainer: NSPersistentContainer = { /* ... */ }()

func readAllPosts() -> [Post] {
    let request: NSFetchRequest<Post> = Post.fetchRequest()
    do {
        return try try self.persistentContainer.viewContext.fetch(request)
    } catch { /* ... */ }
}

このように、データを書き込む場合も読み込む場合も、基本的には Context を通しますが、Context 上へのデータモデルの変更 = 永続化ではないため、データの読み書きのための一時保存領域と捉えることもできます。また、読み込む場合も書き込む場合も、同一の Entity のインスタンスは Context 上にはただ1つしか存在できないことが保証されています (一意な Entity の識別には objectId が利用できます)。Apple のドキュメントだと、Context は Scratch Pad のようなものだとも説明されています 7

基本的なデータの読み書きについては上述のとおりですが、Context を利用する上で考慮しなくてはならない重要な点として、マルチスレッドからの利用が挙げられます。Context を通して操作する Entity、すなわち NSManagedObject のインスタンスは、スレッド間の受け渡しができません。しかし、例えば UI で利用する場合はメインスレッドから、大きめのデータを保存する場合にはバックグラウンドスレッドから NSManagedObject の操作を行いたいかもしれません。このようにマルチスレッドでデータの操作を行うためには、Context を複数作成したり、複数の Context 間で親子関係を構築して、変更内容が伝播していくように設定する必要があります。が、この記事では詳細な説明は割愛します🙏

Core Data の機能

Core Data は非常に多機能です。その中で今回は、CloudKit との連携を実装していく上で利用する Query Generation, Persistent History、さらにそれらの背景知識として知っておくと理解が進む Faulting について紹介します。

Faulting

faulting とは、永続化されたモデルを fetch する際のメモリ使用量を削減するための仕組みです 8 9
例を交えつつ説明していきます。Core Data を通して扱うデータ構造は、複数の Entity が互いに関連し合うようなオブジェクトグラフを形成します。このオブジェクトグラフが複雑化していくと、1つの Enitity が最終的に数多くの Entity と関連することもあり得ます。

例えば、モデルの定義 で例示した Post という Entity について着目してみます。Post は複数の attachments や tags と関連していて、プロパティ定義は下記のようになっています。

Post+CoreDataProperties.swift
extension Post {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Post> {
        return NSFetchRequest<Post>(entityName: "Post")
    }
    @NSManaged public var createdAt: Date?
    @NSManaged public var id: UUID?
    @NSManaged public var title: String?
    @NSManaged public var updatedAt: Date?
    @NSManaged public var attachments: NSSet?
    @NSManaged public var tags: NSSet?
    @NSManaged public var user: User?
}

このようなオブジェクトグラフを利用するアプリにて、記事の一覧画面を実装したいとします。全ての Post を取得する場合のリクエストは、下記のようになります。

let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try? self.container.viewContext.fetch(request)

// 最初の記事及びそのタグの情報を出力してみる
print(posts.first?.id)
print((posts.first?.tags?.allObjects as [Tag]).first?.id)

出力は下記のようになります。Post の情報も Tag の情報も正常に取得できていることがわかります。

Optional(BC63B1F2-E447-4030-B2F7-155884FAA539)
Optional(2B788959-3A93-4547-8B84-00D1B70E95DB)

こうしてみると、一度に関連する Entity のオブジェクトが全て取得されているように見えます。もし、Post の一覧を取得したいだけなのに、関連する全ての Entity が全てメモリ上に読み込まれてしまっている場合、特に、Attachment 等のバイナリを含んだ Entity まで同時に読み込んでしまうと、メモリ不足に陥る可能性もあります。

が、Core Data はそのような問題に陥らないようになっています。そのために存在しているのが faulting という仕組みです。faulting は、リクエストされた Entity をメモリ上に即座に読み出すことはせず、値の参照時に初めてメモリ上に読み出します。参照されるまでは、faults オブジェクトと呼ばれるプレースホルダーオブジェクトが代わりに格納されます。
試しに、下記のような print 文を挟んでみます。

let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try? self.container.viewContext.fetch(request)

// 全記事の情報を出力する
print(posts)
// 1番目の要素にのみアクセスする
print(post.first?.id)
// 再び全記事の情報を出力する
print(posts)

すると、下記のように出力されます。<fault> となっているのが、faults オブジェクトであることを示しています。最初の print では全て faults オブジェクトであったのが、1番目の要素にアクセス以降は、1番目の要素のみ実際のデータが読み込まれていることがわかります。

[
    <Post: 0x600001fa11d0> (entity: Post; id: 0xe456a2ac9847cce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p1>; data: <fault>),
    <Post: 0x600001fa10e0> (entity: Post; id: 0xe456a2ac984bcce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p2>; data: <fault>),
    <Post: 0x600001fa1040> (entity: Post; id: 0xe456a2ac984fcce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p3>; data: <fault>)
]
Optional(BC63B1F2-E447-4030-B2F7-155884FAA539)
[
    <Post: 0x600001fa11d0> (entity: Post; id: 0xe456a2ac9847cce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p1>; data: {
        attachments = "<relationship fault: 0x600003cdda60 'attachments'>";
        createdAt = "2020-12-20 08:25:32 +0000";
        id = "BC63B1F2-E447-4030-B2F7-155884FAA539";
        tags = "<relationship fault: 0x600003cdda80 'tags'>";
        title = Untitled;
        updatedAt = "2020-12-20 08:25:32 +0000";
        user = nil;
    }),
    <Post: 0x600001fa10e0> (entity: Post; id: 0xe456a2ac984bcce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p2>; data: <fault>),
    <Post: 0x600001fa1040> (entity: Post; id: 0xe456a2ac984fcce6 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p3>; data: <fault>)
]

上記の例を見ると、読み込まれた後の Post 内の tagsattachments 等も faults オブジェクトになっていることがわかります。このように、Entity に関連する他の Entity も遅延して読み込まれます。以下を試してみます。

let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try? self.container.viewContext.fetch(request)

print(posts.first)
print((posts.first?.tags?.allObjects.first as! Tg?)?.id)
print(posts.first)

結果は下記のようになります。tags にアクセスした時点で、全ての関連する Tag がメモリ上にロードされます。

Optional(<Post: 0x600003a5b3e0> (entity: Post; id: 0xf8d5deba04494d0b <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p1>; data: {
    attachments = "<relationship fault: 0x60000196ce20 'attachments'>";
    createdAt = "2020-12-20 08:25:32 +0000";
    id = "BC63B1F2-E447-4030-B2F7-155884FAA539";
    tags = "<relationship fault: 0x60000196ece0 'tags'>";
    title = Untitled;
    updatedAt = "2020-12-20 08:25:32 +0000";
    user = nil;
}))
Optional(2B788959-3A93-4547-8B84-00D1B70E95DB)
Optional(<Post: 0x600003a5b3e0> (entity: Post; id: 0xf8d5deba04494d0b <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Post/p1>; data: {
    attachments = "<relationship fault: 0x60000196ce20 'attachments'>";
    createdAt = "2020-12-20 08:25:32 +0000";
    id = "BC63B1F2-E447-4030-B2F7-155884FAA539";
    tags =     (
        "0xf8d5deba04454d09 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Tag/p2>",
        "0xf8d5deba04414d09 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Tag/p3>",
        "0xf8d5deba04494d09 <x-coredata://6656FA46-9EB7-44EB-B163-B78D64022D04/Tag/p1>"
    );
    title = Untitled;
    updatedAt = "2020-12-20 08:25:32 +0000";
    user = nil;
}))

faults オブジェクトにアクセスして実際にメモリ上に読み出すような操作を faults の発火 のような表現をします(どのような操作が faults を発火させるかについては、公式Doc を参照してください)。この仕組みによって、Core Data におけるデータ読み込みはメモリ使用量をなるべく少なく抑えることができるようになっています。
一方で、Core Data のデータがアプリ外で更新された場合には問題が発生します。例えば、ある Post の Tag が、起動中のアプリ内では faults オブジェクトとして存在していたけど、別の端末でその Tag が削除され iCloud 同期によってそれがアプリ起動中の端末に同期されてしまったり、App Extension 経由で Tag が削除されてしまったりした場合、faults オブジェクトにアクセスしてデータを読み込もうとしても対象のデータが存在しません。
このようなケースは、iOS 8 まではクラッシュを引き起こす要因となっていましたが、iOS 9 からは shouldDeleteInaccessibleFaults というフラグが出来ました。これは、faults オブジェクトが対象としているデータがメモリに読み出す前に削除されるなどした場合に、対象のプロパティを削除されたものとして扱うフラグです。これは default で true に設定されているため、削除済みの faults オブジェクトに対してアクセスしても削除されたという扱いになり、クラッシュはしないようになっています。

Query Generation

shouldDeleteInaccessibleFaults によって、バックエンドのデータが削除済みの faults オブジェクトにアクセスすると、対象のプロパティを削除状態にすることでクラッシュを回避できます。が、逆にいうと、faults オブジェクトにアクセスするまではそのデータに実際アクセス可能かどうかはわからない、ということになります。そのため、UI から削除済みのデータへのアクセスがリクエストされた時の振る舞いについて考える必要があります。
これを解決するための機能が、iOS 10 から導入された Query Generation です 10 11。ここでいう Generation は「世代」の意味であり、NSManagedObject 及び Context が参照するデータを、特定時点での Persistent Store のスナップショットに固定する 機能となっています。Context の参照先を特定時点のスナップショットに固定するということは、特定時点以降にどんなにアプリ外からデータに変更が加えられても、常に特定時点で保持されていたデータにアクセス可能&メモリ上にロード可能になることを表しています。よって、faults オブジェクトにアクセスしたら、実は削除されていた!と言ったことは起きえなくなり、考えることが減ります。
デフォルトでは、NSManagedObject は特定スナップショットに固定されておらず、常に最新の Persistent Store の状態を参照します (このような Context の状態は unpinned と呼ばれます)。とある時点の Persistent Store のスナップショットに固定したい場合は、setQueryGenerationFrom(_:).current を指定します。

try? context.setQueryGenerationFrom(.current)

あるいは、特定の世代の token を Context から queryGenerationToken 経由で取得できるため、これを利用することもできます。

let token = context.queryGenerationToken
// ...
context.setQueryGenerationFrom(token)

逆に、unpin したい場合は nil を設定します。

try? context.setQueryGenerationFrom(nil)

また、pinned な Context をあるタイミングで更新したくなった場合には、再び .current を対象として setQueryGenerationFrom(_:) を呼び出すか、save()mergeChanges(fromContextDidSave:) 等のメソッドを呼び出します 10

Persistent History

概要

Query Generation を利用すれば Context が参照するデータを固定することができますが、そうではなくて、Persistent Store 内のデータが更新されたらそれを検知して何か処理を行いたい ケースがあります。これは例えば、基本的には Context は特定の世代に pin しておくけれど、何か特定のデータに変更があったらをそれを検知して、変更点を都度 Context に適切にマージしていきたい、といったケースが考えられます。
Persistent Store の更新を検知する仕組みには、Notification による通知があります。種類は下記の3種類です。

が、これらの通知は Batch Request によって複数のリクエストにより同時にデータが更新された場合には飛ばない、全てのデータ更新について通知が飛ぶため不要な通知も飛んでしまい効率が悪い、等の問題があります。
これを解決するより効率的な監視を実現可能なのが Persistent History という機能であり 12 13 14、iOS 11 から導入されました。

Persistent History のトラッキングと監視を有効にする

Persistent History は、Persistent Store への1回の変更を トランザクション という単位で扱います。トランザクションからは、その変更が何によって行われたのか?どのような変更か?などの情報を得ることができます。
この Persistent History のトラッキングはデフォルトではオフになっています。オンにするためには Persistent Store のロード時に下記のように設定が必要です。

class AppDelegate: UIResponder, UIApplicationDelegate {
    lazy var persistentContainer: NSPersistentContainer = {        
        let container = NSPersistentContainer(name: "DataModel")

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Persistent Store Description の取得に失敗")
        }
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

        // ...

        return container
    }()
}

さらに、新しいトランザクションがリモートで発生した場合に Notification を飛ばすこともできます。この Notification はデフォルトでは飛ばないので、これも Persistent Store のロード時に設定します

class AppDelegate: UIResponder, UIApplicationDelegate {
    lazy var persistentContainer: NSPersistentContainer = {        
        let container = NSPersistentContainer(name: "DataModel")

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Persistent Store Description の取得に失敗")
        }
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteNotificationPostOptionKey)

        // ...

        return container
    }()
}

その後、下記のように通知を受け取る Observer を設定します。これで、新しいトランザクションが発生したときに Selector に設定されたメソッドが呼び出されるようになります。

NotificationCenter.default.addObserver(self,
                                       selector: /* ... */,
                                       name: .NSPersistentStoreRemoteChange,
                                       object: self.persistentContainer.persistentStoreCoordinator)

トランザクションに含まれる情報

トランザクションは NSPersistentHistoryTransaction オブジェクトとして表現されます。実際にトランザクションの取得方法を見る前に、そもそもトランザクションからどのような情報を取得できるのか?というと、下記のような情報が挙げられます。

情報 概要
storeID Persistent Store を識別するID
bundleID 変更元の環境の Bundle Identifier
processID プロセスを一意に識別するID
contextName Contextのnameに設定された名前が取得できる。通常、Contextにつき1回設定する
author ContextのtransactionAuthorに設定された値が取得できる。通常、Contextのsaveごとに設定する
timestamp タイムスタンプ

特に、contextNameauthor は、開発者が必要に応じて独自に設定することができます。これによって、例えば同一アプリ内での変更でも、変更元が App なのか App Extension なのか、App 内だとしたらどの操作による変更なのか、など、詳細な情報を、トランザクションの受信先に伝えることができます。主に、これらの情報は必要なトランザクションをフィルタリングして取得するのに活用できます。

Persistent History を取得する

過去に発生した全てのトランザクションは、明示的に削除しない限りはアプリの Container 内に永続化されます。これを必要に応じて参照することになります。このリクエストには NSPersistentHistoryChangeRequest を利用します。

// トランザクションはトークンで一意に識別でき、任意のトークン (=トランザクション) より後に発生したトランザクションの取得、のようなリクエストができる(詳細は割愛)
let fetchHistoryRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)

// 特定の author による変更のみ取得したい場合に、フィルタを設定することができる。可能な限りリクエスト時にフィルタした方が効率が良い
let fetchRequest = NSPersistentHistoryTransaction.fetchRequest!
fetchRequest.predicate = NSPredicate(format: "author != %@", "hoge")
request.fetchRequest = fetchRequest

// UI 操作に影響を与えないため、バックグラウンドスレッドで動作する Context を生成し、リクエストを処理させる
let context = self.persistentContainer.backgroundContext
guard let historyResult = try? context.execute(fetchHistoryRequest) as? NSPersistentHistoryResult,
      let transactions = historyResult!.result as? [NSPersistentHistoryTransaction] else {
    fatalError("Could not convert history result to transactions.")
}

// history にアクセスする
for let transaction in transactions {
    // ...
}

Core Data を CloudKit 連携させる

ここまで長々と Core Data の基本や機能について説明してきましたが、やっと本題に入ります。具体的な実装内容のサンプルは Apple のサンプルコード 参照していただくのが良いと思いますが、この記事ではこちらの実装をベースに、CloudKit 連携をどのように実装していくと良さそうか?について補足していきます。

Core Data で CloudKit をサポートする

既存の Core Data を活用しているアプリに対して、CloudKit 対応のために必要な主な修正は、NSPersistentContainerNSPersistentCloudKitContainer に変更することのみです。基本的には、たったこれだけの変更によって、Core Data を利用した iCloud 同期が実現できます。CloudKit との連携とはいうものの、CloudKit を直接開発者がいじる必要はありません。iCloud 上のデータとの同期やコンフリクトの解消などは、全て NSPersistentCloudKitContainer が請け負うことになります。とても便利ですね :clap:

-    lazy var persistentContainer: NSPersistentContainer = {
-        let container = NSPersistentContainer(name: "DataModel")
+    lazy var persistentContainer: NSPersistentCloudKitContainer = {
+        let container = NSPersistentCloudKitContainer(name: "DataModel")
         container.loadPersistentStores(completionHandler: { (storeDescription, error) in
             if let error = error as NSError? {
                 fatalError("Unresolved error \(error), \(error.userInfo)")

mergePolicy を設定する

Context が Persistent Store のデータをロードした際に、稀に Persistent Store 内のオブジェクトと Context 上のオブジェクトの内容がコンフリクトするケースがあります。このような場合に何も設定していないとアプリがクラッシュしてしまいます。そのため、コンフリクト発生時に Persistent Store 側と Context 側のどちらを優先すべきか?を Context に Merge Policy として設定する必要があります。
基本的には Context (メモリ上) のデータの方が真であるはずのため、Context の方を優先するように設定します。

container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

必要な変更に対してのみ UI の更新を行う (Query Generation & Persistent History)

Apple のサンプルコードだと、Query Generation と Persistent History が各々有効になっています。Core Data + CloudKit による同期に関する Doc にも記載がありますが、これは以下の 2 つを実現するためです。

  • 他の端末での変更によりデータが削除されるなどしても、UI に矛盾が生じないようにするため
    • 正常に変更でき、変更内容は Context の save 時などに遅延して結合する
  • 他の端末での変更により UI に変更が必要となった場合に、それを検知するため
    • 特に、検知が必要な場合のみ通知を受け取れるようにする
class AppDelegate: UIResponder, UIApplicationDelegate {
    lazy var persistentContainer: NSPersistentContainer = {        
        let container = NSPersistentCloudKitContainer(name: "DataModel")

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Persistent Store Description の取得に失敗")
        }
        // Persistent History のトラッキングを有効にする
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        // リモートの変更を検知できる
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteNotificationPostOptionKey)

        // ...

        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Unable to load persistent stores: \(error)")
            }
        }

        // Context が親子関係を持つとき、親の変更を自動的に自身に伝播させるかのフラグ
        // この viewContext の場合、親は Context ではなく Persistent Store になる
        // つまり、Persistent Store で変更があった場合に、その変更を自動的に viewContext にマージする、ということを示す (ただし、ローカルの変更しか検知できない)
        container.viewContext.automaticallyMergesChangesFromParent = true
        do {
            // Contextを現在のPersistentStoreにpinする
            try container.viewContext.setQueryGenerationFrom(.current)
        } catch { /* ... */ }

        // リモートからの新規トランザクションを監視し、必要に応じて Context の更新などを行う
        NotificationCenter.default.addObserver(self,
                                               selector: /* ... */,
                                               name: .NSPersistentStoreRemotechange,
                                               // Apple のサンプルコードだとここは container になっているが、persistentStoreCoordinator を監視するのが正しいようだ
                                               object: container.persistentStoreCoordinator)

        return container
    }()
}

特に Persistent History Tracking については、どうやら CloudKit によるデータの同期を行うために内部的に利用されているようです。注意点として、Persistent History が無効な状態で過去に保存されていたデータは、iCloud に同期されない 点があります。

Do these records have entries in persistent history? Without them NSPersistentCloudKitContainer can't "see" the records.
This is by design. However we will take your feedback reports on this issue as an enhancement request to make it easier to use NSPersistentCloudKitContainer with existing store files.
https://developer.apple.com/forums/thread/120328

もし、既存の Core Data を採用しているプロジェクトで CloudKit 連携を採用したいが、Persistent History が有効になっていなかった場合は、既存のデータを同期するためになんらかの workaround が必要となります (データに iCloud に同期済みのフラグを設けるとか... :innocent:)。15

あとは、新しいリモートのトランザクションを検知したときに必要な処理を実装すれば良いでしょう。Apple のサンプルコードでは Context との変更のマージのほかに、タグ名が重複した際の deduplicate なども、トランザクションの検知のタイミングで実施しています。

Tips

その他、Core Data および CloudKit 連携を実装した中で悩んだことや得た知見を簡単に紹介します。

Core Data の Entity に ID プロパティを付与すべきか?について

データのモデリング時に迷った部分でした。Core Data の Attribute には RDBMS のような主キーという概念はありません。SQLite を利用している場合、SQLite 保存時には種キーが必要になりますが、これは Core Data が自動で生成しており、開発者からは見えないようになっています。そのため、主キーをわざわざ Attribute に付与する必要はない!という意見があります。

Do Core Data Entities Need Primary Keys?

が、SQLite 内で完結している主キーとは別に、クライアント側でデータを扱う際に一意にデータを識別できた方が嬉しいケースもあると思います。そのため、自分の開発したアプリでは、各 Entity には ID として UUID を持たせ、識別できるようにしています。

SOLVED: UUID in core data

Core Data で大きめの画像を保存する

通常、データベースに大きめの画像を直接保存することは悪手とされています。データベースファイルが肥大化し、パフォーマンスに影響を与える恐れがあるためです。Realm でも、大きめの画像は別途ストレージサービスを利用することを推奨しています 16
しかし、Core Data には external storage という興味深い設定があります。Binary型のプロパティに対してこれをオンにしていると、小さめの画像であれば SQLite のレコードとして、大きめの画像であればファイルシステム上に直接保存する、といった挙動になります17。外部ストレージと Core Data を連携させるのは (特に、iCloud 同期を絡めるとすると) 割と面倒ですが、こちらのオプションを利用すれば Core Data 内で画像データも管理できるため、考えることが少なくなって非常に便利でした。

ただし、iOS 13 だとバグにより、CloudKit 連携がうまく動作せず、iOS 14 では修正されている そうなので注意が必要です。18

iCloud 同期状態を監視する

iCloud 同期の状態は、現状 NSPersistentCloudKitContainer 内部に完全に隠蔽されています。そのため、例えば今現在同期が走っているかどうか?みたいなことを確認することができません... ただし、どうやら iOS 14 からは、同期状態を Observe することはできるようになったようです19。監視すべき通知の名前は NSPersistentCloudKitContainer.eventChangedNotification となっています。
こちらの監視のサンプルコードとしては、下記が参考になりました。

https://github.com/ggruen/CloudKitSyncMonitor

通知されたイベントは NSPersistentCloudKitContainer.Event として取得でき、setup, import, export の3種類があります。また、イベントが成功したかどうかも確認できます。
ただし、これは現状あまり使い道がないかもしれません... ただし、イベントの失敗を検知した場合、端末のローカルデータと iCloud 側との間でデータの不整合が生じる可能性があります。そのような生じてしまった不整合を解決する手段は、現状だと一度端末のローカルデータを全て削除し、再度 iCloud 同期を最初から行うくらいしかないようです。現状の自分の個人開発アプリでは、これを自動的に行うような機能は一旦導入していません (失敗する場合も確認できていないため) が、もしユーザさんから不整合の問い合わせが来た場合にはそのように案内する必要があったり、エラーをなんらかの方法で集計するなどの対策を取ることはできるかもしれません。

まとめ

Core Data + CloudKit 連携について、Core Data の基礎から振り返りながら紹介してみました。Core Data は多機能で難しいイメージもありますが、CloudKit 連携がとても楽にできるという点では魅力的なのではないかなと思います。自分もまだまだわからない部分があるので、Core Data の利用ユーザが増えれば... という思いから記事の執筆に至りました :pray:
開発を振り返ってみると、以下のような感想を得ました。

  • Core Data さえ怖くなければ CloudKit 連携が割と簡単に実装できて便利
    • 個人的には画像データの同期が割と簡単にできたのはよかった
  • Core Data はそこまで怖くはなかった
    • 自分の実装の範囲内では、わけもわからずクラッシュしまくる!不安定!といったことは少なくともなかった
    • ただやはり学習コストは高めには感じた... (Context はちゃんと実装できているのかいまだに自信がない)
    • 今後も知見を公開していきたい

最終的にリリースしたアプリは LikePics という、Web 上の画像を DL して管理できるアプリです。もちろん Core Data + CloudKit によるデータ同期をサポートしています。こちらはえいや!でリリースしたものでまだまだ機能が足りませんが、主に自分用に制作したアプリなので、今後も細々と機能拡張をしていく予定です。

65
47
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
65
47