2010-12-28

ZSH のパス設定

以前、MacBook と Mac mini Server の間で git clone しようとして失敗したことがあった(→「MacBook にも Git を (あるいはリモートリポジトリの準備)」)。原因は、リモート側のシェルの PATH 設定だった。git でリモートリポジトリから SSH 経由で clone しようとする場合、リモート側で(SSH 経由で) git コマンドが実行される。リモート側のシェルのパスに git コマンドをふくむディレクトリが入っていなければ、git コマンドが見つからずエラーになる。

シェルは起動時に設定ファイルを読み込む(実行する)ことで実行環境を整える。このとき、たいていのシェルでは実行モードによって読み込む設定ファイルが変わる。実行モードというのはログインシェル、対話シェル、そしてスクリプト実行用シェルの 3 つのことで、git clone で起動されるシェルはスクリプト実行用シェルになる。このため、たとえ ssh コマンドでリモートログインした状態で対話的に git コマンドを使えたとしても、git clone しようとすると (git コマンドが見つからず)エラーになることがある。

今日も、MacBook と iMac の間で git clone しようとして同じエラーが出て失敗した。以前解決したはずの問題にまた遭遇したことになる。というのも、あれからしばらくしてシェルを zsh に変えたからだ(→「twitter より (2010-08-03)」)。最初の設定のときに、ssh コマンドでリモートログインしてローカルなシェルの場合と同様に使えることを確認して安心していた。今日まで git clone のようなリモートのコマンドを直接実行することはなかったから、この問題に気付かなかった。

では、zsh を使う場合リモート(のスクリプト実行用)のシェルが参照するパスを設定するにはどうすれば良いのか? より具体的に言えば、git のリモートリポジトリを置いたコンピュータのシェルが zsh のとき、git clone 等が失敗しないようにするにはどうすれば良いのか?

答えは ~/.zshenv

結論を先に書くと、スクリプト用シェルのために PATH を設定するには ~/.zshenv に書けば良い。書式は通常のシェルスクリプトと同じ。つまり、git clone が失敗する問題を解決するためには、リモート側(clone されるリポジトリがある方)に ~/.zshenv を作り、以下のように PATH の設定をしてやれば良い(git コマンドを MacPorts でインストールしている場合)。

export PATH=/opt/local/bin:/opt/local/sbin:$PATH

これまで、zsh のためのパスは ~/.zshrc で設定してきた。この方法どこが悪かったのだろう?

それは実行モードごとの設定ファイルの読み込みが以下のようになっているからだ(→「【コラム】漢のzsh (1) 最強のシェル、それは「zsh」 | マイコミジャーナル」参照)。一言で言えば、~/.zshrc はスクリプト実行用シェルのときは読み込まれない。

zsh の設定ファイル読み込み順序
実行モード 読み込み順
ログインシェル
  1. ~/.zshenv
  2. ~/.zprofile
  3. ~/.zshrc
  4. ~/.zlogin
対話シェル
  1. ~/.zshenv
  2. ~/.zshrc
スクリプト用シェル
  1. ~/.zshevn

これを見れば、スクリプト用シェルが参照する PATH は ~/.zshenv で設定しなければならないことがわかる。

~/.zshenv を読むより前に起きていること

さて、これで MacBook と iMac の間で git clone に失敗する問題は解決できたが、ついでに zsh における PATH の設定について調べてみた。

zsh の man ページによれば、シェルが起動時に最初に読み込むのは /etc/zshenv だとのこと。その後、上述の表のような順序で設定を読み込む。では、/etc/zshenv はどうなっているのか?

Mac OS X 10.6.5 の /etc/zshenv の内容を以下に示す。

# system-wide environment settings for zsh(1)
if [ -x /usr/libexec/path_helper ]; then
 eval `/usr/libexec/path_helper -s`
fi

ここに書いてあるのは /usr/libexec/path_helper があれば、それを実行しろというもの(バッククォート記法なので、正確には path_helper の実行結果をスクリプトとして実行しろ、となる)。

この path_helper というコマンドは PATH を設定するためのシェルスクリプトを生成するためのもので -s オプションで B-shell 用のスクリプトが作られる。man ページには直接実行するものじゃないゾ、と書いてあるが、そこをあえて実行してみた結果を以下に示す。実行の前に PATH と MANPATH の設定を消しているのは、このコマンドが現在の設定に追加するようになっているから。空にしておくことで、zsh が /etc/zshenv でこのコマンドを実行したときの結果がわかる。

[imac] mnbi% PATH=''
[imac] mnbi% MANPATH=''
[imac] mnbi% /usr/libexec/path_helper -s
PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/usr/X11/bin"; export PATH;
MANPATH="/usr/share/man:/usr/local/share/man:/usr/X11/share/man"; export MANPATH;

では、path_helper は PATH (と MANPATH) に入れるべきディレクトリをどうやって知るのだろう? man ページによれば、それは /etc/paths/etc/manpaths だとのこと。そして、ここに書かれたものに、さらに /etc/paths.d/etc/manpaths.d に置かれたファイルの内容を付け加えて、PATH と MANPATH とする。

以下に、Mac OS X 10.6.5 の /etc/paths の内容を示す。

/usr/bin
/bin
/usr/sbin
/sbin
/usr/local/bin

同じく、Mac OS X 10.6.5 では /etc/paths.d には X11 というファイルがあり、その内容は以下のようになっている。

/usr/X11/bin

以上のことから、~/.zshenv を使ってユーザごとにパスを設定する以外にも、追加したいパスを書いたファイルを /etc/paths.d に置けば、システム全体で(正確には zsh を使っているすべてのユーザで)同様の効果が得られることがわかる。

関連リンク

関連記事

2010-12-27

NSTableView でソートしたとき、選択された要素をモデル配列から正しく取り出す方法

NSTableView では、見出し行をクリックするとカラムの内容によって行をソートすることができる。ところが、これはモデル(となっている NSMutableArray)をソートしているわけではないから、ソートしたビュー上の並び順とモデル内の要素の並び順は一致していない。

たとえば、もともと「りんご」「みかん」「ぶどう」の順にモデル配列の中に収められているとして、これをビュー上でソートして「ぶどう」「みかん」「りんご」の順に表示されているとする。ここでビュー上で「りんご」を選択すると、そのインデックスは(0 から数えるので) 2 となる。このインデックスを使い、モデル配列から objectAtIndex: で要素を取り出せば、それは「りんご」ではなく「ぶどう」になってしまう。

つまり、ビューでソートした状態でビュー上の選択(のインデックス)に合わせてモデルの要素を取り出すと、ビューの選択とは異なる要素を取り出してしまうことになるのだ。

MacBloggerGlass の場合で言うと、記事の一覧をタイトルまたは日付けでソートした状態で一覧から記事の選択を行うと、表示される記事の内容がずれてしまう。

こうしたビューとモデルにおける要素の並び順の不一致を避けるには、両者を結びつけている NSArrayController を使えば良い。

具体的には、NSArrayController から selectedObjects で要素オブジェクト(の配列)を取り出せば、ビューでソートしているかどうかに関係なく、適切なオブジェクト(つまりビュー上の選択と同じオブジェクト)が取り出せる。

MacBloggerGlass の場合、以下のようなコードを使う(AppController.m より抜粋)。

    Entry *entry = [[feedController selectedObjects] objectAtIndex:0];

feedController が記事一覧(と記事内容)用のモデル配列のための NSArrayController だ。AppController の IBOutlet として確保し、Interface Builder で NSArrayController のインスタンスと結びつけている。記事一覧では複数選択を許していないから、selectedObjects の返す配列の最初の要素を取り出している。

ちなみに、この NSArrayController を使う解決方法はググって見つけた(→Cocoaの日々: NSArrayController を使った NSTableView で選択行の情報を取得する)

NStableView と NSArrayController の関係

では、なぜ NSArrayController からは、ソートの状態に関係なく、適切なモデルオブジェクトを取り出すことができるのだろうか?

オブジェクトの配列をモデルとして、その内容を NSTableView に表示させる場合(配列の要素になっているオブジェクトのプロパティを表のカラムに表示させる、という意味)、Cocoa Bindings では両者を NSArrayController で結びつける。

これは詳細に言えば、NSTableColumn の値として、NSArrayController の arrangedObjects が返す配列の要素のプロパティを結びつける、ということだ。初期状態では、arrangedObjects はモデルの配列の並び順そのままの配列を返す。だから、モデル配列の順序がそのままビューの表示順序になる。一方、NSTableView の見出し行でソートしたときは、NSArrayController の arrangedObjects が返す配列の並び順が変わる。その結果、ビューの表示順序が変わる。

実はビュー(NSTableView)の行がソートされるのはむしろ結果で、実際にはコントローラ(NSArrayController)が返す要素の順序がソートされていたのだ。これは、NSArrayController がソートされた状態のモデル配列を保持している、と言っても良い。だから、ビュー上でソートされた状態のインデックスを使っても、適切なオブジェクトを取り出すことができるのだ。

