NSURLConnectionを利用しての非同期、並行ダウンロード

iPhoneアプリの開発で、複数の画像を非同期にダウンロードしたかったので、NSURLConnectionを利用しての非同期、並行ダウンロードクラスを実装してみました。

基本的な方法

ダウンロード対象のURLはQueueのかたちに(実際はNSMutableArray)で登録されるようなかたちとする.
Queueに追加されたダウンロード対象URLの要素を監視する新規スレッドを起動させ、
NSURLConnectionにより非同期ダウンロード処理の起動を行っていく.
NSURLConnectionよるダウンロード完了などは、NSURLConnectionのDelegateである、
各ダウンロード要素管理のクラス(QueuedURLDownloaderElem)のインスタンスに通知され、さらに
このダウンローダー(QueuedURLDownloader)に設定されてるDelegate(
QueuedURLDownloaderDelegateプロトコルの実装)のメソッドに転送することにより、ダウンロード
された結果を得ることができる。

構成要素 - 以下のクラス、プロトコルで構成する

QueuedURLDownloader - ダウンローダーの本体、実行待ちキューを管理、順番に実行していく.
QueuedURLDownloaderElem - ダウンロード要素(1つのURLに対応)
QueuedURLDownloaderDelegate - ダウンロードの通知を受けるDelegateのプロトコル

Interface部

#import <Foundation/Foundation.h>


/*!
 @class QueuedURLDownloader
 NSURLConnectionを利用して非同期にファイルのダウンロードを行う.
 初期化時に同時にダウンロードする最大を指定,
 addURL:withUserInfoでダウンロード対象のURLをQueueに登録してダウンロードを行っていく。
 この登録を行うことにより、非同期にダウンロードが行われる.
 QueuedURLDownloaderDelegateプロトコルを実装したDelegateクラスの
 メソッド(didFinishLoading:withUserInfo)によりダウンロードした内容を取得する。
 
 以下の手順で、ダウンロードの処理を起動、終了をさせる。
 ダウンロードの起動処理は、新規スレッドで非同期に行われる。
 
 1.このクラスインスタンスの初期化.
 2.delegateにQueuedURLDownloaderDelegateプロトコルを実装したインスタンスを設定.
 3.startメソッドでダウンロード開始(URLが追加されたらダウンロードがはじめる状態にする).
 4.ダウンロード対象のURLを追加していく(addURL:withUnserInfo メソッド).
 5.finishメソッドでこれ以上、ダウンロードするものがないことを通知.
 例.
 // 初期化
 QueuedURLDownloader *downloader = [[QueuedURLDownloader alloc] initWithMaxAtSameTime:3];
 downloader.delegate = self;  // QueuedURLDownloaderDelegateプロトコルを実装したもの
 // 開始
 [downloader start];
 //
 NSDictionary *dict;
 dict = [[NSDictionary alloc] 
		  initWithObjectsAndKeys:@"value1, @"key1", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString1 ]
	withUserInfo:dict];
 dict = [[NSDictionary alloc] 
	initWithObjectsAndKeys:@"value2, @"key2", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString2 ]
	withUserInfo:dict];
 dict = [[NSDictionary alloc] 
	initWithObjectsAndKeys:@"value3, @"key3", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString3 ]
 withUserInfo:dict];
 ...
 // これ以上、ダウンロードするものがないことを通知
 [downloader finishQueuing];
 
 ========
 実装について
 新規スレッドにより、Queueに追加されたダウンロード対象URLの要素を監視して、順番に
 NSURLConnectionにより非同期ダウンロード処理の起動を行っていく.
 NSURLConnectionよるダウンロード完了などは、NSURLConnectionのDelegateである、
 各ダウンロード要素管理のクラス(QueuedURLDownloaderElem)のインスタンスに通知され、さらに
 このダウンローダー(QueuedURLDownloader)に設定されてるDelegate(
 QueuedURLDownloaderDelegateプロトコルの実装)のメソッドに転送することにより、ダウンロード
 された結果を得ることができる。
 
 */
@protocol QueuedURLDownloaderDelegate;

@interface QueuedURLDownloader : NSObject {
  NSInteger maxAtSameTime;
  NSObject<QueuedURLDownloaderDelegate>  *delegate;
@private
  NSMutableArray *waitingQueue;
  NSMutableDictionary *runningDict;
  BOOL queuingFinished;
  BOOL completed;
  // 処理が開始されている?
  BOOL started;
  // 停止が要求されている?
  BOOL stoppingRequired;
  NSInteger completedCount;
  NSLock *lock;
  NSTimeInterval timeoutInterval;
}

