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

Android

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

  • In App Purchasing
    4.1.2
  • Unity
    2020.3.21.f1
  • Android
    10/11

この記事では実装についてまとめます。 セットアップや評価については以下の記事をご参照ください。




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 PurchaseManager : MonoBehaviour, IStoreListener
{

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

	public enum PURCHASE_STATE
	{
		NOT_PURCHASED = 0,
		PURCHASED = 1,
		PENDING = 2,
	};
	PURCHASE_STATE purchaseState = 0;

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

	public void InitializePurchasing()
	{
		// If we have already connected to Purchasing ...
		if (IsInitialized()) {
			return;
		}
		var module = StandardPurchasingModule.Instance();
		var builder = ConfigurationBuilder.Instance(module);
		builder.AddProduct(productIDNonConsumable, ProductType.NonConsumable, new IDs()
			{
				{ productNameGooglePlayNonConsumable,  GooglePlay.Name }
			});
#if false
		/* for clearing consumable item for android */
		builder.AddProduct(productIDNonConsumable, ProductType.Consumable, new IDs()
			{
				{ productNameGooglePlayNonConsumable,  GooglePlay.Name }

			});
#endif
		UnityPurchasing.Initialize(this, builder);
	}
	public void OnInitializeFailed(InitializationFailureReason error)
	{
		/* 初期化失敗時の処理 */
		Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
	}

	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);
		}
	}

	//
	// --- IStoreListener
	//
	public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
	{
		// Purchasing has succeeded initializing. Collect our Purchasing references.
		Debug.Log("OnInitialized: PASS");
		// Overall Purchasing system, configured with products for this application.
		storeController = controller;
		// Store specific subsystem, for accessing device-specific store features.
		storeExtensionProvider = extensions;
		// レシートの検証
		if (storeController.products.all[0].hasReceipt) {
			//レシートあり
			purchaseState = checkGoogleReceipt(storeController.products.all[0].receipt);
		} else {
			// レシートなし
			purchaseState = PURCHASE_STATE.NOT_PURCHASED;
		}
		isInitialized = true;	
	}

	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));
			checkGoogleReceipt(args.purchasedProduct.receipt);

		} else {
			Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
		}
		return PurchaseProcessingResult.Complete;
	}
	public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
	{
		/* 購入失敗時の処理 */
		Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}", product.definition.storeSpecificId, failureReason));
	}
	PURCHASE_STATE checkGoogleReceipt(string receipt)
    {
		PURCHASE_STATE resultstate;
		// エディターの難読化ウィンドウで準備した機密を持つ
		// バリデーターを準備します。
		var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
			AppleTangle.Data(), Application.identifier);

		try {
			// Google Play で、result は 1 つの product ID を取得します
			// Apple stores で、receipts には複数のプロダクトが含まれます
			var result = validator.Validate(receipt);
			// 情報提供の目的で、ここにレシートをリストします
			Debug.Log("Receipt is valid. Contents:");

			GooglePurchaseState lastGoogleResult = GooglePurchaseState.Cancelled;
			DateTime lastData = DateTime.Today;
			bool setdata = false;
			foreach (IPurchaseReceipt productReceipt in result) {
				Debug.Log(productReceipt.productID);
				Debug.Log(productReceipt.purchaseDate);
				Debug.Log(productReceipt.transactionID);
				GooglePlayReceipt google = productReceipt as GooglePlayReceipt;
				if (null != google) {
					Debug.Log(google.purchaseState);
					if (!setdata) {
						setdata = true;
						lastData = productReceipt.purchaseDate;
						lastGoogleResult = google.purchaseState;
					} else {
						if (lastData < productReceipt.purchaseDate) {
							lastData = productReceipt.purchaseDate;
							lastGoogleResult = google.purchaseState;
						}
					}
				}
			}
			if (lastGoogleResult == GooglePurchaseState.Purchased) {
				resultstate = PURCHASE_STATE.PURCHASED;
			} else if ((int)lastGoogleResult == 4) {
				resultstate = PURCHASE_STATE.PENDING;
			} else {
				resultstate = PURCHASE_STATE.NOT_PURCHASED;
			}
		} catch (IAPSecurityException) {
			Debug.Log("Invalid receipt, not unlocking content");
			resultstate = PURCHASE_STATE.NOT_PURCHASED;
		}
		return resultstate;
	}
	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;
	}
}

もしGooglePlayTangleが見つからずエラーとなる場合はServices→In-App Purchasing → Receipt Validation Obfuscator… からPublic Keyを設定することで解決します。

IAP Public Keyを設定

