おおばログ

おっさんプログラマーのブログ。

課金コンテンツをAppleに預ける - How to implement "Hosting Content with Apple"

iOSアプリ内課金についての記事です。

前置き

iOSのアプリ内課金を実装するとき、プロダクト(課金アイテム)をどのように提供するか、検討が必要になります。そうはいっても採択できる方法は次の2つになるのですが。

  1. プロダクトをアプリに内包し課金後それが使用できる実装にする
  2. プロダクトをアプリ外(公開サーバ等)に配置し課金後にダウンロードし使用できるようにする

前者は、軽量なコンテンツを提供する場合や、課金アイテムの実体が要らない場合で採用されることが多いです。

対して後者は、比較的大きなコンテンツを提供するときに使われます。たとえばコンテンツがゲームの追加ステージの場合、ステージの画像データ、BGMなどが含まれるでしょう。それらをアプリに内包してしまうと、アプリのサイズが肥大化、アプリダウンロードに問題が生じる可能性があります(モバイル回線でダウンロード可能なiOSアプリのサイズは100MBまでです)。

例として、iOSアプリの「太鼓の達人」は、アプリ内課金で楽曲を追加購入することができ、購入後に楽曲データを公開サーバからダウンロードしています。
f:id:tworks:20140103181245j:plain

コンテンツ管理サーバを用意する?

さて、プロダクトをアプリ外に配置し課金後にダウンロードできるようにするためには、プロダクトの配置先となる公開サーバが必要になります。またプロダクトを単純に配置してしまうと、購入していないユーザもダウンロードできてしまいますので、正規購入者だけがダウンロードできるようなセキュリティーの実装(Webアプリケーションの実装など)が必要になります。
f:id:tworks:20140103182226p:plain
しかし公開サーバの運用は、多くの場合でコストがかかります。私もそうですが、個人の開発者にとってこれが足かせになることが多くあるように思います。そしてこれがボトルネックとなり、プロダクトの提供が思うようにできないことがあるかもしれません。

コンテンツ管理サーバが不要な "Hosting Content with Apple"

このボトルネックについてAppleが解決策を提供しています。ダウンロードさせるプロダクトをAppleのサーバに預ける(Hostingする)ことができる "Hosting Content with Apple" です。"Hosting Content with Apple" を利用することで、開発者は公開サーバの運用やセキュリティーの実装を省略でき、iOSアプリの開発だけに集中することができます。
f:id:tworks:20140103182948p:plain

手順

"Hosting Content with Apple" は、次の手順と開発生産物で利用できるようになります。

  1. iTunes Connectに "Hosting Content with Apple" のプロダクトを登録する
  2. Xcodeで "Hosting Content with Apple" のプロダクトを生成
  3. Xcodeで "Hosting Content with Apple" のプロダクトをiTunes Connectへアップロード
  4. アプリ内課金の実装に、"Hosting Content with Apple"プロダクトのダウンロードを追加

1. iTunes Connectに "Hosting Content with Apple" のプロダクトを登録する

iTunes Connectから「消耗型プロダクト」「非消耗型プロダクト」を登録する手順とほぼ変わりませんが、現状 "Hosting Content with Apple" に対応しているのは「非消耗型プロダクト」だけです。

iTunes Connectにログインし [Manage Your Apps] --> [アプリ選択] --> [Manage In-App Purchases] --> [Create New]と進み、新規プロダクトの登録に進みます。そして「非消耗型プロダクト」を登録するため [Non-Consumable]を選択します。
f:id:tworks:20140103184325p:plain

[Product ID]や[Price Tier]を入力していきます。今回はプロダクト購入後に画像データをダウンロードさせる想定で "Picture" というIDを付けました。
f:id:tworks:20140103184524p:plain

続いて[Language]と[Hosting Content with Apple]を設定していきます。多くの例では[Hosting Content with Apple]に"No"を指定しますが、今回は"Yes"にします。
f:id:tworks:20140103184909p:plain

あとは適当なスクリーンショットをアップロードして登録完了です。登録済みプロダクトの一覧に"Picture"が追加され[Status]が"Waiting for Upload"(プロダクトのアップロード待ち)になっていることを確認しましょう。
f:id:tworks:20140103185127p:plain

iTunes Connectへのプロダクト登録は以上です。

2. Xcodeで "Hosting Content with Apple" のプロダクトを生成

