2010-12-11

Cocoa アプリに JavaScript のライブラリを組み込む - Xcode の使い方

MacBloggerGlass に google-code-prettify を組み込んだ。今回はその手順を説明する。概略は次の通り。

  1. JavaScript ライブラリをプロジェクトのリソースとして追加する。
  2. JavaScript ファイルをコンパイル対象から外す。
  3. アプリバンドルへのファイルコピーのためのビルドフェーズを追加する。

今回の手順は、ビルドにより作られるアプリバンドルを以下のような構造にするためのものだ。また、Xcode でプロジェクトウィンドウの「グループとファイル」で見たときにも lib/prettify という構造に見え、かつ Finder から見えるフォルダ構造(つまりファイルシステム上の構造)も同じになるようにしている。Xcode では、プロジェクトのグループ構造が実際のフォルダ構造と同じである必要はないし、アプリバンドルの構造と一致させる必要もない。しかし、同じにしておく方がわかりやすい。むしろ、異なる構造にしておくと間違いの元だろう。

MacBloggerGlass.app/
+-- Contents/
    +-- Info.plist
    +-- MacOS/
    |   +-- ...
    +-- PkgInfo
    +-- Resources/
        +-- English.lproj/
        |   +-- ...
        +-- template.html
        +-- default.css
        +-- lib/
            +-- prettify/
                +-- ...
                +-- prettify.js
                +-- prettify.css

ちなみに、以下が現在のプロトタイプ #0 のスクリーンショット。google-code-prettify を組み込んだことによりソースコードが色付けされていることがわかる。

MacBloggerGlass プロトタイプ #0
アプリウィンドウ
google-code-prettify を組み込んだ。

JavaScript ライブラリをプロジェクトのリソースとして追加する

google-code-prettify は以下のように ~/tmp の下の lib フォルダに置いてあるものとする。この lib 以下をプロジェクトに追加することになる。

~/tmp/lib/
+-- prettify/
    +-- ...
    +-- prettify.js
    +-- prettify.css

まず、プロジェクトウィンドウの「グループとファイル」から Resources を選択した状態で、メニューより「プロジェクト」>「プロジェクトに追加...」を実行する。ファイル選択シートが現れるので、上記の lib フォルダを選択し「追加」ボタンを押す。

ここで、さらにオプションを選択するシートが現れるが、そこでは右のスクリーンショットのように選ぶ。これにより、プロジェクトのフォルダ内に lib フォルダ、さらにその中に prettify フォルダが作られ、ライブラリのファイルがコピーされている。

また、同時に「グループとファイル」では Resource の中に lib グループ、その中に prettify グループが作られている(右のスクリーンショットを参照)。

JavaScript ファイルをコンパイル対象から外す

Xcode のデフォルトルールでは、JavaScript ファイルはコンパイルが必要な「ソースファイル」の一種に設定されている。このため、JavaScript ファイルをプロジェクトに追加すると、ビルドフェーズの「ソースをコンパイル」に追加されてしまう(右のスクリーンショット参照)。

このままでもビルド時に warning が出るだけで実害はないけれど、大量の黄色の三角を見るのは気に入らない。この warning を消すには「ソースをコンパイル」から JavaScript ファイルを削除すれば良い。具体的には「グループとファイル」から「ターゲット」>「(アプリ名).app」>「ソースをコンパイル」を開き(右のスクリーンショットの状態になる)、js ファイルをすべて選択して削除すれば良い。ここに並んでいるのはファイルへの参照だけなので、ここで削除してもプロジェクトに追加したファイル自体が(プロジェクトから)消えることはない。

もう一つ。google-code-prettify には prettify.css という CSS ファイルがふくまれている。Xcode のデフォルトルールでは CSS ファイルはコピーされるべきバンドルリソースになっており、ビルドフェーズの「バンドルリソースをコピー」に追加される。このままでは適切な場所((アプリ名).app/Contents/Resources/lib/prettify/)にコピーされないので、ここからは削除しておく。手順は js ファイルの場合と同様。

アプリバンドルへのファイルコピーのためのビルドフェーズを追加する

最後のステップは、ビルドによって作られるアプリバンドルに JavaScript ライブラリが構造を保ったままコピーされるようにすることだ。これには、ファイルコピーのためのビルドフェーズを新たに追加すれば良い。

具体的には、メニューから「プロジェクト」>「新規ビルドフェーズ」>「新規コピーファイル」を実行する。右のスクリーンショットのようなダイアログ(インスペクタのパネル)が現れるので、「デスティネーション」として「リソース」を選び(デフォルトでそうなっている)、「パス」に lib/prettify を入力する。これは、アプリバンドルのリソースフォルダ中に lib/prettify を作って対象ファイルをコピーする、というビルドフェーズを意味する。

後は、このビルドフェーズに Resouces/lib/prettify の下にあるファイルをすべて追加するだけ(選択してドラッグ&ドロップ)。右がその結果だ。ビルドフェーズの名前もわかりやすいものに変更している。

確認には、一度、クリーンしてからビルドを行い、Finder から build/Debug (または build/Release) の下にできているアプリバンドル(アプリ名.app)の内容を調べる。つまり、ポップアップメニューから「パッケージの内容を表示」でアプリバンドルを開き、Contents、Resources とたどり、そこに lib があること、またその中に prettify が、さらにその中には js ファイル(と 1 つの CSS ファイル)があることを確かめる。

おまけ

前回の記事(→「内部リンクの置き換えを実現する」)の HTML テンプレートと CSS ファイルの下りで、WebView にわたすベース URL について少し書いた。そこではアプリバンドルのトップの位置を(ファイル URL の形で)ベース URL に指定していた。このため、テンプレート中で CSS ファイルをリンクするために href='Contents/Resources/default.css' と書く必要があった。しかし、前回の記事中にも書いたように、これはベース URL の位置が適切とは言えない。Contents/Resources がベースになるべきだろう。

