2010年6月19日土曜日

NSOperation を使って外部 API から非同期に結果を取得してみる

iPhone / iPad のアプリを作っていると、頻繁に登場するのが「外部 API を HTTP 経由で実行して結果を XML / JSON で取得し、それを解析してモデルクラスに変換してデータ構造に突っ込む」パターンです。当然たくさんの先人の皆様がすでに効率的なライブラリを作成されているのですが、あえて私も車輪の再発明に挑戦してみました。今回使用したのは NSOperation クラスです。 NSURLConnection クラスとデリゲートを使うだけでも簡単に非同期通信を実現することができるのですが、さらに NSOperation クラスと NSOperationQueue を使うことでさらにタスク間の依存関係を簡単に設定できたり、タスクの並列度を簡単に制御したりできそうなので、挑戦してみました。

2010/12/29追加: 発展版をASIHTTPRequestを使って作成してみました。


■実装コードと使い方

http://gist.github.com/441620

これが基底クラスとなる FetchOperation クラスです。使用する際にはこのクラスのサブクラスを作成して、以下のように parseResponseBody: メソッドをオーバーライドします。
@interface FetchMyAPI : FetchOperation
@end

@implementation FetchMyAPI
- (void)parseResponseBody:(NSData *)bodyData {
  NSString *string = [[[NSString alloc] initWithBytes:[bodyData bytes] length:[bodyData length] encoding:NSUTF8Encoding] autorelease];
  JSON *json = [MYJSONLibrary jsonFromString:string];
  // json を解析してモデルクラスに突っ込むなど・・・
  // parseResponseBody: メソッドはtry-catchで囲まれており、さらに Core Data Managed Object Context に対して lock をかけています
}
@end
あとはこのサブクラスのインスタンスを生成して、requet プロパティに任意の NSURLRequest を渡して、普通の NSOperation を使うときのように KVO を使って状態を監視すればOK。
FetchOperation *operation = [[[FetchMYAPI alloc] init] autorelease];
operation.request = myAPIRequest;
[operation addObserver:self forKeyPath:@"isCancelled" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];
[operation addObserver:self forKeyPath:@"isFinished" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];
[appDelegate.operationQueue addOperation:operation];
もちろん NSOperation のサブクラスなので、 FetchOperation 同士に依存関係をつけるのも簡単です。たとえばログインAPIを実行した後にゲットユーザーAPIを実行したい場合には、
[getUserOperation addDependency:loginAPIOperation];
などとして、おなじ NSOperationQueue に突っ込んでやるだけで、自動的にログインAPI→ゲットユーザーAPIの順に並列実行してくれます。超便利です。

上記コードは基本的にご自由にお使い頂いて結構です。ただし、そのままコピペしても NSOperationQueue がなかったり Core Data Managed Object Context がなかったりで、まず間違いなく動かないと思います。適当にご自身の環境に合わせて調整していただければ幸いです。テストは結構行っているので多分大丈夫だと思うのですが、バグとか残っているかもしれません。何かあっても責任は持てません、ごめんなさい><


■NSOperation クラスのサブクラスの作り方

さてここからは実装のお話です。基本的には iPhone SDK についてくるリファレンスの NSOperation のページに全ての注意事項が載っているため、こちらを注意深く読んで実装すれば難しいことはありません(全部英語ですが><)。

最初に覚えておくべき知識として、 NSOperation クラスには二通りの実行モードが有ります。 以下、iPhone OS 3以下での挙動です。 iOS 4以降は挙動が変わり、Mac OS X 10.6以降と同じ挙動になります。
  • 非並列実行モード
    • isConcurrent プロパティが NO を返すとき、またはiOS 4以降は isConcurrent プロパティの値に関係なく常にこちらのモードになります
    • NSOperationQueue クラスが自動的に新スレッドを1本作って、そこで実行してくれるので、なにも考えなくても並列処理になります
    • main メソッドの実行が完了したら自動的に処理が完了したとみなされます
  • 並列実行モード
    • isConcurrent プロパティが YES を返すとき
    • NSOperationQueue クラスは自分のいるスレッドで NSOperation を実行します。そのため、 NSOperation 側で並列処理を行わないとスレッドが固まります
    • main メソッドの実行が完了しても処理完了とはみなされません。自分で明示的に処理が終わったことを通知できるようにする必要があります
