第2のドワンゴ Advent Calendar 2016 5日目の記事です。
はじめに
この記事では Dash っぽい Xcode8 のドキュメント閲覧アプリを作ってみたという話をします。
今年の10月に Dash が AppStore から削除されるという出来事がありました。
この事件の展開がなんだかもにょもにょしていたので、このようなアプリを自分で作ってみたらどうなるんだろうと思い、試しに作ってみることにしました。
やってみた結果、いきなりエーッっていう結論を書いてしまうと、普段のコーディングのお供として便利に使えるようにするには相当細かいところまで作り込まないとダメそうだなと思いました。おとなしく Dash を買いましょう。
実際にやってみてわかったことを書いていこうと思います。
そもそもXcode に付属するやつじゃダメなのか?
Xcode のメニュー Help > Documentation And API Reference
から、オフラインでも API リファレンスをみる事ができるようになっています。
オンラインでは、ウェブブラウザでも見ることができます。 https://developer.apple.com/reference/
付属のものがあるのになぜ人々は Dash のような別のドキュメントビューアに頼るのかというと、圧倒的に検索が速いし、メソッドやプロパティなどの一覧が表示されてすぐにジャンプできるなど、とても使いやすいからです。かゆいところに手が届くのです。
また、Dash の場合は macOS や iOS のドキュメントだけでなく、他の言語やフレームワークにも対応しているので一つのアプリでいろんなドキュメントを同じような操作感で参照できるのもうれしいところです。
ドキュメントのファイルはどこへいった
Xcode7 までは設定画面で APIドキュメントをダウンロードすると、docset パッケージが作られていました。このパッケージにドキュメントの HTML ファイルや画像ファイルなどが含まれていたので、これらを WebView で表示するようなアプリを作れば今回の目的は達成できるだろうと考えていました。
しかし、Xcode8 の環境で探してみると目的の docset パッケージが見当たりません。
/Applications/Xcode.app/Contents/Developer/Documentation/DocSets/
に com.apple.adc.documentation.docset
というパッケージは見つかりますが、これは違うもののようです。
docset パッケージではありませんが、以下のディレクトリになんだか怪しげなファイルを見つけました。
/Applications/Xcode.app/Contents/SharedFrameworks/DNTDocumentationSupport.framework/Resources/external/
external
├── 0.txt
├── 1.txt
├── 2.txt
├── cache.db
├── fs
│ ├── xxx.png
│ ├── xxx.png
│ ├── ...
│ └── xxx.png
└── map.db
txt ファイルは メソッド名などのシンボルらしきものが羅列されており、 cache.db, map.db は SQLite3 の db ファイルのようです。
中を詳しく見ていきましょう。
どうやって読めばよいのだ
それっぽいファイルたちを見つけましたが、このファイルからドキュメントのHTMLは得られるのでしょうか。
それぞれのファイルの中身を見てみましょう。
{0, 1, 2}.txt
どうやらシンボル名が長い順に記述されているようですね。
例えば 2.txt の最初の5行は以下のようになっています。
> head -n 5 2.txt | cat -v
presentPopoverFromBarButtonItemPermittedArrowDirectionsAnimated^@20^@hj8ylchPs9^@
presentPopoverFromRectInViewPermittedArrowDirectionsAnimated^@20^@hj7ZDocZvY^@
withScriptExecutionContextAndScriptStateWithSpacesAttribute^@21^@hj2Q6L81Uf^@
transitionControllerWillMakeViewControllerVisibleAnimated^@20^@hjkEWhjOVZ^@
transitionControllerDidMakeViewControllerVisibleAnimated^@20^@hjaT2Pf5um^@
各行は symbol_name \0 type \0 request_key \0\n
という形式のようですね。
request_key
は後述する map.db と cache.db にも登場するのでそこから推測しました。
map.db
SQLite3 クライアントで中を見てみます。
> sqlite3 map.db
sqlite> .tables
map
sqlite> .schema map
CREATE TABLE map (row_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,request_key TEXT NOT NULL,topic_id INTEGER NOT NULL,source_language INTEGER NOT NULL,reference_path TEXT NOT NULL,UNIQUE(request_key, reference_path) ON CONFLICT REPLACE);
CREATE INDEX map_index ON map(request_key);
CREATE INDEX topic_id_index ON map(topic_id, source_language);
CREATE INDEX reference_path_index ON map(reference_path);
sqlite> select count(*) from map;
count(*)
----------
232121
sqlite> select * from map limit 10;
row_id request_key topic_id source_language reference_path
---------- ------------ ---------- --------------- --------------
1 cjcloudkitjs 1628786 2 cloudkitjs
2 cjinstallerj 1662824 2 installerjs
3 hj_O0gC-N7 1628678 2 cloudkitjs/clo
4 tj1812145 1812145 2 installerjs/ap
5 hjbTe8HAly 1628513 2 cloudkitjs/clo
6 hj9BAEGWM9 1628509 2 cloudkitjs/clo
7 tj1812189 1812189 2 installerjs/ap
8 hjjZDpjYgP 1628647 2 cloudkitjs/clo
9 tj1812142 1812142 2 installerjs/ch
10 cjapplepayjs 1778092 2 applepayjs
なるほど?レコードをながめてみた感じ、 source_language
の値は 0 が Swift, 1 が Objective-C, 2 が JavaScript のもののように見えました。
cache.db
こちらも同様に SQLite3 クライアントで中を見てみます。
> sqlite3 cache.db
sqlite> .tables
response
sqlite> .schema response
CREATE TABLE response (row_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,request_key TEXT NOT NULL,uncompressed_size INTEGER,response_data BLOB,UNIQUE(request_key) ON CONFLICT REPLACE);
CREATE INDEX response_index ON response(request_key);
sqlite> select count(*) from response;
count(*)
----------
228599
sqlite> select * from response limit 10;
row_id request_key uncompressed_size response_data
---------- ----------- ----------------- -------------
1 as 16223 bvx2_?
2 ac 16774 bvx2�A
3 aj 1052 bvxn
4 cjcloudkitj 21043 bvx23R
5 cjinstaller 3782 bvxn�
6 hj_O0gC-N7 7833 bvx2�
7 hjbTe8HAly 3048 bvxn�
8 tj1812145 1095 bvxnG
9 hjjZDpjYgP 817 bvxn1
10 tj1812142 2740 bvxn�
なんだかバイナリデータっぽいのが現れました。 uncompressed_size
というカラムがあるので何かが圧縮されたものが response_data
にはいっているのかな?
response_data を解読したい
cache.db の response_data
カラムの blob をファイルに書き出し、いくつかバイナリエディタで見てみると、bvx2
や bvxn
ではじまり、 bvx$
で終わるという共通点が見えてきます。なんなのだこのフォーマットは。
調べてみると、どうやら LZFSE というアルゴリズムで圧縮されたもののようです。
LZFSE は Apple が発表し iOS9, OSX 10.11 El Capitan で導入された新しい圧縮アルゴリズムです。
エンコード・デコードの速度とエネルギー効率のバランスを取ったアルゴリズムらしいですね。
https://developer.apple.com/reference/compression/1665429-data_compression
C実装も公開されています。https://github.com/lzfse/lzfse
README に従ってビルドし、試しに row_id: 9 の response_data を復号してみると以下のようになりました。
> /tmp/lzfse.dst/usr/local/bin/lzfse -decode -i 9.txt
{"pa":{"pt":["reference\/cloudkitjs\/cloudkit"],"u":"cl\/CloudKit","i":1628678,"k":"cl","nm":"CloudKit","t":{"x":"CloudKit"}},"pt":["reference\/cloudkitjs\/cloudkit\/1628647-getdefaultcontainer"],"u":"instm\/CloudKit\/getDefaultContainer","k":"instm","d":[{"x":"<dc><cl><rt>CloudKit.Container<\/rt> <k>getDefaultContainer<\/k> ();<\/cl><\/dc>","ps":["cloudkitjs"]}],"t":{"x":"getDefaultContainer"},"a":{"x":"<a><p>Returns the default container.<\/p><\/a>"},"i":1628647,"r":"symbol","m":1,"ls":["javascript"],"rv":{"x":"<returnValue><p>The default container (the first container that appears in the configuration list of containers).<\/p><\/returnValue>"},"l":"javascript","md":[{"pt":["reference\/cloudkitjs"],"i":1628786,"t":{"x":"CloudKit JS"},"ps":["cloudkitjs"]}],"y":[{"cr":"2.0.1","ir":"1.0","p":"cloudkitjs"}]}%
お、謎の JSON が手に入りましたね。対応するのはこのページっぽいぞ。 https://developer.apple.com/reference/cloudkitjs/cloudkit/1628647-getdefaultcontainer
このページの HTML が手に入るとうれしかったのですが、残念ながらこの先はわかりませんでした。
このJSONを入力するとHTMLにしてくれるプログラムがあっても良さそうなんだけど…
この記事ではひとまずこの JSON をもとに HTML を組み立てる mac アプリを作ろうと思います。
mac アプリを作るぞ
表示するデータがそろいそうなことがわかったので mac アプリを作ります。
完成イメージはこんな感じです。
シュッとドキュメントを読みたいアプリなのでアプリ名は Syu (シュッ) にします。
検索インデックスを作るぞ
検索フィールドにクラス名やメソッド、プロパティ名を入れたらそれにマッチするリストを表示し、選択された項目に対応する request_key
なるものから例のJSONを取得できればドキュメントを表示できそうです。
検索しやすいように、例の場所にある {0, 1, 2}.txt
ファイルの内容から必要な行だけを取り出して記録したDBを作りましょう。
今回は Swift のドキュメントにしぼって表示するために map.db も参照します。
SQLite3 のファイルを読む必要があったので、このDBも SQLite で作ります。
アプリの起動時にこのDBがなかったら txt ファイルや map.db を読んで検索インデックスを作るようにすればよさそうです。
Swift から SQLite3 のDBを読み書きするためには SQLite.swift を使いました。
DBのカラム名とその型を明示することができるので良い感じでした。
例えば SQLite.swift を使った map DB のクエリは以下のように記述できます。
struct Map {
struct Column {
static let requestKey = Expression<String>("request_key")
static let topicID = Expression<Int64>("topic_id")
static let sourceLanguage = Expression<Int64>("source_language")
static let referencePath = Expression<String>("reference_path")
}
}
let swiftDocumentKeys: Set<String>
do {
let path = resourcesPath.appendingPathComponent("external/map.db", isDirectory: false)
let connection = try Connection(path.absoluteString, readonly: true)
let query = Table("map").select(Map.Column.requestKey)
.filter(Map.Column.sourceLanguage == 0)
swiftDocumentKeys = Set(try connection.prepare(query).map { $0[Map.Column.requestKey] })
} catch {
// error handling
}
こうして Swift のドキュメントの request_key を持つ行をファイルから読み込んでガッとインサートします。
検索インデックスを作っているところについてはこのファイルを読んでください。
https://github.com/slightair/syu/blob/master/Syu/Model/SearchIndexCreator.swift
ドキュメント一覧を表示するぞ
検索インデックスが準備できたら、次は検索フィールドに入力された文字列をもとに絞り込んだドキュメント一覧を表示します。
RxSwift をつかって検索フィールドの変更を監視し、検索語をDBから絞り込んだ結果を NSTableView に流します。
DB への問い合わせ結果を Observable<[SearchIndex]>
で返すようにすれば簡単ですね。
searchField.rx.text.throttle(0.3, scheduler: MainScheduler.instance)
.distinctUntilChanged { lhs, rhs in
lhs == rhs
}
.flatMapLatest { keyword -> Observable<[SearchIndex]> in
guard let keyword = keyword, !keyword.isEmpty else {
return .just([])
}
return self.documentation.search(keyword: keyword).catchErrorJustReturn([])
}
.observeOn(MainScheduler.instance)
.bindTo(contentListView.rx.items)
.addDisposableTo(disposeBag)
ただし、RxSwift というか RxCocoa は iOS 向けのコンポーネントは大変充実しているのですが、macOS 向けのコンポーネントはそこまで充実していません。
肝心の NSTableView への bind がなくて悲しかったので UITableView への実装を参考に自分ででっちあげることにしました。
筆者は普段 iOS アプリの開発しかしていないので NSTableView の気持ちがそこまでわかりません。
テーブルの複数のカラムにも対応していないですが今回の用途であればこれくらいでよいでしょう。
そんなこんなでドキュメント一覧を検索して表示する画面ができました。
https://github.com/slightair/syu/blob/master/Syu/ViewController/ContentListViewController.swift
ドキュメントを表示するぞ
一覧から選択された項目の request_key
をもとに cache.db から例のJSONを取り出して WebView に表示する HTML を構築します。
LZFSE のデコード
調査中は LZFSE の CLI コマンドを使って復号しましたが、アプリから利用する場合は Compression フレームワークを import すれば簡単に LZFSE のエンコード・デコードが使えます。
Swift3 だとこんな感じでデコードできます。
import Compression
let result = encodedData.withUnsafeBytes { (sourceBuffer: UnsafePointer<UInt8>) -> Data in
let sourceBufferSize = encodedData.count
let destBufferSize = 1048576
let destBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: destBufferSize)
let size = compression_decode_buffer(destBuffer, destBufferSize, sourceBuffer, sourceBufferSize, nil, COMPRESSION_LZFSE)
return Data(bytesNoCopy: destBuffer, count: size, deallocator: .free)
}
SQLite.swift を使って DB にアクセスしているので、response_data
カラムをこのデコードも含めた独自のデータ型として定義します。
https://github.com/slightair/syu/blob/master/Syu/Model/ResponseData.swift
すると、以下のようにしてDBからデータを取り出せるようになります。
func responseData(of key: String) -> ResponseData? {
let requestKey = Expression<String>("request_key")
let responseData = Expression<ResponseData>("response_data")
let query = Table("response").select(responseData).filter(requestKey == key)
if let row = try? cacheDB.pluck(query), let record = row {
return record[responseData]
} else {
return nil
}
}
HTML の組み立てと表示
JSON をもとに HTML を組み立てます。
JSON のデコードには Himotoki を、HTMLの組み立てには GRMustache.swift を使います。
cache.db から手に入れた JSON をもう一度見てみましょう。
{
"pa": {
"pt": [
"reference/cloudkitjs/cloudkit"
],
"u": "cl/CloudKit",
"i": 1628678,
"k": "cl",
"nm": "CloudKit",
"t": {
"x": "CloudKit"
}
},
"pt": [
"reference/cloudkitjs/cloudkit/1628647-getdefaultcontainer"
],
"u": "instm/CloudKit/getDefaultContainer",
"k": "instm",
"d": [
{
"x": "<dc><cl><rt>CloudKit.Container</rt> <k>getDefaultContainer</k> ();</cl></dc>",
"ps": [
"cloudkitjs"
]
}
],
"t": {
"x": "getDefaultContainer"
},
"a": {
"x": "<a><p>Returns the default container.</p></a>"
},
"i": 1628647,
"r": "symbol",
"m": 1,
"ls": [
"javascript"
],
"rv": {
"x": "<returnValue><p>The default container (the first container that appears in the configuration list of containers).</p></returnValue>"
},
"l": "javascript",
"md": [
{
"pt": [
"reference/cloudkitjs"
],
"i": 1628786,
"t": {
"x": "CloudKit JS"
},
"ps": [
"cloudkitjs"
]
}
],
"y": [
{
"cr": "2.0.1",
"ir": "1.0",
"p": "cloudkitjs"
}
]
}
なるほど、わからんという感じの JSON です。
t
とか x
とか人間には難しいので、気持ちをうまく感じ取ってモデルにマッピングしていきます。
GRMustache.swift は Swift で使えるテンプレートエンジンで、今回 WebView に表示するような定形の HTML を出力するのに便利なライブラリです。
以下のようなテンプレートを用意することで、良い感じに変数が当てはめられた HTML を得ることができます。
https://github.com/slightair/syu/blob/master/Syu/Template/document.mustache
Mustache によって展開される変数には独自型であっても、MustacheBoxable
プロトコルに準拠させることで適用することができます。
ResponseData 型から取得し Mustache に渡す Content 構造体は以下のような実装になりました。
https://github.com/slightair/syu/blob/master/Syu/Model/Content.swift
以下のようして Mustache のテンプレートから HTML を作り、 WebView に表示すればドキュメントビューアの完成です。
func makeHTMLString() -> String {
do {
let template = try Template(named: "document")
return try template.render(content)
} catch {
return "error!"
}
}
webView.mainFrame.loadHTMLString(makeHTMLString(), baseURL: contentBaseURL)
おわりに
Xcode.app に含まれるファイルから API ドキュメントのデータを読み込んで WebView に表示する簡単なアプリを作ってみました。
ソースコードはこちらです。 https://github.com/slightair/syu
スクリーンショットを見たり、実際にビルドして試してもらったりするとわかると思いますが、機能が全然足りないので実用には耐えられるものではありません。
簡単に思いつくものでも以下のものを実装しないとだめでしょう。
- 単純な前方一致ではなく賢い検索
- クラスのメソッド・プロパティ一覧の表示
- ページ間の遷移
そもそも JSON から HTML 組み立てじゃなくて完全な HTML を得られる手段はないのか… (見つけられないだけである気がするんだけど)
今すぐ使えるアプリが提供できるわけでもなく、もやもやする終わり方になってしまいましたが、ちょっとした謎解きだったりこういうアプリだったらこういう風に実装できますねという例は示せたんじゃないかと思います。