CloudKitを使ってみた Swift4版

いよいよ本腰を入れてCloudKitに挑戦してみました。

環境：swift 4.2、Xcode 10.1

#準備
まずXcodeの[TARGETS]の[Capabilities]で[iCloud]を有効にします。
そしてserviceにある[CloudKit]にチェックを入れます。

続いてCloudKitで使用するレコードの定義を行う為にCloudKit Dashboardを起動します。
先ほどチェックを入れた[CloudKit]のちょっと下にある[CloudKit Dashboard]ボタンをクリックします。
<img width="824" alt="スクリーンショット 2019-02-22 20.06.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/362719/0665f705-4d6e-2d1a-58f3-71c31d8e7326.png">


そうするとWebブラウザが起動しCloudKit Dashboardが表示されますのでApple Idでログインします。
CloudKit DashboardにログインするとまずiCloud Containerの一覧が並んでいるので、今回作成するアプリが使用するiCloud Containerを選択します。
そうすると以下のようなコンソール画面が表示されます。
![スクリーンショット 2019-02-22 15.06.08.png](https://qiita-image-store.s3.amazonaws.com/0/362719/5d5f71f2-3013-7ae8-4938-bdaffb5a7403.png)

Development(開発用)とProduction(本番用)の2つの設定がありますので、まずDevelopmentの設定を行います。
Developmentの[Data]-[RecordType]を選択しRecordTypeの設定を行います。
RecordTypeはリレーショナルデータベースでいうテーブルの様なものと考えればいいでしょう。
RecordTypeの名称を決めた後、フィールドを追加し各フィールドのデータ型を定義します。
![スクリーンショット 2019-02-22 15.07.19.png](https://qiita-image-store.s3.amazonaws.com/0/362719/92c0e9e3-c6a8-abe9-f181-f61b84d265ea.png)


続いてINDEXの設定を行います。
INDEXは検索条件、ソート条件に使用されるフィールドに設定する必要があります。
検索条件に使用するフィールドにはIndex Type:QUERYABLEを指定し、ソートに使用するフィールドにはIndex Type:SORTABLEを指定します。
![スクリーンショット 2019-02-22 17.28.21.png](https://qiita-image-store.s3.amazonaws.com/0/362719/2804bc58-bd67-526d-a11e-976b63e32180.png)

INDEXという名前を聞くとリレーショナルデータベースのINDEXを連想しますが、CloudKitのINDEXはそれとは異なり、検索、ソートに使用する全てのフィールドに定義しないと検索が行われない様になっていますので要注意です。

以上でCloudKitのデータベースを使用する為の設定は完了です。
開発が終わって本番環境にアプリをリリースする時にはDevelopmentの下にある「Deploy to Production」ボタンをクリックするとDevelopmentに作成したオブジェクトをProductionに全てコピーすることができます。
![スクリーンショット 2019-02-22 15.12.48.png](https://qiita-image-store.s3.amazonaws.com/0/362719/bd1d1475-a117-159d-8df4-64d2c035da58.png)

#レコードのINSERT
ではいよいよCloudKitのレコード操作を行ってみたいと思います。
まずレコードのINSERT(新規作成)を行ってみます。

```swift:ViewController.swift
import CloudKit
-------------- (中略) --------------
     /**
     Cloud KitにデータをINSERTする
     @param INSERTするデータ code:コード name:名称 costRate:原価率 salesPrice:売単価
     */
    private func insertData(code:String,name:String,costRate:Double,salesPrice:Int){
        let ckDatabase = CKContainer.default().privateCloudDatabase
        
        //INSERTするデータを設定
        let ckRecord = CKRecord(recordType: "GoodsMaster")
        ckRecord["code"] = code
        ckRecord["name"] = name
        ckRecord["costRate"] = costRate
        ckRecord["salesPrice"] = salesPrice
        
        //データのINSERTを実行
        ckDatabase.save(ckRecord, completionHandler: { (ckRecords, error) in
            if error != nil {
                //INSERTがエラーになった場合
                print("\(String(describing: error?.localizedDescription))")
            }
        })
    }

```

まずクラスの先頭で`import CloudKit`を宣言する必要があります。
次にCloudKitのデータを操作する為にCKDatabaseを生成します。
CKDatabaseにはパブリックデータベースとプライベートデータベースの2種類がありそれぞれ以下の様に記述します。

・パブリックデータベースの場合
`let ckDatabase = CKContainer.default().publicCloudDatabase`

・プライベートデータベースの場合
`let ckDatabase = CKContainer.default().privateCloudDatabase`

パブリックデータベースは複数のユーザー間でデータを共有したい場合に使用するデータベースで、ユーザーがiCloudを有効にしていなくても使用できますが、アプリのアクティブユーザー数に応じてリソースに制限がかかるようになっています。リソースの詳細については以下のサイトをご参照下さい。
[CloudKit - iCloud - Apple Developer](https://developer.apple.com/icloud/cloudkit/)

プライベートデータベースはユーザーのデバイス間だけでデータを共有すれば良い時に使用するデータベースで、各ユーザーが契約しているiCloudの残容量が制限になります。またiCloudを有効にしていないユーザーは使用できません。

CKDatabaseを取得した後は更新対象のRecordTypeを引数にしてCKRecordを作成し値を設定して、最後はCKRecordを引数にしてCKDatabaseのsaveメソッドを実行すればINSERTは完了になります。
尚、saveメソッドはネットワーク通信が切断されていてもエラーにはなりません(遅延更新が行われます)。

### 2019.11.24 追記：デフォルトコンテナ以外のコンテナに接続する場合
コンテナにアクセスする時に上記の様に CKContainer.default() とすると、"iCloud.[アプリのBundle Identifier]"で自動生成されるデフォルトコンテナに接続されます。
デフォルトコンテナ以外のコンテナ(Catabilityで選択したコンテナ)に接続したい場合は以下の様にコンテナのIdentifierを明示的に指定する必要があります。

```swift
let ckDatabase = CKContainer(identifier: "iCloud.Studio-K-s.CloudKitSample").privateCloudDatabase
```

#レコードの検索
続いて検索を行ってみます。

```swift:ViewController.swift
import CloudKit
-------------- (中略) --------------
    private let tableView:UITableView = UITableView()    
    private var goodsMasters:[GoodsMaster] = [GoodsMaster]()

    struct GoodsMaster: Codable {
        var code:String
        var name:String
        var costPrice:Double
        var salesPrice:Int
    }
-------------- (中略) --------------
    /**
     Cloud Kitからデータを検索する
     @param 検索条件 minSalesPrice:検索条件のsalesPriceの最小値 maxSalesPrice:検索条件のsalesPriceの最大値
     */
    private func searchData(minSalesPrice:Int,maxSalesPrice:Int){
        let ckDatabase = CKContainer.default().privateCloudDatabase

        //検索条件指定
        let ckQuery = CKQuery(recordType: "GoodsMaster", predicate: NSPredicate(format: "salesPrice >= %d and salesPrice <= %d", argumentArray: [minSalesPrice,maxSalesPrice]))
        
        //ソート条件指定
        ckQuery.sortDescriptors = [NSSortDescriptor(key: "salesPrice", ascending: false),NSSortDescriptor(key: "costRate", ascending: true)]
        
        //検索実行
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                //検索エラー
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //検索成功
                self.goodsMasters.removeAll()
                for ckRecord in ckRecords!{
                    let goodsMaster = GoodsMaster(code: ckRecord["code"]!, name: ckRecord["name"]!, costPrice: ckRecord["costRate"]!, salesPrice: ckRecord["salesPrice"]!)
                    self.goodsMasters.append(goodsMaster)
                }
                DispatchQueue.main.async {
                    self.tableView.reloadData()
                }
            }
        })
    }
```

まずINSERTの時と同じ様にCKDatabaseを生成します。
その後は検索条件、ソート条件を指定するCKQueryを生成します。
検索条件の詳細はNSPredicate、ソート条件の詳細はNSSortDescriptorを使用します。ここはCore Dataと同じですね。
最後はCKQueryを引数にしてCKDatabadseのperformメソッドを実行すれば検索を行うことができます。

前述の様に検索、ソートに使用するフィールドにINDEXが設定されていないと検索が行われません。具体的にはエラーは発生せずレコードが0件で返ってくる動きをします。その為、レコードの更新が成功しているのに検索ができない現象に遭遇した時には真っ先にINDEXを疑ってみると良いでしょう。

尚、検索の時はネットワークが切断されているとエラーが発生します。

#データのUPDATE
続いて既存のレコードをUPDATE(更新)してみます。
UPDATEの時は
1.対象のレコードを検索
2.検索したレコードの値を書き換えて更新
という手順になります。

```swift:ViewController.swift
import CloudKit
-------------- (中略) --------------
    /**
     指定した条件でCloud KitのデータをUPDATEする
     @param whereCode:更新対象のコード updateName:名称の更新値 updateCostRate:原価率の更新値 updateSalesPrice:売単価の更新値
     */
    private func updateData(whereCode:String,updateName:String,updateCostRate:Double,updateSalesPrice:Int){
        let ckDatabase = CKContainer.default().privateCloudDatabase
        
        //1.更新対象のレコードを検索する
        let ckQuery = CKQuery(recordType: "GoodsMaster", predicate: NSPredicate(format: "code == %@", argumentArray: [whereCode]))
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //2.検索したレコードの値をUPDATEする
                for ckRecord in ckRecords!{
                    ckRecord["name"] = updateName
                    ckRecord["costRate"] = updateCostRate
                    ckRecord["salesPrice"] = updateSalesPrice
                    ckDatabase.save(ckRecord, completionHandler: { (ckRecord, error) in
                        if error != nil {
                            print("\(String(describing: error?.localizedDescription))")
                        }
                    })
                }
            }
        })
    }
```
先に紹介したINSERTと検索のコードを組み合わせた様なコードになりますね。

#データのDELETE
既存のレコードのDELETE(削除)もUPDATE(更新)と同様に
1.対象のレコードを検索
2.検索したレコードを削除
という手順になります。

```swift:ViewController.swift
import CloudKit
-------------- (中略) --------------
    /**
     指定した条件でCloud KitのデータをDELETEする
     @param whereCode:削除対象のCode
     */
    private func deleteData(whereCode:String){
        let ckDatabase = CKContainer.default().privateCloudDatabase
        
        //1.削除対象のレコードを検索する
        let ckQuery = CKQuery(recordType: "GoodsMaster", predicate: NSPredicate(format: "code == %@", argumentArray: [whereCode]))
        ckDatabase.perform(ckQuery, inZoneWith: nil, completionHandler: { (ckRecords, error) in
            if error != nil {
                print("\(String(describing: error?.localizedDescription))")
            }else{
                //2.検索したレコードを削除する
                for ckRecord in ckRecords!{
                    ckDatabase.delete(withRecordID: ckRecord.recordID, completionHandler: { (recordId, error) in
                        if error != nil {
                            print("\(String(describing: error?.localizedDescription))")
                        }
                    })
                }
            }
        })
    }
```

削除する時には検索したレコードのレコードIDを引数にしてCKDatabaseのdeleteメソッドを実行します。

#その他注意点
CloudKitでデータ操作を行った時は処理結果をCompletionHandler(コールバック)で受けることになりますので、処理結果を受けた後でUI操作を行いたい時は`DispatchQueue.main.async {}`を使用してメインスレッドで処理を行う様に指定する必要があります。

#GitHub
今回紹介したサンプルコードはGitHubで公開しています。

https://github.com/naosekig/CloudKitSample

#参考文献
[CloudKitを使ってみた Swift版 - Qiita](https://qiita.com/tfutada/items/83b30311a5fbc3fad212)
[[iOS 8]CloudKitを使ってみよう(2) レコードの追加/更新/削除 - DevelopersIO](https://dev.classmethod.jp/references/ios8-cloudkit-record/)

#関連記事
[CloudKitで画像を連携する](https://qiita.com/NaoSekig/items/3fad1d9448495002395d)
[CloudKitで画像を連携する - CKAsset編](https://qiita.com/NaoSekig/items/b3b25794fc075225a80d)
[iOS / iPadOS 13からCore DataとCloudKitの連携が自動化されたということで試してみた](https://qiita.com/NaoSekig/items/98589e8b445db90d056b)