iOSでHTTP通信 -- NSURLConnectionのまとめ

iOSでネットワーク通信をするのは初めてだったのでメモ。

iOS Developer Libraryに公式のチュートリアル日本語版)があるので、まずはこれをこなすのが良いかと思います。

実験環境:

基本

iOSもといCocoaではNSURLConnectionクラスを利用してHTTP通信を行うことが出来ます。このクラスですが、時代とともにメソッドが追加されており、今のところ以下の3通りの手段が実装されています。

  1. initWithRequest:...とデリゲートを使う方法。昔からある方法。
  2. sendSynchronousRequest:...で同期的に取得する方法。
  3. sendAsynchronousRequest:...とブロックで非同期的に取得する方法。

NSRequestに関して

どの方法でも、NSRequestというリクエスト情報を詰め込んだオブジェクトを作成する必要があるので、ここで取り上げておきます。

NSRequestは、HTTPのリクエストを抽象化したクラスです……が、抽象度は低めです。そのため、例えばクエリに関しては、GETにしろPOSTにしろ自力で構成する必要があります。文字列のエンコードも手動で行う必要があります。文字列のエンコードはNSStringのstringByAddingPercentEscapesUsingEncoding:を使うことで……と見せかけて*1CFURLCreateStringByAddingPercentEscapesという、いずれにしてもあまりに名前が長すぎて使う気が失せるメソッドで実現できます。

NSString* encodeURI(NSString *str) {
    return (NSString *)CFURLCreateStringByAddingPercentEscapes(
        kCFAllocatorDefault,
        (CFStringRef)str,
        NULL,
        CFSTR(":/?#[]@!$&'()*+,:="),
        kCFStringEncodingUTF8);
}

この記事ではGETだけ扱いますが、POSTの場合はHTTPBodyにデータを入れる必要があります。細かい話はマニュアルに丸投げします。

デリゲートを使う方法

大雑把な流れは以下の通りです。

  1. NSConnectionDataDelegateプロトコルを採用し、メソッドを適当に実装。
  2. NSURLなどでNSRequestを作成。
  3. NSURLConnectionのinitWithRequest:delegate:で実行。

initWithRequest:delegate:ですが、インスタンス化した時点で通信が開始する点には注意が必要です*2initWithRequest:delegate:startImmediately:でstartImmediatelyにNOを渡せば、明示的に開始することも出来ます(startメソッドで開始)。

NSURLConnectionDataDelegateに関して

NSURLConnectionに関連したプロトコルとして、NSURLConnectionDelegate、NSURLConnectionDataDelegate、NSURLConnectionDownloadDelegateというのがマニュアルに出てくるのですが、基本的にはNSURLConnectionDataDelegateだけ採用すればOKです。

もう少し詳しく書くと、

  • NSURLConnectionDelegateはNSURLConnectionDataDelegateとNSURLConnectionDownloadDelegateの共通部分をまとめたプロトコル
  • NSURLConnectionDataDelegateとNSURLConnectionDownloadDelegateは、いずれもNSURLConnectionDelegateを採用している。
  • NSURLConnectionを使うときはNSURLConnectionDataDelegateを、Newsstand KitのdownloadWithDelegate:を使うときはNSURLConnectionDownloadDelegateを採用する。

とのことです。とにかく、NSURLConnectionを使うときはNSURLConnectionDataDelegateを採用しておけば良いでしょう。

サンプルプログラム

適当なラッパークラスを作ったので載せておきます。

// WebRequest.h
#import <Foundation/Foundation.h>

@interface WebRequest : NSObject <NSURLConnectionDataDelegate>

+ (void)getURL:(NSString *)url block:(void (^)(NSData *))block;
+ (void)getURLAsJSON:(NSString *)url block:(void (^)(id))block;

@end
// WebRequest.m
#import "WebRequest.h"

@implementation WebRequest {
    NSMutableData *_receivedData;
    void (^_callback)(NSData *);
}

+ (void)getURL:(NSString *)url block:(void (^)(NSData *))block
{
    return [[self alloc] getURL:url block:block];
}

- (void)getURL:(NSString *)url block:(void (^)(NSData *))block
{
    NSLog(@"GET %@", url);
    
    _callback = [block copy]; // IMPORTANT
    _receivedData = [NSMutableData dataWithCapacity:0];
    
    NSURLRequest *req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]
                                         cachePolicy:NSURLRequestUseProtocolCachePolicy
                                     timeoutInterval:30.0];
    NSURLConnection *connection = [NSURLConnection connectionWithRequest:req delegate:self];
    
    if (!connection) {
        block(nil);
        _receivedData = nil;
        _callback = nil;
    }
}

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
    NSLog(@"connection:didFailWithError");
    _callback(nil);
    _receivedData = nil;
    _callback = nil;
}

- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
    NSLog(@"connection:didReceiveResponse");
    [_receivedData setLength:0];
}

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
    NSLog(@"connection:didReceiveData");
    [_receivedData appendData:data];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    NSLog(@"connectionDidFinishLoading");
    _callback(_receivedData);
    _receivedData = nil;
    _callback = nil;
}

#pragma mark - Additional Methods

+ (void)getURLAsJSON: (NSString *)url block:(void (^)(id))block
{
    [WebRequest getURL:url block:^(NSData *data) {
        NSError *error = nil;
        id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
        if (error) {
            NSLog(@"%@", error);
            block(nil);
            return;
        }
        // NOTE:
        // [json respondsToSelector:@selector(objectForKey:)])
        // ? (json is a NSDictionary)
        // : (json is a NSArray)
        block(json);
    }];
}

@end
// main.c
// このブログのRSSをJSONで取得して、
// 最新エントリ3件の日付とタイトルを表示するプログラム
#import "WebRequest.h"

NSString* encodeURI(NSString *str) {
    return (NSString *)CFURLCreateStringByAddingPercentEscapes(
        kCFAllocatorDefault,
        (CFStringRef)str,
        NULL,
        CFSTR(":/?#[]@!$&'()*+,:="),
        kCFStringEncodingUTF8);
}

void printRecentEntries(void (^finish)()) {
    NSString *paramURL = encodeURI(@"http://shin.hateblo.jp/rss");
    NSString *url = [NSString stringWithFormat: @"https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=3&q=%@", paramURL];
    
    [WebRequest getURLAsJSON:url block:^(NSDictionary *data) {
        if (!data) {
            NSLog(@"Error: network error or invalid request.");
            finish();
            return;
        }
        
        int status = [data[@"responseStatus"] intValue];
        if (status != 200) {
            NSLog(@"Response Error: %d - %@", status, data[@"responseDetails"]);
            finish();
            return;
        }
    
        NSArray *entries = data[@"responseData"][@"feed"][@"entries"];
        for (int i = 0; i < [entries count]; ++i) {
            NSDictionary *entry = entries[i];
            NSString *date = entry[@"publishedDate"];
            NSString *title = entry[@"title"];
            NSLog(@"[%@] %@", date, title);
        }
        finish();
    }];
}

int main(int argc, char* argv[])
{
    @autoreleasepool {
        __block BOOL finished = NO;
        printRecentEntries(^{ finished = YES; });
        while (!finished) {
            [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.5]];
        }
        NSLog(@"Finish!");
    }
    return 0;
}

何もしないと通信終了を待たずに終わってしまうので、実行ループ(Run Loop)で待っています。

以下、コンパイルと実行です。

$ clang main.m WebRequest.m -framework Foundation
$ ./a.out
2014-05-04 15:03:42.035 a.out[50649:507] GET https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=3&q=http%3A%2F%2Fshin.hateblo.jp%2Frss
2014-05-04 15:03:43.166 a.out[50649:507] connection:didReceiveResponse
2014-05-04 15:03:43.167 a.out[50649:507] connection:didReceiveData
2014-05-04 15:03:43.167 a.out[50649:507] connection:didReceiveData
2014-05-04 15:03:43.167 a.out[50649:507] connection:didReceiveData
2014-05-04 15:03:43.168 a.out[50649:507] connection:didReceiveData
2014-05-04 15:03:43.168 a.out[50649:507] connection:didReceiveData
2014-05-04 15:03:43.168 a.out[50649:507] connectionDidFinishLoading
2014-05-04 15:03:43.169 a.out[50649:507] [Wed, 30 Apr 2014 06:16:58 -0700] Apacheでシンボリックリンクを使うときに注意すべき点
2014-05-04 15:03:43.170 a.out[50649:507] [Wed, 30 Apr 2014 05:43:11 -0700] 現在開いているファイルの入ったディレクトリ
2014-05-04 15:03:43.170 a.out[50649:507] [Tue, 29 Apr 2014 05:13:17 -0700] Perlにおけるコマンドライン引数の処理
2014-05-04 15:03:43.546 a.out[50649:507] Finish!

send(As|S)ynchronousRequestを使う方法