ここからXcodeを使って実装を進めます。まずはアップロードするプロダクトを作ります。
新規Projectのテンプレートは[iOS] --> [Other] --> [In-App Purchase Content]を選択します。
f:id:tworks:20140103185506p:plain

[Product Name]と[Company Identifier]を入力します。ここは任意の値で構いません。
f:id:tworks:20140103191101p:plain
これでProjectが生成できました。

Projectの初期状態では [Product Identifier]が [Company Identifier]+[Product Name]になっています。
f:id:tworks:20140103191447p:plain
実はここが重要で、この値はiTunes Connectに登録したときの[Product ID]に合わせる必要があります。そこでここを書き換えていきます。

[ContentInfo.plist]を選択し[IAPProductIrentifier]を参照します。
f:id:tworks:20140103191543p:plain
"${PRODUCT_NAME:rfc1034identifier}"となっている初期値を、iTunes Connectに登録した[Product ID]と同じ "Picture" という固定値に書き換えます。
f:id:tworks:20140103191806p:plain

そして、購入後にダウンロードさせたいコンテンツ(=ファイル)を追加します。[Supporting Files]グループの中にファイルを追加します。データのフォーマットやファイル名は任意で構いません。
f:id:tworks:20140103191905p:plain

ビルドしてエラーが出なければ、プロダクトの生成は完了です。

3. Xcodeで "Hosting Content with Apple" のプロダクトをiTunes Connectへアップロード

引き続きXcodeで続けます。メニューから[Product] --> [Archive]を選択し、アップロードするコンテンツを出力します。
f:id:tworks:20140103192439p:plain

Archive後に[Organizer] --> [Archives]を表示すると、Archive出力されていることが確認できます。また[Status]列が空になっていると思います。
f:id:tworks:20140103192703p:plain

Archiveしたコンテンツをアップロードしていきます。Organizerの[Distribute]をクリックします。
f:id:tworks:20140103192806p:plain

"Select the method of distribution:"(配布方法の選択)は[Submit in-app purchase content]を選択します。
f:id:tworks:20140103192957p:plain

"Log in to iTunes Connect:" ではiTunes Connectにプロダクトを登録したときと同じアカウント情報を入力します。
f:id:tworks:20140103193121p:plain

"Choose in-app purchase content record:" では、iTunes Connectに登録済みのHosting Content with AppleがYesになっているProduct IDが表示されます。アップロード先のProduct IDを選択します。
f:id:tworks:20140103193318p:plain

[Submit]をクリックするとアップロードが始まり、しばらくすると完了します。
f:id:tworks:20140103193404p:plain
"No issures ware found"(問題無し)のメッセージが出ていればアップロード完了です。

Organizerに戻り、Archiveの[Status]が"Submitted"になっていますね。
f:id:tworks:20140103193624p:plain
またiTunes Connectも"Processing Content"に変わっているはずです。
f:id:tworks:20140103193700p:plain

プロダクトのアップロードはこれで完了です。*1

4. アプリ内課金の実装に、"Hosting Content with Apple"プロダクトのダウンロードを追加

シーケンスは次のようになります。
f:id:tworks:20140103194340p:plain
SKpaymentQueueのstartDownloadsをコールするとStoreKitによりダウンロードが実行され、ダウンロードの状況やダウンロードしたデータが (void)paymentQueue:queue updatedDownloads:downloads に通知されます。

