🐰

macOSのパッケージ形式ドキュメントとZipによるアーカイブ化

2022/10/17に公開

ドキュメントとは

Document Icons

macOSのDocument-Based Appにおける、パッケージ形式のドキュメントを実装するための技術を紹介します。前提とするUIフレームワークはAppKitです。SwiftUIのDocument Appには言及しません。

ドキュメント (Document) とは、ユーザがアプリケーション内で生成した内容を収録するための抽象表現です。永続化の際には“ファイル”として任意の形式に書き出され、アプリケーションにロードされるとドキュメントとして復元されます。AppKitではNSDocumentのサブクラスで取り扱います。

macOSネイティブアプリケーションでドキュメント(ファイル)を扱う場合、それを表すための構造体を選ぶことになります。プリミティブなテキスト形式や画像等のバイナリデータを直接扱う方法のほか、ディレクトリベースのパッケージ形式で独自のファイルフォーマットを実装する方法などがあります。

  • Text
  • Binary Data
  • Package
  • Package + Archiving

パッケージ形式はバンドル (Bundle) と呼ばれることもあります。macOS Developerの方にはこちらの名前の方が馴染みあるかと思います。そのほか、ドキュメントにCore Data (NSPersistentDocument[1]) を採用してデータを永続化するアプローチもあるようですが、詳しい技術資料が乏しいため深く触れません。

いずれの方法でも基本的にはCocoaドキュメントアーキテクチャに倣って実装することになります。Cocoaドキュメントアーキテクチャに従わずにドキュメントファイルのI/O処理を実装することも可能ですが、なるべく最大限に“macOSらしい振る舞い”を享受できることを目指したいのでアーキテクチャに倣いたいと思います。

今回はパッケージ形式と、それをアーカイブ化してさらに扱いやすくするための技術を簡単に紹介します。


Cocoaドキュメントアーキテクチャの様相: https://developer.apple.com/documentation/appkit/documents_data_and_pasteboard/developing_a_document-based_app

ドキュメントにパッケージ形式を採用する意図

まずパッケージ形式について簡単におさらいしておくと、これはファイルシステム上では通常のフォルダとして見えるものです。Finderでは一部のパッケージやバンドルをフォルダとしてではなく単一のファイルのように見せることがあります[2].app, .bundle, .plugin, .menuなどが代表的ですが、特定の拡張子が必ずパッケージ/バンドルとして表現されるものではなく、拡張子はデベロッパーが好きに名付けられるようになっています。

CocoaではパッケージをFileWrapper[3]で操作します。FileWrapperはファイルシステム上のノード表現をコードから操作するためのクラスで、ディレクトリを操作するようにドキュメントファイルの構成データを取り扱えるため、独自形式のドキュメントをCocoaドキュメントアーキテクチャに適合しながら永続化する方法としては最も手っ取り早い手段の一つとなります。

要するにパッケージ形式を採用すると、独自のドキュメントを設計しやすく、ドキュメントファイルの中身をディレクトリ階層で直接表現できます。

パッケージ形式でもドキュメントの自動保存やバージョン復元などの基本機能はほぼそのまま使えるので、macOSらしいDocument-Based Appを簡単に作りやすことが最大の優位点になります。Finderで直接覗けるのでデバッグもしやすいです。扱うコンテンツがプレーンテキストや画像などの一般データではない場合、かつドキュメントの構造が複雑で独自のアーキテクチャで設計されるとなったときには、まずパッケージ形式を検討するのが良いでしょう。

“パッケージ形式のアーカイブ化”アプローチ

純粋なパッケージ形式の問題点は、マルチプラットフォーム展開に不向きであることです。パッケージがFinder以外の多くのファイルシステムからは単なるディレクトリ群として見えてしまうので、たとえばWindowsとのやり取りやクラウドストレージ等を介したやり取りの際には、ドキュメントファイルが破損する恐れがあります。ディレクトリ(フォルダ)なのでmacOS以外の共有相手にとっては扱いづらいデータとなってしまいます。そこでこれらの対策として考えられるのが、パッケージをアーカイブ化して単一のバイナリ形式のデータ(ファイル)として書き出す方法です。具体的には、パッケージを保存する際にZipでアーカイブする処理を挟むのです。