NSBundle のリファレンスを探してみたが、都合良く Contents/Resources をパスなり URL なりで取り出すメソッドは見つからなかった。そこで考えたのは、Contents/Resources をベースと考えるのではく、template.html の位置をベースとするというものだ。

追記@2010-12-12

上記の「都合良く Contents/Resources をパスなり URL なりで取り出すメソッド」は存在していて(resourcePath と resourceURL)、見つけられなかったのは、単にわたしの目がフシアナだっただけ。別件で「Objective-C逆引きハンドブック」を調べていて見つけた。「NSBundle Class Reference」を見直したら、そこにもちゃんと書かれていた。

このメソッドを使うなら、以下に書いた baseURL メソッドはこう書ける。

- (NSURL *)baseURL {
    return [[NSBundle mainBundle] resourceURL];
}

そうだよなあ。ないはずないよなあ。昨日のわたしはどんな目をしてたんだろう。(´・ω・`)

Objective-C逆引きハンドブック
林 晃
シーアンドアール研究所 ( 2010-02-26 )
ISBN: 9784863540514

具体的には NSBundle のメソッドで template.html の位置をファイル URL として取得し、パスのコンポーネントに分解。最後の要素(template.html そのもの)を取り除いた上で、残った要素からファイル URL を組み立てる。これで template.html が置かれたフォルダのファイル URL が出来上がる。

該当する部分のコードを以下に示す(AppController.m より)。

- (NSURL *)fileURLForHTMLTemplate {
    NSBundle *mainBundle = [NSBundle mainBundle];
    return [mainBundle URLForResource:BGHTMLTemplateName withExtension:@"html"];
}

- (NSURL *)baseURL {
    // the base URL must be identical to the path of the HTML template.
    NSMutableArray *components = 
    [NSMutableArray
     arrayWithArray:[[self fileURLForHTMLTemplate] pathComponents]];

    [components removeLastObject]; // remove the template name

    return [NSURL fileURLWithPathComponents:components];
}
@end

ただし、この方法では、template.html がローカライズ対象となった場合に他のリソース(CSS や JavaScript)もローカライズ対象にしなければならない。CSS ならフォントの指定あたりで一部ローカライズすることもあるだろうが、さすがに JavaScript をローカライズすることはないと思う。ローカライズ対象に指定したところで English.lproj とか Japanese.lproj にコピーされるだけのことだが、少しムダかなと言う気にはなる。

今回は template.html をベース URL のための目印に使ったが、ローカライズが絡む場合は別の目印を使うか、ビルドフェーズでアプリバンドル内にシンボリックリンクを張るなどの工夫が必要になるかもしれない。

関連リンク

関連記事

2010-12-10

内部リンクの置き換えを実現する (MacBloggerGlass)

MacBloggerGlass を作り始めたとき(→「欲しいのはオフライン機能 - Cocoa アプリとして作り直す #0」)、機能実現のステップとして以下の 5 つを挙げた。

  1. 一覧表示
  2. Google Data API Objective-C Client Library の組み込み
  3. 記事表示
  4. ラベル検索
  5. 内部リンクの置き換え

多少、順序は入れ替わっているが、これまでの作業で 1 〜 3 が大筋で実現できている。今回は、内部リンクの置き換えを実現する。GAE 版のときにも書いたが、これができるようになると、アプリが実用的になる。以下が現在のプロトタイプ #0 のスクリーンショットだ。

MacBloggerGlass プロトタイプ #0
アプリウィンドウ
ウィンドウの上部に記事一覧、下部に一覧で選択した記事の内容をそれぞれ表示できるようになった。JavaScript を組み込んでいないものの、その他の点では Blogger Glass と同じスタイルで表示している。

Apple イベントの処理の作り込み

前回のサンプルアプリ(→ 「アプリに Apple イベントの処理を作り込む」)と同等の作り込みを行った。

独自スキームの定義

以下の内容を MacBloggerGlass-Info.plist に追加。太字の値はこちらで定義、他は Xcode のエディタではリストから選ぶだけで良い。

  • URL types
    • Item 0
      • URL identifier → MacBloggerGlass Internal Link
      • Document Role → Viewer
      • URL Schemes
        • Item 0 → bgls

独自スキームを使った URL は以下の形とした。記事の識別に必要な情報だけを詰め込んでいる。

bgls://<blog-id>/<post-id>
Apple イベントハンドラの定義

追加するのは Get URL イベントのハンドラのみ。前回のサンプルアプリと同様、公式ドキュメントで推奨されているようにアプリのデリゲートクラス (MacBloggerGlassAppDelegate) に追加している。

追加後の MacBloggerGlassAppDelegate.m を以下に示す。

#import "AppController.h"
#import "MacBloggerGlassAppDelegate.h"

@implementation MacBloggerGlassAppDelegate

@synthesize window, appController;

- (void)applicationWillFinishLaunching:(NSNotification *)notification {
    NSAppleEventManager *appleEventManager =
    [NSAppleEventManager sharedAppleEventManager];
    [appleEventManager setEventHandler:self
                           andSelector:@selector(handleGetURLEvent:withReplyEvent:)
                         forEventClass:kInternetEventClass
                            andEventID:kAEGetURL];
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
 // Insert code here to initialize your application 
}

#pragma mark -
#pragma mark apple event handler
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
           withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
    NSAppleEventDescriptor *directObjectDescpritor =
    [event paramDescriptorForKeyword:keyDirectObject];
    if (! directObjectDescpritor) return;

    NSString *urlString = [directObjectDescpritor stringValue];
    if (! urlString) return;

    NSLog(@"APPLE EVET(GetURL): %@", urlString);

    // custom URL must be in the form like:
    //     bgls://<blog-id>/<post-id>
    NSURL *objectURL = [NSURL URLWithString:urlString];
    NSString *blogID = [objectURL host];
    // path will return a string with the form '/<post-id>'.
    NSString *postID = [[objectURL path] substringFromIndex:1];

    [appController openPost:postID inBlog:blogID];
}
@end

AppController.h を import しているのは、イベントハンドラの最後で、AppContoller のメソッドを呼び出しているから。アプリデリゲートでは、イベントから URL を取り出し、Blog ID と Post ID を切り出すところまでを行い、その後の処理はアプリコントローラに任せている。

また、アプリデリゲートとアプリコントローラの結びつけは、デリゲート側にコントローラ用のアウトレットを設け、Interface Builder 上で接続している。どちらのオブジェクトも MainMenu.xib 内にインスタンス化されているので、これが一番手軽な方法だと思う。

メインコントローラ (AppController) への作り込み

コントローラ側での処理を以下に示す。ここでは、カスタム URL にふくまれている Blog ID を現在表示中のものと比較している。他のブログ(自分で作ったもの)の扱いについては、ひとまず無視することにしている。どう扱うと実用的かは将来の検討課題だ。

#pragma mark -
- (void)openPost:(NSString *)aPostID inBlog:(NSString *)aBlogID
{
    NSLog(@"Open post request: %@ (%@)", aPostID, aBlogID);
    if (! [aBlogID isEqualToString:blogID]) {
        NSLog(@"The blog requested is not currently displayed. (%@)", aBlogID);
        return;
    }
    NSUInteger index = [feed findPost:aPostID];
    if (index == NSNotFound) return;

    [postTable selectRowIndexes:[NSIndexSet indexSetWithIndex:index]
           byExtendingSelection:NO];

    [self showPostWithIndex:index];
}

指定した Posd ID に対応する記事オブジェクト(EntryBlogPost)を探すメソッドを一覧オブジェクト(FeedBlogPost)に実装している。検索は単純な線形探索。これで表示が遅くなるほどの記事数にはならないと思っている。

- (NSUInteger)findPost:(NSString *)aPostID {
    EntryBlogPost *entry;
    for (NSUInteger i = 0; i < [entries count]; i++) {
        entry = [entries objectAtIndex:i];
        if ([entry.postID isEqualToString:aPostID]) {
            return i;
        }
    }
    return NSNotFound;
}

内部リンクの置き換え

内部リンクの置き換えのために、GAE 版でも作ったように(→「内部リンクを置き換える #2」)、permalink と Post ID のひもづけを保持する仕組みが必要になる。

PairOfLinks は辞書で実現

蓄積と探索の処理を単純にするため、Cocoa 標準の辞書オブジェクト(NSMutableDictionary)を使った。ひもづけを保持するのは FeedBlogPost オブジェクトにした。これはこのオブジェクトが記事オブジェクト(EntryBlogPost)の配列を保持しているから、データを抽出するのに相応しいと考えたからだ。以下に FeedBlogPost.m 内の抽出処理を示す。

- (void)extractPairOfLinks {
    pairOfLinks =
    [[NSMutableDictionary alloc] initWithCapacity:[entries count]];
    EntryBlogPost *post;
    for (post in entries) {
        [pairOfLinks setObject:post.postID forKey:post.permalink];
    }
}
置換処理の実際

記事データ中のリンク文字列の置換は、WebView に HTML データをわたす直前に行っている。href="<permalink>" と href='<permalink>' をそれぞれ置換するという手抜きコーディングにしてある。

- (void)renderHTML:(NSString *)content
             title:(NSString *)title
       withBaseURL:(NSURL *)baseURL {
    // replace internal links into those have custom URL.
    NSString *replacedContent = content;
    NSString *permalink;
    for (permalink in [feed.pairOfLinks allKeys]) {
        NSString *aPostID = [feed.pairOfLinks objectForKey:permalink];
        NSString *customLink =
        [NSString stringWithFormat:@"href='bgls://%@/%@'", blogID, aPostID];

        // replace href attribute values wrapped with double quotation
        NSString *targetLink = [NSString stringWithFormat:@"href=\"%@\"", permalink];
        replacedContent =
        [replacedContent stringByReplacingOccurrencesOfString:targetLink
                                                   withString:customLink];

        // replace href attribute values wrapped with single quotation
        targetLink = [NSString stringWithFormat:@"href='%@'", permalink];
        replacedContent =
        [replacedContent stringByReplacingOccurrencesOfString:targetLink
                                                   withString:customLink];
    }
    
    NSString *htmlData =
    [NSString stringWithFormat:templateData, title, replacedContent];

    NSLog(@"Base URL: %@", baseURL);
    [[postView mainFrame] loadHTMLString:htmlData baseURL:baseURL];
}

このメソッドはアプリウィンドウで記事の一覧が選択されたときに呼ばれる。その際、baseURL としてアプリバンドル(MacBloggerGlass.app)のトップが URL としてわたってくる。その部分を以下に示す。

- (void)showPostWithIndex:(NSUInteger)index {
    EntryBlogPost *entry = [feed objectInEntriesAtIndex:index];
    NSLog(@"Post ID: %@", entry.postID);
    [self renderHTML:[entry content]
               title:[entry title]
         withBaseURL:[[NSBundle mainBundle] bundleURL]];
}

表示機能の作り込み

最後に、記事表示機能の作り込みとして、記事データを流し込む HTML のテンプレートと CSS ファイルの組み込みについて説明する。

HTML のテンプレートと CSS ファイル

テンプレートも CSS ファイルも、アプリバンドル (MacBloggerGlass.app) の中に置くことにした。

以下はテンプレートをロードする処理で、アプリコントローラ (AppController) で定義している。NSBundle を使ってテンプレートファイルのパスを特定し、NSString に読み込んでいる。templateData という変数は AppContoller のメンバ変数だ。

- (void)loadHTMLTemplate:(NSString *)templateName {
    NSBundle *mainBundle = [NSBundle mainBundle];
    NSString *pathForTempalte =
    [mainBundle pathForResource:templateName ofType:@"html"];

    templateData = [[NSString alloc] initWithContentsOfFile:pathForTempalte
                                                   encoding:NSUTF8StringEncoding
                                                      error:NULL];
}

読み込まれるテンプレートは以下のもの。注意すべきは 6 行目のスタイルシートへのリンクだ。このテンプレートと CSS ファイルはアプリのリソースの一部として、アプリバンドル(MacBloggerGlass.app)の中にコピーされる。先に示したように、WebView にわたすベース URL としてアプリバンドルのトップを指定している。このため、CSS ファイルの相対パスは 6 行目のようになるわけだ。ただ、これはベース URL の指定を変えて、Contents/Resources がベースになるようにすべきかもしれない。後で、NSBundle のリファレンスをもう少し調べてみよう。

<!DOCTYPE HTML>
<html lang='ja'>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type'>
<title>MacBloggerGlass</title>
<link type='text/css' rel='stylesheet' href='Contents/Resources/default.css'>
</head>
<body>
<article>

<div id='view-header'>
  <h3>%@</h3>
</div>

<div id='content'>
%@
</div>

</article>
</body>
</html>

CSS は Blogger Glass で使っているものを手直しして流用。手直しは、テンプレートの構造の変更に対応するためのもの。冒頭のスクリーンショットは、現在の MacBloggerGlass のものだが、GAE 版の Blogger Glass と同じスタイルが使われていることが見てとれる。

次の一手

記事表示の機能については、まだ細部の詰めが残っている。たとえば、ソースコード表示用の JavaScript を組み込むことなど。

記事表示が GAE 版と同等になったら、その次はラベル検索に取り組むか。機能的には GAE 版と同じく GData を使って検索するもので良いが、問題はユーザ体験だ。まずは、そこからになるな。

いや、それより先に「オフライン機能」すなわちデータの保存について考えるべきだろう。そもそもそのための Cocoa 版なのだから。

関連リンク

関連記事

twitter より (2010-12-09)

Powered by twtr2src.

2010-12-09

アプリに Apple イベントの処理を作り込む - アプリを開くリンク #2

前回(→「アプリを開くリンク #1」)に引き続き、Launch Services が送ってくる Apple イベントを処理するための作り込みについて調べた。簡単なサンプルアプリを作って動作を確認した。

以下では、WebView でアプリの独自スキームを持った URL へのリンクをクリックしたときに、アプリに送られてくる get URL という Apple イベントを処理するための作り込みの手順を説明する。

今回の記事のネタは主に「Cocoa Scripting Guide: How Applications Handle Apple Events」から拾っている。

Cocoa アプリにおける Apple イベントの処理

Automator.app を使ってアプリを使った作業の自動化(→「AppleScript を使って URL を Safari のタブで開く」)をしようとしてみると、アプリの中には Apple Script でさまざまな制御が可能なものと、そうでないものがあることに気付く。Safari などは前者で、ウィンドウのサイズを変えたり、指定した URL を開かせたりといったことが Apple Script から制御できる。一方、Cocoa Emacs のように限られた制御(アプリの起動等)しかできないアプリもある。前者のように様々な制御を受けるアプリのことを「スクリプト制御可能(scriptable)」だと言う。

アプリがスクリプト制御可能なように作られているかどうかに関係なく、Cocoa は Mac OS X が GUI アプリに送る必須イベントに対するデフォルトの処理(ハンドラ)を提供している。一番、わかりやすい例はアプリを起動する open application イベントだろう。このデフォルトの実装があるおかげで「Apple Script やイベントのことなんて知らない」と言うアプリでも、Launch Services から起動することができる。また、デフォルトハンドラはデリゲートの仕組みを使っており、NSApplication のデリゲート(NSApplicationDelegate プロトコルを実装したクラス)でデリゲートメソッドを定義してやればハンドラの動作をカスタマイズすることができる(イベントとデリゲートメソッドの組み合わせについては「Cocoa Scripting Guide: How Applications Handle Apple Events」を参照のこと)。

ただ、Cocoa が提供するデフォルトのイベントハンドラは限られていて、具体的には「アプリの起動」「アプリの再起動(アクティベート)」「ドキュメントを開く」「印刷」「コンテンツを開く」、そして「アプリの終了」だ。残念ながら URL を開くイベント(get URL)に対するハンドラはデフォルトの実装には含まれていない。これについては、アプリが独自ハンドラとして追加しなければならない。

Apple イベントハンドラの追加

イベントハンドラの追加はそれほど難しいことではない。特定の形のメソッドを定義し、それをハンドラとしてイベントマネージャ(NSAppleEventManager)に登録するだけで良い。

ハンドラは以下の形にする。ただ、handleAppleEvent の部分はイベントに応じて名前を変えても良いようだ。たとえば、後述するサンプルアプリで定義したハンドラではここは handleGetURLEvent となっている(これは「Cocoa Scripting Guide: How Applications Handle Apple Events」の Installing a Get URL Handler で例として使われている名前でもある)。一方で、Mac OS X Reference Library のサンプルコードでは同じイベントのハンドラに対して handleOpenLocationAppleEvent という名前が付いている。引数の数と型が一致していれば良いのかも。

- (void)handleAppleEvent:(NSAppleEventDescriptor *)event
          withReplyEvent:(NSAppleEventDescriptor *)replyEvent;

Cocoa Scripting Guide: How Applications Handle Apple Events」には具体的なハンドラの定義は載っていなかったため、Mac OS X Reference Library を検索したところ、サンプルコードに get URL イベントのハンドラの例を見つけた(→ CoreRecipesApp/AppDelegate.m)。次に示すサンプルアプリのハンドラの定義は、ここから流用したものだ。

サンプルアプリ

独自スキームの定義

以下は前回の記事(→「アプリを開くリンク #1」)に載せたスクリーンショットだが、再掲しておく。このサンプルアプリが受け付ける独自 URI スキームに sample を設定している。

Info.plist に URL タイプを追加する様子
URI スキームとして sample という文字列を指定している。
Get URL ハンドラの定義

ハンドラの定義とその登録は、ドキュメントの推奨どおりにアプリケーションデリゲイトで行っている。以下はその部分の抜粋だ。applicationWillFinishLaunching: で、Get URL ハンドラ handleGetURLEvent:withReplyEvent を登録している。

- (void)applicationWillFinishLaunching:(NSNotification *)notification
{
    NSAppleEventManager *appleEventManager = 
    [NSAppleEventManager sharedAppleEventManager];
    [appleEventManager setEventHandler:self 
                           andSelector:@selector(handleGetURLEvent:withReplyEvent:)
                         forEventClass:kInternetEventClass
                            andEventID:kAEGetURL];
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
 // Insert code here to initialize your application 
}

// Apple event handler
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
           withReplyEvent:(NSAppleEventDescriptor *)replyEvent
{
    NSAppleEventDescriptor *directObjectDescriptor =
    [event paramDescriptorForKeyword:keyDirectObject];

    if (directObjectDescriptor) {
        NSString *urlString = [directObjectDescriptor stringValue];
        if (urlString) {
            NSURL *objectURL = [[NSURL alloc] initWithString:urlString];
            if (objectURL) {
                NSString *host = [objectURL host];
                NSString *path = [objectURL path];
                NSString *description =
                [NSString stringWithFormat:@"%@ in (%@)", path, host];
                [appController updateContent:description];
            }
        }
    }
}

このハンドラは受け取ったイベントの内容から URL を取り出し、さらにそこからホスト名とパスを抽出して、WebView を抱える AppController に渡している。

AppController では、イベントハンドラから受け取った文字列を WebView に渡す HTML に埋め込み、再表示させている。以下にその実装部(AppController.m)を示す。

#import <WebKit/WebKit.h>

#import "LaunchAppAppDelegate.h"
#import "AppController.h"

NSString * const HTMLTemplate = 
@"<html>\n"
@"<body>\n"
@"<h1>%@</h1>\n"
@"<ul>\n"
@"<li>Requested: %@</li>\n"
@"</ul>\n"
@"<p><a href='sample://1234567890/0987654321'>Click me!</a>"
@"(sample://1234567890/0987654321)</p>\n"
@"</body>\n"
@"</html>\n";

@interface AppController (PrivateMethods)
- (void)renderHTML:(NSString *)html;
@end


@implementation AppController
- (void)awakeFromNib
{
    appDelegate.appController = self;
    [self renderHTML:[NSString
                      stringWithFormat:HTMLTemplate, @"Sample App", @""]];
}

- (void)updateContent:(NSString *)request
{
    [self renderHTML:[NSString
                      stringWithFormat:HTMLTemplate, @"Sample App", request]];
}

@end

@implementation AppController (PrivateMethods)
- (void)renderHTML:(NSString *)html
{
    [[display mainFrame] loadHTMLString:html
                                baseURL:[NSURL URLWithString:@"http://localhost"]];
}
@end

HTMLTemplate の定義では見易くするために文字列オブジェクト定数を並べて置いている。これらはコンパイル時に連結され 1 つの文字列オブジェクト定数となる。

動作確認

クリックする前と後のスクリーンショットを示す。これにより、アプリの抱える WebView からそのアプリの独自 URL へのリンクをクリックすることで、実行中のアプリ自身にイベントを飛ばせることが確認できた。MacBloggerGlass で内部リンクの置き換えを実現できる目処が立ったことになる。

WebView で独自 URL をクリックした際の動作確認
クリック前
「Requested」の横には何も表示されていない。Click me! をクリックすると……
クリック後
「Requested」の横に URL にふくまれていた情報が抜き出されてきた。

関連リンク

関連記事

twitter より (2010-12-08)

  • 09:51  今の iPad は少々大きいと思う。Retina ディスプレイがあれば同じ画素数でももっと小さくできるよね。B6ぐらいのサイズで作ってくれないかな。 → http://bit.ly/hejP3z
  • 10:10  RT @yukihiro_matz: 字下げ依存構文の解析 - 再帰の反復: http://bit.ly/hzJwtX
Powered by twtr2src.

2010-12-08

アプリを開くリンク #1 - 内部リンクの置き換えのために (MacBloggerGlass)

以前の記事で、内部リンクの置き換えを実現するためには記事内容をファイルとしてローカルのストレージに書き出すしかないかも、と書いた(→「記事の表示機能を作る (MacBloggerGlass)」)。ここ数日、Cocoa Bindings の使い方をあれこれ調べる間もそのことについて考え続けてきた。今日になって、「あれ、そう言えば iTunes Store のリンクって Safari から iTunes に飛ぶよな」と気付いた。

たとえば、以下のリンクは「itms://」で始まる URL で、Safari でクリックすれば iTunes.app で Coldplay の新曲(2010-12-08 時点)のページが開く(Mac と iPad で確認)。

itms://itunes.apple.com/jp/album/christmas-lights-single/id406970808

ちなみに、Safari 以外のブラウザだと、iTunes.app を開く前に確認のダイアログが現れる。右のスクリーンショットは Google Chrome でこのリンクをクリックしたときに出てくるものだ。

Safari (つまり WebView) でリンクをクリックしてアプリに通知させることができるなら、内部リンクをそれで置き換えれば良い。そうすれば MacBloggerGlass (以下、MBG) から、MBG 自身に開きたい記事の情報を伝えることができる。フィードデータから記事ごとに HTML ファイルを作って書き出して、file:/// でアクセスするよりもスマートだ。

もっとも、MBG を作る動機にもなっている「オフラインでも読める」ようにするためには、記事の内容をふくめたフィードデータをローカルに保存しなくてはならないんだけど。

ともあれ、これで方向性が定まった。まずは「アプリを開くリンク」について調べるところからだ。

itms:// を開こうとしたとき、誰が iTunes を起動するのか?

では、特定の URI スキーム (http://... の http の部分) とそれを扱うアプリの組み合わせを知っているのは誰だろう? Safari のようなブラウザ自身だろうか? 少なくとも Mac OS X では(おそらく iOS でも) 専用のサービス (API) が用意されている。

(「Launch Services Programming Guide: Introduction」より)
Launch Services is an API that enables a running application to open other applications or their document files or URLs (uniform resource locators) in a way similar to the Finder or the Dock. Using Launch Services, an application can perform such tasks as:

  • Open (launch or activate) another application
  • Open a document or a URL in another application
  • Identify the preferred application for opening a given document or URL
  • [...snip...]

この Launch Services がドキュメントのタイプや URL タイプとアプリの組み合わせを保持している。では、Launch Services はその組み合わせの情報をどこから得るのか? それはアプリが持っている Info.plist の記述からだ。

具体的には、Info.plist 中の CFBundleURLTypes にアプリが開くことのできる URL タイプが記述されている。

Property List の書き方

Info.plist は Property List と呼ばれる XML 形式で記述する。以下に Xcode が作るデフォルトの Info.plist を示す。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
 <key>CFBundleDevelopmentRegion</key>
 <string>English</string>
 <key>CFBundleExecutable</key>
 <string>${EXECUTABLE_NAME}</string>
 <key>CFBundleIconFile</key>
 <string></string>
 <key>CFBundleIdentifier</key>
 <string>com.yourcompany.${PRODUCT_NAME:rfc1034identifier}</string>
 <key>CFBundleInfoDictionaryVersion</key>
 <string>6.0</string>
 <key>CFBundleName</key>
 <string>${PRODUCT_NAME}</string>
 <key>CFBundlePackageType</key>
 <string>APPL</string>
 <key>CFBundleShortVersionString</key>
 <string>1.0</string>
 <key>CFBundleSignature</key>
 <string>????</string>
 <key>CFBundleVersion</key>
 <string>1</string>
 <key>LSMinimumSystemVersion</key>
 <string>${MACOSX_DEPLOYMENT_TARGET}</string>
 <key>NSMainNibFile</key>
 <string>MainMenu</string>
 <key>NSPrincipalClass</key>
 <string>NSApplication</string>
</dict>
</plist>

URL タイプは、トップレベルにある dict 要素の子として記述する。つまり、CFBundleExecutable などと同じレベルの要素にする。その際、CFBundleURLTypes が dict の array になるように構成する。そして、各 dict の中身には以下の 4 つの要素を置く。

CFBundleURLType 内の要素 (「Launch Services Programming Guide」より)
Key Type Description
CFBundleTypeRole string アプリがドキュメントをどのように扱うものなのか。Launch Services が認識するのは Editor、Viewer、そして None の 3 つのみ。
CFBundleURLName string URL タイプの名前
CFBundleURLIconFile string このタイプの URL を表示する際に使われるアイコンファイルの名前
CFBundleURLSchemes array このタイプにふくまれる URL のスキームの配列。各スキームは string として記述する。

典型的な CFBundleURLTypes の構造は以下のようになる。

CFBundleURLTypes
+-- array
    +-- dict
    |   +-- CFBundleTypeRole
    |   |   +-- string
    |   +-- CFBundleURLName
    |   |   +-- string
    |   +-- CFBundleURLIconFile
    |   |   +-- string
    |   +-- CFBundleURLSchemes
    |       +-- array
    |           +-- string
    |           +-- string
    |           ...
    +-- dict
        ...

iTunes.app の Info.plist を覗いてみると、CFBundleURLIconFile は省略しても構わないようだ。

以下のスクリーンショットは、実際に Xcode 中で Info.plist を編集し、URL タイプを 1 つ、追加したところのものだ。

Info.plist に URL タイプを追加する様子
左に表示されている要素の種類はドロップダウンリストの中から選ぶことができる。トップレベルで URL Types を選ぶとその子要素の構造も同時に作られる(デフォルトでは URL Identifier だけが追加されている)。この例では URI スキームとして sample という文字列を指定している。

アプリ側での作り込み

Launch Services はアプリに対してアプリの起動や URL を開く等の Apple イベントを送ってくる。つまり、アプリは送られてきたイベントを適切に処理することができなければならない。

そのためにはどういうコードを書けば良いのか? それは次回の記事で(ドキュメントは見つけてあるがまだ読んでいない→「Cocoa Scripting Guide: How Cocoa Applications Handle Apple Events」)。

関連リンク

関連記事

twitter より (2010-12-07)

Powered by twtr2src.

2010-12-07

Cocoa Bindings の肝は KVC 準拠だ

ようやく Cocoa Bindings の謎が解けたように思う。少なくとも、Cocoa Bindings を使ったアプリを作るための「心得」のようなものを見つけることができた。以下で、それを簡単に説明してみる。

用語の整理

まずは用語の整理。Cocoa Bindings 関連のドキュメントを読むためには以下の KVC の用語とそれが意味する概念を理解しておく必要がある。

KVC (Key-Value Coding) とは、オブジェクトが持つプロパティに対してキーとなる名前を介してアクセスすること、およびそのためのオブジェクト側の実装のことだ。「KVC でアクセスする」とか「このクラスは KVC に準拠させてある」などと使う。

プロパティ(property)とは、オブジェクトがその内部に抱える「何か」のことで、典型的にはオブジェクトの内部状態を示す様々な値のことだ。ただし、プロパティとはオブジェクトの外部から見えるもののことで、必ずしもプロパティの値そのものが内部で保持されているとは限らない。プロパティとはオブジェクトのインタフェースのうち「何か」を返すものと言っても良い。

プロパティはその値とオブジェクトとの関係によって、以下の 3 つに分けられる。

  • 属性(attribute)
  • 対一関係(to-one relationship)
  • 対多関係(to-many relationship)

属性(attribute)とはプロパティのうち、Objective-C で言う単純型のことで、数値や文字列の他、NSColor や NSNumber のような不変オブジェクトもふくまれる。

対一関係(to-one relationship)とは、プロパティがそれ自身のプロパティを持ったオブジェクトの場合を言う。以下に示す、AppController のプロパティ entry がこれにあたる。

@interface Entry : NSObject {
    NSString *title;
    NSString *content;
    NSDate *lastUpdateDate;
}

@interface AppController : NSObject {
    Entry *entry;
}

対多関係(to-many relationship)とは、簡単に言えばプロパティが配列のような集合型になっている場合のことだ。以下の Feed のプロパティ entries がこれにあたる。

@interface Feed : NSObject {
    NSMutableArray *entries;
}

KVC 準拠

あるクラスを(正確にはあるクラスの特定のプロパティを) KVC 準拠にするためには、いくつかの要件を満たさなければならない。これは、要は、プロパティに対するアクセス用メソッドを一定の規則による名前で作る、ということだ。

たとえば、title という文字列のプロパティを持ったクラスの場合なら、以下の 2 つのメソッドを定義する。

- (NSString *)title;
- (void)setTitle:(NSString *)aTitle;

この 2 つがあるなら、必ずしも属性を保持するインスタンス変数を定義する必要はない。

加えて、プロパティへのアクセスにはすべて(クラスの内部でも) KVC 方式を用いる。これは準拠に必須ではないが、こうすることで KVO が正しく機能する。KVC 方式とはインスタンス名に「.」(dot) でプロパティ名を連結した記法のことだ。たとえば、先のクラスのインスタンス名が entry なら、entry.title としてアクセスする。クラス内部では、インスタンス名として self を使えば良い。つまり、self.title で読み書きする。

対一関係の場合も同様だ。さらに、この場合、プロパティも KVC に準拠しているなら「.」でつなげてアクセスする。たとえば、self.entry.title のように。

対多関係の場合の追加

一方、対多関係の場合はプロパティが配列(の類)であるため、その要素にアクセスするためのメソッドも必要になる。最初に挙げた Feed の場合、以下のようなメソッドを定義する必要がある(要素として Entry を抱えると想定)。

// for KVC compliance (required)
- (void)insertObject:(Entry *)entry inEntriesAtIndex:(NSUInteger)index;
- (void)removeObjectFromEntriesAtIndex:(NSUInteger)index;
- (void)replaceObjectInEntriesAtIndex:(NSUInteger)index withObject:(Entry *)entry;
// for KVC compliance (optional)
- (int)countOfEntries;
- (Entry *)objectInEntriesAtIndex:(NSUInteger)index;

これらのメソッド名にはプロパティ名が埋め込まれている。たとえば、insert の場合、insertObject:in<key>AtIndex: の key のところにプロパティ名の entries が埋め込んである。他も同様だ。

KVO 準拠

KVO 準拠で何より大事なことはプロパティのアクセスはすべて KVC 方式で行う、ということだ。たとえば以下は、ここ数日、悩まされてきたエラーメッセージだ。このエラーの原因が、あるクラスで自分自身のプロパティへのアクセスに KVC 方式を使っていなかった(部分があった)ことにあった。

(実行時のログより)
Cannot update for observer <NSAutounbinderObservance 0x113761290> for the key path "feed.entries" from <PreferenceController 0x11375cc50>, most likely because the value for the key "feed" has changed without an appropriate KVO notification being sent. Check the KVO-compliance of the PreferenceController class.

具体的には、以下のようなコードになっていた。本来、reload の中で newArray をプロパティにセットした際に、これを監視しているオブジェクトたちに通知が飛ぶことになる。ところが、reload が実行される前に awakeFromNib の中で KVC 方式を使わずに entries を変更してしまっていたため、この通知が送れなくなっていた。それが上のエラーだ。awakeFromNib の中でも、reload と同様に self.entries という表記を使うようにすることで、この問題は解消した。

@implementation AppController
[...snip...]
- (void)awakeFromNib
{
    [...snip...]
    entries = [[NSMutableArray alloc] init];
    [...snip...]
}

- (IBAction)reload:(id)sender
{
    [...snip...]
    self.entries = newArray;
    [...snip...]
}

また、対多関係プロパティの場合、プロパティへの要素の追加・変更・削除では、先述のように KVC 準拠のために定義したメソッドを使わなければならない。

まとめ

以上が、ここ数日、悩まされてきた問題に対する一応の解答になる。まとめると、Cocoa Bindings を使ったアプリを作る上で肝心なことは、とにもかくにも KVC 準拠ということになる。

そして、KVC 準拠でクラスを作ろうと思うなら徹底して KVC 準拠にすることだ。特定のプロパティだけを KVC 準拠にしようなんて中途半端なことを考えていると、抜けが出てくる。ひとつのクラスの一部は KVC 準拠で他は違うというような状態では、(プログラムが動かないとわかってから)準拠の抜けを探すのは難しい。デバッグで苦労するよりも、コーディング中に少し余分に気を使う方が良い。

Cocoa Bidings を使ったアプリ (MVC アーキテクチャを想定) を作るなら、まずはモデルのクラスをを KVC 準拠で作ること。また、モデル内部の他のメソッドでも、モデルオブジェクトを扱うコントローラの実装でも、とにかくモデルのプロパティへのアクセスには KVC を使うこと。対多関係を使うなら(モデルが配列等を内部に抱えるなら)、内部の配列に直接アクセスするのではなく、insert や remove、replace といったメソッドを KVC 準拠の形式で定義すること。

あとひとつ付け加えるとすれば、やはり Interface Builder に対する慣れだろうか。インスペクタで GUI 部品の設定をあちこちいじっていると、どこをどう変更したのかがわからなくなってくる。どうしても動かなくて困っていたところ、部品を一度削除して最初から設定し直すと動いた、なんてことが時々起こる。こればかりは慣れるしかないと思う。部品を汎用に作ろうとすれば設定項目を増やさざるを得ず、設定項目が増えればそのためのツールは複雑になるものだから。

参考文献

詳解 Objective-C 2.0
荻原 剛志
ソフトバンククリエイティブ ( 2008-05-28 )
ISBN: 9784797346800

関連リンク

関連記事

2010-12-06

まだ Cocoa Bindings の謎が解けない

前回の状態(→「Cocoa Bindings がわからなくなってきた」)からまだ抜け出せない。以下に、今、ハマっている状況を簡単に説明してみる。

モデルとコントローラの構造と関係は以下のようになっている。Feed の entries は配列(NSMutableArray)で Entry のインスタンスを抱えている。Entry のプロパティは文字列(NSString)や日付(NSDate)といったオブジェクトだ。このモデルをアプリの主コントローラが抱えている、という形だ。Atom 形式のデータの構造を扱うアプリを想定している(MacBloggerGlass もその一つ)。

+--------------+
| AppContoller |
+--------------+        +--------------+
| feed        --------->| Feed         |
| ...          |1     1 +--------------+       +--------------+
+--------------+        | entries     -------->| Entry        |
                        | ...          |1    * +--------------+
                        +--------------+       | title        |
                                               | content      |
                                               | lastUpdate   |
                                               | ...          |
                                               +--------------+

やりたいことは、Feed が抱える Entry オブジェクトのデータを表(NSTableView)に写し出すことだ。

Cocoa Bindings を使わないなら、AppController を NSTableView のデータソースにすることになる。これは簡単に言うと、NSTableView から「この行のここの列に表示するデータはどれ?」という問い合わせに AppController が答える方式だ。行と列を指定して、配列に収められたオブジェクトのプロパティを取り出すコードを AppController に実装することになる。

Cocoa Bindings を使えばそのコードを書かなくて済む。NSArrayController を介してモデルとビュー(NSTableView)を結びつけることでコードを書かずに同じことを実現できる……はずなのだが、なんだかうまくいかない。いや、うまく動くときもある。まったく動かないなら間違っていることだけは確実にわかる。けれど、同じように書いたり設定したりしたつもりのものが動いたり動かなかったりすると、かえってわかりにくくなる。

どうやら、肝となるのはモデルとコントローラが KVC と KVO に準拠することらしい。で、KVC に準拠するにはどうすれば良いか、についてはわかってきた。だが、KVO への準拠のための施策が良くわからない。ドキュメント(Key-Value Observing Programming Guide)に Ensuring KVO Compliance という項目があるものの、そこに書かれているのは 2 つの項目だけ。しかもそのうちの 1 つは KVC に準拠しろ、というもの。残る一つがこれ。

(「Key-Value Observing Programming Guide: Ensuring KVO Compliance」より)

  • The class must allow automatic observer notifications for the property, or implement manual key-value observing for the property.

これだけじゃなあ。もう少し細かく書いて欲しいよ。(´・ω・`)

