Objective-Cで大きなデータをストリーム処理する

筆者があるiOSアプリケーション（Objective-Cで書かれている）の改修を行なっていた際に、「大きなファイルデータをアプリ内で取得し、転送などの処理に使いたい」という課題があり、Objective-Cで大きなファイルデータを扱う時にどんな手段があるかを調べたのですが、ことのほか苦労を強いられたためここに備忘録として載せておこうと考えました。同様のことを試みている方の一助になれば幸いです。

## ケーススタディ
以下のようなコードがあり、```getDataFromSource```（ライブラリ等で与えられているとお考えください）で取得できるファイルデータをストリームで一つにまとめて、別の処理に渡したい、という状況で```getData```の処理内容を考えます。
なお、この方法で取得したファイルは端末に保存する必要はありません。

```objective_c:ExampleCode.m
- (void)getData:(int)fileNo
       fileSize:(UInt64)fileSize
    onCompleted:(nonnull void (^)(NSData *))onCompleted
        onError:(nonnull void (^)(NSError *))onError {
    [self getDataFromSource:fileNo
                onCompleted:^{
        /* ここでonCompleted()にoutputStreamでまとめたデータを設定したい */
    } onError:^(NSError *error){
        /* エラーログ記録やエラーハンドリングは略 */
    } onUpload:^(NSData *data){
        /* ここでは[outputStream write:[data bytes]]したい */
    }];
}

/**
 * fileNoに対応するファイルデータを逐次的に取得する。
 * onUpdateのたびにデータのチャンク（一部分）が取得されるので、全て結合した物を最終的なデータとする。
 * @param fileNo ファイルデータを取得したいファイル番号
 * @param onCompleted データ取得が完了したときに呼ばれる
 * @param onError 取得中にエラーが発生したときに呼ばれる
 * @param onUpdate データのチャンクが取得成功するたびに呼ばれる
 */
- (void)getDataFromSource:(int)fileNo
              onCompleted:(void (^)(void))onCompleted
                  onError:(void (^)(NSError *))onError
                 onUpdate:(void (^)(NSData *))onUpdate {
    /* 処理略。 */
}
```