簡単に実装できるのは非並列実行モードなのですが、これで実装するためには NSURLConnection を同期モードで実行する必要があります。で、困ったことに NSURLConnection の同期モードはひどい実装で処理も遅ければメモリもムダ食いするしタイムアウトの時間すら自分で決められないという有様なので、まったく使えません。そのため今回はやむを得ず並列実行モードを頑張って実装しました。

並列実行モードを自分で実装する手順は以下の通り。

まず何はなくとも isConcurrent で YES を返すようにします。
- (BOOL)isConcurrent {
    return YES;
}
続いて以下の4つの状態通知メソッドを実装します。これはコピペでOKですが、ready, executing, finished, cancelledは自分で宣言してください。
- (void)setReady:(BOOL)b {
        if (ready != b) {
                [self willChangeValueForKey:@"isReady"];
                ready = b;
                [self didChangeValueForKey:@"isReady"];
        }
}
- (void)setExecuting:(BOOL)b {
        if (executing != b) {
                [self willChangeValueForKey:@"isExecuting"];
                executing = b;
                [self didChangeValueForKey:@"isExecuting"];
        }
}
- (void)setFinished:(BOOL)b {
        if (finished != b) {
                [self willChangeValueForKey:@"isFinished"];
                finished = b;
                [self didChangeValueForKey:@"isFinished"];
        }
}
- (void)setCancelled:(BOOL)b {
        if (cancelled != b) {
                [self willChangeValueForKey:@"isCancelled"];
                cancelled = b;
                [self didChangeValueForKey:@"isCancelled"];
        }
}
次に start メソッドを適当に実装します。
- (void)start {
        // Follows the behavior of NSOperation in Mac OS 10.6 (and iPhone OS 3.0)
        // また、start前にrequestがセットされていない場合にも例外を発生させる
        if (finished || cancelled) {
                [self cancel];
                return;
        }
        if (!ready || executing || !request) {
                @throw NSInvalidArgumentException;
        }

        // ここで適当に事前処理を行う
        // ここは同期実行でいいです、すぐ終わるなら

        // main実行直前に現在の状態をexecutingにする
        [self setReady:NO];
        [self setExecuting:YES];
        [self setFinished:NO];
        [self setCancelled:NO];

        // mainを実行する
        [self main];
}
最後に主処理を main メソッドに書きます。 main メソッド自身か、または main メソッドの大部分は並列実行されるようにしてください。
主処理が完了したら以下のように自身の状態を更新すればOKです:
[self setReady:NO];
[self setExecuting:NO];
[self setFinished:YES];
[self setCancelled:NO];
必要に応じて cancel メソッドも実装します
- (void)cancel {
        // ここで自身の主処理をキャンセルする

        // 現在の状態をcancelにする
        [self setReady:NO];
        [self setExecuting:NO];
        [self setFinished:YES];
        [self setCancelled:YES];
}


■FetchOperationの解説

以下のような順番で処理が進みます。
  1. FetchOperation のオブジェクトが生成される (init)
  2. FetchOperation のオブジェクトが NSOperationQueue に追加される (start)
  3. 並列実行モードで FetchOperation が実行され、 start メソッドが main メソッドを呼び出す (main)
  4. main メソッドの中で NSURLConnection が非同期実行される (main)
  5. NSURLConnection がレスポンスを受け取って、データを受信する (connection:didReceiveResponse:)
  6. NSURLConnection がすべてのデータを受信する (connectionDidFinishLoading:)
  7. NSInvocationOperation クラスを使って受信したデータの解析処理を並列実行する (parseMain:)
  8. parseMain: の終了を監視して、終了し次第 FetchOperation の実行を完了する (observeValueForKeyPath:ofObject:change:context:)
大きく分けると、1〜3が事前準備、4〜6が通信部、7〜8が解析部になっています。7〜8は NSInvocationOperation を使ってお手軽に並列実行していますが、別の NSOperation にしたほうがより良い実装になると思います。現実装のように NSInvocationOperation を使っていると、何かの間違いで FetchOperation 自身に複数のスレッドからのアクセスが発生して状態が壊れてしまう恐れがあります。

isConcurrent プロパティで YES を返すようにしているため、 FetchOperation 自身は NSOperationQueue が置いてあるスレッドと同じスレッドから実行されます。なので、実際に並列実行されるのは NSURLConnection が通信をしている部分と、 parseResponseBody の中だけです。それ以外の部分で重い処理を実行すると思いっきり固まるのでご注意ください。