では、なぜ NSArrayController の arrangedObjects の並び順が変わるのか? それは、NSTableView の見出し行がクリックされたときに NSArrayController に対して setSortDescriptors が呼ばれ、NSSortDescriptor がセットされることによる。これはソートの方法を指示するオブジェクトで、NSArrayController は arrangedObjects を作り出す際に参照するものだ。初期状態ではこれがセットされていないため(ソートされず)、arrangedObjects はモデルの要素順と同じ順序の配列になっている。

  • 見出しをクリックしてからの一連の動きを時系列で並べると以下のようになる。
    1. NSTableView の見出しがクリックされる。
    2. NSTableView が NSArrayController に対して、NSSortDescriptor をセットする。
    3. NSTableView (実際には NSTableColumn) が NSArrayController に対して arrangedObjects を要求する(メソッドを呼び出す)。
    4. NSArrayController は先にセットされた NSSortDescriptor にしたがってソートした配列を返す。

    まとめると、ソートはビューで起きているのではなく、コントローラの内部で起きている、ということになる。だから、コントローラはソートされた状態のインデックスから適切なオブジェクトを取り出すことができる、と。

    iOS アプリではどうする?

    Cocoa Bindings が使えない iOS アプリでは NSArrayController に相当する部分を自前で用意しなければならないはず。なんだか面倒なことになりそうな予感がする。ソートしなければ(できないようにすれば)良いんだけど。そもそも、iOS のアプリで表をソートするアプリって見かけないような……。

    関連リンク

    関連記事

    2010-12-25

    進捗状況を表示する (MacBloggerGlass)

    フィードをネットから取得する際には、ブログの記事数にもよるが、やはり多少の時間(数秒)がかかる。その間、ユーザにフィードバックがないのはユーザ体験上の問題だ。GAE 版ではブラウザが矢印を回したり、アドレスバーを塗り潰したりして「ネットにアクセスしてますよ」と主張してくれるが、Cocoa 版ではこれも自前でどうにかしなければならない。

    フィードバックの方法

    今回は、進捗状況をフィードバックする仕組みとしては、最も手軽な部類の「ステータスメッセージの表示」を作り込むことにした。

    メッセージを表示する場所は、Safari などでもお馴染のアプリウィンドウの最下部。ここにテキスト 1 行分の領域を空け、ラベル (NSTextField) を配置する。あとは、アプリコントローラで進捗に合わせてメッセージを setStringValue: してやれば良い。

    問題は進捗状況をどうやって知るのか。実際のフィードの取得は GData ライブラリの中でアプリの実行ループとは並列に実行されている。スレッドなのか、あるいは他の仕組みなのかまではまだ調べていないが、便宜上、別タスクと呼んでおく。このフィード取得用の別タスクが、アプリに対してコールバックを呼ぶなどして途中の状況を知らせてくれれば簡単なのだが、そういう仕組みは GData ライブラリには用意されていない。データをアップロードする場合には、そういうものがあるが、取得(ダウンロード)時にはないようだ。

    しばらく悩んだ後、何も難しく考えることはないと気付いた。フィードの取得が完了したことは GData ライブラリからのコールバックでわかる。ならば、取得を開始するときにタイマで定期的に進捗していることを知らせるメッセージを表示させ、完了したらそれを止めれば良い。「ネットにアクセスしているよ」を知らせるだけなら、これで十分だ。

    タイマを使った遅延実行

    NSObject には、指定した時間間隔の後、オブジェクトに対してメッセージを送る(つまり、そのオブジェクトでメソッドを実行する)仕組みが存在する。それが以下のメソッドだ。

    - (void)performSelector:(SEL)aSelector
                 withObject:(id)anArgument
                 afterDelay:(NSTimeInterval)delay;
    

    delay には遅延させる時間を秒単位で指定するが、NSTimeInterval は実は double 型なので 1.0 や 10.0 のように実数(というか浮動小数)で指定する。

    他にもときどき見かけるけど、基本型を typedef しただけの型っていうのは何か落ち着かない。クラスにしてファクトリなりイニシャライザを用意して欲しいよ。即値をどう指定したものか迷うから。NSTimeInterval なら ファクトリに intervalWithSeconds:(NSUInteger)sec とかがあれば迷わずにすむ。書くのは少しメンドウだけど。

    遅延実行を使った進捗メッセージの表示

    1. フィードの取得開始時にメッセージとして「Loading.」を表示
    2. 一定時間後にメッセージを更新するようにタイマをセット
    3. メッセージの更新では、「.」を追加したメッセージを表示するとともに、再び一定時間が経過したらメッセージの更新するようにタイマをセット
    4. 取得が完了したら、メッセージの更新を停止した上で、「(Complete)」を追加したメッセージを表示
    5. ある程度時間が経過したら、メッセージを消す。

    つまり、最初に「Loading.」と表示され、その後時間の経過とともに「Loading...」と「.」が増えて行き、完了した時点で「Loading........(Complete)」と表示される、というものだ。

    フィードの取得開始と完了を通知する

    あと少し問題が残っている。それはフィードの取得開始をどうやってアプリコントローラ(AppController)に伝えるか、というもの。

    実際にネットからフィードを取得するかどうかは、FeedManager だけが知っている(→「FeedManager が完成」)。アプリコントローラから「このフィードをよこせ」と言われたとき、すでにメモリ中に FeedBlogPost クラスのインスタンスがあればそれを返すし、インスタンスがないときにもすぐにはネットから取得せず、まずはローカルのストレージ中で保存されたフィードを探す。そこにもないとなって、ようやくネットから取得することになる。この動きは FeedManager 中にカプセル化されていて、アプリコントローラからは見えない。

    FeedManager をアプリコントローラに依存するようにはしたくなかったので、通知(NSNotification)を使って、取得の開始と完了を知らせることにした(→「設定変更を通知する」)。

    FeedManager の実装部で開始を通知する部分のコードがこれだ。

    - (void)fetchPostsForReceiver:(id)feedReceiver
                             blog:(EntryBlog *)blog
                          account:(NSString *)account
                         password:(NSString *)password {
        [...snip...]
        // send notification
        NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
        NSDictionary *userInfo = [NSDictionary dictionaryWithObject:blog
                                                             forKey:@"fetchingBlog"];
        [nc postNotificationName:BGKeyPostsLoadStartNotification
                          object:self userInfo:userInfo];
        [...snip...]
    }
    

    同様に完了を通知するコードは以下になる。

    - (void)postsTicket:(GDataServiceTicket *)ticket
       finishedWithFeed:(GDataFeedBlogPost *)gfeed
                  error:(NSError *)error {
        [...snip...]
        // send notification
        NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
        [nc postNotificationName:BGKeyPostsLoadCompleteNotification object:self];
    }
    

    実装: 進捗メッセージの表示

    実際に進捗メッセージを表示するアプリコンントローラの実装を以下に示す(AppController.m より抜粋)。

    - (void)awakeFromNib {
        NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
        [nc addObserver:self selector:@selector(handleBlogIDChange:)
                   name:BGKeyBlogIDChangeNotification object:nil];
    
        [nc addObserver:self selector:@selector(handleFeedLoadStart:)
                   name:BGKeyPostsLoadStartNotification object:nil];
        [nc addObserver:self selector:@selector(handleFeedLoadComplete:)
                   name:BGKeyPostsLoadCompleteNotification object:nil];
    
        [statusMessage setStringValue:@""];
        [...snip...]
    }
    
    - (void)handleFeedLoadStart:(NSNotification *)note {
        EntryBlog *blog = [[note userInfo] objectForKey:@"fetchingBlog"];
        [self startIndicatorWithMessage:[NSString
                                         stringWithFormat:@"%@: Loading.",
                                         blog.title]];
    }
    
    - (void)handleFeedLoadComplete:(NSNotification *)note {
        [NSObject cancelPreviousPerformRequestsWithTarget:self
                                                 selector:@selector(handleUpdateIndicator:)
                                                   object:nil];
        NSString *message =
        [NSString stringWithFormat:@"%@(Complete)", [statusMessage stringValue]];
        [statusMessage setStringValue:message];
    
        [self performSelector:@selector(handleClearIndicator:)
                   withObject:nil afterDelay:BGIndicatorClearInterval];
    }
    
    - (void)startIndicatorWithMessage:(NSString *)message {
        [NSObject cancelPreviousPerformRequestsWithTarget:self
                                                 selector:@selector(handleClearIndicator:)
                                                   object:nil];
        
        [statusMessage setStringValue:message];
        [self performSelector:@selector(handleUpdateIndicator:)
                   withObject:nil afterDelay:BGIndicatorUpdateInterval];
    }
    
    - (void)handleUpdateIndicator:(id)object {
        NSString *message = [NSString stringWithFormat:@"%@.",
                                      [statusMessage stringValue]];
        [statusMessage setStringValue:message];
    
        [self performSelector:@selector(handleUpdateIndicator:)
                   withObject:nil afterDelay:BGIndicatorUpdateInterval];
    }
    
    - (void)handleClearIndicator:(id)object {
        [statusMessage setStringValue:@""];
    }
    

    取得開始の通知を受ける handleFeedLoadStart: で startIndicatorWithMessage: の遅延実行をセットする。startIndicatorWithMessage では開始メッセージを表示した後、handleUpdateIndicator: の遅延実行をセットする。handleUpdateIndicator: ではメッセージを更新(「.」を追加)した後、handleUpdateIndicator: (つまり自分自身)の遅延実行をセットする。こうして handleUpdateIndicator: の実行が繰り返され、取得完了通知を受けた handleFeedLoadComplete: でキャンセルされるまで続く。また、handleFeedLoadComplete: では、完了メッセージを表示した後、進捗メッセージを消去するための遅延実行をセットしている。これにより、いつまでも完了メッセージがステータス行に残ることを防いでいる。

    まとめ

    メンドウだと言ってしまえばその通りなんだけど、GUI っていうのはそういうものだ。それに利用している仕組みも難しいものではないし。

    一方で、テキストメッセージを表示するというフィードバックは、GUI アプリのユーザ体験としてはほめられたものじゃない。グルグル回ったり、塗り潰されたりする方が見やすいし、わかりやすい。フィードバックを表示するための裏の仕掛けには、今回のように通知と遅延実行を使えば良いが、何を表示するかには工夫の余地がある。

    Cocoa Touch 版(つまり iPhone アプリ)では、この方式は使えない。グラフィカルな仕掛けが必要だ。グルグル回るやつとか。

    参考文献

    詳解 Objective-C 2.0 改訂版
    荻原 剛志
    ソフトバンククリエイティブ ( 2010-12-17 )
    ISBN: 9784797361780

    タイマを使ったメソッドの実行については「15-01 アプリケーションと実行ループ」に記述がある。とくに今回用いた遅延実行については p.351 の「メッセージの遅延実行」を参考にした。

    関連リンク

    関連記事

    2010-12-24

    PasswordManager の導入 - 非公開ブログの記事を取得する (MacBloggerGlass)

    今日の作業は「いくつか気になっている細部のつめ」の一つ。非公開ブログの記事を取得できるようにすること。GAE 版では Google サービスに対する認証処理をサボったため公開ブログだけしか扱えなかった。しかし、Cocoa 版ではブログの一覧を取得するために認証付きのアクセスを利用している。記事の取得でも同様にすれば良いだけのこと。

    GData ライブラリを使った記事の取得を実行している部分は、FeedManager (の一部のメソッド)に局所化されている。そこだけを変更すればできると考えた。が、この目論見は甘かった。必要な情報を伝播させるため、あちこちに手を入れる必要があった。結局、大小さまざまの変更をほぼ全体に施すことになってしまったのだ。以下で説明する PasswordManager もその一つ。

    これまではアカウント(Google アカウント)のパスワードは PreferenceController だけで保管、利用してきた。取得にパスワードが必要な情報(ブログ一覧)を使うのがここだけだったから。一方、ブログ記事を取得するトリガーは AppController にある。非公開ブログの記事を取得するためには AppController から FeedManager にパスワードを知らせなければならない。

    環境設定パネル(PreferenceController で制御している)で入力したパスワードを AppController でも利用できるようにするための仕組みが PasswordManger だ。パスワードの保管と読み出しを局所化することがその目的になる。インタフェース部を以下に示す。FeedManager 同様、これもシングルトンパターンを実装している(→「FeedManager の実装」)。メソッドの実装は、PreferenceController にあったキーチェーンを使った保存と読み出しをほぼそのまま流用している(「→キーチェーンサービスを使ってパスワードを保存する」)

    @interface PasswordManager : NSObject {
        NSMutableDictionary *passwords;
    }
    
    + (PasswordManager *)sharedManager;
    
    - (NSString *)currentPassword:(NSString *)account;
    - (void)updatePasswordForAccount:(NSString *)account
                            password:(NSString *)password;
    
    @end
    

    この他にも、GData API の認証付きサービスを利用するために必要な情報をFeedManager にわたすための仕掛けが必要になったり、と結構大掛かりな変更になった。FeedManager だけを 2、3 行変更するだけのつもりで始めた変更だっただけに、なおさら変更量が多く感じられたのかも。

    関連記事

    2010-12-23

    ラベルによる記事の抽出 - ラベル検索の実装 #3 (MacBloggerGlass)

    前回(→「ドロワー (NSDrawer) の使い方」)に続いて、ラベルの付いた記事を抽出する仕組みを作り込む。

    Feed にクエリを指定して Entry を絞り込む

    GAE 版では、GData API への問い合わせにクエリーを指定することで、取得するフィードデータの段階で記事を絞り込んでいた。Cocoa 版ではフィードデータをローカルストレージに保存しているため、この方式は使えない。

    記事の抽出は MVC のうち、M (モデル) でも C (コントローラ) でも可能だが、今回は M (モデル) で行うことにした。この場合、記事の抽出というよりも絞り込みと言うべきだろう。

    具体的には、Feed クラスに対して絞り込みのための「何か」を付与することで、Feed のプロパティ entries (配列)に収められる要素(Entry のインスタンス)を制限する。Feed が抱える要素(Entry)をフィルタにかけるようなイメージだ。

    Feed に付与する「何か」をクエリー(Query)と呼ぶ。その実体は、Entry クラスの述語メソッドと引数となるオブジェクトを組み合わせたものだ。Feed が抱える Entry のインスタンスそれぞれに対して、述語メソッドを(組み合わされた引数とともに)適用し、結果が真 (BOOL の YES) のものだけを残すわけだ。

    今回は、ラベルによる絞り込みなので、Entry クラスには - (BOOL)hasLabel:(NSString *)label というメソッドを定義しておく。以下にその定義を示す。

    - (BOOL)hasLabel:(NSString *)aLabel {
        NSString *label;
        for (label in self.labels) {
            if ([label isEqualToString:aLabel]) {
                return YES;
            }
        }
        return NO;
    }
    

    NSInvocation を使った Query の実現

    Query の定義を以下に示す。これを見てわかるように、Query の実体は NSInvocation そのものだ。Entry のインスタンスに適用するために特殊化された NSInvocation と言っても良い。

    NSInvocation は言わば、オブジェクトに対するメソッド呼び出し自体を独立したオブジェクトとして扱えるようにするためのものだ。そこには、メソッドの情報(セレクタとシグナチャ)、引数、ターゲットとなるオブジェクトが収められる。もともと、分散処理を実現するための仕組みの一つのようだが、今回のような目的にも利用できる。

    単にラベル(文字列)による要素の絞り込みを実現するだけなら、NSInvocation のような仕組みは必要ない。ただラベル(の配列)を Feed クラスにわたすだけで良い。単純な方法をとらなかったのは、他の種類のクエリーもサポートするためだ。たとえば、投稿日時による絞り込み等。Entry で適切な述語を定義してやれば、そういった絞り込みも可能になる。

    @interface Query : NSObject {
        NSInvocation *invocation;
    }
    
    + (Query *)queryWithPredicate:(SEL)predicate argument:(id)object;
    - (id)initWithInvocation:(NSInvocation *)anInvocation;
    
    - (BOOL)applyTo:(Entry *)target;
    
    @end
    
    @implementation Query
    
    + (Query *)queryWithPredicate:(SEL)predicate argument:(id)object {
        NSMethodSignature *signature =
        [Entry instanceMethodSignatureForSelector:predicate];
    
        NSInvocation *anInvocation =
        [NSInvocation invocationWithMethodSignature:signature];
        [anInvocation setSelector:predicate];
        [anInvocation setArgument:&object atIndex:2];
    
        Query *query = [[Query alloc] initWithInvocation:anInvocation];
        return [query autorelease];
    }
    
    - (id)initWithInvocation:(NSInvocation *)anInvocation {
        [super init];
        invocation = [anInvocation retain];
        return self;
    }
    
    - (void)dealloc {
        [invocation release];
        [super dealloc];
    }
    
    - (BOOL)applyTo:(Entry *)target {
        [invocation invokeWithTarget:target];
    
        BOOL result;
        [invocation getReturnValue:&result];
        return result;
    }
    @end
    

    コントローラ(AppController)では、ドロワー内のテーブルビューで選択が変わったときに、Feed に対して Query を付与している。ラベルが複数選択された場合は Query も複数付けられることになる。

    - (void)tableViewSelectionDidChange:(NSNotification *)notification {
        id object = [notification object];
        if (object == postTable) {
            [...snip...]
        } else if (object == labelsTable) {
            NSIndexSet *indexes = [labelsTable selectedRowIndexes];
            NSArray *selectedLabels = [feed.labels objectsAtIndexes:indexes];
            NSString *label;
            NSMutableArray *queries =
            [NSMutableArray arrayWithCapacity:[indexes count]];
            for (label in selectedLabels) {
                Query *query =
                [Query queryWithPredicate:@selector(hasLabel:) argument:label];
                [queries addObject:query];
            }
            [feed setValue:queries forKey:@"queries"];
        }
    }
    

    Feed での絞り込みは entries プロパティに対するアクセサを上書きすることで実現している。これを見てわかるように、複数の Query が付けられた場合、AND 結合による絞り込みとなる。

    - (NSArray *)entries {
        if ([queries count] == 0) return entries;
        NSMutableArray *filteredEntries = [NSMutableArray array];
        Entry *entry;
        Query *query;
        for (entry in entries) {
            BOOL result = YES;
            for (query in queries) {
                result = [query applyTo:entry];
                if (! result) break;
            }
            if (result) {
                [filteredEntries addObject:entry];
            }
        }
        return filteredEntries;
    }
    

    ちなみに今回は、この絞り込みは Feed クラスではなく、Feed から派生させた FilteredFeed クラスに実装している。そして記事フィードを表現する FeedBlogPost クラスをこの FilteredFeed クラスから派生するように変更した。というのも、ブログ一覧(のフィード)を表現する FeedBlog クラスには絞り込みは必要ない。絞り込みの実装がそちらに影響を及ぼすことのないようにしておきたかったのだ。

    Cocoa 版 BloggerGlass の完成

    ラベル検索が実現できたことで、Mac 用アプリとして作り始めたときの目標(→「欲しいのはオフライン機能」)を達成できた。

    MacBloggerGlass プロトタイプ #0
    ラベル検索
    スクリーンショットだけではわかりにくいが、左のドロワーで選択したラベルの付いた記事だけが一覧に現れている。

    これで Cocoa Touch 版、すなわち iOS デバイス向けのアプリとして作り直すための基礎ができた。Cocoa 版との大きな違いは、GUI の部品が異なること、Cocoa Bindings が使えないことなどだろうか。UI としては、Cocoa 版よりもむしろ iPhone 向けに最適化した GAE 版に近いものになるかもしれない。Cocoa Bindings を使わないとすると、コントローラの実装も変わる(コードが増える)はず。

    一方で、モデルと言うべき GData オブジェクトのアダプタ(Entry と Feed)クラスおよび FeedManager はほぼそのまま流用できるだろう。

    Cocoa Touch 版にとりかかるのは 2011 年になってからになるか。2010 年の残りはいくつか気になっている細部をつめる作業をしよう。

    関連リンク

    関連記事

    2010-12-22

    ドロワー (NSDrawer) の使い方 - ラベル検索の実装 #2 (MacBloggerGlass)

    ラベルの一覧を表示する UI として、ドロワーを使うことにした。ドロワーとは、アプリウィンドウの端に(大抵は左右のどちらか)付加的に表示できるウィンドウのことだ。通常のアプリウィンドウと同じく、様々なビューを置くことができる。

    最近の Mac OS X 用アプリではあまり見かけなくなった(少々古臭い) UI だが、表示中はメインウィンドウにくっついており見失うこともないし、不要なときは隠しておけばメインウィンドウ上のレイアウトを圧迫することもない。

    MacBloggerGlass のメインウィンドウは記事一覧表示とその内容表示で、すでに 2 分割(2 ペイン)しているが、ここにラベル一覧表示領域を追加するとさらに分割することになってしまう。その方が使いやすいならそうするが、まずは表示領域の分割ではなく、必要な時だけ領域を追加する方式で試してみることにした。

    Interface Builder での操作

    Cocoa アプリにドロワーを追加するには、ライブラリツールから Windows グループにある Windows and Drawer (右のスクリーンショットを参照)をドラッグしドキュメントウィンドウにドロップする。

    ライブラリツール上は 1 つのオブジェクトのように置かれているが、ドキュメントウィンドウにドロップすると以下の 3 つのオブジェクトが現れる(下のスクリーンショット参照。)。

    • Window
    • Drawer Content View
    • Drawer

    この 3 つのオブジェクトは、ドロップされた状態ですでに互いに関連づけられている。具体的には Drawer に備わったアウトレットのうち、parentWindow が Window に、contentView が Drawer Content View にそれぞれ結びつけられている。

    大抵の場合、アプリウィンドウはプロジェクトを作ったときから (MainMenu.xib 内に) 存在しているため、上記の Window オブジェクトは不要だ。当然、削除することになるが、そのときは Drawer オブジェクトの parentWindow を適切に設定しなければならない。

    Drawer オブジェクト(の parentWindow)にアプリのウィンドウを結びつけるには、Ctrl キーを押しながら Drawer オブジェクトからアプリウィンドウまでドラッグする。アプリウィンドウ上に接続するアウトレットの候補が現れるので、parentWindow を選ぶ。

    この Interface Builder のドキュメントにも書いてある正式な手順(Ctrl + ドラッグ)は正直言ってわかりづらい。いつもどっちからドラッグするのかで迷ってしまう。それよりも、アウトレットを備えたオブジェクト上でポップアップメニューを出す方がずっとわかりやすい。マウスなら(特に設定を変えていない限り)右クリック、TrackPad なら 2 本の指でタップした状態でクリックでポップアップメニューが出てくる。メニューにあるアウトレットの右端の端子(○)を接続したいオブジェクトまでドラッグする。Ctrl キーを押す必要はない。

    ドロワーの表示

    ドロワーを表示するにはアプリコントローラからアウトレットを通して openOnEdge: メソッドを呼ぶ。このとき、親ウィンドウのどの端にドロワーを付けるかを引数として指定する。隠す際は close メソッドだ。

    以下は、AppController.m からの抜粋だ。labelsDrawer は AppController クラスのアウトレットで、Interface Builder 上で Drawer オブジェクトに接続してある。

    以下のコードでは、現在のドロワーの状態を取得し、それに応じて表示中なら close、非表示中なら openOnEdge: を呼んでいる。openOnEdge: にわたしている NSMinXEdge はウィンドウの左端を意味する定数だ。ドロワーを右端に表示したいなら NSMaxXEdge を指定する。

    - (IBAction)toggleLabelsDrawer:(id)sender {
        NSDrawerState state = [labelsDrawer state];
        if (state == NSDrawerOpeningState || state == NSDrawerOpenState) {
            [labelsDrawer close];
            [showDrawerMenu setTitle:@"Show Labels"];
        } else {
            [labelsDrawer openOnEdge:NSMinXEdge];
            [showDrawerMenu setTitle:@"Hide Labels"];
        }
    }
    

    上記で、showDrawerMenu に対して setTitle: しているのは、ドロワーの状態によってドロワーを表示(または非表示)にするためのメニュー項目の文字列を変えるためだ。非表示状態では「Show Labels」に、表示中なら「Hide Labels」に置き換えている。showDrawerMenu も AppController のアウトレットで NSMenuItem * として宣言し、Interface Builder 上でメニュー項目と接続してある。

    ラベル一覧の表示

    前回(→「ラベル検索の実装 #1」)のラベル列挙と合わせると、ラベル一覧表示が可能になる。以下がそのスクリーンショットだ。

    MacBloggerGlass プロトタイプ #0
    ラベル一覧表示
    ラベル一覧をドロワーとして実現した状態。一覧表示にふくまれているすべての記事に付いたラベルを集めてドロワー内のテーブルに表示している。

    関連リンク

    関連記事

    2010-12-21

    ラベル検索の実装 #1 (MacBloggerGlass)

    ラベル検索を実現するために必要な仕組みは以下の 2 つ。

    • ラベルの列挙
    • 指定されたラベルの付いた記事の抽出

    今回は、まず簡単な方、ラベルの列挙を実装してみる。

    GDataCategory

    GData ライブラリで Blogger の記事は GDataEntryBlogPost クラスが表現している。各記事に付けられたラベルはこのクラスの categories というプロパティに収められている(実際に categories が定義されているのは GDataEntryBlogPost の基底クラスである GDataEntryBase)。このプロパティは NSArray として取り出すことができ、その各要素は GDataCategory クラスのインスタンスになっている。

    もともと、Blogger の記事データを表す Atom フィードでは、カテゴリは以下のような XML 要素として表現されている。この term 属性が Blogger の記事に付けたラベルになっている。

    <category
        scheme="http://www.blogger.com/atom/ns#"
        term="1. Macをプログラムする">
    </category>
    

    GDataCategory クラスには上記の term 属性を参照するための term プロパティが定義されている。ややこしいことに label というプロパティも定義されているが、こちらは Blogger のラベルとは関係ない。

    実装

    Entry クラス

    Entry クラスでは、内部に抱えた GDataEntryBlogPost の categories プロパティから各 GDataCategory のインスタンスの term プロパティを取り出し、NSArray の一時オブジェクトとして返すメソッド(プロパティへのアクセサ)を定義した。

    - (NSArray *)labels {
        NSArray *categories = [gDataEntry categories];
        if (! categories) return nil;
        NSMutableArray *lbls = [NSMutableArray arrayWithCapacity:[categories count]];
        GDataCategory *category;
        for (category in categories) {
            [lbls addObject:[category term]];
        }
        return lbls;
    }
    
    Feed クラス

    Feed クラスでは、初期化時に各 Entry からラベルを(上述の labels メソッドで)取り出し、NSMutableSet にまとめておき、labels プロパティとして保持するコードを追加した。

    - (void)gatherLabels {
        labels = [[NSMutableSet alloc] init];
        Entry *entry;
        NSString *label;
        for (entry in entries) {
            for (label in entry.labels) {
                [labels addObject:label];
            }
        }
    }
    

    配列ではなく集合を使ったのは、集合は addObject 時に要素の重複を排除してくれるから。

    FeedManager クラス

    また、内部に抱えた Feed インスタンスの labels プロパティをそのまま返す、同名のプロパティを FeedManager クラスにも用意することにした。アプリコントローラからは FeedManager 経由でアクセスすることにしたいから。

    次は?

    ラベルの「見せ方」を考えているところだが、まずは単純にドロワー(NSDrawer)に NSTableView でラベルを列挙する方式にするつもり。

    ドロワーは最近ではあまり見かけなくなったけど、標準で用意されている部品だし、使わないときは隠しておけるし。

    関連記事

    2010-12-20

    Xcode プロジェクトを git で管理する

    MacBloggerGlass のプロジェクトの git 管理を開始することにした。Xcode はツール自身が生成するファイルがいろいろあって、何を git 管理に任せれば良いのかが難しい。ツールのことを知るためには自分で試行錯誤を繰り返すのが一番だが、ここは素直に先人の知恵に頼ることにした。

    Xcode-Git-User-Script

    今回利用したのは、github に登録されている Xcode-Git-User-Script というプロジェクトのスクリプトだ。この先、何度も繰り返すことになることだから、ツールの機能として取り込む方が良いと判断した。

    このプロジェクトが提供するスプリプトは Xcode のスクリプトメニュー(「ウィンドウ」メニューと「ヘルプ」メニューの間にあるスクロールっぽいアイコン)にユーザスクリプトとして追加するもので、メニューから実行することで Xcode のプロジェクトに対して .gitignore と .gitattributes を追加してくれる。

    ユーザスクリプトとして追加

    まず、github の Xcode-Git-User-Script プロジェクトを開き、pasteMe_into_xcode_script_menu というファイルの内容を全選択しコピーしておく。その後の手順は以下のとおり。

    1. Xcode のスクリプトメニュー(スクロールっぽいアイコン)から「ユーザスクリプトを編集…」を実行する。
    2. 「ユーザスクリプトを編集」ダイアログが現れるので、左下にある「+」ボタンを押す。
    3. (「+」ボタンを押すことで現れる)追加メニューの中から「新規シェルスクリプト」を選択する。
    4. 右側のペインにスクリプトをペーストする。
    5. 必要なら左側のリスト項目から追加したスクリプトの名前を変更しておく(ダブルクリックで編集状態になる)。

    右のスクリーンショットは「Create .gitignore」という名前で追加したところ。

    スクリプトを実行してみる

    スクリプトメニューから追加したスクリプトの名前を選ぶ。するとフォルダを選択するダイアログが出てくる。.gitignore を追加したいプロジェクトのフォルダを選べば良い。README にも書かれているが、このダイアログで「保存」ボタンを押すと、右のスクリーンショットにあるような警告パネルが現れる。気にせず「"."を使用」を押せば良い。

    また、このスクリプトでは .gitignore に加えて、.gitattributes というファイルも追加するため、スクリプトの実行中にフォルダを選ぶダイアログが 2 回現れる。当然、警告パネルも 2 回現れることになる。

    あとは、対象のプロジェクトのフォルダ(ディレクトリ)で git init すればリポジトリのセットアップが完了だ。

    Xcode の次のバージョンに期待

    Xcode の次のバージョンでは git のサポートも入るらしい。そうなれば .gitignore のことなんかもツール自身が面倒を見てくれるはず。ま、いつ出てくるのかはわからないけど。β版を試す勇気はないしな。

    関連リンク

    関連記事

    2010-12-19

    「詳解 Objective-C 2.0」の改訂版が出た

    詳解 Objective-C 2.0 改訂版
    荻原 剛志
    ソフトバンククリエイティブ ( 2010-12-17 )
    ISBN: 9784797361780

    荻原(2.0)本の改訂版が出た。「はじめに」に書かれている主な更新点は以下の 5 つ。

    • ブロックオブジェクトの章を新設 (→ CHAPTER 14)
    • 並列処理に関する記述を一新 (→ CHAPTER 19)
    • iOS に関する記述を充実 (→ 07-03 等)
    • Core Foundation の概要を追加 (→ APPENDIX B)
    • NSURL に関する記述を充実 (→ 08-07)

    大きな変化は Snow Leopard で導入されたブロックオブジェクトと GCD に関する解説が追加されたことだろう。これら新しい並列処理機能に興味があるかどうかが、旧版を持っているプログラマが新版を買うかどうかの目安になるか。

    目次

    今後の参照のため、目次を引き写しておく。章立てレベルで目に付く旧版との差異は「CHAPTER 14 ブロックオブジェクト」が追加されたことと、旧版での「CHAPTER 18 スレッド」と「CHAPTER 19 分散オブジェクトが新版では「CHAPTER 19 並列プログラミング」に統合されたこと、そして「APPENDIX B CoreFoundation フレームワークの概要」が追加されたこと。

    • CHAPTER 01 オブジェクトに基づくソフトウェアの作成
    • CHAPTER 02 Objective-C のプログラム
    • CHAPTER 03 継承とクラス
    • CHAPTER 04 オブジェクトの型と動的結合
    • CHAPTER 05 リファレンスカウンタを用いたメモリ管理方式
    • CHAPTER 06 ガーベジコレクション
    • CHAPTER 07 NSObjectクラスとランタイムシステム
    • CHAPTER 08 Foundation フレームワークの重要なクラス
    • CHAPTER 09 カテゴリ
    • CHAPTER 10 抽象クラスとクラスクラスタ
    • CHAPTER 11 プロトコル
    • CHAPTER 12 宣言プロパティとアクセサ
    • CHAPTER 13 オブジェクトのコピーと保存
    • CHAPTER 14 ブロックオブジェクト
    • CHAPTER 15 メッセージ送信のパターン
    • CHAPTER 16 アプリケーションの構造
    • CHAPTER 17 例題: 簡易画像ビューア
    • CHAPTER 18 例外とエラー
    • CHAPTER 19 並列プログラミング
    • CHAPTER 20 キー値コーディング
    • APPENDIX A Foundationフレームワークの概要
    • APPENDIX B CoreFoundation フレームワークの概要
    • APPENDIX C コーディングの指針

    ブロックオブジェクト

    新設された「CHAPTER 14 ブロックオブジェクト」をざっと読んでみた。まずは章の冒頭部分から引用。

    (「詳解 Objective-C 2.0 改訂版」p.322 より)
    ブロックオブジェクト (block object) は、Mac OS X 10.6 (Snow Leopard) および iOS 4.0 で利用できるようになった機能で、Objective-C ではなく、C 言語の機能として実装されています。 [...snip...] 他のプログラミング言語ではクロージャ (closure) として知られている言語機能に相当します。

    ブロックってクロージャだったのか。それは知らなかったよ。

    (「詳解 Objective-C 2.0 改訂版」p.327 より)
    どうやら、ブロックオブジェクトは、そのブロックリテラルが記述された位置での自動変数の値を保存しているようです。

    なるほど、これなら確かにクロージャだ。これがなければただの関数ポインタになってしまうよな。

    この章の前半は、ブロックオブジェクトの使い方と実現のための仕組みの簡単な紹介になっている。仕組みについては、ブロックオブジェクトがスタック上に確保されることの説明があるが、これはブロックを理解するめの基本と言えそうだ。「クロージャはオブジェクトの裏返し」と言われる意味が、この説明を読んで理解できた。

    また、後半では、ブロックオブジェクト(という名のクロージャ)の Cocoa アプリでの使用例として、コレクションクラス(配列や辞書)に追加されたブロック対応のメソッドを使ったソートと検索の方法と、シート(パネルの一種でウィンドウにくっついているもの)のデリゲートの置き換えとして使う方法が紹介されている。前者は、ブロックを for (item in collection) の裏返しとして使う方法ってところか。ループの中に記述するコードをクロージャとしてコレクションクラスに適用するもの。

    そう言えば、GData ライブラリの実装でも(利用可能なら)ブロックを使うようになっていたな。CHAPTER 19 の並列プログラミングとあわせてじっくり読んでみるか。

    参考文献

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

    こちらは旧版。これまでこのブログでは「荻原(2.0)本」として何度も参照してきた。旧版についても目次を写してある(→「荻原(2.0)本」)

    関連リンク

    関連記事

    twitter より (2010-12-18)

    • 15:57  iphone の ipod からairplay で apple tv に音を送るとまるで iphone がリモコンのよう。実際は本体なんだけどな。
    • 16:07  apple tv って iphone (または ipod touch) の再生装置としても良いかも(・∀・)b ipod のささるスピーカーでもいいけど、あれだと iphone 自体で離れた場所から操作できない。大抵リモコンがあるけど操作性はやはり iphone そのものが上。
    • 16:10  ってか、アレだな。airplay 対応の再生装置が出てくればそれでも良いのか。
    • 20:00  iOS/Mac 向けアプリ開発者で iPad を持っているなら、おすすめです。→ http://logrepo.blogspot.com/2010/12/ibook-apple.html (iBook で Apple 提供の開発者向けドキュメントが読める)
    Powered by twtr2src.

    2010-12-18

    iBook で Apple 提供の開発者向けドキュメントが読める

    昨夜(2010-12-17)のこと、iPod touch で App Store を開くと iBook のアップデートが配布されていることに気付いた。他にもいろいろアップデートが出ていたのでまとめてアップデートした。で、ふと iBook を開き、さらに iBookStore を開いてみたところ、iOS 関連の開発者向けドキュメントが配信されていることに気付いた(右のスクリーンショットは iPad で iBookStore を開いたもの)。

    そう、ようやく Apple がやってくれた。ずっと、これが欲しかった。もともと PDF 版が配布されているから iPad に取り込んで読むことはできたし、さらに iPad がネットにつながるなら Safari でウェブ版を開けば iPad に最適化された状態で読むこともできた(→「iPad で Apple 提供の開発者向けドキュメントを読む」)。実際、このウェブ版は、iPad というデバイスの特性とあいまって、「ADC のドキュメントを読むならコレだ!」と思わせるぐらい読みやすいものになっていた。iMac で Xcode を開いているときにも、そばに立てた iPad でガイド系の文書(のウェブ版)を開いて参照していた(Xcode のドキュメントブラウザは主にクラスリファレンスを参照するのに使っている)。

    とはいえ、ウェブ版のドキュメント(というよりブラウザで長い文書を読むこと)には不満がある。スクロールして読む読書体験と、ページをめくって読む読書体験を比較すれば、後者の方が圧倒的に心地良い。画面上を文字が移動するのは「読む」ことを前提にした場合は気持の良いものじゃない(せめて、iPad 上の Safari に「Page Up」と「Page Down」があれば……)。もっとも、これはわたしがデジタル以前の世界で育った世代からかもしれない。情報の入れ物としての「本」というカタチに慣れてしまっていることがそう思わせるのだろう。

    また、いくら Wi-Fi 接続だろうとネット上のリソースを開くのには「遅れ」が生じる。アドレスバーが水色で塗られていくのを眺めていても楽しくない。

    だから、iPad で ADC ドキュメントを読むようになってからずっと思っていた。いや、むしろ願っていた。Apple が ebook として提供してくれるようになることを。

    iPad で読んでみる

    いくつかスクリーンショットを撮ったので貼ってみる。すべて、iPad のスクリーンショット機能(電源ボタン + ホームボタンの同時押し)で撮影したもの。ここに並ぶんでいるのはサムネイル。実際の見え方は画像をクリックして Picasa ウェブアルバムにアップしたものを見てほしい。

    iPad の iBook で ADC ドキュメントを読む
    iBook の本棚に並んだ iOS 開発者向けドキュメントたち
    早速、ダウンロードしてみた。最上段の 5 つが iOS 開発者向けドキュメントだ。O'Reilly の ebook と比べると表紙が文字だけで少しさびしい。
    iOS Application Programming Guide を開いてみた (#1)
    文字はもちろん、写真もクッキリ。
    iOS Application Programming Guide を開いてみた (#2)
    図もはっきり見える。図中の文字も十分に判別できる。

    もう、なんて言うかさ、iPad はこのために作られたんじゃないか、って思えるぐらい。Apple は開発者向けに ADC ドキュメントを(iOS 用のものだけじゃなく)丸ごと収録した iPad を販売するべきだよ。有料の Dev program (iOS 向けと Mac 向けのものがある)に参加している場合はちょっと安くしてさ。

    iPod touch で読んでみる

    画素数ではともかく、画面が小さいから iPad ほどの見やすさは得られないとわかっているけど、せっかくなので iPod touch でも試してみた。以下は iPod touch のスクリーンショット機能で撮ったもの。残念ながらスクショでは iPod touch の画面での見た目は再現できないから参考程度に見てほしい。

    iPod touch の iBook で ADC ドキュメントを読む
    iOS Application Programming Guide を開いてみた (#1)
    スクショだとやけに文字が大きく見えるが、実機で見るとこれより小さいと読みづらくなるサイズ。ま、見えやすさには個人差が出ると思うが。
    iOS Application Programming Guide を開いてみた (#2)
    iPad のスクショと同じ図。実機で見ると、図中の文字の判別はかなり厳しい。
    図だけを表示(横向き)
    横向きで、図だけを表示させると少し大きくなる。ただ、やはり実機上では図中の文字は読みづらい。

    iBook では図をダブルタップすると図だけを表示することができる。この状態ではピンチオープン、クローズによる拡大縮小も可能。iPod touch でも、ダブルタップからピンチオープンで拡大すれば図中の文字も読み取れる。

    他のドキュメントも欲しい

    iBook 向けに提供されているのはまだたった 5 つだけ。ADC にはそれこそ山ほどのドキュメントがある。iOS 向けだけじゃなくて、Mac 向けの方のものも iPad で読みたい。

    Apple さん、どんどん配信してください。お願いします。

    関連リンク

    関連記事

    2010-12-17

    FeedManager が完成 - オフライン機能の実現 #5 (MacBloggerGlass)

    ようやく、FeedManager の実装とそれを使うコントローラ(AppController、PreferenceController)部分の修正が完了した。FeedManager は必要に応じてフィードを GData API を通じて入手し、ローカルに保存する。これで、取得ずみのフィードが存在すれば、ネットワークにアクセスすることなく記事を表示することができるようになった。つまりは、オフライン機能が実現できたことになる。

    XML テキストの保存と復元

    ファイルへの書き出し

    最初は、XML テキストの保存に NSString の writeToFile 系のメソッドを使っていた。問題なくファイルに書き込めていたのだけれど、気になったのはファイルの許可情報。保存先が ~/Library の下だということもあり、所有者以外の読み取り許可を付けたくなかった。ところが、NSString の writeToFile だとファイル作成時に許可情報を指定することができない。デフォルトだと 0644 で作られてしまう(グループと全ユーザに対して読み取り許可が付いている)。

    作った後に許可を変更することも考えたが、何かまずいことが起きてアプリがクラッシュした際に、許可を変更できないままのファイルが残る(という可能性がある)ことが気持ち悪い。結局、NSString から NSData に変換し、NSFileManager の createFileAtPath:contents:attributes: を使うことにした。該当する部分のコード(FeedManager のメソッド)を以下に示す。

    - (void)writeFileAtPath:(NSString *)path contents:(NSString *)text {
        // always overwrites a existing file
        NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding];
        if (! data) {
            NSLog(@"Some data might be lost in converting into the UTF-8 encoding.");
            return;
        }
    
        NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
        [attrs setObject:[NSNumber numberWithInt:0600]
                  forKey:NSFilePosixPermissions];
    
        BOOL result = [fileManager createFileAtPath:path contents:data attributes:attrs];
        if (! result) {
            NSLog(@"Fail to write a file: %@", path);
        }    
    }
    

    実はこの部分はちょっと気になっている。というのも、Feed オブジェクトから XML データをテキストとして抽出する際に、NSData から NSString に変換している。ファイルに書き出す際に、それを FeedManager で NSData に戻していることになる。どう考えてもムダだ。途中で NSString を利用しているわけでもないし。

    気が向いたら修正しておこう。

    XML テキストからの復元

    ファイルから XML テキストを読み込み、Feed オブジェクトを復元しているコードを以下に示す。まずは、FeedManager のメソッドから。ここでは指定されたファイルの内容を NSString として取り出し、Feed クラスのファクトリーメソッドにわたしている。

    - (Feed *)restoreFeedFromFile:(NSString *)path feedClass:(Class)classOfGData {
        Feed *feed;
        NSString *xmltext =
        [NSString stringWithContentsOfFile:path
                                  encoding:NSUTF8StringEncoding
                                     error:NULL];
        feed = [Feed feedWithXMLText:xmltext feedClass:classOfGData];
        return feed;
    }
    

    次は、実際に Feed オブジェクトを生成している部分。こちらは Feed クラスのクラスメソッドだ。

    + (Feed *)feedWithXMLText:(NSString *)xmltext feedClass:(Class)classOfGData {
        NSXMLDocument *xmldocument =
        [[NSXMLDocument alloc] initWithXMLString:xmltext
                                         options:0
                                           error:NULL];
        GDataFeedBase *gfeed =
        [[classOfGData alloc] initWithXMLElement:[xmldocument rootElement]
                                          parent:nil];
        
        Feed *feed;
        if (classOfGData == [GDataFeedBlog class]) {
            feed = [[[FeedBlog alloc] initWithGDataFeed:gfeed] autorelease];
        } else if (classOfGData == [GDataFeedBlogPost class]) {
            feed = [[[FeedBlogPost alloc] initWithGDataFeed:gfeed] autorelease];
        } else {
            NSLog(@"Unexpected class was specified: %@", classOfGData);
            feed = nil;
        }
    
        [xmldocument release];
        [gfeed release];
        return feed;
    }
    

    ここで引っかかったのは、引数としてわたってきた NSString から NSXMLDocument を生成する部分。より正確には NSXMLDocument の initWithXMLString:options:error に対する第 2 引数 options の値だ。始めこれに NSXMLDocumentTidyXML という定数を指定していた。リファレンスによれば構造的に誤りをふくむ XML を正しい XML に修正する、と書いてあった。とくに XML の修正機能が必要だったわけではなく、ただなんとなく選んだだけだ。ところが、この指定で記事内容(HTML で書かれた部分)の pre 要素の改行がすべて取り除かれてしまった。

    たしかにリファレンスには「pretty-print で付けられた余分の空白等を取り除く」と書かれていた。ここを見落としたのだ。

    ともあれ、この値として 0 (余分な動作は何もしない) を指定して、期待した通りの結果を得ることができた。

    Feed オブジェクトをキャッシュする

    メモリ中に生成した Feed オブジェクトをキャッシュしておくための仕組みを FeedManager に組み込んだ。といっても、実体は NSMutableDictionary だ。

    この仕組みは、ブログ一覧 (FeedBlog) の方はアカウントをキーとしてキャッシュし、ブログ記事 (FeedBlogPost) の方はアカウントと Blog ID をキーとしてキャッシュする。環境設定パネルでブログが切り替えられたとき(またはアカウントが変更されたとき)に、すでに生成ずみの Feed オブジェクトがあれば、ネットワークに取りに行くこともなく、さらにはディスクから読み直すこともせずに、メモリ中のオブジェクトを使うことができるようにするためのものだ。

    以下にインタフェース部だけを示す。実装は、単なる NSMutableDictionary へのアクセスで、ほぼ自明だから。Blog ID とアカウントの 2 つをキーとして指定する方は、単に文字列として連結しているだけだ。

    // FeedCache is made to encapsulate the implementation detail
    // to manage cache for Feed instances.
    @interface FeedCache : NSObject
    {
        NSMutableDictionary *blogFeeds;
        NSMutableDictionary *postsFeeds;
    }
    
    // manage cache for Feed instances
    - (Feed *)feedBlogForAccount:(NSString *)account;
    - (void)setFeedBlog:(Feed *)feed account:(NSString *)account;
    
    - (Feed *)feedBlogPostForBlogID:(NSString *)blogID account:(NSString *)account;
    - (void)setFeedBlogPost:(Feed *)feed blogID:(NSString *)blogID account:(NSString *)account;
    
    - (NSString *)keyForBlogID:(NSString *)blogID account:(NSString *)account;
    
    @end
    

    プロトタイプ #0 の完成まで、もう少し

    始めに挙げた機能のうち、残るはラベルによる検索だけとなった(→「欲しいのはオフライン機能」参照)。

    必要になる仕組みとしては、おおよそ「ラベルの列挙」と「指定されたラベルが付いた記事の抽出」の 2 つ。前者は記事を表現した Entry オブジェクト (実体は GDataEntryBlogPost) からラベルをかき集めれば良い。後者については、GAE 版では GData API にクエリーをかけたが、Mac 版ではフィードはすべてローカルに保存してある(メモリ中にオブジェクトとして、あるいはディスク中に XML テキストとして)から、GData API をたたく必要はない。GData ライブラリにメモリ中のオブジェクトに対してフィルタをかけられればそれを使うし、できないならその部分を自前で作り込むことになる。

    実装よりも「見せ方」をどうするかが悩みどころ。プロトタイプなのだから、凝った UI を作るつもりはないのだけれど……。

    参考文献

    詳解 Objective-C 2.0
    荻原 剛志
    ソフトバンククリエイティブ ( 2008-05-28 )
    ISBN: 9784797346800
    Objective-C逆引きハンドブック
    林 晃
    シーアンドアール研究所 ( 2010-02-26 )
    ISBN: 9784863540514

    関連リンク

    関連記事

    twitter より (2010-12-16)

    • 18:34  Safari の Command + Ctrl + D を知らなかったよ。単語をダブルクリックで選択状態にして、Command + Ctrl + D。単語のそばに小さい辞書ウィンドウが開く。ナニコレ、便利すぎる。→ http://bit.ly/ethdtl
    Powered by twtr2src.

    2010-12-16

    クラスクラスタの導入 - オフライン機能の実現 #4 (MacBloggerGlass)

    FeedManager を実装していて、どうにも FeedBlog と FeedBlogPost の取り扱いがメンドウになってきたので、両者に共通の基底クラスを作ることにした。ついでに、これらをクラスクラスタとしてまとめることにした。

    クラスクラスタとは

    荻原(2.0)本から引用する。

    (「詳解 Objective-C 2.0」p.245 より)
    クラスクラスタ (class cluster) とは、同じインタフェースを持ち、同じ機能を提供する複数のクラスの集合体です。インタフェースを表す抽象クラスのみを公開し、これをそのクラスクラスタのパブリッククラス (public class) と呼びます。個々の具体的なクラスはパブリッククラスのインタフェースによって抽象化され、クラスタの内部に隠蔽されます。

    Cocoa (Foundation フレームワーク)で提供されている具体的なクラスタには、NSString や NSArray などがある。

    公式ドキュメントにも独立した項目として解説されているが(→「Cocoa Core Competencies: Class Cluster」等)、言語および実行環境として特別なサポートの仕組みが提供されているわけではない。設計上の慣習 (convention) と見るべき。デザインパターンよりはもっと広い概念だ。

    ずい分、昔のことだけど、C++ で可変 (mutable) オブジェクトとして生成したものを、不変 (immutable) オブジェクトとして返すなんてコードを書いていた。あれもクラスクラスタの一種だと言える。

    実例: Feed クラス

    MacBloggerGlass のモデルクラスは、これまで FeedBlog と FeedBlogPost として作ってきた。前者は Google アカウントごとのブログの一覧を表し、GData ライブラリのクラス、GDataFeedBlog に対応する。後者は、ブログごとの記事のフィードを表現するもので、GData ライブラリでは GDataFeedBlogPost に対応する。FeedBlog は環境設定パネルでブログの一覧を表示するために使い、FeedBlogPost はメインウィンドウで記事の一覧とその内容表示に使っている。

    GData ライブラリのフィードを表現するクラスたちには共通の基底クラス (GDataFeedBase) があり、ほとんどの機能はこのクラス(あるいはさらにその基底クラス)で定義されていて、GDataFeedBlog と GDataFeedBlogPost の違いはわずかなものだ。当然、そのラッパーと言うべき FeedBlog と FeedBlogPost にも大きな差はなかった。

    以下に、Feed クラスタの公開クラス Feed のインタフェース部を示す。

    @interface Feed : NSObject {
        NSMutableArray *entries;
        NSMutableDictionary *pairOfLinks;
        GDataFeedBase *gDataFeed;
    }
    
    @property (readwrite, retain) NSMutableArray *entries;
    @property (readonly) NSMutableDictionary *pairOfLinks;
    
    // class factories
    + (Feed *)feedWithGDataFeedBlog:(GDataFeedBlog *)gfeed;
    + (Feed *)feedWithGDataFeedBlogPost:(GDataFeedBlogPost *)gfeed;
    + (Feed *)feedWithXMLText:(NSString *)xmltext feedClass:(Class)classOfGData;
    
    // initializer
    - (id)initWithGDataFeed:(GDataFeedBase *)gfeed; // desiginated initializer
    
    // element manipulation
    // subclass must be override the following 2 methods.
    + (Class)classForEntries;
    + (Entry *)entryWithGDataEntry:(GDataEntryBase *)gentry;
    
    // 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;
    
    // utilities
    - (NSString *)XMLText;
    - (NSUInteger)findEntry:(NSString *)identifier;
    
    // for subclasses
    - (void)setGDataFeed:(GDataFeedBase *)gfeed;
    @end
    

    継承関係にあるクラス群をクラスクラスタとして機能させるには、状況に合わせて具体的な派生クラスを生成するためのファクトリーメソッドが必要になる。上記の例では 3 つのファクトリーメソッドを Feed クラスのクラスメソッドとして定義している。対応する GDataFeedBase オブジェクトを指定して生成するものが 2 つと、もう 1 つは XML テキスト(フィードの生データ)から生成するものだ。XML から GDataFeedBase オブジェクトを作り、それに対応する Feed クラスを生成することを想定している。このため、どの GDataFeedBase クラスを作るかを引数でわたすようになっている。

    また、Feed クラスが要素として抱えるオブジェクトの方も同様の理由でクラスクラスタとしている(Entry)。ただ、こちらの方は Feed よりもさらに派生クラスの差異が目立たないため、ほとんど Entry 単独のクラスのようになってしまった。まあ、クラスクラスタ化してしまえば、後から派生クラスを基底クラスにまとめたとしても(あるいは派生クラスを増やしたとしても)、それを使う側のコードに影響はほとんどない。派生クラスが不要だと思えば、削除してしまえば良い。

    実はこのコード、まだ実装が完了していないので、動くかどうかは未確認。正確には、Feed クラスタの実装は完了しているが、それを使う FeedManager の方がまだできていない。明日にはできると思うのだけど……。

    参考文献

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

    クラスクラスタの説明は「CHAPTER 10 抽象クラスとクラスクラスタ」の「10-02 クラスクラスタ」にある。あと、(インスタンス)オブジェクトの所属するクラスを調べる方法については「07-01 NSObject クラス」に書かれている。

    関連リンク

    関連記事

    2010-12-15

    FeedManager の実装 - オフライン機能の実現 #3 (MacBloggerGlass)

    前回の記事で書いたように(→「オフライン機能の実現 #2」)、フィードデータの保存、読み込みは、FeedManager と呼ぶ管理用のクラスを作り、ネットワークからのデータの取得と合わせて、その詳細を隠すことに決めた。

    今日は、その FeedManager を作っている。残念ながら FeedManager はまだ完成していない。今回は、この実装の過程で気付いた注意点を 2 点ほど書き留めておく。

    シングルトン・パターン

    FeedManager にはシングルトン・パターンを使う。Cocoa でシングルトン・パターンを使うには少し注意が必要だ。簡単に言うと、NSObject で定義ずみのいくつかのメソッドを上書きしなければならない。また、シングルトン・パターンには付きもののファクトリーメソッドの定義にも Cocoa 独特の「お作法」がある。詳しくは公式ドキュメントの Cocoa Fundamentals Guide: Cocoa Objects: Creating a Singleton Instance を参照のこと。

    この「Creating a Singleton Instance」には、シングルトン・パターンを実装するための実例が載っていて、そのまま流用できる。以下が、FeedManager 用に書き直したもの。sharedManager の戻り値以外は、実例そのままになっている。

    + (FeedManager *)sharedManager {
        if (sharedFeedManager == nil) {
            sharedFeedManager = [[super allocWithZone:NULL] init];
        }
        return sharedFeedManager;
    }
    
    + (id)allocWithZone:(NSZone *)zone {
        return [[self sharedManager] retain];
    }
    
    - (id)copyWithZone:(NSZone *)zone {
        return self;
    }
    
    - (id)retain {
        return self;
    }
    
    - (NSUInteger)retainCount {
        return NSUIntegerMax;
    }
    
    - (void)release {
        // do nothing
    }
    
    - (id)autorelease {
        return self;
    }
    

    コールバック方式への対応

    GData ライブラリではフィードデータの取得は非同期に実行される。このため、アプリ(のコントローラ)は、コールバックのメソッドを呼び出してもらうことでその完了を知る。データ取得が非同期に実行されるということは、取得したデータからオブジェクトを生成して返す GetFeedBlog のような関数を定義できないことを意味する。

    @implementation FeedManager
    - (void)trigger {
        [...snip...]
        FeedBlog *feedBlog = GetFeedBlog(account, password);
        [...snip...]
    }
    @end
    

    非同期方式の場合は、取得開始のトリガーとなるコードと、取得したデータを受け取るコードの間で、引数と戻り値を使った直接的な情報の交換(通信)ができない。これを解決するための方法は 2 つある。1 つは、トリガーと受け取りのコードを同一のオブジェクトのメソッドとして実装し、オブジェクトのインスタンス変数を介して情報のやり取りを行うもの。以下のような感じのコードになる。

    @interface FeedManger : NSObject {
        FeedBlog *feedBlog;
    }
    @end
    
    @implementation FeedManager
    - (void)trigger {
        Service *service = [...snip...]
        [service fetch];
        [...snip...]
    }
    
    - (void)callback:(id)dataObject {
        feedBlog = [[FeedBlog alloc] initWithDataObject:dataObject];
    }
    @end
    

    GData ライブラリの GDataServiceBase クラスでは、上記とは別の方法をサポートしている。それは、サービス(フィードデータの取得)がコールバックに対して送り返すデータの一部として、クライアント(サービスを利用するコード)がユーザデータとして任意のオブジェクトを添付する仕組みだ。具体的には GDataServiceBase のインスタンスメソッド setServiceUserData だ。

    GDataServiceBase に setServiceUserData でわたされたオブジェクトは、その後の同サービスオブジェクトに対するフィード取得のリクエストを処理する中で、コールバックに引数としてわたすオブジェクト(GDataServiceTicket)に封入される。コールバックメソッドでは、GDataServiceTicket の userData として、トリガーコードが添付したオブジェクトを受け取ることができる。おおよそ、こういう感じ(↓)。

    @implementation FeedManager
    - (void)trigger {
        GDataServiceBase *service = [...snip...]
        id userData = /* an arbitrary object */;
        [service setServiceUserData:userData];
        [service fetchFeedWithURL:[...snip...]]n;
    }
    
    - (void)callback:(GDataServiceTicket *)ticket [...snip...]] {
        id userData = [ticket userData];
        [...snip...]
    }
    

    この userData として、フィードデータ(から生成したオブジェクト)を受け取るオブジェクト(レシーバー)を添付すれば、callback の中でレシーバーにフィードデータをわたすことができる(レシーバーのメソッド経由で)。

    FeedManager ではフィードデータから生成されるオブジェクトを保持するインスタンス変数を別の目的(オブジェクトが生成ずみかどうかの判別)で使っているため、GDataServiceBase (のサブクラス)に userData を添付する方法を採用している。

    関連リンク

    関連記事

    2010-12-14

    オフライン機能の実現 #2 (MacBloggerGlass)

    GData オブジェクトの保存については実現の目処が立った(→「GData オブジェクトを保存する」)ので、今回はオフライン機能全般について検討してみた。

    ユーザ体験

    前にも書いたように、MacBloggerGlass はドキュメントアーキテクチャに基づいたアプリではない。言わば(Blogger で作ったブログ専用の)ブログブラウザだが、アプリの分類では特殊用途の RSS リーダだ。そして、ユーザ体験から言えば Mail.app と言ったところ(ただし閲覧専用)。

    Mail.app (を始めとするメーラのほとんど)がそうであるように、このアプリの場合もユーザが明示的に保存を指示することはない。Mac (や PC) のアプリでは、こういうタイプは少数派だが、iOS アプリではむしろ主流となっている。データを保存し読み込むというよりは、(アプリの終了によって)中断した状態を復元する方式だ。

    現状の UI は、Google アカウントを切り替えることを想定したものではないが(わたし自身、複数のアカウントは使っていないし)、オフライン機能のためのデータ保存はアカウントごとに行う。これは、アカウントを切り替えたとしてもデータを上書きしないことを意味する。この方式を取った場合、アカウントごとにデータを削除する手段も用意すべきだが、それは将来の検討課題としておく。

    一方で、ブログを切り替える UI を持つのだから、データの保存をブログごとに独立して行うのは当然。ただし、こちらもデータの削除は将来の検討課題に。

    データの保存はユーザに意識させないが、データの更新はユーザに明示的な指示を出してもらう。メニューの「Reload」をそれに使う。アプリの動きとしては、(User Defaults に保管されている)ブログ ID に対応するフィードデータが存在した場合、データの取得は行わなず、保存されたデータから GData オブジェクトを復元し、表示する。もちろん、データが保存されていないければ、特にユーザからの指示がなくとも取得する。

    デザイン(設計)

    保存先は ~/Library/BloggerGlass とする。この中にアカウント名でフォルダを作る。そこにさらに Blog ID でフォルダを作り、データファイルはこの Blog ID によるフォルダに置く。ファイル名は feedblogpost.xml とする。将来的にフィードデータを分割保存することがあるかもしれない。その場合は feedblogpost-20101214.xml のように日付をファイル名に取り込む。

    また、アカウントごとのブログ一覧のフィードも、アカウント名のフォルダ直下に置く。ファイル名は feedblog.xml とする。

    データファイルの形式は XML のテキスト。言わゆる human readable な形にしておく。

    「ユーザ体験」のところで書いたように、ユーザに保存を指示させない方式だとすると、そのトリガーをどうするか? まずは、フィードの取得が完了し(GData オブジェクトが作られ)た直後に実行される、GData ライブラリのコールバックが良いだろう。ユーザへのレスポンスの速さが気になるなら別スレッドで非同期に保存すれば良い。ただ、今の段階では同期的に保存する方式で。

    では読み込みのトリガーは? これはブログの切り替えになる。つまり、環境設定パネルでブログが切り替えられた通知のハンドラで行うことになる。今は、このタイミングでフィードをネットワーク経由で取得に行っている。保存が実現でき後は、ここでまず保存されたデータの有無を確認し、あればそれを読み込み、なければネットワークにアクセスする、となる。

    データの保存、読み込みはアプリコントローラとは独立させる。FeedManager クラス(仮名)を作り、ローカルのデータとネットワーク経由の取得の違いを隠す。アプリコントローラは FeedManager にアカウント名と Blog ID をわたして、FeedBlogPost オブジェクトを受け取ることになる。

    実装に関する考慮点

    ~/Library に保存用のフォルダを作る際には、パスを直接指定するのではなく(そもそもユーザ ID を知らずにユーザのホームディレクトリを直接指定できない)、Cocoa に用意された API 経由でライブラリ用フォルダを取得する。それが NSSearchPathForDirectoriesInDomains 関数だ。

    NSSearchPathForDirectoriesInDomains 関数は 3 つの引数を取る。1 つ目がフォルダの種類、2 つ目がフォルダのドメイン(のマスク)、そして 3 つ目がチルダ(~)を展開するか否か。

    ドメインマスクには NSUserDomainMask、NSLocalDomainMask、NSNetworkDomainMask、NSSystemDomainMask、NSAllDomainMask のうちから 1 つを指定する。今回の実装ではユーザのライブラリフォルダ(のパス)を知りたいのだから、NSUserDomainMask で良い。

    戻り値は配列になる。ユーザドメイン(のマスク)を指定すれば要素は 1 つだけだ。

    以下に簡単なサンプルプログラムを示す。

    int main (int argc, const char *argv[]) {
        NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
        
        NSArray *paths =
        NSSearchPathForDirectoriesInDomains(NSLibraryDirectory,
                                            NSUserDomainMask,
                                            YES);
        
        NSString *path;
        for (path in paths) {
            NSLog(@"Path: %@", path);
        }
        
        [pool drain];
        return 0;
    }
    

    上記のプログラムの実行結果は以下の通り(コンソールへの出力)。

    2010-12-14 22:35:48.525 ExpApp[11456:903] Path: /Users/mnbi/Library
    

    関連リンク

    関連記事

    2010-12-13

    GData オブジェクトを保存する - オフライン機能の実現 #1 (MacBloggerGlass)

    今回から、オフライン機能を実現するための作業を開始する。まずは、フィードから構築したオブジェクトの保存と復元について実験する。

    GData オブジェクトの保存と復元

    MacBloggerGlass では内部データとして GData ライブラリのオブジェクト(以下では GData オブジェクトと呼ぶ)をそのままの形で保持している。モデルオブジェクトで覆うことで、表示のために加工が必要なデータ(ID や日付け)はキャッシュしているものの、記事のタイトルや内容は GData オブジェクトのデータをそのまま使っている。このためオフライン機能を実現するためには、GData オブジェクトの保存と復元が必要だ。

    GData オブジェクト自体には Cocoa アプリでそのまま保存、復元できるための仕組み(NSCoding プロトコルの実装)はないが、その代わりに NSXMLDocument として参照することができるように作られている。

    NSXMLDocument は NSCoding プロトコルこそ実装していないものの、NSData 化するためのメソッドが用意されており、これを抱えるクラスで NSCoding を実装することは難しくない。

    GData オブジェクト (GDataObject とその派生クラスたち)を NSData 化するための手順は以下のようになる:

    1. GData オブジェクトから NSXMLDocument オブジェクトを取り出す。 (XMLDocument メソッド)
    2. 取り出した NSXMLDocument を NSData 化する。 (XMLData メソッド)

    また、NSData から GData オブジェクトを復元する手順は以下の通り:

    1. NSData から NSXMLDocument オブジェクトを作る。 (initWithData:options:error: メソッド)
    2. NSXMLDocument からルート要素を取り出す。 (rootElement メソッド)
    3. 取り出したルート要素で GData オブジェクトを初期化する。 (initWithXMLElement:parent: メソッド)

    実験的な実装

    環境設定パネルのモデルとなっている FeedBlog は内部にブログ一覧のフィード(GDataFeedBlog)を抱えるオブジェクトで、初期化に GDataFeedBlog のオブジェクトを指定する。今回は、このクラスを使って GDataFeedBlog の保存と復元を作り込むことにする。

    インタフェース部では NSCodig プロトコルを実装することを宣言する。

    @interface FeedBlog : NSObject <NSCoding> {
        NSMutableArray *entries;
        GDataFeedBlog *gDataFeed;
    }
    

    実装部では、NSCoding プロトコルの 2 つのメソッドを実装する。

    // for conformance to NSCoding protocol
    - (id)initWithCoder:(NSCoder *)aDecoder {
        [super init];
        NSData *data = [aDecoder decodeDataObject];
        NSXMLDocument *xmldocument =
        [[NSXMLDocument alloc] initWithData:data
                                    options:NSXMLDocumentTidyXML
                                      error:NULL];
        NSXMLElement *xmlroot = [xmldocument rootElement];
        GDataFeedBlog *aFeed =
        [[GDataFeedBlog alloc] initWithXMLElement:xmlroot parent:nil];
        return [self initWithGDataFeed:aFeed];
    }
    
    - (void)encodeWithCoder:(NSCoder *)aCoder {
        NSData *data = [[gDataFeed XMLDocument] XMLData];
        [aCoder encodeDataObject:data];
    }
    

    NSCoding プロトコルを実装したオブジェクトは、保存時に NSKeyedArchive を使って NSData 化する。逆に、NSData オブジェクトからの復元には NSKeyedUnarchive を使う。

    以下は環境設定パネルのコントローラクラス(PreferenceController)の実装の一部で、FeedBlog を NSData 化したものを User Defaults に保存している。本来、ここに保存するようなデータではないが、とりあえずこれで FeedBlog の保存と復元を確認することができる。

        NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
        [...snip...]
        // feed data
        NSData *data = [NSKeyedArchiver archivedDataWithRootObject:self.feed];
        [defaults setObject:data forKey:BGKeyFeedBlogData];
    

    復元時は User Defaults から NSData オブジェクトを取り出し、NSKeyedUnarchive にかけている。

        // load feed blog data
        NSData *data = [self getUserDefaults:BGKeyFeedBlogData];
        @try {
            self.feed = [NSKeyedUnarchiver unarchiveObjectWithData:data];        
        }
        @catch (NSException * e) {
            NSLog(@"Exception: %@", e);
            self.feed = [[[FeedBlog alloc] init] autorelease];
        }
        @finally {
        }
    

    次の一手

    保存と復元の仕組みはわかったので、次はアプリとして、どこに、どのような形式でデータを書き出すかを考えなくてはならない。

    MacBloggerGlass は、Cocoa のドキュメントアーキテクチャを使ったアプリではない。フィードを XML として書き出すことを選んだとしても Finder からそれをダブルクリックしてアプリを開くというタイプのものではない。ユーザ体験から見たアプリのタイプとしては、Mail.app に近いものを想定している。~/Library の下にアプリ専用のフォルダを作って、そこにブログごとのフィードデータを置くというような方式になるか。

    書き出しの形式に関しては、まずは XML (のテキスト)で良いだろう。パフォーマンス上の問題が出てこない限り、人が読める形にしておくとデバッグをふくめて、何かと便利だから。ただ、データをブログごとに分けるのは当然として、同じブログのすべての記事を 1 つにまとめるのか、それとも一定の数で分割するのかについては検討する必要がある。

    参考文献

    Cocoa Programming for Mac OS X
    Aaron Pablo Hillegass
    Addison-Wesley Professional ( 2008-05-15 )
    ISBN: 9780321503619

    Chapter 10 Archiving では NSCoder を使ったオブジェクトの保存と復元のサンプルが載っている。

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

    CHAPTER 10 に XML の操作に関する項目が載っている。NSXMLDocument の NSData 化は 187、ルート要素の取り出しは 188。

    関連リンク

    関連記事

    2010-12-12

    キーチェーンサービスを使ってパスワードを保存する

    以前の記事で書いたように(→「設定の保存にまつわる問題」)、ユーザのパスワード、それもアプリとは異なる別のサービス用のアカウントのものを、アプリの設定(User Defaults)にそのまま保存するのは望ましくない。たとえ、アプリの設定(~/Library/Preferences に plist ファイルとして保存される)がバイナリ形式で、かつファイルモードが 0600 であったとしても。

    パスワードを保存するなら、やはり「キーチェーン」を使うべきだ。

    (「Keychain Services Programming Guide: Introduction」より)
    Keychain Services provides secure storage of passwords, keys, certificates, and notes for one or more users. A user can unlock a keychain with a single password, and any Keychain Services–aware application can then use that keychain to store and retrieve passwords.

    今回は、このキーチェーンサービスについて調べてみた。

    アプリへの組み込み

    アプリからキーチェーンサービスにアクセスするために必要な関数は以下の 2 つ。

    • SecKeychainAddGenericPassword (キーチェーンにパスワードを追加)
    • SecKeychainFindGenericPassword (キーチェーンからパスワードを取得)

    これをアプリ内で使う場合の注意点は、(a) security/security.h を import すること、(b) Security.framework をリンクすること、の 2 点だ。(a) はソースに書くだけ。一方、(b) のためには Xcode のプロジェクトウィンドウで以下のような操作を行う。

    1. 「グループとファイル」の「Frameworks」グループ上でポップアップメニューを出し「追加」>「既存のフレームワーク...」を選ぶ。
    2. 現れたシートから Security.framework を選び「追加」ボタンを押す。

    上記を実行すれば「Frameworks」グループ内に Security.framework が追加されるが、実際にはどこでポップアップメニューを開いても大差はない。それよりも重要なのは「ターゲット」のビルドフェーズ「バイナリをライブラリにリンク」に Security.framework が追加されていることだ。

    サンプルアプリ

    この 2 つの関数を使って、簡単なサンプルアプリを作ってみた。右のスクリーンショットがそのアプリウィンドウだ。Save Password でアカウントとパスワードを指定して「Save」ボタンを押すと、パスワードがキーチェーンに保存される。次に、Load Password でアカウントを入力し「Load」ボタンを押すと、下のパスワードフィールドに保存したパスワードが表示される。もちろん、アカウントが間違っていればパスワードの取得に失敗する。

    デフォルトのキーチェーンがロックされていれば、保存時にロックを解除するかというダイアログが出てくるし、同様に取得時にはキーチェーンへのアクセスを許可するかというダイアログも出てくる。また、実際にパスワードが保存できていることは「キーチェーンアクセス.app」を起動すれば確認できる。

    以下に、アプリのソースの一部(AppController.{h,m})を示す。

    #import <Cocoa/Cocoa.h>
    
    
    @interface AppController : NSObject {
        NSTextField *accountFieldForSave;
        NSTextField *passwordFieldForSave;
        NSTextField *accountFieldForLoad;
        NSTextField *passwordFieldForLoad;
    }
    
    @property (assign) IBOutlet NSTextField *accountFieldForSave;
    @property (assign) IBOutlet NSTextField *passwordFieldForSave;
    @property (assign) IBOutlet NSTextField *accountFieldForLoad;
    @property (assign) IBOutlet NSTextField *passwordFieldForLoad;
    
    - (IBAction)load:(id)sender;
    - (IBAction)save:(id)sender;
    
    @end
    
    #import <Security/Security.h>
    #import "AppController.h"
    
    NSString * const KCTServiceName = @"KeyChainTest App";
    
    @implementation AppController
    
    @synthesize accountFieldForSave, passwordFieldForSave;
    @synthesize accountFieldForLoad, passwordFieldForLoad;
    
    - (IBAction)load:(id)sender {
        NSString *account = [accountFieldForLoad stringValue];
    
        const char *serviceName = [KCTServiceName UTF8String];
        const char *accountName = [account UTF8String];
    
        void *passwordData = nil;
        SecKeychainItemRef itemRef = nil;
        UInt32 passwordLength;
        
        OSStatus status;
        status = SecKeychainFindGenericPassword(NULL,
                                                strlen(serviceName),
                                                serviceName,
                                                strlen(accountName),
                                                accountName,
                                                &passwordLength,
                                                &passwordData,
                                                &itemRef);
    
        NSLog(@"Load password: status = %d", status);
        if (status == noErr) {
            NSString *password =
            [[NSString alloc] initWithBytes:passwordData
                                     length:passwordLength
                                   encoding:NSUTF8StringEncoding];
            [passwordFieldForLoad setStringValue:password];
    
            status = SecKeychainItemFreeContent(NULL,
                                                passwordData);
            [password release];
        } else {
            [passwordFieldForLoad setStringValue:@""];
        }
    
    }
    
    - (IBAction)save:(id)sender {
        NSString *account = [accountFieldForSave stringValue];
        NSString *password = [passwordFieldForSave stringValue];
        // !!!:
        const char *serviceName = [KCTServiceName UTF8String];
        const char *accountName = [account UTF8String];
        const char *passwordData = [password UTF8String];
        OSStatus status;
        status = SecKeychainAddGenericPassword(NULL,
                                               strlen(serviceName),
                                               serviceName,
                                               strlen(accountName),
                                               accountName,
                                               strlen(passwordData),
                                               passwordData,
                                               NULL);
        NSLog(@"Save password: status = %d", status);
    }
    
    @end
    

    Cocoa のオブジェクトにくるまれていないサービスのため、使うのが少し面倒(NSString を直接わたせなかったり)だが、実質的に 2 つの関数を呼び出すだけでパスワードのような機密性の高い情報を安全に保管できる。やってみると思ったよりも簡単だった。

    実は、この 2 つの関数だけではパスワードを変更することができない(同じアカウントで Add を呼ぶとエラーになる)。実際のアプリ(MacBloggerGlass)に組み込むときは、もちろん変更もできるようにしなければならない。

    関連リンク

    関連記事

    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 版なのだから。

    関連リンク

    関連記事