[Unity] IAP でiOSアプリ課金を実装(実装編)

iPhone

iOSにおいて、Unity IAP を使って課金処理を実装しました。色々なサイトを参考にさせていただいたのですが、実装してみないとわからない点が多くありましたので、複数回に分けて記事にまとめます。

  • In App Purchasing
    4.1.2
  • Unity
    2020.3.21.f1
  • iOS
    iPhone SE 15.1
    iPad mini (第五世代) 15.2

この記事ではIAPの実装についてまとめます。 セットアップ・課金機能概要、評価についてはこちらの記事を参照してください。




Unity IAP 実装説明

検索すると、Unity IAP のサンプルコードはたくさんあるのですが、いずれも不足している点があり、そのまま使用することができませんでした。

ここでは以下の条件での実装について説明します。

  • 非消費アイテムのみの取り扱い
  • レシートの検証はアプリ内で実施

IAP コード全容

まずソースコード全容を先に添付します。

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Security;
using UnityEngine.UI;

public class PurchaseManagerApple : MonoBehaviour, IStoreListener
{

	static IStoreController storeController;                        // Purchasing システムの参照
	static IExtensionProvider storeExtensionProvider;               // 拡張した場合のPurchasing サブシステムの参照
	static string productIDNonConsumable = "nonconsumable";			// 非消費型製品の汎用ID
	static string productNameAppleNonConsumable = "TestItem";       // Apple App Store identifier for the non-consumable product.

	public enum PURCHASE_STATE
	{
		NOT_PURCHASED = 0,
		PURCHASED = 1,
		PENDING = 2,
	};
	bool isInitialized = false;

	void Awake()
	{
		if (storeController == null) {
			// 初期化
			InitializePurchasing();
		}
	}

	public void InitializePurchasing()
	{
		// If we have already connected to Purchasing ...
		if (IsInitialized()) {
			// ... we are done here.
			return;
		}
		var module = StandardPurchasingModule.Instance();
		var builder = ConfigurationBuilder.Instance(module);
		builder.AddProduct(productIDNonConsumable, ProductType.NonConsumable, new IDs()
			{
				{ productNameAppleNonConsumable,       AppleAppStore.Name },
			});
		UnityPurchasing.Initialize(this, builder);
	}

	private bool IsInitialized()
	{
		return storeController != null && storeExtensionProvider != null;
	}

	public void BuyNonConsumable()
	{
		BuyProductID(productIDNonConsumable);
	}

	public void BuyProductID(string productId)
	{
		try {
			if (IsInitialized()) {
				Product product = storeController.products.WithID(productId);
				if (product != null && product.availableToPurchase) {
					Debug.Log(string.Format("Purchasing product asychronously: '{0}' - '{1}'", product.definition.id, product.definition.storeSpecificId));
					storeController.InitiatePurchase(product);
				}
				// Otherwise ...
				else {
					Debug.Log("BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
				}
			} else {
				Debug.Log("BuyProductID FAIL. Not initialized.");
			}
		} catch (Exception e) {
			Debug.Log("BuyProductID: FAIL. Exception during purchase. " + e);
		}
	}
	public void RestorePurchases()
	{
		if (!IsInitialized()) {
			Debug.Log("RestorePurchases FAIL. Not initialized.");
			return;
		}
		// RestorePurchases started
		Debug.Log("RestorePurchases started ...");
		// Fetch the Apple store-specific subsystem.
		var apple = storeExtensionProvider.GetExtension<IAppleExtensions>();
		// Begin the asynchronous process of restoring purchases. Expect a confirmation response in the Action<bool> below, and ProcessPurchase if there are previously purchased products to restore.
		apple.RestoreTransactions((result) => {
			// The first phase of restoration. If no more responses are received on ProcessPurchase then no purchases are available to be restored.
			Debug.Log("RestorePurchases continuing: " + result + ". If no further messages, no purchases available to restore.");
		});
	}
	PURCHASE_STATE checkAppleReceipt(string receipt)
	{
		PURCHASE_STATE resultstate = PURCHASE_STATE.NOT_PURCHASED;
		var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
			AppleTangle.Data(), Application.identifier);

		try {
			var result = validator.Validate(receipt);
			Debug.Log("Receipt is valid. Contents:");
			foreach (IPurchaseReceipt productReceipt in result) {
				Debug.Log(productReceipt.productID);
				Debug.Log(productReceipt.purchaseDate);
				Debug.Log(productReceipt.transactionID);
				AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt;
				if (null != apple) {
					Debug.Log(apple.originalTransactionIdentifier);
					Debug.Log(apple.subscriptionExpirationDate);
					Debug.Log(apple.cancellationDate);
					Debug.Log(apple.quantity);
					resultstate = PURCHASE_STATE.PURCHASED;
				} else {
					resultstate = PURCHASE_STATE.NOT_PURCHASED;
				}
			}
		} catch (IAPSecurityException) {
			Debug.Log("Invalid receipt, not unlocking content");
			resultstate = PURCHASE_STATE.NOT_PURCHASED;
		}
		return resultstate;
	}