とにかく、今日も先には進めていない。明日も、今日の続きで Cocoa Bindings の謎と格闘することになる。

追記@2010-12-08

この記事に書いた問題はひとまず解決した。「Cocoa Bindings の肝は KVC 準拠だ」を参照のこと。

関連リンク

関連記事

2010-12-05

Cocoa Bindings がわからなくなってきた

この数日、Cocoa Bindings の使い方で悩んでいる。というより、混乱している。

MacBloggerGlass に環境設定パネルを作る際にモデルとビュー(パネルに配置した GUI 部品)を結びつけるために、Cocoa Bindings を使った。試行錯誤とドキュメントの流し読みで、どうにか動くものができた。その過程で Cocoa Bindings についても理解した、と思っていた。

同じことをアプリのメインウィンドウでもやろうとした。環境設定パネルでブログの一覧をテーブルビューに表示させたように、メインウィンドウでも記事一覧をテーブルビューに表示させるのに Cocoa Bindings を使おうとしたのだ。これがうまくいかない。環境設定パネルの時と同じように書いたら動かない。

試行錯誤を繰り返し、メインウィンドウでも表示できるようになったが、そのコードは環境設定パネルのものとは少し異なっていた。具体的には、プロパティの更新を KVO で通知する部分が違う。

なにかおかしい。もちろん、おかしいのはわたしの理解の方だ。基本に立ち返って、KVC、KVO の理解を点検するところから始めようと思う。ここをクリアにしない限り、先には進めない。

追記@2010-12-08

この問題については、以下の後続記事を参照のこと。一応、解決している。

関連リンク

関連記事