App内課金。
なかなか大変だが避けて通れない道。
手順を追っていこう。
◯契約
1. iTunes Connect へ行く。
「契約/税金/口座情報」のページへ行き、必要事項を記入。これについては長くなるので、ググって別記事を参照のこと。
これをしていなくても以降の手順は行えるが、実際に端末でテストした時に課金アイテム情報が取得できず、詰まることになるので注意!◯アイテム登録
1. iTunes Connect へ行く。
最近また変わったので、スクリーンショットも付けて。
まずアプリのページを開き、「App内課金」を選択。
2. App内課金の画面。
以下のようになっている(これはすでにいくつか課金アイテムを登録した後の画面)
Create New をクリック。
3. 課金タイプを選択
消耗型(Consumable)…何度でも購入できるアイテム。
非消耗型(Non-Consumable)…一度しか購入できないアイテム。アプリを削除しても、ユーザがリストアできるようになっていなければならない(後述)
その他は定期購読雑誌などに使うタイプの物。省略
4. 情報を登録
アイテム名、ID、価格、説明文を記述する。
またスクリーンショットも登録する。これは必須。無いとテストもできない。何度でも差し替えられるので、とりあえず何でもいいから画像を登録しておく。
5. 登録できたことを確認
Doneをクリックすると、App内課金のトップ画面に戻り、今つくったアイテムが表示される。
ステータスが「Ready to Submit」になっていればOK。
◯実装
Xcode側の実装
1. ライブラリ登録
StoreKit.framework
を追加
2.
とりあえずRootViewController.mmに実装したものが以下。
外部から呼び出されて駆動するイメージで書いている。
(本筋と関係のない一部メソッドは省略)
#import <UIKit/UIKit.h>
#import "RootViewController.h"
#import "AppInfo.h"
@interface RootViewController()<UIAlertViewDelegate, SKProductsRequestDelegate, SKPaymentTransactionObserver>
{
//purchase 関係
NSArray *allProducts;
PurchaseCallback purchaseCallback;
PurchaseCallback restoreCallback;
}
@end
@implementation RootViewController
- (void)dealloc {
[self purchaseUnregister];
}
#pragma mark - purchase
- (void)purchaseUnregister{
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
allProducts = nil;
}
- (void)purchaseRegister{
DNSLog(@"purchaseRegister start");
if (![SKPaymentQueue canMakePayments]) {
UIAlertView *alert =[[UIAlertView alloc]initWithTitle:@""
message:@"お使いの端末ではApp内課金の機能が制限されています。[設定]>[一般]>[機能制限]>[App内課金]をONに設定したうえで再度お試しください"
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil];
[alert show];
return;
}
//PaymentQueueにオブザーバー登録(これで課金処理の同期ができるようになる)
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
[self showIndicator:@"App Storeに問い合わせ中…"];
//productRequest
NSSet *productSet = [NSSet setWithArray:ALL_PRODUCT_IDS];
SKProductsRequest *productRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productSet];
productRequest.delegate = self;
[productRequest start]; //-(void)productsRequest: didReceiveResponse: が呼び出される
allProducts = nil;
DNSLog(@"purchaseRegister end");
}
#pragma mark - SKProductsRequestDelegate
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
DNSLog(@"productsRequest: didReceiveResponse: start");
#if DEBUG
NSLog(@"Valid PRODUCTS ");
for (SKProduct* prod in response.products) {
[self productDescription:prod];
}
NSLog(@"Invalid PRODUCTS");
for (NSString* prodId in response.invalidProductIdentifiers) {
NSLog(@"%@", prodId);
}
#endif
if ([response.products count] > 0){
allProducts = [[NSArray alloc] initWithArray:response.products];
}
else{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:nil message:@"App Storeに登録されたアイテムがありません。" delegate:self cancelButtonTitle:@"OK" otherButtonTitles:nil, nil];
[alert show];
}
[self hideIndicator];
//リストア処理を自動的に行う
//[self restoreItems];
DNSLog(@"productsRequest: didReceiveResponse: end");
}
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
DNSLog(@"request: didFailWithError: start");
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"エラー" message:[NSString stringWithFormat:@"App Storeに接続できませんでした。\n%@", [error localizedDescription]] delegate:self cancelButtonTitle:@"はい" otherButtonTitles:nil, nil];
[alert show];
[self hideIndicator];
DNSLog(@"request: didFailWithError: end");
}
- (void)requestDidFinish:(SKRequest *)request{
DNSLog(@"requestDidFinish: start");
[self hideIndicator];
DNSLog(@"requestDidFinish: end");
}
#pragma mark - 購入、リストアなどユーザアクション
- (void) buyItem:(NSString*)productId withCallback:(PurchaseCallback)callback{
DNSLog(@"buyItem: withCallback: start");
//待ち表示
[self showIndicator:@"購入処理をしています……"];
//productIdが実在していれば、PaymentQueueに追加
for (SKProduct *product in allProducts) {
if ([product.productIdentifier isEqualToString:productId]) {
SKPayment* payment = [SKPayment paymentWithProduct:product];
[[SKPaymentQueue defaultQueue] addPayment:payment]; //ここでトランザクション開始。payment:updatedTransactions:が呼ばれるようになる
DNSLog(@"addPayment (PRODUCT_ID=%@)", productId);
break;
}
}
purchaseCallback = callback;
DNSLog(@"buyItem: withCallback: end");
}
-(void) restoreItemsWithCallback:(PurchaseCallback)callback{
DNSLog(@"restoreItems start");
[self showIndicator:@"購入情報を復元中です……"];
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
restoreCallback = callback;
DNSLog(@"restoreItems end");
}
#pragma mark - SKPaymentQueue observerMethod
// Sent when the transaction array has changed (additions or state changes). Client should check state of transactions and finish as appropriate.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions{
DNSLog(@"paymentQueue: updatedTransactions: start");
for (SKPaymentTransaction *transaction in transactions) {
switch (transaction.transactionState) {
case SKPaymentTransactionStatePurchasing:
//nothing to do
break;
case SKPaymentTransactionStatePurchased:
[self completeTransaction:transaction];
break;
case SKPaymentTransactionStateFailed:
[self failedTransaction:transaction];
break;
case SKPaymentTransactionStateRestored:
[self restoreTransaction:transaction];
break;
case SKPaymentTransactionStateDeferred:
[self deferredTransaction:transaction];
break;
}
}
DNSLog(@"paymentQueue: updatedTransactions: end");
}
// Sent when transactions are removed from the queue (via finishTransaction:).
- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray *)transactions{
DNSLog(@"paymentQueue: removedTransactions: start");
[self hideIndicator];
DNSLog(@"paymentQueue: removedTransactions: end");
}
// Sent when an error is encountered while adding transactions from the user's purchase history back to the queue.
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error{
DNSLog(@"paymentQueue: restoreCompletedTransactionsFailedWithError: start");
[self hideIndicator];
DNSLog(@"paymentQueue: restoreCompletedTransactionsFailedWithError: end");
}
// Sent when all transactions from the user's purchase history have successfully been added back to the queue.
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue{
DNSLog(@"paymentQueueRestoreCompletedTransactionsFinished: start");
[self hideIndicator];
DNSLog(@"paymentQueueRestoreCompletedTransactionsFinished: end");
}
// Sent when the download state has changed.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedDownloads:(NSArray *)downloads{
DNSLog(@"paymentQueue: updatedDownloads: start");
DNSLog(@"paymentQueue: updatedDownloads: end");
}
#pragma mark - inner method for SKPaymentQueue observerMethod
- (void) failedTransaction: (SKPaymentTransaction *)transaction{
DNSLog(@"failedTransaction: start");
[self hideIndicator];
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
//エラーコード全種
//SKErrorUnknown,
//SKErrorClientInvalid, // client is not allowed to issue the request, etc.
//SKErrorPaymentCancelled, // user cancelled the request, etc.
//SKErrorPaymentInvalid, // purchase identifier was invalid, etc.
//SKErrorPaymentNotAllowed, // this device is not allowed to make the payment
//SKErrorStoreProductNotAvailable, // Product is not available in the current storefront
if (transaction.error.code != SKErrorPaymentCancelled) {
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"" message:@"購入できませんでした。\n再びお試しください。" delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil];
[alert show];
}
DNSLog(@"failedTransaction: end");
}
- (void) deferredTransaction: (SKPaymentTransaction *)transaction{
DNSLog(@"deferredTransaction: start");
[self hideIndicator];
if (transaction.error.code != SKErrorPaymentCancelled) {
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"" message:@"購入できませんでした。\n再びお試しください。" delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil];
[alert show];
}
DNSLog(@"deferredTransaction: end");
}
//リストアしたときのトランザクション
- (void) restoreTransaction: (SKPaymentTransaction *)transaction{
DNSLog(@"restoreTransaction: start");
DNSLog(@"productId = %@ x %ld", transaction.payment.productIdentifier, (long)transaction.payment.quantity);
//リストア時処理コールバック呼び出し
NSString *productId = transaction.payment.productIdentifier;
const char *productId_c = [productId cStringUsingEncoding:NSUTF8StringEncoding];
if (restoreCallback) {
DNSLog(@"restoreCallback start");
restoreCallback(productId_c); //コールバック呼び出し。ここでリストア時処理を行うこと
DNSLog(@"restoreCallback end");
}
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
[self hideIndicator];
DNSLog(@"restoreTransaction: end");
}
//購入に成功したトランザクションを処理
// 非消費アイテムを再度購入しようとし、無料で復元された場合もここに来る
- (void) completeTransaction: (SKPaymentTransaction *)transaction{
DNSLog(@"completeTransaction: start");
DNSLog(@"productId = %@ x %ld", transaction.payment.productIdentifier, transaction.payment.quantity);
//購入時処理コールバック呼び出し
NSString *productId = transaction.payment.productIdentifier;
const char *productId_c = [productId cStringUsingEncoding:NSUTF8StringEncoding];
if (purchaseCallback) {
DNSLog(@"purchaseCallback start");
purchaseCallback(productId_c); //コールバック呼び出し。ここで購入時処理を行うこと
DNSLog(@"purchaseCallback end");
}
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
[self hideIndicator];
DNSLog(@"completeTransaction: end");
}
@end
以下、解説
◯SKProductsRequest
◯SKProductsResponse
プロダクト情報をAppleに問い合わせるためのクラス。
initWithProductIdentifiers: メソッドでプロダクトIDの配列を渡してインスタンスを作成する。
start: メソッドを呼ぶと問い合わせを開始する。
delegateをセットしたインスタンス(id
)の以下のメソッドが呼ばれる。
・問い合わせに成功した場合
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response;
responseには、正常に情報が取れたSKProductの配列(products)と、情報が取れなかったIDの配列(invalidProductIdentifiers)が入っている。
・失敗したとき場合
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error;
・完了した場合(成功したあとにも呼ばれる)
- (void)requestDidFinish:(SKRequest *)request;
◯SKProduct
課金アイテムの情報を持つクラス。メソッドは持っていない。
主なプロパティは以下。
//現地語の説明文
localizedDescription
//現地語のアイテム名
localizedTitle
//金額
price
//金額のロケール
priceLocale
//ID
productIdentifier
国ごとの金額を文字列で取得する場合(日本では120円、アメリカでは$1、のようなこと)
priceとpriceLocaleを組み合わせて以下のように記述する。
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
[numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
[numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
[numberFormatter setLocale:product.priceLocale];
NSString* priceString = [numberFormatter stringFromNumber:product.price];
◯SKPayment
Apple側に、購入する内容を伝えるためのクラス。プロダクトIDや数量をプロパティに持つ。
インスタンスを生成するときは、SKProductを引数に取る。
SKPaymentをSKPaymentQueueにaddPaymentすることで、購入トランザクション(SKPaymentTransaction)が作成される。
◯SKPaymentTransaction
1つの購入トランザクション。
メソッドは持っておらず、プロパティの集合体のクラス。
主なプロパティは以下。
//エラー内容。エラー発生時のみ
error
//このトランザクションを作成するときに使ったSKPaymentの内容
payment
//トランザクションの状態。列挙値は先述
transactionState
//トランザクションID
transactionIdentifier
//レシート。iOS8以降は非推奨となった
transactionReceipt (iOS 7.0)
//トランザクション完了日時。成功/リストア完了した場合だけ有効な値が入る
transactionDate
//SKDownloadの配列。ダウンロードできるデータの情報。購入成功時だけ有効な値が入る
downloads
//元のトランザクション。リストア完了時だけ有効な値が入る
originalTransaction
◯SKPaymentQueue
購入トランザクション(SKPaymentTransaction)を管理するクラス。
・購入可能かのチェック
[SKPaymentQueue canMakePayments]
で、このアプリが課金アイテム購入ができるかどうかチェック。
・オブザーバー登録
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
でオブザーバーを登録する。トランザクションに変化が起きたとき、オブザーバーに対して通知が行く(メソッドが呼ばれる)。delegateのようなもの。
selfは idであること。
オブザーバーがいないと、例えば購入が完了してもそれを関知できない。つまり課金だけしてその結果をアプリ内に反映できなかったりする。
だから必ずオブザーバーはつけておく。
バックグラウンドに行ったときにオブザーバーを削除するような実装もあるが、購入トランザクションの途中でバックグラウンドに行き、バックグラウンドの状態で購入完了してしまうと、まさに上記の現象になってしまう。
だからバックグラウンドに行ってもオブザーバーを削除しない方がいい。どうしても削除するなら、SKPaymentQueue内のトランザクションをすべて終了させてから。(ただし購入途中のトランザクションに対してfinishTransaction:を実行すると例外が投げられるので注意)
・購入トランザクションの開始
購入を始めるとき(ユーザが購入ボタンを押したとき)は、
[[SKPaymentQueue defaultQueue] addPayment:payment];
とする。paymentはSKPaymentクラスのインスタンス。
・リストアを行う
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
これですべての購入済み非消耗型アイテムのトランザクションを元に購入情報をAppleに問い合わせる。
・購入トランザクションの終了
トランザクションを終了するとき(正常に購入できた、正常にリストアできた、エラーが起きて終了するとき)は
[[SKPaymentQueue defaultQueue] finishTransaction: transaction];
とする。
・購入トランザクションに変化が起きたとき
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions;
メソッドが呼ばれる。これはSKPaymentTransactionObserver プロトコルのメソッド。
何が起きたかは、各トランザクションのtransactionStateを見れば分かる。
SKPaymentTransactionState 列挙子の値一覧
//購入中
SKPaymentTransactionStatePurchasing, // Transaction is being added to the server queue.
//購入成功
SKPaymentTransactionStatePurchased, // Transaction is in queue, user has been charged. Client should complete the transaction.
//購入失敗
SKPaymentTransactionStateFailed, // Transaction was cancelled or failed before being added to the server queue.
//リストア成功
SKPaymentTransactionStateRestored, // Transaction was restored from user's purchase history. Client should complete the transaction.
//ペアレント許可待ち
SKPaymentTransactionStateDeferred NS_ENUM_AVAILABLE_IOS(8_0), // The transaction is in the queue, but its final status is pending external action.
それぞれの状態に応じた処理を行う。
成功ならアプリ内に反映。アイテム付与など。トランザクションは終了する。
リストアならアプリ内に反映。アイテム数を確認し、正常な値にセット。トランザクションは終了する。
失敗ならエラー表示。トランザクションは終了する。
許可待ちなら、メッセージ表示。
購入中なら何もしないで待つ。
・購入トランザクションが削除されたとき
- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray *)transactions;
が呼び出される。
finishTransaction: の結果として呼び出される。
・リストアがすべて終了したとき
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue;
が呼び出される。
1つ1つのアイテムのリストアは
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions;
において
transaction.transactionState == SKPaymentTransactionStateRestored で呼び出される。
すべてのアイテムが終わったら、このメソッドが呼び出されることになる。
・リストアが失敗したとき
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error;
これが呼ばれる。どのような対処が有効なのだろうか…
◯
流れで言うと、
iTunes Connectで設定したプロダクトID(前述のスクショで言うと、TEST_ITEM。com.yourcompany.app.TEST_ITEM とかではない。むしろこれだとSKProductResponseで情報が取れない)を起点に、
起動時(情報取得)
全プロダクトID
→SKProductRequest (initWithProductIdentifiers: PRODUCT_ID_LIST)
→SKProductResponse (- (void)productsRequest: didReceiveResponse:)
→SKProduct (response.products)
購入時
プロダクトID
→SKProduct (保持SKProductから検索)
→SKPayment ([SKPayment paymentWithProduct: product])
→SKPaymentQueueに追加 ([[SKPaymentQueue defaultQueue] addPayment:payment];)
→購入処理 (- (void)paymentQueue: updatedTransactions: で transactionState == SKPaymentTransactionStatePurchased)
→失敗処理(- (void)paymentQueue: updatedTransactions: で transactionState == SKPaymentTransactionStateFailed)
→許可待ち処理(- (void)paymentQueue: updatedTransactions: で transactionState == SKPaymentTransactionStateDeferred)
リストア時
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
→各リストア処理 (- (void)paymentQueue: updatedTransactions: で transactionState == SKPaymentTransactionStateRestored)
→全リストア完了通知 (paymentQueueRestoreCompletedTransactionsFinished:)
・どんなときリストアするか
消耗型アイテム以外を購入できる場合は、必ずリストア機能が必要だ。
リストアボタンを置く必要がある。(ないとリジェクト)
ゲームではリストアボタンを置く必要があるのか。
一度しか買えないけど、リストアする(端末間共有)することが必要かどうかはまた別問題。
例えば、取得経験値が永久2倍になるようなアイテム。ゲーム的に一度しか買えないようになっているものだが、これは非消耗型アイテムなのか。それとも、消耗型アイテムとして登録し、アプリ側で一度しか買えないように制限するのか。
非消耗型アイテムを「端末間共有されると想定される」ものとして定義するなら、この永久2倍アイテムは非消耗型アイテムではない。
消耗型アイテムとして登録するのが相応しい。
購入できる回数によって分けるということでは必ずしも無い。
非消耗型アイテムとして登録すると、ゲームの購入画面にリストアボタン(復元ボタン)を置かなければならなくなる。しかしそうすると、他の「消耗型アイテム」も復元されるはずではないか?とユーザは思ってしまう可能性がある(ユーザには消耗型、非消耗型の区別は見えないのだから当然だ)。そう考えると、すべてのアイテムを消耗型として作っておく方がユーザにとっても誤解がないということがあると思われる。
◯レシート検証
検証ロジックは開発者が各自で行うべきという考えになっている。
クラックされないようにするためだ。
レシートデータは、iOS7以前だと購入トランザクションの中に入っていて、SKPaymentTransactionStatePurchased だと正常なものが格納されている。以下で取得できる。
NSData* receiptData = transaction.transactionReceipt
iOS8以降だと、transactionReceiptは非推奨になっている。
どうするかというと、バンドル内に保存されているのでそれを読み出す。
NSBundle の appStoreReceiptURL というメソッドでパスが取得できる。
以下の流れになる。
NSURL *url = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [[NSFileManager defaultManager] contentsAtPath:url.path];
検証のために参考になるページ
Qiitaの記事公式ドキュメント◯動作確認の手順
まず、実機端末で行う事(シミューレーターでは不可)
1. メアドを作成し(gmailの+付きメアドでもいいかも)、iTunesConnectでsandboxテスターとして登録しておく
2. 実機端末で、AppStoreからサインアウトしておく
3. Xcodeからバイナリを転送。この時、開発用provisioningで作成したバイナリであること。本番用だとテスト用課金はできない
4. アプリを起動し、課金購入を行う。この時、アカウント情報を求められるので、さっき作ったアカウントを入力
5. 購入ダイアログが出る。[Environment: sandbox]と出ていれば、テスト環境での課金になっている
公式ドキュメント(日本語)PR