	public PURCHASE_STATE GetPurchaseState()
	{
		PURCHASE_STATE resultstate;
		if (storeController != null) {
			if (storeController.products.all[0].hasReceipt) {
				resultstate = checkAppleReceipt(storeController.products.all[0].receipt);
			} else {
				resultstate = PURCHASE_STATE.NOT_PURCHASED;
			}
		} else {
			resultstate = PURCHASE_STATE.NOT_PURCHASED;
		}
		return resultstate;
	}

	//
	// --- IStoreListener
	//
	public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
	{
		Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}", product.definition.storeSpecificId, failureReason));
	}
	public string GetlocalizedPriceString()
	{
		string retstr = "";
		if (storeController != null) {
			byte[] bytesData = System.Text.Encoding.UTF8.GetBytes(storeController.products.all[0].metadata.localizedPriceString);
			if (bytesData[0] == 0xC2 && bytesData[1] == 0xA5) {
				retstr = "\\";
				retstr += storeController.products.all[0].metadata.localizedPriceString.Substring(1, storeController.products.all[0].metadata.localizedPriceString.Length - 1);
				retstr += "(" + storeController.products.all[0].metadata.isoCurrencyCode + ")";
			} else {
				retstr = storeController.products.all[0].metadata.localizedPriceString;
				retstr += "(" + storeController.products.all[0].metadata.isoCurrencyCode + ")";
			}
		} else {
			retstr = null;

		}
		return retstr;
	}
	public void OnInitializeFailed(InitializationFailureReason error)
	{
		Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
	}


	public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
	{
		if (String.Equals(args.purchasedProduct.definition.id, productIDNonConsumable, StringComparison.Ordinal)) {
			Debug.Log(string.Format("ProcessPurchase: PASS. Product: '{0}'", args.purchasedProduct.definition.id));
			checkAppleReceipt(args.purchasedProduct.receipt);
		} else {
			Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
		}
		return PurchaseProcessingResult.Complete;
	}

	public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
	{
		Debug.Log("OnInitialized: PASS");
		storeController = controller;
		storeExtensionProvider = extensions;
		var apple = storeExtensionProvider.GetExtension<IAppleExtensions>();

		if (storeController.products.all[0].hasReceipt) {
#if false
			Debug.Log("RefreshAppReceipt: START");
			apple.RefreshAppReceipt(result => {
				Debug.Log("RefreshAppReceipt OK");
				if (storeController.products.all[0].hasReceipt) {
					checkAppleReceipt(storeController.products.all[0].receipt);
				}
				isInitialized = true;
			},
			()=> {
				Debug.Log("RefreshAppReceipt Error");
				if (storeController.products.all[0].hasReceipt) {
					checkAppleReceipt(storeController.products.all[0].receipt);
				}
				isInitialized = true;
			});
#else
			checkAppleReceipt(storeController.products.all[0].receipt);	
#endif
		} else {
			isInitialized = true;
		}
	}
}

コード解説

ほぼAndroidと同じ実装なので、差分について説明します。Androidの実装はこちらの記事を参照してください。

レシートの検証

レシートの中身がPlay Storeのものと異なります。

	PURCHASE_STATE checkAppleReceipt(string receipt)
	{
		PURCHASE_STATE resultstate = PURCHASE_STATE.NOT_PURCHASED;
		var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
			AppleTangle.Data(), Application.identifier);

		try {
			var result = validator.Validate(receipt);
			Debug.Log("Receipt is valid. Contents:");
			foreach (IPurchaseReceipt productReceipt in result) {
				Debug.Log(productReceipt.productID);
				Debug.Log(productReceipt.purchaseDate);
				Debug.Log(productReceipt.transactionID);
				AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt;
				if (null != apple) {
					Debug.Log(apple.originalTransactionIdentifier);
					Debug.Log(apple.subscriptionExpirationDate);
					Debug.Log(apple.cancellationDate);
					Debug.Log(apple.quantity);
					resultstate = PURCHASE_STATE.PURCHASED;
				} else {
					resultstate = PURCHASE_STATE.NOT_PURCHASED;
				}
			}
		} catch (IAPSecurityException) {
			Debug.Log("Invalid receipt, not unlocking content");
			resultstate = PURCHASE_STATE.NOT_PURCHASED;
		}
		return resultstate;
	}