## Objective-Cの入出力ストリーム
[```NSStream```](Apple:https://developer.apple.com/documentation/foundation/nsstream)に属する```NSInputStream```, ```NSOutputStream```はそれぞれ入力（ファイルの読み込み）と出力（書き出し）を行うのに特化しています。そのため```NSOutputStream```は書き込み単独ならできるのですが、書き込んだ結果のデータを読み込んで別途使いたい場合には、例えば```[[NSOutputStream alloc] initToBuffer:buffer capacity:fileSize]```というように書き込み先のバッファ(```buffer```)を用意するか、```[[NSOutputStream alloc] initToFileAtPath:somePath append:YES]```などとしてファイルパスを指定して```NSOutputStream```を初期化する、などといった方法が必要です。

### inputStreamとoutputStreamの「ペア」を使う
とはいえ、初めはデータだけが取れれば良いならファイルを使用する方向性は考えておらず、ストリームで取得するデータに対し```NSInputStream```, ```NSOutputStream```の両方が同時に定義できて相互に使用できるものがないかを調べたところ、WebSocketでの利用などに特化したペアの作り方については比較的解説が多くあったのですが、今回はそうではなく任意のファイルサイズを返しうるストリームから取得をしたいので、[```CFStreamCreateBoundPair```](https://developer.apple.com/documentation/corefoundation/1539710-cfstreamcreateboundpair)を使うことを考えました（検索してみたものの実装例があまりに少なかったので、さほどは使われていないようです）。

これを用いて```getData```の処理を書いてみたのが、下記となります。

```objective_c:ExampleCode1.m
#import <Foundation/Foundation.h>
#import "ExampleCode1.h"

@implementation ExampleCode1

- (void)getData:(int)fileNo
       fileSize:(UInt64)fileSize
    onCompleted:(nonnull void (^)(NSData *))onCompleted
        onError:(nonnull void (^)(NSError *))onError {
    CFReadStreamRef readStream = NULL;
    CFWriteStreamRef writeStream = NULL;
    // このペア生成でoutputStreamで書き込んだデータがinputStreamで使えるようになる
    CFStreamCreateBoundPair(NULL, &readStream, &writeStream, fileSize);
    __block NSInputStream *inputStream = (__bridge_transfer NSInputStream *)readStream;
    __block NSOutputStream *outputStream = (__bridge_transfer NSOutputStream *)writeStream;
    // ExampleCode1は、header側でNSStreamDelegateを継承しているものとする
    inputStream.delegate = self;
    outputStream.delegate = self;
    [inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    [outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    [inputStream open];
    [outputStream open];
    
    UInt64 readSize = 1024 * 128; // 128KB。大きすぎる値を指定するとcrashした
    
    [self getDataFromSource:fileNo
                onCompleted:^{
        NSMutableData *data = [NSMutableData data];
        uint8_t buffer[readSize];
        NSUInteger result;
        // 指定したサイズでファイルを読み込みdataに追加して、最終成果物をコールバックに返す。
        for (UInt64 i = 0 ; i < 1 + (fileSize / readSize) ; i++) {
            result = [inputStream read:buffer maxLength:readSize];
            if (result > 0) {
                [data appendBytes:buffer length:result];
            }
        }
        onCompleted(data);
        [inputStream close];
        [outputStream close];
    } onError:^(NSError *error) {
        [inputStream close];
        [outputStream close];
        onError(error);
    } onUpdate:^(NSData *data) {
        // チャンクの書き込みを行う。
        [outputStream write:[data bytes] maxLength:data.length];
    }];
}

- (void)getDataFromSource:(int)fileNo
              onCompleted:(void (^)(void))onCompleted
                  onError:(void (^)(NSError *))onError
                 onUpdate:(void (^)(NSData *))onUpdate {
    /* 処理略。 */
}

@end
```

この方法であれば、ファイルサイズの大きさがそれなりの場合であっても実行することができますが、当然ながら端末のメモリを超える容量のファイルは取り扱えません。例えばiPhone 7（メモリ2GB）で2GBを超えるファイルの書き込みを行おうとしても、適切なバッファが確保できず、```inputStream, outputStream```が作られないので処理が空振りしてしまいます。

### 一時フォルダに書き出す

結局のところ、メモリ以上のサイズのファイルを扱う想定であれば、端末のアプリ用の一時フォルダ(```NSTemporaryDirectory```)内にファイルを生成して、処理の終了時、あるいはアプリの強制終了時に削除されるようにしておくのが一番簡単な方法になります。

```objective_c:ExampleCode2.m
#import <Foundation/Foundation.h>
#import "ExampleCode2.h"

@implementation ExampleCode2

- (void)getData:(int)fileNo
       fileSize:(UInt64)fileSize
    onCompleted:(nonnull void (^)(NSString *))onCompleted // ファイルパスを渡すためNSStringに変更
        onError:(nonnull void (^)(NSError *))onError {
    NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"%d.temp", fileNo]];
    __block NSOutputStream *outputStream = [[NSOutputStream alloc] initToFileAtPath:path append:YES];
    outputStream.delegate = self;
    [outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    [outputStream open];
    
    [self getDataFromSource:fileNo
                onCompleted:^{
        onCompleted(path);
        [outputStream close];
    } onError:^(NSError *error) {
        [outputStream close];
        onError(error);
    } onUpdate:^(NSData *data) {
        // チャンクの書き込みを行う。
        [outputStream write:[data bytes] maxLength:data.length];
    }];
}

- (void)getDataFromSource:(int)fileNo
              onCompleted:(void (^)(void))onCompleted
                  onError:(void (^)(NSError *))onError
                 onUpdate:(void (^)(NSData *))onUpdate {
    /* 処理略。 */
}

@end
```

ただし、ExampleCode2の方法を使う上で、もう一つ注意しないと引っかかるポイントがあり、例えばこの方法で生成したファイルに対して分割アップロード機能を実行したいなどという目的で、1つのチャンクがメモリサイズ以内に収まるように分割して送信したいという場合であっても、```NSInputStream```インスタンスを生成しようとしたその時点でout of memoryが発生してアプリが強制終了してしまいます。

```objective_c:ExampleCode3.m
#import <Foundation/Foundation.h>
#import "ExampleCode2.h"
#import "ExampleCode3.h"

@implementation ExampleCode3

- (void)doChunkUpload:(int)fileNo {
    ExampleCode2 *example = [[ExampleCode2 alloc] init];
    
    // ファイルサイズと分割数は本来はファイルに応じて設定するが、特に今回の議論で重要でないのでマジックナンバー
    UInt64 fileSize = 3000000000;
    int splitSize = 13;
    
    [example getData:fileNo
            fileSize:fileSize
         onCompleted:^(NSString *path){
        NSInputStream *inputStream = [[NSInputStream alloc] initWithFileAtPath:path];
        inputStream.delegate = self;
        [inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
        [inputStream open];
        for (int i = 0 ; i < splitSize ; i++) {
            UInt64 chunkSize = fileSize / splitSize;
            // 割り算の余り分は最後のチャンクに移す
            if (i == splitSize - 1) {
                chunkSize += fileSize % splitSize;
            }
            uint8_t *buffer = malloc(sizeof(UInt8) * chunkSize);
            [inputStream read:buffer maxLength:chunkSize];
            NSData* chunkData = [NSData dataWithBytes:buffer length: chunkSize];
            /* chunkDataを分割アップロード処理にかける */
        }
        /* 一時ファイル削除の後処理は略 */
    } onError:^(NSError *error) {
        /* エラーハンドリング、一時ファイル削除の後処理は略 */
    }];
}

@end
```
分割処理をしたい場合、[```NSFileHandle```](https://developer.apple.com/documentation/foundation/nsfilehandle/)を使用すれば、メモリにファイルの全量を読み出すことを回避できます。
```objective_c:ExampleCode4.m
#import <Foundation/Foundation.h>
#import "ExampleCode2.h"
#import "ExampleCode4.h"

@implementation ExampleCode4

- (void)doChunkUpload:(int)fileNo {
    ExampleCode2 *example = [[ExampleCode2 alloc] init];
    
    UInt64 fileSize = 3000000000;
    int splitSize = 13;

    [example getData:fileNo
            fileSize:fileSize
         onCompleted:^(NSString *path){
        UInt64 offset = 0;
        for (int i = 0 ; i < splitSize ; i++) {
            UInt64 chunkSize = fileSize / splitSize;
            // 割り算の余り分は最後のチャンクに移す
            if (i == splitSize - 1) {
                chunkSize += fileSize % splitSize;
            }
            // ここをNSFileHandleで取得するように変更
            NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:path];
            NSData* chunkData = [fileHandle readDataOfLength:chunkSize];
            /* chunkDataを分割アップロード処理にかける */
            // offsetの値を読み込んだチャンクファイルのサイズ分だけ動かす
            offset += [chunkData length];
            [fileHandle seekToFileOffset:offset];
        }
        /* 一時ファイル削除の後処理は略 */
    } onError:^(NSError *error) {
        /* エラーハンドリング、一時ファイル削除の後処理は略 */
    }
    ];
}

@end
```

Swiftで同様のことを実施したいのであれば、ExampleCode2, 4の方法を実践するのが良いでしょう（その場合、接頭辞に```NS```のあるクラスは、それがついていないクラスを使うことになるかと思いますが、使い方はほぼ同じです。ただし[```FileHandle```](https://developer.apple.com/documentation/foundation/nsfilehandle?language=objc)については、執筆時点で同名のメソッドがdeprecatedになっているものがあるので、読み込みとオフセットの移動をそれぞれ```readDataUpToLength: error: ```/```seekToOffset: error: ```で読み替えてください）。