iWorkシリーズ(Keynote, Pages, Numbers)の各ドキュメント .key, .pages, .numbers やSketchドキュメント .sketch では、macOSのパッケージ形式でドキュメントを表現しつつ、それをさらにZipでアーカイブするアプローチをとっています。試しにこれらのアプリケーションで作ったドキュメントファイルの末尾に .zip を付加してunzipしてみると解凍できるはずです。


.sketch ファイルをunzipした後の中身。SketchのドキュメントはJSONファイル等を収めた単なるディレクトリ階層だとわかります。

アーカイブ形式にZipを採用する理由は、単に扱いやすいからだと考えられます。昔から広く普及しているオープンソースのフォーマットであるため、ポータビリティが高いことが挙げられます。なのでたとえばZipで固めたドキュメントファイルをWebに上げた際に、Webの技術でもそれを簡単に展開できる余地があります。macOS版のエディターとWeb版のビューアといったプロダクト展開を見込めるわけです。

それからZipの圧縮を利用すればドキュメントのデータサイズを小さくできる可能性もあります。独自に圧縮処理を書かずともZipの仕組みに頼ってしまえば、macOS Developerとしては実装がとても楽です。もちろん非圧縮Zipにすることも可能です。

ちなみにiWorkシリーズのドキュメントには複数の形式があり、「シングルファイル」形式ではZipアーカイブされたパッケージ、「パッケージ」形式では通常のディレクトリ型パッケージになります。iCloud以外のクラウドストレージでやり取りする場合は「シングルファイル」形式の方が適しています。

ドキュメント形式の選択

macOS / Swift開発環境のおすすめはZIP Foundation

ZIP FoundationはSwiftコードからZipアーカイブの作成や展開を簡単に行えるライブラリです。Appleプラットフォームで使えるZip系ライブラリはいくつかありますが、ZIP Foundationが一番有名かつ使いやすそうに思えます。

ちなみに圧縮処理には別の外部ライブラリに依存するのではなくApple製のCompressionを利用しているそうなので、依存関係もシンプルに保ちながらSwift Package Managerでサクッと導入できて扱いやすいように思います。非圧縮Zipの作成にも対応していますので、圧縮処理のコストが気になるようなら非圧縮でアーカイブだけする方法も採れます[4]

ZIP Foundationを使ったNSDocumentのI/O処理実装方針

なんとなく処理の流れをシーケンスで描くとこんな具合です。書き込み処理はNSDocumentのdata(ofType: String)でデータを返すとそれがドキュメントファイルとして扱われますが、一旦パッケージを実体化するためにそれを一時ディレクトリに書き込み、さらにそれを読み込んでzipして…と進めてゆき、Zipデータをドキュメントとして正式に書き込む流れです。

読み込み処理はNSDocumentのメソッドで2度行います。

NSDocumentのサブクラスを作って実装していきます。ドキュメントのI/O処理のためのメソッドは複数あり、用途ごとにどれを使うかが決まっています。今回処理の中心に使うメソッドは3つです。

まず読み込み処理ではZipアーカイブの読み込み用とパッケージの読み込み用とで2つ使用します。実際に呼ばれるのはread(from: URL, ofType: String)の方で、ここにZipファイルのURLが入ってきます。それを使ってunzipして、FileWrapperを作ったら自分でread(from: FileWrapper, ofType: String)をコールし、FileWrapperの中身をメモリに展開する処理を行います。

NSDocumentに読み込むメソッド
// Zipアーカイブの読み込み
nonisolated func read(
    from url: URL,
    ofType typeName: String
) throws

// パッケージの読み込み
nonisolated func read(
    from fileWrapper: FileWrapper,
    ofType typeName: String
) throws

read(from: URL, ofType: String)
read(from: FileWrapper, ofType: String)

一方書き込み用ではZipアーカイブのデータをファイルに書き込むためのメソッドを1つ使用します。ここに至るまで複数回の書き込み処理を別途行いますが、それらはFileManagerやFileWrapper, ZIP Foundation経由で自分でコールします。

