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 の「メッセージの遅延実行」を参考にした。

関連リンク

関連記事

0 件のコメント:

コメントを投稿