@property (nonatomic, retain) NSObject<QueuedURLDownloaderDelegate> *delegate;
@property (readonly) NSInteger completedCount;
/*!
 Timeout時間,単位:秒,Default 10.0秒
 */
@property (nonatomic) NSTimeInterval timeoutInterval;

/*!
 同時にダウンロードする最大数を指定しての初期化
 */
- (id) initWithMaxAtSameTime:(NSInteger)count;

/*!
 ダウンロードするURLを追加
 */
- (void) addURL:(NSURL *)URL withUserInfo:(NSDictionary *)info;

/*!
 ダウンロードを開始
 */
- (void) start;

/*!
 @method requireStopping
 @discussion ダウンロード処理を停止を要求,実際に停止されたかは、isCompletedで確認する必要がある
 */
- (void) requireStopping;

/*!
 @method isCompleted
 @discussion Download処理が完了しているかの判定
 */
- (BOOL) isCompleted;

/*!
 @method waitCompleted
 @discussion Download処理が完了するまで待つ,まだ開始されていない場合は、すぐに返る。
 */
- (void) waitCompleted;

/*!
 これ以上、ダウンロードURLするものがないことを通知.
 この通知後、ダウンロード要素の追加は受け付けられず、現在実行待ち、実行中のダウンロードの処理が完了すれば、
 ダウンロード処理のスレッドが終了する。
 */
- (void) finishQueuing;

/*!
 ダウンロード実行待ち要素数
 */
- (NSInteger)waitingCount;


/*!
 ダウンロード実行中の要素数
 */
- (NSInteger)runningCount;


/*!
 同時にダウンロード処理を行う最大数
 */
@property (nonatomic) NSInteger maxAtSameTime;

/*!
 現在のQueueの要素数
 */
//@property (readonly) NSInteger count;

@end


/*!
 ダウンローダー(QueuedURLDownloader)のDelegate
 didFinishLoading:withUserInfoメソッドにより、QueuedURLDownloaderのaddURL:withUserInfo
 で指定したURLからのダウンロード通知を受け、ファイルの内容を得る。
 */
@protocol QueuedURLDownloaderDelegate


/*!
 @method didFinishLoading:withUserInfo:
 @discussion Download完了の通知.
 @param data ダウンロードしたデータ
 @param info QueuedURLDownloaderのaddURL:withUserInfoで渡した userInfo
 */
- (void)didFinishLoading:(NSData *)data withUserInfo:(NSDictionary *)info;

/*!
 @method downloadDidFailWithError:withUserInfo:
 @discussion Download時のエラー発生通知.
 @param  error 
 @param info QueuedURLDownloaderのaddURL:withUserInfoで渡した userInfo
 */
- (void)downloadDidFailWithError:(NSError *)error withUserInfo:(NSDictionary *)info;

@optional 
/*!
 @method didReceiveResponse:withUserInfo:
 @discussin 指定URL先からのレスポンスの通知
 @param response
 @param info QueuedURLDownloaderのaddURL:withUserInfoで渡した userInfo
 */
- (void)didReceiveResponse:(NSURLResponse *)response withUserInfo:(NSDictionary *)info;

/*!
 @method didReceiveResponse:withUserInfo:
 @discussin 指定URL先からのデーターの通知(部分的にデータ受信した場合も通知されることがある)
 @param data
 @param info QueuedURLDownloaderのaddURL:withUserInfoで渡した userInfo
 */
- (void)didReceiveData:(NSData *)data withUserInfo:(NSDictionary *)info;


/*!
 @method didAllCompleted
 @discussion すべてのダウンロードが完了したときの通知
 */
- (void)didAllCompleted;

/*!
 @method dowloadCanceled
 @discussion ダウンロードがキャンセルされたときの通知
 */
- (void)dowloadCanceled;

@end

実装部

#import "QueuedURLDownloader.h"

/*!
 @class QueuedURLDownloaderElem 
 @discussion ダウンロード要素.ダウンロード元URLごとに生成する.
 URLコネクションの管理、データ取得時の通知メソッドの起動などを行う.
 */