ここで説明するUnityとIn App PurchasingのバージョンではWindowメニューではなく、Servicesメニューに該当項目があるので注意してください。

IAP Public Key設定

コード解説

初期化

初期化はInitializePurchasing()で実施します。初期化に成功するとOnInitialized()がコールバックされるのですが、ネットに出回っている多くのサンプルコードにはここにIStoreControllerとIExtensionProviderの保存くらいしか処理が入っていません

初期化完了の段階でレシートの有無の検証を入れておくことで、購入済みかどうかや購入状態を判断できて便利なので、ここではその処理を入れています。

	public void InitializePurchasing()
	{
		// If we have already connected to Purchasing ...
		if (IsInitialized()) {
			return;
		}
		var module = StandardPurchasingModule.Instance();
		var builder = ConfigurationBuilder.Instance(module);
		builder.AddProduct(productIDNonConsumable, ProductType.NonConsumable, new IDs()
			{
				{ productNameGooglePlayNonConsumable,  GooglePlay.Name }
			});
#if false
		/* for clearing consuble item for android */
		builder.AddProduct(productIDNonConsumable, ProductType.Consumable, new IDs()
			{
				{ productNameGooglePlayNonConsumable,  GooglePlay.Name }

			});
#endif
		UnityPurchasing.Initialize(this, builder);
	}

public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
	{
		// Purchasing has succeeded initializing. Collect our Purchasing references.
		Debug.Log("OnInitialized: PASS");
		// Overall Purchasing system, configured with products for this application.
		storeController = controller;
		// Store specific subsystem, for accessing device-specific store features.
		storeExtensionProvider = extensions;
		// レシートの検証
		if (storeController.products.all[0].hasReceipt) {
			//レシートあり
			purchaseState = checkGoogleReceipt(storeController.products.all[0].receipt);
		} else {
			// レシートなし
			purchaseState = PURCHASE_STATE.NOT_PURCHASED;
		}
		isInitialized = true;	
	}

レシートの検証

レシートの検証はcheckGoogleReceipt()で処理しています。Androidはコンビニ払いで支払いが保留中になっている時、その状態もレシートで確認することができます。In App Purchasing 4.1.2ではGooglePurchaseStateにその状態の定義がなかったので、lastGoogleResultに直値で4という値が入っている時、Pending状態と判断しています。

	PURCHASE_STATE checkGoogleReceipt(string receipt)
    {
		PURCHASE_STATE resultstate;
		// エディターの難読化ウィンドウで準備した機密を持つ
		// バリデーターを準備します。
		var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
			AppleTangle.Data(), Application.identifier);

		try {
			// Google Play で、result は 1 つの product ID を取得します
			// Apple stores で、receipts には複数のプロダクトが含まれます
			var result = validator.Validate(receipt);
			// 情報提供の目的で、ここにレシートをリストします
			Debug.Log("Receipt is valid. Contents:");

			GooglePurchaseState lastGoogleResult = GooglePurchaseState.Cancelled;
			DateTime lastData = DateTime.Today;
			bool setdata = false;
			foreach (IPurchaseReceipt productReceipt in result) {
				Debug.Log(productReceipt.productID);
				Debug.Log(productReceipt.purchaseDate);
				Debug.Log(productReceipt.transactionID);
				GooglePlayReceipt google = productReceipt as GooglePlayReceipt;
				if (null != google) {
					Debug.Log(google.purchaseState);
					if (!setdata) {
						setdata = true;
						lastData = productReceipt.purchaseDate;
						lastGoogleResult = google.purchaseState;
					} else {
						if (lastData < productReceipt.purchaseDate) {
							lastData = productReceipt.purchaseDate;
							lastGoogleResult = google.purchaseState;
						}
					}
				}
			}
			if (lastGoogleResult == GooglePurchaseState.Purchased) {
				resultstate = PURCHASE_STATE.PURCHASED;
			} else if ((int)lastGoogleResult == 4) {
				resultstate = PURCHASE_STATE.PENDING;
			} else {
				resultstate = PURCHASE_STATE.NOT_PURCHASED;
			}
		} catch (IAPSecurityException) {
			Debug.Log("Invalid receipt, not unlocking content");
			resultstate = PURCHASE_STATE.NOT_PURCHASED;
		}
		return resultstate;
	}

購入処理

