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 年の残りはいくつか気になっている細部をつめる作業をしよう。

    関連リンク

    関連記事