@interface QueuedURLDownloaderElem : NSObject {
@private
  NSDictionary *userInfo;
  NSURL *URL;
  NSObject<QueuedURLDownloaderDelegate> *delegate;
  NSURLConnection *con;
  QueuedURLDownloader *queuedDownloader;
  NSMutableData *data;
}

/*!
 ダウンロード元URL
 */
@property (nonatomic, readonly) NSURL *URL;
/*!
 データ取得、接続などの通知先Delegate
 */
@property (nonatomic, readonly) NSObject<QueuedURLDownloaderDelegate> *delegate;
/*
 ダウンロード元URLへのコネクション
 */
@property (nonatomic, retain) NSURLConnection *con;
/*!
 これを要素として含むDownloader
 */
@property (nonatomic, readonly) QueuedURLDownloader *queuedDownloader;
/*!
 付加情報
 */
@property (nonatomic, readonly) NSDictionary *userInfo;

- (id) initURL:(NSURL *)URL WithUserInfo:(NSDictionary *)info 
  withDelegate:(NSObject<QueuedURLDownloaderDelegate> *) myDelegate
withQueuedDownloader: (QueuedURLDownloader *)downloader;

// NSURLConnection delegate
- (void)connection:(NSURLConnection *)connection 
didReceiveResponse:(NSURLResponse *)response;
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error;
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)fragment;
- (void)connectionDidFinishLoading:(NSURLConnection *)connection;

@end

@interface QueuedURLDownloader(Private)

/*!
 @method run
 @discussion 未実行Queueからダウンロード対象情報を取り出し、ダウンロードを起動を起動.
 Queueが空になるまで繰り返す.
 */
- (void) run;

/*!
 @method finishDownload:
 @discussion 1要素のダウンロードが完了したときの処理. QueuedURLDownloaderElemから通知される.
 後かたずけをする.
 */
- (void) finishDownload:(QueuedURLDownloaderElem *)elem;


@end

@implementation QueuedURLDownloaderElem

@synthesize userInfo;
@synthesize delegate;
@synthesize URL;
@synthesize con;
@synthesize queuedDownloader;

- (id) initURL:(NSURL *)RequestURL 
  WithUserInfo:(NSDictionary *)info 
  withDelegate:(NSObject<QueuedURLDownloaderDelegate> *) myDelegate 
withQueuedDownloader: (QueuedURLDownloader *)downloader {
  self = [super init];
  URL = RequestURL;
  [URL retain];
  userInfo = info;
  [userInfo retain];
  delegate = myDelegate;
  [delegate retain];
  con = nil;
  queuedDownloader = downloader;
  [queuedDownloader retain];
  data = nil;
  return self;
}


- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)fragment {
  NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
  NSLog(@"connection:didReceiveData");
  if(fragment) {
    //NSLog(@"data length = %d", [fragment length]);
  }
  if ([delegate respondsToSelector:@selector(didReceiveData:withUserInfo:)])
    [delegate didReceiveData:fragment withUserInfo:userInfo];
  if(!data) {
    data = [[NSMutableData alloc] init];
  }
  [data appendData:fragment];
  [pool drain];
}

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
  NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
  NSLog(@"connection:didFailWithError - %@", error);
  if ([delegate respondsToSelector:@selector(downloadDidFailWithError:withUserInfo:)])
    [delegate downloadDidFailWithError:error withUserInfo:userInfo];
  [self.queuedDownloader finishDownload:self];
  [pool drain];
}

- (void)connection:(NSURLConnection *)connection 
didReceiveResponse:(NSURLResponse *)response {
  NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
  NSLog(@"connection:didReceiveResponse ");
  if ([delegate respondsToSelector:@selector(didReceiveResponse:withUserInfo:)])
    [delegate didReceiveResponse:response withUserInfo:userInfo];
  [pool drain];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
  NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
  NSLog(@"connection:connectionDidFinishLoading ");
  if(data) {
    NSLog(@"data length = %d", [data length]);
  }
  if(self.userInfo) {
    /*
     NSEnumerator *enumerator = [self.userInfo keyEnumerator];
     id key;
     
     while ((key = [enumerator nextObject])) {
     //  NSLog(@"key = %@, value = %@", key, [self.userInfo valueForKey:key]);
     } 
     */
  }
  if ([delegate respondsToSelector:@selector(didFinishLoading:withUserInfo:)])
    [delegate didFinishLoading:data withUserInfo:userInfo];
  [self.queuedDownloader finishDownload:self];
  [pool drain];
  NSLog(@"drain");
}