デリゲートを使う方法は、細かい制御が利く分、何かと面倒ですが、send(As|S)ynchronousRequestを使うと、もっとサックリと書けます。

あんまり説明することもないのでコード載せます。

sendSynchronousRequest

sendSynchronousRequestはスレッドを止めるので、UIがあるようなプログラムでは、スレッドを別に作成する必要があります。

#import <Foundation/Foundation.h>

NSString* encodeURI(NSString *str) {
    return (NSString *)CFURLCreateStringByAddingPercentEscapes(
        kCFAllocatorDefault,
        (CFStringRef)str,
        NULL,
        CFSTR(":/?#[]@!$&'()*+,:="),
        kCFStringEncodingUTF8);
}

void printRecentEntries() {
    NSString *paramURL = encodeURI(@"http://shin.hateblo.jp/rss");
    NSString *url = [NSString stringWithFormat: @"https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=3&q=%@", paramURL];
    NSURLRequest *req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]
                                         cachePolicy:NSURLRequestUseProtocolCachePolicy
                                     timeoutInterval:30.0];
    
    NSError *error;
    NSURLResponse *res;
    NSData *data = [NSURLConnection sendSynchronousRequest:req
                                         returningResponse:&res
                                                     error:&error];
    if (error) {
        NSLog(@"%@", error);
        return;
    }
    
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
    if (error) {
        NSLog(@"%@", error);
        return;
    }
    
    int status = [json[@"responseStatus"] intValue];
    if (status != 200) {
        NSLog(@"Response Error: %d - %@", status, json[@"responseDetails"]);
        return;
    }

    NSArray *entries = json[@"responseData"][@"feed"][@"entries"];
    for (int i = 0; i < [entries count]; ++i) {
        NSDictionary *entry = entries[i];
        NSString *date = entry[@"publishedDate"];
        NSString *title = entry[@"title"];
        NSLog(@"[%@] %@", date, title);
    }
}

int main(int argc, char* argv[])
{
    @autoreleasepool {
        printRecentEntries();
    }
    return 0;
}

sendAsynchronousRequest

sendAsynchronousRequestは内部的に、GDCでスレッドを立てて、sendSynchronousRequestを呼んでいるだけのようです(詳しくはこちらを参照)。

#import <Foundation/Foundation.h>

NSString* encodeURI(NSString *str) {
    return (NSString *)CFURLCreateStringByAddingPercentEscapes(
        kCFAllocatorDefault,
        (CFStringRef)str,
        NULL,
        CFSTR(":/?#[]@!$&'()*+,:="),
        kCFStringEncodingUTF8);
}

void printRecentEntries(void (^finish)()) {
    NSString *paramURL = encodeURI(@"http://shin.hateblo.jp/rss");
    NSString *url = [NSString stringWithFormat: @"https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=3&q=%@", paramURL];
    NSURLRequest *req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]
                                         cachePolicy:NSURLRequestUseProtocolCachePolicy
                                     timeoutInterval:30.0];
    
    NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    [NSURLConnection sendAsynchronousRequest:req queue:queue completionHandler:^(NSURLResponse *res, NSData *data, NSError *error) {
        if (error) {
            NSLog(@"%@", error);
            finish();
            return;
        }
    
        NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
        if (error) {
            NSLog(@"%@", error);
            finish();
            return;
        }
    
        int status = [json[@"responseStatus"] intValue];
        if (status != 200) {
            NSLog(@"Response Error: %d - %@", status, json[@"responseDetails"]);
            finish();
            return;
        }

        NSArray *entries = json[@"responseData"][@"feed"][@"entries"];
        for (int i = 0; i < [entries count]; ++i) {
            NSDictionary *entry = entries[i];
            NSString *date = entry[@"publishedDate"];
            NSString *title = entry[@"title"];
            NSLog(@"[%@] %@", date, title);
        }
        finish();
    }];
}


int main(int argc, char* argv[])
{
    @autoreleasepool {
        // ここでは処理終了を待つのにGCDのセマフォを使ってみたが、
        // 最初の例のように実行ループで待っても多分OK。
        dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        printRecentEntries(^{ dispatch_semaphore_signal(semaphore); });
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        dispatch_release(semaphore);
    }
    return 0;
}

*1:stringByAdding…はエスケープしてくれる文字が少ないらしいので使ってはいけない模様。なんのため〜に〜う~まれて♪

*2:コネクションを作った=通信が始まる、と考えれば自然な設計とも言えます。