NSDocumentを書き込むメソッド
func data(ofType typeName: String) throws -> Data

data(ofType: String) -> Data

Document TypesとExported Type Identifiersの定義

Document-Based Appの設計ではInfo.plistにDocument TypesとType Identifiersの定義が必要です。

Document Types (CFBundleDocumentTypes) は必須項目、Imported Type Identifiers (UTImportedTypeDeclarations) は既存のファイル形式を扱う場合、Exported Type Identifers (UTExportedTypeDeclarations) は独自のファイル形式を定義して扱う場合の項目です。今回は独自形式(実体はZipだけれども)とするためExportedの方に情報を書き込んでおきます。

以下のスクリーンショットは一例ですが、Identifier, Description, Extensions, Icon Textは各自のアプリケーション設計に合わせて任意の内容に書き換えてください。Classには紐付けるNSDocumentのサブクラス名を指定します。

Exported TypeのConforms To項目は、この形式が継承するUTIを指定します。純粋なパッケージ形式の場合はここをcom.apple.packageにしますが、今回はZipアーカイブで表すので public.dataにしてみます[5]

修正追記
参考として、TextBundleのZipアーカイブ形式であるTextPack (.textpack) の仕様を見てみると、継承先UTIをcom.pkware.zip-archiveと規定しているため、それに倣ってcom.pkware.zip-archiveを記入するのが良いと思います。
http://textbundle.org/spec/


Document TypesとExported Type Identifiersの定義例。

CFBundleDocumentTypes / UTImportedTypeDeclarations / UTExportedTypeDeclarations の違い

CFBundleDocumentTypes

CFBundleDocumentTypesはアプリが開くことのできるドキュメント形式の属性を宣言します。UTI, ファイル拡張子, MIME type, OS Type(Classic Mac OS / Carbonで使われていた4文字のコード)などさまざまな方法でそれを記述することができますが、これは歴史的経緯によるものです。一部は非推奨になっていますので、基本的には UTI を使った宣言方法を使いましょう。ロールの定義は必須です。

UTIで記述する場合、UTImportedTypeDeclarationsとUTExportedTypeDeclarationsの定義も同時に行うのが良いでしょう。

UTImportedTypeDeclarations

UTImportedTypeDeclarationsは既知のデータ形式で、それを自身のアプリが所有していない場合に、それをサポートしたい場合に関連するUTIや属性を記述します。例えばpublic.json, public.xmlなどが該当するでしょう。UTExportedTypeDeclarations とよく似ていますが、所有していない形式に対応する場面で使うことに注意しましょう。

UTExportedTypeDeclarations

UTExportedTypeDeclarationsは自身のアプリが独自に定義するデータ形式を宣言したい場合にそのUTIや属性を記述します。例えばKeynoteならcom.apple.iwork.keynote.keyという具合に独自のタイプを記述しています。そのデータ形式の所有者が自身のアプリである場合にこの定義を使いましょう。

より詳しい情報は公式資料を参考にしてください。

ドキュメントを書き込む処理

FileWrapperオブジェクトはドキュメントをファイルに書き込むにあたり必要なデータを整頓するために使います。要はファイルの抽象表現です。書き込み(保存)処理の前にFileWrapperに必要なデータを収録しておきます。

FileWrapperを直接ファイルシステムに書き込むにはwrite(to:options:originalContentsURL:)を使います。これでドキュメントの内容をパッケージ形式で一時的にファイルシステムに書き込んでおき、次にこれのURLを使ってzip処理にかけます。

FileWrapperを直接ファイルシステムに書き込む
let tempDirURL: URL = ...
let documentFileWrapper = FileWrapper(directoryWithFileWrappers: [:])

try? documentFileWrapper.write(to: tempDirURL, options: [], originalContentsURL: nil)

Zipアーカイブの作成にはZIP Foundationが拡張したFileManagerのメソッドを使用します。直接ファイルシステムにZipファイルが書き込まれます。

Zipアーカイブを作成
import ZIPFoundation

let tempDirURL: URL = ... // documentFileWrapperの書き込み先
let tempZipURL: URL = ... // Zipを置く場所