- (void)dealloc {
  if(userInfo)
    [userInfo release];
  if(delegate)
    [delegate release];
  if(con)
    [con release];
  if(URL)
    [URL release];
  if(queuedDownloader)
    [queuedDownloader release];
  if(data)
    [data release];
  [super dealloc];
}

@end




@implementation QueuedURLDownloader

@synthesize maxAtSameTime;
@synthesize delegate;
@synthesize completedCount;
@synthesize timeoutInterval;

- (id) init {
  [self initWithMaxAtSameTime:1];
  return self;
}

- (id) initWithMaxAtSameTime:(NSInteger)count {
  self = [super init];
  if(self == nil)
    return self;
  maxAtSameTime = count;
  waitingQueue = [[NSMutableArray alloc] init];
  runningDict = [[NSMutableDictionary alloc] init];
  queuingFinished = NO;
  completed = NO;
  completedCount = 0;
  timeoutInterval = 10.0;
  lock = [[NSLock alloc] init];
  return self;
}

/*
 - (NSInteger) count {
 return [queue count];
 }
 */

- (void) addURL:(NSURL *)URL withUserInfo:(NSDictionary *)info {
  QueuedURLDownloaderElem *elem = [[QueuedURLDownloaderElem alloc] 
                                   initURL:URL 
                                   WithUserInfo:info
                                   withDelegate:self.delegate
                                   withQueuedDownloader:self];
  [lock lock];
  if(!queuingFinished) {	// もうURLを追加しないよう通知されている。
    [waitingQueue addObject:elem];
  }
  [lock unlock];
}


- (void)finishQueuing {
  queuingFinished = YES;
}

- (void) start {
  
  [NSThread detachNewThreadSelector:@selector(run) 
                           toTarget:self 
                         withObject:nil];
}

- (void) requireStopping {
  [lock lock];
  stoppingRequired = YES;
  [lock unlock];
}

- (BOOL) isCompleted {
  BOOL ret;
  [lock lock];
  ret = completed;
  [lock unlock];
  return ret;
}

- (void) waitCompleted {
  BOOL ret = NO;
  while (YES) {
    [lock lock];
    ret = completed || !started;
    [lock unlock];
    if(ret == YES) {
      if(stoppingRequired && started && 
         [delegate respondsToSelector:@selector(dowloadCanceled)] ) {
        [delegate dowloadCanceled];
      }
      break;
    }
    [NSThread sleepForTimeInterval:0.01f];
  }
}


- (void) run {
  [lock lock];
  started = YES;
  [lock unlock];
  while (1) {
    BOOL downloading = NO;
    [lock lock];
    if([waitingQueue  count] == 0 && [runningDict count] == 0 && queuingFinished || 
       stoppingRequired == YES) {
      [lock unlock];
      break;
    }
    if([waitingQueue count] > 0 && [runningDict count] < maxAtSameTime) {
      // 未実行のもののうちで一番先にQueueに登録されたものを得る
      // autoreleaseのObjectがLeakするので AutoReleasePool
      NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
      QueuedURLDownloaderElem *elem = [waitingQueue objectAtIndex:0];
      [waitingQueue removeObjectAtIndex:0];
      NSURLRequest *request = [[NSURLRequest alloc] 
                               initWithURL:elem.URL 
                               cachePolicy:NSURLRequestUseProtocolCachePolicy 
                               timeoutInterval:timeoutInterval];
      // DownLoadを開始
      elem.con = [[NSURLConnection alloc] initWithRequest:request delegate:elem];
      [request release];
      [runningDict setObject:elem forKey:elem.URL];
      [elem.con start];
      downloading = YES;
      [lock unlock];
      [[NSRunLoop currentRunLoop] run];
      [pool drain];
    }
    else {
      [lock unlock];
    }
    if(downloading == NO) {	// LoopでCPU率があがらないように少しsleep
      [NSThread sleepForTimeInterval:0.01f];
    }
  }
  // 完了の通知
  if(delegate == nil)
    return;
  if([delegate respondsToSelector:@selector(didAllCompleted)] ) {
    [lock lock];
    if(stoppingRequired) {
      [lock unlock];
    }
    else {
      [lock unlock];
      [delegate didAllCompleted];
    }
  }
  [lock lock];
  completed = YES;
  [lock unlock];
}