cancellationDatesubscriptionExpirationDateなどは返金とは関係なく、サブスク系アイテムの購入に関する情報が入るようです。ここでは非消費型アイテムが一つのみの例なので、レシートがあれば購入済みと判断しています。

初期化

初期化完了のコールバックでコメントアウトしていますが、RefreshAppReceipt()をコールしています。RefreshAppReceipt()はStoreのレシートを更新する処理で、リストアに似ていますが、レシートを持っている状態でコールする必要があるようです。

	public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
	{
		Debug.Log("OnInitialized: PASS");
		storeController = controller;
		storeExtensionProvider = extensions;
		var apple = storeExtensionProvider.GetExtension<IAppleExtensions>();

		if (storeController.products.all[0].hasReceipt) {
#if false
			Debug.Log("RefreshAppReceipt: START");
			apple.RefreshAppReceipt(result => {
				Debug.Log("RefreshAppReceipt OK");
				if (storeController.products.all[0].hasReceipt) {
					checkAppleReceipt(storeController.products.all[0].receipt);
				}
				isInitialized = true;
			},
			()=> {
				Debug.Log("RefreshAppReceipt Error");
				if (storeController.products.all[0].hasReceipt) {
					checkAppleReceipt(storeController.products.all[0].receipt);
				}
				isInitialized = true;
			});
#else
			checkAppleReceipt(storeController.products.all[0].receipt);	
#endif
		} else {
			isInitialized = true;
		}
	}

RefreshAppReceipt() を呼び出すことで、返金済みのレシートを削除してくれる効果があることが分かったのですが、 RefreshAppReceipt() の完了コールバックが呼ばれる前にアプリをタスクキルしたり、Application.Quit()でアプリを終了した際に二度とアプリが起動しなくなる現象が結構な再現頻度で発生しました。

Xcodeに接続してログを確認したところ、UnityPurchasing.Initialize()で初期化が完了せず、どこかで刺さっていました。

まだバグがあるようなので、怖くて使用していません。

リストア(復元)

復元処理と復元受付コールバックを実装しています。復元受付コールバックはほとんど意味をなしていません。リストアするレシートがある場合はProcessPurchase()で通知されます。通知されない場合は未購入ということになりますが、非同期で通知されるのが厄介です。

	public void RestorePurchases()
	{
		if (!IsInitialized()) {
			Debug.Log("RestorePurchases FAIL. Not initialized.");
			return;
		}
		// RestorePurchases started
		Debug.Log("RestorePurchases started ...");
		// Fetch the Apple store-specific subsystem.
		var apple = storeExtensionProvider.GetExtension<IAppleExtensions>();
		// Begin the asynchronous process of restoring purchases. Expect a confirmation response in the Action<bool> below, and ProcessPurchase if there are previously purchased products to restore.
		apple.RestoreTransactions((result) => {
			// The first phase of restoration. If no more responses are received on ProcessPurchase then no purchases are available to be restored.
			Debug.Log("RestorePurchases continuing: " + result + ". If no further messages, no purchases available to restore.");
		});
	}

ちなみに購入済みのアイテムを再度購入しても、購入済みである旨が表示され、課金はされません。

課題

Sandbox環境でしか購入したことがないのですが、アプリの返金の反映がされません。一度か二度、初期化時に返金済みのレシートが消えたのですが、なかなかうまく動いてくれません。

RefreshAppReceipt() で返金レシートが反映されたのですが、前述の通りアプリが起動できなくなる問題がありましたので、使用していません。

本番環境であればUnityPurchasing.Initialize()でレシートが同期されるのか、確認が必要です。

おわりに

Androidに比べるとリストアが必要だったり、返金済みレシートが反映されなかったり、色々と手間や課題が多いです。ただ、コンビニ払いのような複雑なシーケンスはないので、その点は楽です。

RefreshAppReceipt() で困っているのは自分だけなのかも気になります。。

コメント