[iOS]今からはじめるドキュメントベースApp

こんにちは。
@gachalatteです。

今回はドキュメントベースAppのお話です。

iOS 11のトピックですので目新しさはありませんが、[Firevault](https://apps.apple.com/us/app/firevault/id1439389822?l=ja&ls=1)の開発で得られた知見を共有したいと思います。


#ドキュメントベースApp

ユーザーが対象を選択し、内容を編集、名前を付けて保存する。iOSでこのようなAppを開発するなら、ドキュメントベースApp(Document-Based App)が最適です。

> UIDocument、Open in Place、Document Provider Extension、iCloud Driveなど、Appleの主要なテクノロジーをドキュメントベースAppに組み込むことで、ユーザーの生産性を高め、素晴らしいユーザー体験を提供することができます。
>
[ドキュメントベースApp - Apple Developer](https://developer.apple.com/jp/document-based-apps/)


ドキュメントベースAppを採用すれば、Appは様々な能力を手に入れることができます。

* 共通のユーザーインターフェースによるファイル操作
    * ユーザーは、`ファイル.app`と同様のインターフェースでファイルを操作することができます。

* フォルダ、タグによるファイル管理
    * ユーザーが自身にとって最適な方法でファイルを整理することができます。

* `iCloud Drive`によるファイル共有
    * ファイルをチームで共同編集したり、読み取り専用での一般公開したりすることができます。

* `Dropbox`や`Google Drive`などの外部プロバイダのサポート
    * ローカルディレクトリと同じ要領で外部プロバイダのコンテナにアクセスすることができます。フレームワークやAPIの呼び出しは不要です。

* シームレスなデバイス間同期
    * ファイルの変更は、即座にユーザーインターフェースに反映されます。これは、ユーザー体験を大幅に向上させます。


この記事では、ファイルパッケージを使ったドキュメントベースAppの開発手順を紹介します。


##ファイルパッケージ
ドキュメントベースAppを開発する前に、どのような形式でファイルを書き出すかを設計する必要があります。もし、ドキュメントに複数のコンテンツが含まれる場合は、ファイルパッケージ形式がおすすめです。

ファイルパッケージの実体は単なるディレクトリですが、iOSやmacOSは、ファイルパッケージを単一のファイルとして扱います。これによって、内包するファイルの整合性が確保されます。クラウド上のファイルは、Appが実行中に変更される可能性があります。一部のファイルだけが更新された瞬間を読み込んでしまうと誤作動を起こすのは避けられません。整合性が確保されているということは、とても重要な要素です。

ファイルパッケージは、非常に扱いやすいという特徴もあります。プログラムでは、通常のディレクトリと同じように操作することができ、MacのFinderでは、`右クリック` > `パッケージの内容を表示`で、中を開くこともできます。

また、更新されたファイルだけが送受信の対象となるため、クラウド上のファイルを効率良く転送することができるのもファイルパッケージの特徴です。


##FileWrapper
ファイルパッケージを読み書きするには`FileWrapper`を使用します。`FileWrapper`を使わず、直接ファイルを読み書きすることもできますが、`FileWrapper`を使用することで以下のメリットを享受することができます。

* 一括操作
    * パッケージの読み書きは一括で行います。個別にファイルを読み書きする必要はありません。`UIDocument`を使用する場合は、読み書きの手続きすら不要です。

* 差分書き出し
    * 変更されたファイルだけを書き出すことができます。これによって書き込み時のパフォーマンス向上が期待できます。

* 遅延読み込み
    * パッケージ内のファイルが必要になった時に読み込むことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。

* ファイルマッピング
    * パッケージ内のファイルをメモリマップトファイルとして開くことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。


##UIDocument
`UIDocument`はドキュメントを表すモデルであると同時に、ファイルの読み書きを行うコントローラの役割を持つクラスです。`UIDocument`には以下の機能があり、ドキュメントベースAppを最小限のコードで開発することができます。

* 協調読み書き
    * ファイルは外部のプロセスによって常に更新される可能性があります。そのため、ファイルの読み書きには`NSFilePresenter`や`NSFileCoordinator`を使用した協調読み書きの手続きが必要になります。`UIDocument`は、これらを適切に使用し、ファイルの協調読み書き行います。

* 非同期の読み書き
    * ファイルを同期的に読み書きすると、その間Appが応答しなくなる可能性があります。`UIDocument`は、バックグラウンドのキューを使い、ファイルの読み書きを非同期で行います。

* 更新の監視
    * `UIDocument`は、ファイルの更新を監視し、自動的に再読み込みを行います。また、ファイルが別の場所に移動された場合でも安全に動作します。

* 安全な書き込み
    * `UIDocument`は、ファイルを一時ディレクトリに書き出し、元のファイルを置き換えます。保存中にクラッシュしてもファイルの整合性が失われることはありません。

* 自動保存
    * `UIDocument`は、ファイルを自動的に保存します。Appを閉じてもそれまでの変更は失われません。

* エラーやコンフリクトの通知
    * `UIDocument`は、エラーやコンフリクトなどの状態を保ち、変化があった時に通知します。Appはこれを監視して適切な処理を行なうことができます。

* サンドボックス外のファイルアクセス
    * ドキュメントベースAppでは、サンドボックス外のファイルを開くことができます。サンドボックス外のファイルのURLは`Security-scoped URL`と呼ばれ、読み書きする前にアクセス権を取得する必要があります。`UIDocument`は、`Security-scoped URL`に対するアクセス権の取得／解放を自動的に行います。

##UIDocumentBrowserViewController
`UIDocumentBrowserViewController`はコンテナに含まれるファイルの一覧を表示し、それぞれのファイルを操作するインターフェースを提供するクラスです。これは`ファイル.app`とほぼ同等の機能を持ちます。


#サンプルプロジェクト
`Xcode 11`では、ドキュメントベースAppのテンプレートが提供されています。これを使ってプロジェクトを作成します。なお、サンプルプロジェクトではSwiftUIを使用します。

https://github.com/gachalatte/DocumentBasedApp

##プロジェクト設定
テンプレートの実装は、サポートするドキュメント形式に画像(public.image)ファイルが定義されています。これをカスタムドキュメントに変更します。

`プロジェクト設定` > `Info`を開き、定義を変更します。

![プロジェクト設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/553312/d43a0b86-50e6-840a-5bd4-8ad5fed14439.png)

###Document Types

|Key|Value|
|:--|:--|
|Name|My Document|
|Types|net.gacha.mydoc|

Nameには、ファイルの種類として画面上に表示されるテキストを指定します。
Typesには、カスタムドキュメントのUTI(Uniform Type Identifier)を定義します。サンプルプロジェクトでは`net.gacha.mydoc`としましたが、ユニークな文字列であれば何でも構いません。

####Additional document type properties
|Key|Type|Value|
|:--|:--|:--|
|CFBundleTypeRole|String|Editor|
|LSHandlerRank|String|Owner|
|LSTypeIsPackage|Boolean|YES|

この部分はAPIドキュメントにも詳細が記されておらず、きちんと説明できるだけの理解が得られませんでした。ただ、今回のケースではこの設定で動作することを確認しています。詳しく知りたい場合は、`CFBundleDocumentTypes`を調べてみてください。


###Exported UTIs

テンプレートの初期状態は空なので、行を追加します。

|Key|Value|
|:--|:--|
|Description|My Document|
|Identifier|net.gacha.mydoc|
|Conforms To|com.apple.package, public.composite-content|

Identifierには、Document Typesで定義したUTIを設定します。
Conforms Toは、カスタムドキュメントが適合するUTIを表します。`com.apple.package`はファイルパッケージを表し、`public.composite-content`は複数の内容物で構成されていることを表します。

####Additional exported UTI properties

|Key|Type|Value|
|:--|:--|:--|
|UTTypeTagSpecification|Dictionary||
|&nbsp;&nbsp;&nbsp;&nbsp;public.filename-extension|Array||
|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Item 0|String|mydoc|

UTTypeTagSpecificationのpublic.filename-extensionにはファイルの拡張子を定義します。


##実装
プロジェクトの設定が終わったら、テンプレートで用意された3つのクラスを実装していきます。

###Document
まずは、`UIDocument`のサブクラスである`Document`を実装します。

```swift:Document.swift
import UIKit

class Document: UIDocument, ObservableObject {

    @Published var image: UIImage?
    @Published var text: String?
        
    override func contents(forType typeName: String) throws -> Any {
        return FileWrapper(directoryWithFileWrappers: Dictionary(uniqueKeysWithValues: FileID.allCases.compactMap(fileWrapper(for:)).map({ ($0.preferredFilename!, $0) })))
    }
    
    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let fileWrapper = contents as? FileWrapper, fileWrapper.isDirectory else { return }
        FileID.allCases.compactMap({ (fileID) -> (FileID, Data?)? in
            guard let child = fileWrapper.fileWrappers?[filename(for: fileID)] else { return nil }
            return (fileID, child.regularFileContents)
        }).forEach({ (fileID, data) in
            setData(data, for: fileID)
        })
    }

}

extension Document {

    private enum FileID: CaseIterable {
        case image
        case text
    }

    private func filename(for fileID: FileID) -> String {
        switch fileID {
        case .image:
            return "image.png"
        case .text:
            return "text.txt"
        }
    }
    
    private func data(for fileID: FileID) -> Data? {
        switch fileID {
        case .image:
            return image?.pngData()
        case .text:
            return text?.data(using: .utf8)
        }
    }
    
    private func setData(_ data: Data?, for fileID: FileID) {
        switch fileID {
        case .image:
            image = {
                guard let data = data else { return nil }
                return UIImage(data: data)
            }()
        case .text:
            text = {
                guard let data = data else { return nil }
                return String(data: data, encoding: .utf8)!
            }()
        }
    }

    private func fileWrapper(for fileID: FileID) -> FileWrapper? {
        guard let data = data(for: fileID) else { return nil }
        let fileWrapper = FileWrapper(regularFileWithContents: data)
        fileWrapper.preferredFilename = filename(for: fileID)
        return fileWrapper
    }

}

```

SwiftUIで使用するため、Documentを`ObservableObject`に適合し、各プロパティには`@Published`属性をつけて変更を通知するようにしています。これは、プロパティが変更されたら画面を更新するということを実現するためのもので、SwiftUIを使わない場合は、`@objc`属性をつけてKVOで監視する方法でも構いません。また、オプショナルにしているのは、ファイルが存在しないケースを考慮しています。

プロパティの`image`と`text`は、それぞれ`image.png`と`text.txt`の内容を保持します。プロパティとファイル、どちらか一方が変更されれば、他方に反映されるようにします。

注目すべきは、`func contents(forType: String) -> Any`と`func load(fromContents: Any, ofType: String?)`です。`UIDocument`は、適切なタイミングでこれらのメソッドを呼び出し、ファイルの読み書きを行います。`contents`は`Data`と`FileWrapper`に対応しているため、これらの形式の値をやり取りするだけで、ファイルの読み書きが実現できます。

サンプルコードの後半、extensionの部分は、上記の実装を効率よく行うためのヘルパーです。


###DocumentBrowserViewController
`DocumentBrowserViewController`は`UIDocumentBrowserViewController`のサブクラスで、アプリ起動時の初期画面となります。

テンプレートの初期状態では、ドキュメントを開くことはできますが、新規作成ができません。ドキュメントの新規作成に対応するために、`func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void)`を実装します。

```swift:DocumentBrowserViewController.swift
    func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void) {
        let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent("Document.mydoc")
        let document = Document(fileURL: temporaryURL)
        document.save(to: temporaryURL, for: .forCreating) { (success) in
            if success {
                importHandler(temporaryURL, .move)
            } else {
                importHandler(nil, .none)
            }
        }
    }
```

まず、Documentオブジェクトを生成し、一時ファイルとして保存します。保存処理が完了したら、`importHandler`を呼び出します。ImportModeとして`.move`を指定しているので、一時ファイルを削除する必要はありません。

一時ファイルのファイル名は、作成するドキュメントのファイル名になります。拡張子は、プロジェクト設定で定義したものにします。今回は、ファイル名を`Document.mydoc`と固定にしていますが、同じ名前のファイルが存在する場合は、`Document 2.mydoc`のように自動的にサフィックスが付与されますので心配はいりません。

なお、このメソッドは非同期でデザインされているため、ファイル名を入力するダイアログボックスや、テンプレートを選択する画面を表示することもできます。最後に`importHandler`を呼び出すのを忘れないように気をつけてください。


###DocumentView
最後に、DocumentViewを実装します。 `DocumentView`は、`Document`の表示、更新を行うユーザーインターフェースを提供します。

```swift:DocumentView.swift
import SwiftUI
import Combine

struct DocumentView: View {
    
    @ObservedObject var document: Document
    
    @State private var showImagePicker: Bool = false

    var dismiss: () -> Void

    var body: some View {

        return VStack(spacing: 30) {
            
            Text(document.localizedName)
                .font(.title)

            Group {
                if document.image == nil {
                    Button(action: {
                        self.showImagePicker = true
                    }) {
                        Image(systemName: "camera.on.rectangle").imageScale(.large).background(RoundedRectangle(cornerRadius: 6).foregroundColor(Color.secondary.opacity(0.1)))
                    }
                } else {
                    Image(uiImage: document.image!).resizable().aspectRatio(contentMode: .fit).frame(width: 240, height: 240)
                        .onTapGesture {
                            self.showImagePicker = true
                    }
                }
            }
                        
            TextView(text: bind(\.text))
                .frame(width: 240, height: 80)
                .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary, lineWidth: 1))
                        
            Button("Done", action: dismiss)
            
        }.sheet(isPresented: $showImagePicker) {
            ImagePicker(image: self.bind(\.image))
        }
    }

}

```

`DocumentView`は、以下の4つのコンポーネントで構成されています。

* Text(document.localizedName)
    * ドキュメント名を表示します。

* Group
    * document.imageの内容を表示、編集します。タップで、ImagePickerを表示します。

* TextView(text: text)
    * document.textの内容を表示、編集します。

* Button("Done")
    * ドキュメントを閉じます。


基本的にはこれだけですが、`UIDocument`を更新するUIの実装で考慮しなければならないことがあります。それは変更の通知です。`UIDocument`は適切なタイミングでドキュメントを保存しますが、それには`UIDocument`自身が変更されたことを知っている必要があります。`document.hasUnsavedChanges`がそれに当たり、`hasUnsavedChanges`が`true`の時、ドキュメントは自動保存されます。ただし、このプロパティはreadonlyのため、直接設定することはできません。`func updateChangeCount(_ change: UIDocument.ChangeKind)`は`hasUnsavedChanges`を更新する手段のひとつですが、よりよい実装として、`UndoManager`を使う方法があります。

`UndoManager`は、操作の取り消し、やり直しを実現するクラスです。`UIDocument`は`UndoManager`のインスタンスを保持しており、これを利用することができます。プロパティを変更した時に`document.undoManager`に変更前の値に戻す処理を登録することで、AppがUndo/Redoの能力を手に入れるのと同時に、`UIDocument`に変更を通知することができます。

以下はSwiftUIの話になりますが、サンプルコードでは、値の変更をハンドリングするために、下記のメソッドを定義して動的に`Binding`オブジェクトをViewコンポーネントに渡すようにしています。ただし、この部分に関しては、あまり自信がありません。もっと良い方法があれば教えてください。

```swift:DocumentView.swift
extension DocumentView {
    
    private func bind<Value>(_ keyPath: ReferenceWritableKeyPath<Document, Value>) -> Binding<Value> {
        let document = self.document
        return Binding<Value>(get: { () -> Value in
            return document[keyPath: keyPath]
        }, set: { (value) in
            let oldValue = document[keyPath: keyPath]
            document.undoManager.registerUndo(withTarget: document) { $0[keyPath: keyPath] = oldValue }
            document[keyPath: keyPath] = value
        })
    }
    
}
```

ここまでできたら、プロジェクトを実行してみましょう。[^1]

`iCloud Drive`にファイルを作成して、デバイス間で相互に変更が反映されることを確認してみてください。ホーム画面でAppアイコンを長押ししてファイル選択をショートカットしたり、`ファイル.app`からAppが起動することも確認してみてください。

[^1]: Xcode 11.3付属のiOS 13シミュレータは、macOS Catalinaより前の環境ではUIDocumentBrowserViewControllerが正常に動作しないという問題があります。


#課題
世の中そんなに甘くはありません。うまい話には裏があります。

実際のプロダクトでドキュメントベースAppを開発するに当たっての課題を紹介します。

##ファイル数の限界
`UIDocument`や`FileWrapper`は、ファイルパッケージを一括で操作するため、含まれるファイル数が増えるにしたがってパフォーマンスが低下します。やむを得ずこれらの使用を断念する場合は、`NSFilePresenter`による変更監視、`NSFileCoordinator`による協調読み書き、バックグランドキューによる非同期のファイルアクセス、サンドボックス外のファイルに対するアクセス権の取得など、すべてを自前で行う必要があります。また、一時ディレクトリを使わずにファイルを直接書き出す場合は、整合性が保証されないことを考慮した設計が必要になり、開発の難易度は一気に上昇します。

ドキュメントベースAppの開発を始める前に、想定する最大数のダミーファイルを用意してパフォーマンスを計測するのをおすすめします。


##ファイルパッケージの越えられない壁
ファイルパッケージは、iOSおよびmacOSでのみ有効です。外の世界に出た瞬間、それは普通のディレクトリとして扱われます。そのため、`Dropbox`や`Google Drive`などの外部プロバイダにファイルパッケージを保存することができません。また、メールなどで送信した場合も期待通りの結果にはなりません。実際のプロダクトでファイルパッケージを採用する場合は、この点が課題となるでしょう。

AppleのGarageBandでは、サポートページに次のような案内があります。

>iOS 用 GarageBand 2.3 では、iOS 用 GarageBand の曲を iPhone、iPad、iPod touch にローカルに、または iCloud Drive にだけ保存できます。
>
>iOS 11 では、iOS 用 GarageBand 2.3 とファイル App を連係させて、GarageBand プロジェクトを管理できます。ファイル App は他社のクラウドストレージサービスに対応していますが、GarageBand のプロジェクトを以下のクラウドストレージサービスに保存することはできません。
>
> * DropBox
> * Google Drive
> * Box
> * Microsoft OneDrive

>[iOS 11 における iOS 用 GarageBand 2.3 と他社のクラウドストレージ App について - Apple サポート](https://support.apple.com/ja-jp/HT208214)

##ファイルの競合（コンフリクト）
ファイルが同時に更新された場合、ファイルが競合状態になることがあります。競合状態は解決しなければなりませんが、その方法は様々です。最新の変更ですべてを上書きすることもできますし、プログラムで判断して自動的にマージすることもできます。ユーザーに選択肢を提示することもできます。


##URLの保持
次回の起動に備えて、最後に開いたファイルのURLを記憶しておきたいと思うことがあるかも知れません。このような場合、ファイルのURLを直接記録してはいけません。なぜなら、ユーザーは次にAppを開く前にファイル名を変更したり、場所を移動したりする可能性があるからです。かわりにURLからブックマークを生成して、それを記録するようにします。


##読み取り専用での共有
iOSではファイルを読み取り専用にすることはできませんが、`iCloud Drive`上のファイルは、読み取り専用で他のユーザーと共有することができます。ユーザーが読み取り専用のファイルを変更しても、自動的に元の状態に戻されるので大きな問題にはなりませんが、編集ボタンをロックするなどして、ユーザーがドキュメントを変更できないようにする方がよいでしょう。読み取り専用かどうかは`URLResourceKey.ubiquitousSharedItemCurrentUserPermissionsKey`で判定することができます。


#さいごに
ドキュメントベースAppを採用することで、とても簡単にリッチで安全なAppが開発できることがおわかりいただけたでしょうか？

実際にプロダクトとして完成させるまでには様々な困難を乗り越えなければなりませんが、土台としてこれほど有用なものはありません。

クラウドサービス全盛の時代、このようなスタンドアロンAppを開発する機会はあまりないかも知れませんが、ドキュメントベースAppというワードだけでも覚えておいて損はないと思います。


最後までお読みいただきありがとうございました。