- (void) finishDownload:(QueuedURLDownloaderElem *)elem {
  NSURL *URL = elem.URL;
  [lock lock];
  if(elem) {
    [runningDict removeObjectForKey:URL];
    [elem release];
    completedCount += 1;
  }
  [lock unlock];
}

- (NSInteger)waitingCount {
  NSInteger result;
  [lock lock];
  result = [waitingQueue count];
  [lock unlock];
  return result;
}


- (NSInteger)runningCount {
  NSInteger result;
  [lock lock];
  result = [runningDict count];
  [lock unlock];
  return result;
}


- (void) dealloc {
  if(delegate) {
    [delegate release];
    delegate = nil;
  }
  [waitingQueue release];
  [runningDict release];
  [lock release];
  [super dealloc];
}

@end

メモリリーク対応

NSURLConnectionの処理の中で、autoReleaseオブジェクトのMemory Leak があるようなので、runメソッド内にAutoReleaseプールを追加(2010.4.28)

同期処理の問題

ダウンロード中に、Viewが閉じるなどをする場合は、処理の中断の要求をしたいので、そのためのrequireStoppingメソッドを追加。
それと、中断しきれないうちにオブジェクトの破棄がされるのを防ぐため、中断or完了を待ってブロックするwaitCompletedメソッドを追加。(2010.6.2)

使用手順(ダウンロードを起動する側)

初期化時に同時にダウンロードする最大を指定,
addURL:withUserInfoでダウンロード対象のURLをQueueに登録してダウンロードを行っていく。
この登録を行うことにより、非同期にダウンロードが行われる.
QueuedURLDownloaderDelegateプロトコルを実装したDelegateクラスの
メソッド(didFinishLoading:withUserInfo)によりダウンロードした内容を取得する。

以下の手順で、ダウンロードの処理を起動、終了をさせる。

    1. このクラスインスタンスの初期化.
    2. delegateにQueuedURLDownloaderDelegateプロトコルを実装したインスタンスを設定.
    3. startメソッドでダウンロード開始(URLが追加されたらダウンロードがはじめる状態にする).
    4. ダウンロード対象のURLを追加していく(addURL:withUnserInfo メソッド).
    5. finishメソッドでこれ以上、ダウンロードするものがないことを通知.

例はこんな感じ

 // 初期化
 QueuedURLDownloader *downloader = [[QueuedURLDownloader alloc] initWithMaxAtSameTime:3];
 downloader.delegate = self;  // QueuedURLDownloaderDelegateプロトコルを実装したもの
 // 開始
 [downloader start];
 //
 NSDictionary *dict;
 dict = [[NSDictionary alloc] 
		  initWithObjectsAndKeys:@"value1, @"key1", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString1 ]
	withUserInfo:dict];
 dict = [[NSDictionary alloc] 
	initWithObjectsAndKeys:@"value2, @"key2", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString2 ]
	withUserInfo:dict];
 dict = [[NSDictionary alloc] 
	initWithObjectsAndKeys:@"value3, @"key3", nil] ;
 [downloader addURL:[NSURL URLWithString:urlString3 ]
 withUserInfo:dict];
 ...
 // これ以上、ダウンロードするものがないことを通知
 [downloader finishQueuing];

使用手順(ダウンロードなどの通知を受けるがわ)

Delegateは、1要素(URL - ファイル)のダウンロードが完了したさいの通知メソッド、エラー完了した場合の通知メソッドが必須となる。

/*!
 ダウンロード完了時の通知
 */
- (void)didFinishLoading:(NSData *)data withUserInfo:(NSDictionary *)info {
  // 引数dataがダウンロードされたデータなので、表示するなり、ファイル保存するなり...
}

/*!
 ダウンロードエラー時の通知
 */
- (void)downloadDidFailWithError:(NSError *)error withUserInfo:(NSDictionary *)info {
  
}

あと、UIViewControllerなどからこのダウンロード機能を使っている場合、ダウンロード中にViewが破棄されるとダウンロードオブジェクトも破棄されて不正メモリアクセスが起こったりする。
それの回避のためには、Viewが非表示になるタイミングなど(viewDidDisappear:)に、中断の要求と完待ちなどをする。

- (void)viewDidDisappear:(BOOL)animated {
  // Download実行中の場合,停止を要求、完了するまで待つ
  if(downloader) {
    [downloader requireStopping];
    [downloader waitCompleted];
  }
}