Help us understand the problem. What is going on with this article?

Dashっぽい Xcode8 のドキュメント閲覧アプリをつくってみる

More than 3 years have passed since last update.

第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 をファイルに書き出し、いくつかバイナリエディタで見てみると、bvx2bvxn ではじまり、 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 アプリを作ります。
完成イメージはこんな感じです。

ld2rr4tynr9l6sefgdl4.png

シュッとドキュメントを読みたいアプリなのでアプリ名は 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 を得られる手段はないのか… (見つけられないだけである気がするんだけど)

今すぐ使えるアプリが提供できるわけでもなく、もやもやする終わり方になってしまいましたが、ちょっとした謎解きだったりこういうアプリだったらこういう風に実装できますねという例は示せたんじゃないかと思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away