iCloud Documentsを使ってファイルの読み書きをする


![macOS-10.15.3](https://img.shields.io/badge/macOS-10.15.3 -blue) ![Xcode-11.3.1](https://img.shields.io/badge/Xcode-11.3.1 -blue) ![iPadOS-13.3.1](https://img.shields.io/badge/iPadOS-13.3.1 -blue)

# まえがき

iCloudに置いたファイルをユーザー/アプリ間で共有し、どちらからでも自由にアクセスできる機能を実装します。（Keynoteとかがそうですね）

iCloud Documents Storageを使うと上手くいきそうな事はすぐ分かったのですが、実際に動くところまで持っていくのに結構苦労しました。そこで得られた知見を共有したいと思います。

公式ドキュメントは[こちら](https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html#//apple_ref/doc/uid/TP40012094-CH2-SW1)です。

**<font color="Red">Apple Developer Programに登録したアカウントが必要</font>**です。


# 下準備
実装を始める前に、環境構築します。

## プロジェクトの作成

Xcodeを立ち上げて、ツールバーからFile → New → Project... を選んでください。
Single View Appを選択し、 好きなProduct Nameを入力します。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/d33550e7-5528-46c3-8f9f-563d75219096.png)

## Capabilityの追加

iCloudの機能を使うには、Capabilityを追加する必要があります。
赤丸で囲ったボタンを押して、iCloudを追加してください。
![c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/b1309797-1a9c-e8f1-c949-eb1c6e1073c7.png)

その後、`iCloud Documents` にチェックを入れます。

## コンテナの追加

iCloudのCapabilityを追加したからと言って、iCloud内の全てにアクセスはできません。コンテナ（フォルダみたいなもの）を指定し、そのコンテナ内のみアクセスできます。

+ボタンを押して新しくコンテナを作成します。コンテナ名は好きなものを入れてください。こだわりが無ければProduct Nameと同じでいいと思います。

[公式ドキュメント](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitQuickStart/EnablingiCloudandConfiguringCloudKit/EnablingiCloudandConfiguringCloudKit.html)にある通り、コンテナは一度作ると<font color="Red">**削除することができません**</font>。typoしてないかよく確認してください。テスト用に適当に作ったものももちろん削除できないので注意してください。
![d.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/effdc0ce-7f61-24b1-f629-5af729142ecd.png)

コンテナ作成後、文字が赤くなっている場合は更新ボタンを押してください。

![vv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/072afac5-0936-0652-8a93-331387842d69.png)

## Info.plistの編集

Info.plistを右クリックし、 Open As → Source Codeを選びます。そして、以下のコードを追加してください。

```xml
<key>NSUbiquitousContainers</key>
<dict>
    <key>iCloud.kakeru.iCloudDocumentTest</key> ← ここにコンテナ名を入れます
    <dict>
        <key>NSUbiquitousContainerIsDocumentScopePublic</key>
        <true/>
        <key>NSUbiquitousContainerName</key>
        <string>iCloudDocumentTest</string>
        <key>NSUbiquitousContainerSupportedFolderLevels</key>
        <string>Any</string>
    </dict>
</dict>
```

Property List形式だと以下のような形になります。
![df.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/f8c7fbfe-11aa-fdb9-ec79-baefb6392362.png)



- NSUbiquitousContainerIsDocumentScopePublic
    - trueにするとコンテナのDocumentsフォルダ内が、iCloud Drive上にフォルダとして見えるようになります。
- NSUbiquitousContainerName
    - iCloud Drive上で表示するフォルダ名です。
- NSUbiquitousContainerSupportedFolderLevels
    - None: Documentsフォルダ内には、フォルダを作ることができません。
    - One: Documentsフォルダ直下であれば、フォルダを作れます。
    - Any: 制限なし

各キーの詳しい情報は[こちら](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251)の公式ドキュメントを確認してください。

# 実装
`test.txt`というファイルの読み書きを実装します。コードが長くなるため、エラーハンドリングは省略しています。各自で追加してください。

まず、UIDocumentを継承したクラスを作成します。このクラスは適当にググって出てきたものをベースに作ったため、もっとスマートな書き方があるかもしれません。

```swift
class Document: UIDocument {
    var text: String? = ""

    override func contents(forType typeName: String) throws -> Any {
        text?.data(using: .utf8) ?? Data()
    }

    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let contents = contents as? Data else { return }
        text = String(data: contents, encoding: .utf8)
    }
}
```

## ファイルの新規作成

以下のコードだけで、`test.txt`ファイルがiCloud上に作成されます。かんたんですね。

```swift
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)!
    .appendingPathComponent("Documents")
    .appendingPathComponent("test.txt")

let document = Document(fileURL: url)
document.save(to: url, for: .forCreating)
```

## ファイルへの書き込み

新規作成とあまり変わりません。

```swift
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)!
    .appendingPathComponent("Documents")
    .appendingPathComponent("test.txt")

let document = Document(fileURL: url)
document.text = document.text! + "追記"
document.save(to: url, for: .forOverwriting)
```



## ファイルの読み込み

以下を実行すると、`test.txt`ファイルの検索が始まります。ファイルが見つかれば中身を出力します。これも適当にググって出てきたものがベースなので、もっとスマートな書き方があるかもしれません。(特にNSMetadataQuery)

```swift
let metadata = NSMetadataQuery()
metadata.predicate = NSPredicate(format: "%K like 'test.txt'", NSMetadataItemFSNameKey)
metadata.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]

NotificationCenter.default.addObserver(forName: .NSMetadataQueryDidFinishGathering, object: metadata, queue: nil) { notification in
    let query = notification.object as! NSMetadataQuery
    if query.resultCount == 0 { return }

    let url = (query.results[0] as AnyObject).value(forAttribute: NSMetadataItemURLKey) as! URL
    let document = Document(fileURL: url)
    document.open { success in
        if success {
            print(document.text)
        }
    }
}

metadata.start()
```

#完全に動くコード

みなさんが欲しいのはこれですよね。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var text: String = ""
    private let containerManager = ContainerManager()
    
    var body: some View {
        VStack() {
            TextField("テキストを入力...", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(width: 200)

            Button("Save") {
                self.containerManager.save(self.text)
            }
            Button("Load") {
                self.containerManager.load {
                    self.text = $0 ?? ""
                }
            }
            Button("Clear") {
                self.text = ""
            }
            Spacer()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

class ContainerManager {
    private var metadata: NSMetadataQuery! // 参照を保持するため、メンバとして持っておく。load()内のローカル変数にするとうまく動かない。
    private var url: URL {
        FileManager.default.url(forUbiquityContainerIdentifier: nil)!
            .appendingPathComponent("Documents")
            .appendingPathComponent("test.txt")
    }
    
    func load(completion: @escaping (String?) -> Void) {
        metadata = NSMetadataQuery()
        metadata.predicate = NSPredicate(format: "%K like 'test.txt'", NSMetadataItemFSNameKey)
        metadata.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
        
        NotificationCenter.default.addObserver(forName: .NSMetadataQueryDidFinishGathering, object: metadata, queue: nil) { notification in
            let query = notification.object as! NSMetadataQuery

            if query.resultCount == 0 {
                print("ファイルが見つからなかったので新規作成")
                let document = Document(fileURL: self.url)
                document.save(to: self.url, for: .forCreating) { success in
                    print(success ? "作成成功" : "作成失敗")
                    completion(nil)
                }
                return
            }

            let url = (query.results[0] as AnyObject).value(forAttribute: NSMetadataItemURLKey) as! URL
            let document = Document(fileURL: url)
            document.open { success in
                if success {
                    print("ファイル読み込み: \(document.text ?? "nil")")
                    completion(document.text)
                } else {
                    print("ファイル読み込み失敗")
                    completion(nil)
                }
            }
        }

        metadata.start()
    }
    
    func save(_ text: String) {
        let document = Document(fileURL: url)
        document.text = text
        document.save(to: url, for: .forOverwriting) { success in
            print("ファイル保存\(success ? "成功" : "失敗")")
        }
    }
}

class Document: UIDocument {
    var text: String? = ""

    override func contents(forType typeName: String) throws -> Any {
        text?.data(using: .utf8) ?? Data()
    }

    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let contents = contents as? Data else { return }
        text = String(data: contents, encoding: .utf8)
    }
}

```


# ハマったところ
Info.plistを編集しても、うまく反映されない事が多々あります。例えば、 `NSUbiquitousContainerIsDocumentScopePublic` の値をtrue → falseに変えても、iCloud Drive上でフォルダが見えたままとか。
[公式のQ&A](https://developer.apple.com/library/archive/qa/qa1893/_index.html)にもありますが、仕様のようです。

Info.plistを編集を編集した場合は、以下おまじないをすると反映されるかと思います。

1. Bundle Identifierを変更する。 Version, Buildの数字を増やす。![スクリーンショット 2020-03-18 17.55.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190739/73569f57-3bba-2846-ffd4-caee3a3f4fee.png)
2. Xcodeを再起動する
3. Info.plistの編集が元に戻っていないか確認する(してることがあります！)
4. Xcodeのツールバー → Product → Clean Build Folder
5. Product → Run
6. 変更が反映されたのを確認したら、Bundle Identifier, Version, Buildを元に戻す。