こんにちは。
@gachalatteです。
今回はドキュメントベースAppのお話です。
iOS 11のトピックですので目新しさはありませんが、Firevaultの開発で得られた知見を共有したいと思います。
#ドキュメントベースApp
ユーザーが対象を選択し、内容を編集、名前を付けて保存する。iOSでこのようなAppを開発するなら、ドキュメントベースApp(Document-Based App)が最適です。
UIDocument、Open in Place、Document Provider Extension、iCloud Driveなど、Appleの主要なテクノロジーをドキュメントベースAppに組み込むことで、ユーザーの生産性を高め、素晴らしいユーザー体験を提供することができます。
ドキュメントベースApp - Apple Developer
ドキュメントベース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
は、バックグラウンドのキューを使い、ファイルの読み書きを非同期で行います。
- ファイルを同期的に読み書きすると、その間Appが応答しなくなる可能性があります。
-
更新の監視
-
UIDocument
は、ファイルの更新を監視し、自動的に再読み込みを行います。また、ファイルが別の場所に移動された場合でも安全に動作します。
-
-
安全な書き込み
-
UIDocument
は、ファイルを一時ディレクトリに書き出し、元のファイルを置き換えます。保存中にクラッシュしてもファイルの整合性が失われることはありません。
-
-
自動保存
-
UIDocument
は、ファイルを自動的に保存します。Appを閉じてもそれまでの変更は失われません。
-
-
エラーやコンフリクトの通知
-
UIDocument
は、エラーやコンフリクトなどの状態を保ち、変化があった時に通知します。Appはこれを監視して適切な処理を行なうことができます。
-
-
サンドボックス外のファイルアクセス
- ドキュメントベースAppでは、サンドボックス外のファイルを開くことができます。サンドボックス外のファイルのURLは
Security-scoped URL
と呼ばれ、読み書きする前にアクセス権を取得する必要があります。UIDocument
は、Security-scoped URL
に対するアクセス権の取得/解放を自動的に行います。
- ドキュメントベースAppでは、サンドボックス外のファイルを開くことができます。サンドボックス外のファイルのURLは
##UIDocumentBrowserViewController
UIDocumentBrowserViewController
はコンテナに含まれるファイルの一覧を表示し、それぞれのファイルを操作するインターフェースを提供するクラスです。これはファイル.app
とほぼ同等の機能を持ちます。
#サンプルプロジェクト
Xcode 11
では、ドキュメントベースAppのテンプレートが提供されています。これを使ってプロジェクトを作成します。なお、サンプルプロジェクトではSwiftUIを使用します。
##プロジェクト設定
テンプレートの実装は、サポートするドキュメント形式に画像(public.image)ファイルが定義されています。これをカスタムドキュメントに変更します。
プロジェクト設定
> Info
を開き、定義を変更します。
###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 | |
public.filename-extension | Array | |
Item 0 | String | mydoc |
UTTypeTagSpecificationのpublic.filename-extensionにはファイルの拡張子を定義します。
##実装
プロジェクトの設定が終わったら、テンプレートで用意された3つのクラスを実装していきます。
###Document
まずは、UIDocument
のサブクラスであるDocument
を実装します。
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)
を実装します。
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
の表示、更新を行うユーザーインターフェースを提供します。
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コンポーネントに渡すようにしています。ただし、この部分に関しては、あまり自信がありません。もっと良い方法があれば教えてください。
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が起動することも確認してみてください。
#課題
世の中そんなに甘くはありません。うまい話には裏があります。
実際のプロダクトでドキュメントベース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 サポート
##ファイルの競合(コンフリクト)
ファイルが同時に更新された場合、ファイルが競合状態になることがあります。競合状態は解決しなければなりませんが、その方法は様々です。最新の変更ですべてを上書きすることもできますし、プログラムで判断して自動的にマージすることもできます。ユーザーに選択肢を提示することもできます。
##URLの保持
次回の起動に備えて、最後に開いたファイルのURLを記憶しておきたいと思うことがあるかも知れません。このような場合、ファイルのURLを直接記録してはいけません。なぜなら、ユーザーは次にAppを開く前にファイル名を変更したり、場所を移動したりする可能性があるからです。かわりにURLからブックマークを生成して、それを記録するようにします。
##読み取り専用での共有
iOSではファイルを読み取り専用にすることはできませんが、iCloud Drive
上のファイルは、読み取り専用で他のユーザーと共有することができます。ユーザーが読み取り専用のファイルを変更しても、自動的に元の状態に戻されるので大きな問題にはなりませんが、編集ボタンをロックするなどして、ユーザーがドキュメントを変更できないようにする方がよいでしょう。読み取り専用かどうかはURLResourceKey.ubiquitousSharedItemCurrentUserPermissionsKey
で判定することができます。
#さいごに
ドキュメントベースAppを採用することで、とても簡単にリッチで安全なAppが開発できることがおわかりいただけたでしょうか?
実際にプロダクトとして完成させるまでには様々な困難を乗り越えなければなりませんが、土台としてこれほど有用なものはありません。
クラウドサービス全盛の時代、このようなスタンドアロンAppを開発する機会はあまりないかも知れませんが、ドキュメントベースAppというワードだけでも覚えておいて損はないと思います。
最後までお読みいただきありがとうございました。
-
Xcode 11.3付属のiOS 13シミュレータは、macOS Catalinaより前の環境ではUIDocumentBrowserViewControllerが正常に動作しないという問題があります。 ↩