まずアプリ内課金の購入完了後に、ダウンロード処理を追加します。
(これより前の処理についてはこの本をご覧ください http://tworks.hatenablog.jp/entry/2014/01/01/000500 )

// StoreKit
// 購入、リストアなどのトランザクションの都度、通知される
- (void)   paymentQueue:(SKPaymentQueue *)queue 
 updatedTransactions:(NSArray *)transactions {
    NSLog(@"paymentQueue:updatedTransactions");
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
                
        // 購入処理完了
        case SKPaymentTransactionStatePurchased:
        {
            NSLog(@"SKPaymentTransactionStatePurchased");
            
            // ProductがHosting Contentなのか判定
            if (transaction.downloads) {
                // Hosting Contentなのでダウンロードを行う
                [queue startDownloads:transaction.downloads];
            }
            else {
                // Hosting Contentではないので、購入を完了する
                [queue finishTransaction:transaction];
            }
            break;
        }
        …

Hosting Contentに対応したプロダクトはtransaction.downloadsがYesになります。それをもってダウンロードを行うか否かを判定します。

ダウンロードが始まると、その状況が paymentQueue:queue updatedDownloads:downloads に通知されます。ダウンロードの状況は download.downloadState を見て判定することができます。ダウンロード進行中は"SKDownloadStateActive"、ダウンロード完了で"SKDownloadStateFinished"となります。

// ダウンロード通知処理
- (void)paymentQueue:(SKPaymentQueue *)queue
    updatedDownloads:(NSArray *)downloads {
    for (SKDownload *download in downloads) {
        if (download.downloadState == SKDownloadStateFinished) {
            [self processDownload:download]; // ダウンロード処理
            //
            [queue finishTransaction:download.transaction];
        }
        else if (download.downloadState == SKDownloadStateActive) {
            NSTimeInterval remaining = download.timeRemaining; // secs
            float progress = download.progress; // 0.0 -> 1.0
            NSLog(@"%lf%% (残り %lf 秒)", progress, remaining);
        }
        else { // waiting, paused, failed, cancelled
            NSLog(@"ダウンロード一時停止またはキャンセルを検出: %ld", (long)download.downloadState);
        }
    }
}

ダウンロード完了時、コンテンツを保存するようにしましょう。ダウンロード先のPathと保存先のPathを作成し、ファイルを保存、保存先のPathを設定(NSUserDefaults)に記憶していきます。

- (void)processDownload:(SKDownload *)download {
    // NSFileManager
    NSString *path = [download.contentURL path];
    
    // ダウンロードしたコンテンツのディレクトリ
    path = [path stringByAppendingPathComponent:@"Contents"];
    
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSError *error = nil;
    NSArray *files = [fileManager contentsOfDirectoryAtPath:path error:&error];
    NSString *dir = [self downloadableContentPath];
    
    for (NSString *file in files) {
        NSString *fullPathSrc = [path stringByAppendingPathComponent:file];
        NSString *fullPathDst = [dir stringByAppendingPathComponent:file];
        
        // 上書きできないので一旦削除
        [fileManager removeItemAtPath:fullPathDst error:NULL];
        
        // ダウンロード先から保存先へファイルを移動する
        if ([fileManager moveItemAtPath:fullPathSrc toPath:fullPathDst error:&error] == NO) {
            NSLog(@"Error: ファイルの移動に失敗: %@", error);
        }
        
        // 設定にプロダクトIDを保持
        [[NSUserDefaults standardUserDefaults] setObject:fullPathDst
                                                  forKey:download.transaction.payment.productIdentifier];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
}

- (NSString *)downloadableContentPath {
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
    NSString *directory = [paths objectAtIndex:0];
    directory = [directory stringByAppendingPathComponent:@"Downloads"];
    
    NSFileManager *fileManager = [NSFileManager defaultManager];
    
    if ([fileManager fileExistsAtPath:directory] == NO) {
        NSError *error;
        if ([fileManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:&error] == NO) {
            NSLog(@"Error: ディレクトリ作成失敗: %@", error);
        }
        
        NSURL *url = [NSURL fileURLWithPath:directory];
        // iCloud backupからダウンロードしたコンテンツを排除しないと、リジェクト対象になるので注意
        if ([url setResourceValue:[NSNumber numberWithBool:YES] forKey:NSURLIsExcludedFromBackupKey error:&error] == NO) {
            NSLog(@"Error: iCloud backup対象除外が失敗: %@", error);
        }
    }
    
    return directory;
}

あとは、保存したファイルをUI側で表示したりデータ参照するだけですね。
f:id:tworks:20140103201811p:plain

まとめ

"Hosting Content with Apple"は、このように手順と実装さえ押さえてしまえば、簡単に扱うことができるものです。使用するために追加で発生するコストもありません(iOS Developer Programのコストに含まれます)。これらから、課金コンテンツを外部サーバに配置するにあたり "Hosting Content with Apple" を使わない理由は特にないように思います。

最後に

・今回のサンプルはgithubに公開しています。
 https://github.com/tomohba/hosting_content_with_apple_sample
・文中のスルメイカ画像は id:ch3cooh393 製です。かわゆす。
iOSアプリ内課金の全般は、著者の著書をご覧ください。
 http://tworks.hatenablog.jp/entry/2014/01/01/000500

*1:プロダクトをアップロードした後、本プロダクトを販売するまでにAppleの審査を受ける必要があります