// `.none`で非圧縮、`.deflate` で圧縮
let compression: CompressionMethod = .none
// zip
try? FileManager.default.zipItem(at: tempDirURL,
				 to: tempZipURL,
				 shouldKeepParent: false,
				 compressionMethod: compression)
zipしたドキュメントのデータを正式に書き込む
class Document: NSDocument {

	// 保存実行時など任意の時期に呼ばれる
	override func data(ofType typeName: String) throws -> Data {
		let tempZipURL: URL = ... // ZipファイルのURL
		
		// Zipファイルを直接Dataオブジェクトにする
		let archivedData = Data(contentsOf: tempZipURL)
		// を返すと正式にドキュメントファイルとして書き込まれる
		return archivedData
	}

ドキュメントを読み込む処理

読み込む処理ではまず read(from: URL, ofType: String) が呼ばれるので、そのURLが示すZipアーカイブをunzipします。その中身はパッケージというかただのディレクトリのはずなので、これを手動でFileWrapperに展開します。この時にNSDocumentのメソッド read(from: FileWrapper, ofType: String) を自分でコールします。

ドキュメントをunzipしてからFileWrapperオブジェクトを作る
import ZIPFoundation

class Document: NSDocument {

	// ドキュメントファイル展開時など任意の時期に呼ばれる
	override func read(from url: URL, ofType typeName: String) throws {
		let tempDirURL: URL = ...
		// 中略
	
		// unzip
		try? FileManager.default.unzipItem(at: url, to: tempDirURL)
		// FileWrapperオブジェクトにする
		let documentFileWrapper = FileWrapper(url: tempDirURL, options: [])
		// FileWrapperの中身を正式に読み込む
		try? read(from: documentFileWrapper, ofType: typeName)
		
		// エラーならthrowする
	}
FileWrapperからデータを取り出す
class Document: NSDocument {

	override func read(from fileWrapper: FileWrapper, ofType typeName: String) throws {
		// `typeName`でUTIチェックして、fileWrapperからデータを取り出す
		// もしくはエラーならthrowする
	}

コアの部分は大体こんな具合です。

Zipに関する処理はZIP FoundationのArchiveEntryを直接利用することでもう少し高度なことも実現可能です。例えばunzipすることなく中身のデータにランダムアクセスすることもできるようです[6]。手っ取り早くI/O処理を実装するなら紹介した方法でシンプルに書けます。

Appとして成り立たせるにはそのほかにも最低限の実装は必要ですが、記事中では割愛します。XcodeのDocument Appテンプレートでプロジェクトを開始して、Info.plistの定義と紹介した設計の流れで基本的には動くはずです。

全体のコードは私のGitHubに置いてあるサンプルを参照してください。
https://github.com/usagimaru/ZipArchivedDocument


脚注
  1. NSPersistentDocumentを使ったDocument-Based Appに言及した日本語資料。
    https://banjun.hatenadiary.org/entry/20070409/1176107618
    https://hylom.net/2017/04/09/macos-app-develop-with-storyboard-and-coredata/ ↩︎

  2. アプリケーションバンドルや一部のプラグインバンドルなどは、コンテクストメニューから「パッケージの内容を表示」を選ぶことでFinderウインドウからでも中身を展開することができる。あるいはCLIから掘っていくとそれらは通常のディレクトリとして見える。 ↩︎

  3. 以前はNSFileWrapperとも呼ばれたクラス。 ↩︎

  4. 圧縮をかけた際に処理時間が具体的にどれだけ増加するかについては未確認。非圧縮だからといって処理が軽くなるとは自信を持って言い切れないかも。 ↩︎

  5. Zipならcom.pkware.zip-archiveでも良いのかもしれませんが。UTI参考
    追記:当初はConforms Toの継承元UTIをpublic.dataにすると紹介していましたが、TextBundleのZipアーカイブ形式であるTextPack (.textpack) の仕様では継承するUTIをcom.pkware.zip-archiveと規定しているため、それに倣ってcom.pkware.zip-archiveを記入するのが良いと思います。
    http://textbundle.org/spec/ ↩︎

  6. 参考記事: https://kean.blog/post/pulse-store ↩︎

Discussion