購入処理はBuyNonConsumable()で処理しています。購入が完了するとProcessPurchase()がコールバックされます。購入による処理が完了したらPurchaseProcessingResult.Completeをreturnします。未完了の場合はPurchaseProcessingResult.PendingをreturnしてConfirmPendingPurchase()で処理を完了させます。

	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 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));
			checkGoogleReceipt(args.purchasedProduct.receipt);

		} else {
			Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
		}
		return PurchaseProcessingResult.Complete;
	}
	public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
	{
		/* 購入失敗時の処理 */
		Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}", product.definition.storeSpecificId, failureReason));
	}

また、購入失敗はOnPurchaseFailed()で通知されます。購入前のキャンセルやネットワーク切断などによる購入失敗が原因です。PurchaseFailureReasonでアプリケーションに失敗理由を表示するのが親切でよいです。

その他

価格の取得にGetlocalizedPriceString()を準備しています。Storeから取得する価格情報はローカライズされた地域に合わせてstoreController.products.all[0].metadata.localizedPriceStringから価格文字が取得できるのですが、英語で日本円の「¥」を取得した際、文字コードがAndroid固有のコードでそのままUnityのTextに出力できなかったので、いったん文字コード(0xC2,0xA5)を判定して¥マークに置き換えています

	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;
	}

呼び出し元の実装注意点

このPurchaseManagerを呼び出す元はPurchaseManagerの初期化完了を同期してから使い始めることが望ましいです。InitializePurchasing()は処理完了が非同期なので、OnInitialized()を待って、レシートの内容が確定してからシステムはPurchaseManagerを使用し始めるべきです。

購入保留中のハンドリング

Androidはコンビニ払いという厄介な決済方法が存在します。ユーザーが選択すると、購入は完了せず保留状態となり、ユーザーがコンビニでの支払い完了後、数分から数十分後にアプリ側に通知が来る仕組みとなっています。

また、支払いが完了しても、アプリを起動して購入完了をProcessPurchase()で処理しないと、購入はキャンセルとなり、支払い額は自動でGoogleのクレジットに補充されるというなんともアプリと連携の取りにくい仕様になっています。

これら一連の仕様について、一切の仕様説明はありません。(見つけられていないだけかもしれませんが)実動作ベースで確認しましたので、備忘録として残しておきます。

購入でコンビニ払いが選択された時

コンビニ払いが選択された時、ProcessPurchase()はコールバックされません。そのまま何事もなかったかのようにアプリにフォーカスが戻ります。

なので、アプリ側はBuyNonConsumable()で購入を開始したタイミングで「購入中」や「購入保留中」といったメッセージを出しておく必要があります。

また、この「購入保留中」に他の画面への遷移を許可するのか、購入されるのをそのまま待つのかなど専用のUIハンドリングが必要となります。

購入保留中のアプリ再起動

購入保留中にアプリ再起動すると、再起動時にレシートから購入保留中であることを知ることができます。

この時、購入が完了するまでアプリを待たせるのか、通常通り動かすのかをハンドリングする必要があります。ここで紹介したサンプルコードは初期化時にPending(購入保留中)をレシートから取得して状態として保持しています

支払い完了時の処理

支払い完了後、数分後にアプリが起動されている場合は突如ProcessPurchase()がコールバックされます。そこで購入処理を実行することになるので、シーケンスを想定しておくことが必要です。

また、支払いがキャンセルされた場合は何も通知が来ません。購入失敗が来ることもなく、アプリ起動時に突如保留中レシートがなくなることでそのことをアプリは知ることになります。

ネットでは「コンビニ払いで購入が反映されない」→「しばらく待ったらいけた」というコメントが多くみられます。このタイムラグのせいでアプリの評価を下げられたらたまったものじゃないですね。。

まとめ

今回はUnity In App Purchasingの実装について説明しました。どういったレシートがどういった時に取得されるのかいまいちネット上に情報がなく、正しくハンドリングするのに非常に苦労しました。

また、コンビニ払いとかいう日本でしか使われないような特殊な決済方法のせいで処理が複雑になってしまうのも残念です。

コメント

  1. 高崎翔太 より:

    貴重な記事を書いて頂き、ありがとうございます
    こちらの記事を参考に課金処理を実装していきたいと考えています
    こちらの記事をそのまま拝借させて頂いたのですが、

    The name ‘GooglePlayTangle’ does not exist in the current context
    The name ‘AppleTangle’ does not exist in the current context

    というエラーが出てしまいます
    既にUnityのダッシュボードよりライセンスキーは登録してあります
    他に何かするべきことはありますでしょうか?
    ご教授頂けましたら幸いです
    返信お待ちしております

  2. 高崎翔太 より:

    すみません
    IAPのバージョンが古かったようでアップデートしたら解決しました

タイトルとURLをコピーしました