Chương 4: In-App Purchase trong Game với App.Iap
Tích hợp hệ thống thanh toán gói mua trong ứng dụng (In-App Purchase) chuẩn Unity IAP 5.x qua giao diện IIapService (truy cập nhanh qua App.Iap). Bảo đảm an toàn giao dịch tuyệt đối với cơ chế cấp hàng 2 bước, sổ cái chống crash PendingOrderStore, tự động hóa xử lý gói Xóa quảng cáo và móc nối xác thực hóa đơn chống gian lận.
1. Mục đích & Triết lý vận hành
Trong phát triển game, lỗi giao dịch IAP là một trong những nguyên nhân hàng đầu khiến người chơi phàn nàn và yêu cầu hoàn tiền (refund). Các tình huống lỗi điển hình gồm:
- Người chơi đã bị trừ tiền trên App Store / Google Play nhưng game bị crash trước khi kịp cộng vàng/kim cương.
- Giao diện Shop cấp vật phẩm ngay trong callback của nút bấm mua hàng khi hóa đơn chưa được kho ứng dụng xác nhận.
- Game cấp hàng hai lần cho cùng một hóa đơn khi người chơi mở lại ứng dụng hoặc mạng bị chập chờn.
Lệnh gọi App.Iap.Purchase(id, callback) chỉ nhằm mục đích phản hồi giao diện (bật/tắt vòng xoay tải trang, thông báo thành công hoặc thất bại cho người dùng).
Toàn bộ việc cộng tài nguyên, mở khóa nhân vật và lưu trữ dữ liệu bắt buộc phải diễn ra bên trong hàm ủy quyền PurchaseGrantHandler được đăng ký qua App.Iap.SetGrantHandler.
2. Bảng tra cứu API IIapService đầy đủ
Mọi thao tác liên quan đến giao dịch IAP đều thực hiện qua interface TapBrain.Core.Services.IIapService:
| Thành viên (Member) | Kiểu & Chữ ký | Mô tả chi tiết & Hợp đồng |
|---|---|---|
IsReady |
bool (get) |
true khi dịch vụ StoreController đã kết nối thành công và tải xong danh mục sản phẩm từ IAP Catalog. |
Ready |
event Action |
Kích hoạt một lần khi danh mục sản phẩm tải xong và IAP sẵn sàng giao dịch. Không phát lại cho người đăng ký muộn (kiểm tra IsReady trước), và không bao giờ bắn nếu tải sản phẩm thất bại hoặc enableIap = false. |
RemoveAdsGranted |
event Action |
Bắn ra khi gói Xóa quảng cáo được mua thành công hoặc được khôi phục từ kho ứng dụng. |
SetGrantHandler() |
void SetGrantHandler(PurchaseGrantHandler handler) |
Đăng ký bộ xử lý giao hàng duy nhất của game. Phải đăng ký thật sớm khi khởi động game. Mọi đơn hàng còn tồn đọng (pending) từ các phiên trước được bàn giao đồng bộ ngay bên trong lệnh gọi này (handler có thể chạy trước khi SetGrantHandler trả về), vì vậy hãy nạp xong dữ liệu lưu của game trước khi đăng ký handler.
|
Purchase() |
void Purchase(string productId, Action<PurchaseResult> onDone) |
Bắt đầu tiến trình mua sản phẩm. Nếu chưa sẵn sàng, onDone được gọi đồng bộ với PurchaseResult.NotReady. Nếu cùng productId đang trong tiến trình xử lý, lệnh gọi thứ hai bị bỏ qua kèm log Warning và onDone của lệnh gọi thứ hai KHÔNG BAO GIỜ được gọi. Vì vậy đừng bật UI chặn (spinner, ShowBlocker) chỉ để chờ callback nếu không chắc không có giao dịch cùng sản phẩm đang chạy: hãy khóa nút mua và chỉ để một nơi gọi Purchase cho mỗi sản phẩm.
|
Restore() |
void Restore(Action<bool> onDone) |
Yêu cầu store khôi phục các giao dịch không tiêu hao (non-consumable) trên iOS hoặc thiết bị mới. Nếu IAP chưa sẵn sàng, onDone(false) được gọi đồng bộ. true chỉ nghĩa là lệnh restore của store thành công (không đảm bảo có sản phẩm nào được khôi phục); callback chạy ngay sau khi SDK yêu cầu tải lại danh sách đơn đã mua, nên gói Xóa quảng cáo được áp dụng ngay sau đó qua RemoveAdsGranted. Core chỉ tự áp dụng gói Xóa quảng cáo; với non-consumable khác, game đọc App.Iap.IsOwned(productId). Gọi Restore lần hai khi lần đầu chưa xong sẽ làm mất callback của lần đầu.
|
GetLocalizedPrice() |
string GetLocalizedPrice(string productId) |
Lấy chuỗi giá tiền bản địa hóa kèm ký hiệu tiền tệ từ store (ví dụ: "$0.99", "23.000 ₫"). Trả về chuỗi rỗng nếu chưa tải xong. |
IsOwned() |
bool IsOwned(string productId) |
Kiểm tra xem người chơi đã sở hữu sản phẩm vĩnh viễn chưa (hữu ích cho gói Xóa quảng cáo hoặc mở khóa vĩnh viễn). Gói Xóa quảng cáo đúng ngay khi cờ core.remove_ads được đặt; các sản phẩm khác trả false cho tới khi store sẵn sàng và đã tải danh sách đơn đã mua. |
Delegate và Enum liên quan
namespace TapBrain.Core.Services
{
// Delivery delegate. Return true ONLY when the goods are granted AND saved to disk.
public delegate bool PurchaseGrantHandler(string productId);
public enum PurchaseResult
{
Success, // Purchase finished and the grant handler already delivered the goods
Cancelled, // The player cancelled or closed the payment dialog
Failed, // Store error (card, network, refusal) or the receipt was rejected
Pending, // Deferred by the store (Ask-to-Buy), or the game handler is missing / refused
NotReady // IAP is not initialized yet
}
}
3. Quy trình cấp hàng 2 bước & Sổ cái PendingOrderStore
Để đảm bảo tính toàn vẹn dữ liệu ngay cả khi ứng dụng bị tắt đột ngột, pin yếu tắt máy hoặc mất sóng mạng giữa chừng, SDK sử dụng mô hình sổ cái nội bộ PendingOrderStore:
Các bước trong luồng giao hàng an toàn
-
Nhận thông báo từ Store: Khi người chơi bấm thanh toán thành công trên Google Play hoặc App Store, kho ứng dụng kích hoạt sự kiện
OnPurchasePendingvới mã giao dịchTransactionID(txId). -
Kiểm tra trùng lặp: SDK kiểm tra xem
txIdnày đã được cấp hàng trong lịch sử chưa (PendingOrderStore.IsGranted(txId)). Nếu đã cấp rồi, SDK lập tức gọiConfirmPurchaseđể đóng đơn trên store mà không cấp lại. -
Xác thực biên lai (Receipt Validation): Nếu game có đăng ký
IReceiptValidator, SDK chuyển biên lai tới để kiểm tra (không có validator thì biên lai được chấp nhận nguyên trạng). Nếu biên lai không hợp lệ, SDK ghi log Error[IAP] Invalid receipt ..., không cấp hàng, không xác nhận đơn với store và callback củaPurchasenhậnPurchaseResult.Failed. -
Ghi nhận vào sổ cái Pending: SDK lưu đơn hàng vào danh sách chờ với thông tin
txId,productIdvà thời gian tạo, sau đó lưu xuống đĩa qua khóacore.iap_orders_v1. -
Chuyển giao cho Game xử lý: SDK gọi hàm ủy quyền
PurchaseGrantHandler(productId)do game đăng ký. -
Game trả về kết quả:
- Nếu Game trả về
true: Hàng đã được cộng và lưu đĩa an toàn. SDK chuyển trạng thái đơn hàng sanggranted(lưu tối đa 200 giao dịch gần nhất), gọiConfirmPurchaseđể chốt đơn trên store và kích hoạt callbackPurchaseResult.Success. - Nếu Game trả về
falsehoặc phát sinh lỗi Exception: Đơn hàng VẪN ĐƯỢC GIỮ NGUYÊN TRẠNG THÁI PENDING ở cả local lẫn trên store. Callback UI nhậnPurchaseResult.Pending. Trong lần mở game tiếp theo, khi game vừa gọiSetGrantHandler, đơn hàng này sẽ được tự động kích hoạt cấp hàng lại!
- Nếu Game trả về
Nhờ ghi sổ cái PendingOrderStore.AddPending trước khi gọi game và chỉ gọi ConfirmPurchase sau khi game trả về true, một đơn hàng đã trả tiền sẽ không bị bỏ sót: nếu game crash trước khi đơn được ghi nhận là đã cấp, đơn vẫn còn pending và được giao lại ở lần mở sau.
Nếu game crash sau khi handler đã cộng và lưu hàng nhưng trước khi SDK ghi MarkGranted, lần mở game tiếp theo handler sẽ được gọi lại cho cùng đơn hàng. Handler chỉ nhận productId (không có txId) nên game không thể tự phân biệt hai lần giao của cùng một đơn. Với vật phẩm mở khóa (nhân vật, gói vĩnh viễn) hãy viết handler idempotent (đặt cờ "đã sở hữu" thay vì cộng dồn). Với tiền tệ, hãy lưu số dư và mọi trạng thái liên quan trong cùng một lần ghi nguyên tử, và chấp nhận rủi ro hiếm gặp này như một giới hạn của mô hình.
4. Khởi tạo Unity IAP, IAP Catalog & Cảnh báo UGS
Dịch vụ IAP của SDK được xây dựng trên nền tảng Unity IAP phiên bản 5.x (sử dụng API StoreController hiện đại):
Khai báo danh mục sản phẩm (IAPProductCatalog.json)
Tất cả sản phẩm phải được định nghĩa trong file danh mục mặc định của Unity IAP: Assets/Resources/IAPProductCatalog.json. Bạn có thể mở giao diện quản lý bằng menu của Unity:
Services > In-App Purchasing > IAP Catalog...
Trong cửa sổ này, bạn thêm từng sản phẩm với:
- Product ID: Định danh chung trong game (ví dụ:
coins_100,remove_ads). - Type:
Consumable(tiêu hao: vàng, gem) hoặcNon-Consumable(không tiêu hao: gói xóa quảng cáo, mở khóa vĩnh viễn). - Store IDs Override: SDK không hỗ trợ ánh xạ này (nó dùng chính Product ID của catalog làm mã sản phẩm trên store). Product ID trong catalog phải trùng chính xác với mã sản phẩm trên Google Play Console và App Store Connect.
Xử lý cảnh báo Unity Gaming Services (UGS)
Khi bắt đầu khởi tạo (trong bước boot IAP kéo dài tối đa 8 giây), SDK sẽ gọi UnityServices.InitializeAsync() nếu dịch vụ UGS chưa khởi chạy. Nếu dự án chưa được liên kết với một Project trên Unity Cloud Dashboard (trong Project Settings > Services), Console sẽ xuất hiện dòng cảnh báo:
[IAP] Unity Gaming Services init failed (project not linked?), continuing without it: ...
Đây là cảnh báo hoàn toàn bình thường trong quá trình phát triển. Quá trình kết nối với cửa hàng thực tế (Google Play / App Store) không bị phụ thuộc vào UGS và hệ thống vẫn tiếp tục nạp sản phẩm bình thường.
Khi khởi tạo IAP thất bại hoặc quá thời gian
- Nếu tải danh sách sản phẩm thất bại (ghi
[IAP] Products fetch failed: ...) hoặc có ngoại lệ lúc khởi tạo ([IAP] Initialization failed: ...), bước boot kết thúc nhưngIsReadyvẫnfalsetrong suốt phiên chơi: mọiPurchasetrảNotReady,Readykhông bắn và SDK không tự thử lại cho tới khi khởi động lại game. - Nếu bước IAP chạy quá 8 giây, boot ghi
[Boot] IAP timed out after 8s, continuingvà đi tiếp; IAP vẫn tiếp tục chạy nền và có thể sẵn sàng muộn hơn (Readybắn lúc đó). - Đặt
enableIap = falsethì IAP không bao giờ được khởi tạo (IsReadyluônfalse). - Vì vậy UI shop luôn phải kiểm tra
IsReadyhoặc đăng kýReadyvà hiển thị trạng thái "chưa sẵn sàng" khi cần.
5. Tích hợp gói Xóa quảng cáo (Remove Ads)
Gói Xóa quảng cáo là sản phẩm IAP đặc biệt được SDK Core tự động quản lý trọn vẹn:
- Điền mã Product ID của gói vào trường
removeAdsProductIdtrong fileCoreConfig.asset(ví dụ:com.mycompany.mygame.remove_ads). Mặc định trường này để trống, nghĩa là game không có gói Xóa quảng cáo. Sản phẩm này cũng phải có trong IAP Catalog với kiểu Non-Consumable. - Khi giao dịch sản phẩm này thành công (hoặc khi người dùng nhấn Restore Purchases), SDK Core tự động nhận diện thông qua Product ID trùng khớp.
- Core tự động lưu giá trị
truevào khóacore.remove_ads(provider quảng cáo đọc cờ này), và kích hoạt sự kiệnApp.Iap.RemoveAdsGranted. Game đọc trạng thái bằngApp.Iap.IsOwned(removeAdsProductId);AdsPolicylà lớp nội bộ của SDK. - Gói này không được chuyển tiếp tới
PurchaseGrantHandlercủa game để tránh việc game phải viết code xử lý thừa.
6. Xác thực hóa đơn biên lai (Receipt Validation & Tangles)
Để loại bỏ biên lai giả, SDK cung cấp cổng kết nối xác thực biên lai cục bộ thông qua IReceiptValidator. Cơ chế này kiểm tra chữ ký của biên lai do store phát hành; nó không thay thế việc kiểm tra phía server và không chống được máy đã root/jailbreak.
Do các khóa bí mật làm rối mã hóa đơn (IAP Tangles) mang tính đặc thù riêng theo từng tài khoản nhà phát triển và từng dự án game, package SDK không thể nhúng sẵn key của bất kỳ ai. Thay vào đó, mỗi dự án game tự sinh file tangle và đăng ký với SDK.
Các bước thiết lập xác thực biên lai cho dự án của bạn
- Lấy chuỗi Base64 License Key từ Google Play Console (mục Monetization setup).
- Trong Unity Editor, mở cửa sổ:
Services > In-App Purchasing > Receipt Validation Obfuscator. - Dán Google Play Key vào và nhấn Obfuscate Keys. Unity IAP sẽ tự động sinh các file mã nguồn:
GooglePlayTangle.cs,AppleTangle.csvàAppleStoreKitTestTangle.cstrong thư mụcAssets/Scripts/UnityPurchasing/generated. -
Tạo một lớp hiện thực
IReceiptValidatorvà đăng ký thật sớm trước khi Boot khởi chạy bằng thuộc tínhAfterAssembliesLoaded. Validator chỉ chạy kiểm tra thật trên thiết bị Android/iOS; trong Editor và FakeStore phải trảtrue(như mẫu dưới), nếu không mọi giao dịch thử trong Editor sẽ bị từ chối. Nếu game dùng assembly definition, nó phải tham chiếu assembly chứa các file Tangle (UnityPurchasing.Tangles) và assembly bảo mật của Unity IAP:
using UnityEngine;
using TapBrain.Core;
using TapBrain.Core.Services;
#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS)
using UnityEngine.Purchasing.Security;
#endif
namespace MyGame.Iap
{
public sealed class GameReceiptValidator : IReceiptValidator
{
// Register before Bootstrapper.Boot so orders re-delivered during IAP init are checked too.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
private static void Register() => SdkModules.RegisterReceiptValidator(new GameReceiptValidator());
// Contract: must not throw; return true only for a genuine receipt.
public bool IsValid(string receipt)
{
#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS)
if (string.IsNullOrEmpty(receipt)) return false;
try
{
// Tangle classes are generated by the Receipt Validation Obfuscator.
var validator = new CrossPlatformValidator(
GooglePlayTangle.Data(),
AppleTangle.Data(),
Application.identifier);
var result = validator.Validate(receipt);
return result != null && result.Length > 0;
}
catch (IAPSecurityException)
{
return false;
}
catch (System.Exception e)
{
Debug.LogWarning($"[Receipt] Validation error: {e.Message}");
return false;
}
#else
// Editor / fake store receipts cannot be validated: accept them.
return true;
#endif
}
}
}
Nếu dự án chưa cấu hình các file Tangle hoặc chưa gọi SdkModules.RegisterReceiptValidator, SDK sẽ ghi nhận log Info: [IAP] No IReceiptValidator registered: receipts accepted without local validation và chấp thuận hóa đơn giả định (thuận tiện cho việc chạy thử nghiệm trong Editor và các bản build nội bộ). Ngược lại, một khi đã đăng ký validator thì mọi biên lai (kể cả trong Editor) đều đi qua nó, nên validator của bạn phải tự trả true trong Editor.
7. Cơ chế theo dõi giao dịch trên SayKit
Khi dự án biên dịch với Provider SayKit (cờ SAYKIT_PURCHASING được SDK Settings tự đặt khi chọn SayKit), SDK tự động gắn thêm bộ theo dõi giao dịch SayKitPurchaseTracker thông qua interface nội bộ IPurchaseTracker.
Mỗi khi có một đơn hàng mới với biên lai hợp lệ (chỉ một lần cho mỗi đơn, không lặp lại khi đơn được giao lại ở lần mở sau; chạy trước khi chuyển vào cho Game cấp hàng), hệ thống sẽ tự động gọi lệnh SayKit.trackOrderPurchase để đẩy doanh thu lên máy chủ SayGames phục vụ việc tối ưu hóa chiến dịch quảng cáo người dùng (UA). Quá trình này diễn ra hoàn toàn tự động phía SDK, game developer không cần phải viết thêm bất kỳ lệnh đo lường IAP nào cho SayKit.
8. Các dòng Console Log chuẩn cần xác minh
Trong quá trình kiểm thử thanh toán, lọc Console theo từ khóa [IAP] để đối chiếu với các trạng thái vận hành của SDK. Lưu ý: các dòng mức Info chỉ được in khi CoreConfig.verboseLog bật hoặc trong Development Build; Warning và Error luôn được in.
| Dòng Log Console | Mức độ | Nguyên nhân & Ý nghĩa |
|---|---|---|
[IAP] Ready, {N} products |
Info | Dịch vụ IAP đã kết nối thành công với Store và tải xong N sản phẩm từ catalog. |
[IAP] No IReceiptValidator registered: receipts accepted without local validation |
Info | Chưa đăng ký bộ kiểm tra Tangle, biên lai được chấp nhận tự động. |
[IAP] Unity Gaming Services init failed (project not linked?), continuing without it: {error} |
Warning | Project chưa link Unity Cloud. Vẫn tiến hành kết nối Store bình thường. |
[IAP] Products fetch failed: {reason} |
Warning | Không thể tải danh sách sản phẩm từ kho ứng dụng (thường do mất mạng hoặc chưa cấu hình trên Store). IsReady giữ false cả phiên, mọi Purchase trả NotReady. |
[IAP] Initialization failed: {message} |
Error | Khởi tạo Unity IAP gặp sự cố nghiêm trọng. IsReady giữ false. |
[Boot] IAP timed out after 8s, continuing |
Warning | Bước boot IAP quá 8 giây nên boot đi tiếp. IAP vẫn chạy nền và có thể sẵn sàng muộn (Ready bắn sau). |
[IAP] Purchase of {productId} already in progress |
Warning | Lệnh Purchase thứ hai của cùng sản phẩm khi giao dịch trước chưa kết thúc. Lệnh thứ hai bị bỏ qua và callback của nó không bao giờ được gọi. |
[IAP] Invalid receipt for {productId} tx={txId}, not granted |
Error | Biên lai không vượt qua được bài kiểm tra của IReceiptValidator (hoặc validator ném ngoại lệ). Callback của Purchase nhận PurchaseResult.Failed; đơn không được xác nhận với store và sẽ được store gửi lại ở lần mở sau. |
[IAP] {productId} tx={txId} kept pending (no handler or handler refused) |
Warning | Game chưa gán SetGrantHandler hoặc hàm ủy quyền trả về false. Đơn hàng tiếp tục được giữ lại. |
[IAP] Confirmed tx={txId} |
Info | Đơn hàng đã được xác nhận kết thúc thành công với kho ứng dụng (Google Play / App Store). |
[IAP] Restore success={bool} {error} |
Info | Kết quả của thao tác khôi phục giao dịch (Restore Transactions). |
9. Hướng dẫn thực hành & Ví dụ mã nguồn C#
Ví dụ 1: Đăng ký Grant Handler sớm & Giao hàng an toàn
Kịch bản: Dịch vụ kinh tế trong game (EconomyService) được tạo đúng một lần ngay ở đầu game (bởi đoạn khởi tạo game của bạn). Nó nạp dữ liệu lưu trước, rồi đăng ký làm người tiếp nhận giao hàng duy nhất; handler tính trạng thái mới, lưu xuống đĩa trước, và chỉ khi lưu thành công mới cập nhật bộ nhớ và trả về true:
using UnityEngine;
using TapBrain.Core;
namespace MyGame.Economy
{
// Plain class, created exactly once by the game's own startup code (no singleton needed:
// App.Iap already holds the single registered handler).
public sealed class EconomyService
{
public const string ProductCoins100 = "coins_100";
public const string ProductCoins500 = "coins_500";
public const string ProductHeroKnight = "hero_knight";
private const string GoldKey = "player_gold";
private const string KnightKey = "hero_unlocked_knight";
private int _gold;
public int Gold => _gold;
public EconomyService()
{
// 1) Load saved state FIRST: pending orders are delivered synchronously inside SetGrantHandler.
_gold = PlayerPrefs.GetInt(GoldKey, 0);
// 2) Register early so orders left pending by a previous session are delivered at once.
App.Iap.SetGrantHandler(OnGrantProduct);
}
// Note: the remove-ads product never reaches this handler, Core grants it itself.
private bool OnGrantProduct(string productId)
{
int newGold = _gold;
bool unlockKnight = false;
switch (productId)
{
case ProductCoins100:
newGold += 100;
break;
case ProductCoins500:
newGold += 500;
break;
case ProductHeroKnight:
unlockKnight = true; // Idempotent: a redelivered order only re-sets the flag
break;
default:
// Unknown product: refuse so the order stays pending for investigation.
Debug.LogError($"[Economy] Unknown product id: {productId}");
return false;
}
// Persist BEFORE committing to memory and BEFORE returning true.
if (!TrySave(newGold, unlockKnight)) return false;
_gold = newGold;
Debug.Log($"[Economy] Granted {productId}, gold={_gold}");
return true;
}
private static bool TrySave(int gold, bool unlockKnight)
{
try
{
PlayerPrefs.SetInt(GoldKey, gold);
if (unlockKnight) PlayerPrefs.SetInt(KnightKey, 1);
PlayerPrefs.Save();
return true;
}
catch (System.Exception e)
{
Debug.LogError($"[Economy] Save failed: {e.Message}");
return false;
}
}
}
}
Ví dụ 2: Nút mua hàng Shop UI & Khôi phục giao dịch Restore
Kịch bản: Một nút mua hàng trong giao diện Shop. Nút bấm kiểm tra trạng thái sẵn sàng, hiển thị giá bản địa hóa lấy từ Store và chỉ sử dụng callback Purchase cho mục đích phản hồi UI. Vì callback của lệnh Purchase/Restore trùng lặp không bao giờ được gọi, mẫu này không dùng ShowBlocker (bộ đếm tham chiếu sẽ bị kẹt nếu callback không về) mà chỉ khóa chính nút bấm bằng cờ riêng và giả định chỉ một view gọi Purchase cho mỗi sản phẩm:
using UnityEngine;
using UnityEngine.UI;
using TapBrain.Core;
using TapBrain.Core.Services;
namespace MyGame.UI
{
public sealed class ShopItemView : MonoBehaviour
{
[SerializeField] private string _productId = "coins_100";
[SerializeField] private Button _buyButton;
[SerializeField] private Text _priceText;
[SerializeField] private Button _restoreButton; // Usually in the shop corner or Settings
// A duplicate in-flight Purchase/Restore never calls back, so never start a second one.
private bool _buying;
private bool _restoring;
private void OnEnable()
{
_buyButton.onClick.AddListener(OnBuyClicked);
if (_restoreButton != null)
{
_restoreButton.onClick.AddListener(OnRestoreClicked);
#if !UNITY_IOS
// Apple requires a Restore button on iOS; other platforms do not need it.
_restoreButton.gameObject.SetActive(false);
#endif
}
if (App.Iap.IsReady) UpdatePriceDisplay();
else App.Iap.Ready += UpdatePriceDisplay; // Ready is not replayed for late subscribers
}
private void OnDisable()
{
_buyButton.onClick.RemoveListener(OnBuyClicked);
if (_restoreButton != null) _restoreButton.onClick.RemoveListener(OnRestoreClicked);
App.Iap.Ready -= UpdatePriceDisplay;
}
private void UpdatePriceDisplay()
{
string price = App.Iap.GetLocalizedPrice(_productId);
if (!string.IsNullOrEmpty(price)) _priceText.text = price;
}
private void OnBuyClicked()
{
if (_buying) return;
if (!App.Iap.IsReady)
{
App.Overlay.Toast("Cửa hàng đang kết nối, vui lòng thử lại sau ít giây!");
return;
}
_buying = true;
_buyButton.interactable = false;
App.Iap.Purchase(_productId, result =>
{
_buying = false;
// UI feedback ONLY. The goods are granted by the grant handler, never here.
ShowResult(result);
// The view may be destroyed by the time the store answers.
if (this != null && _buyButton != null) _buyButton.interactable = true;
});
}
private static void ShowResult(PurchaseResult result)
{
switch (result)
{
case PurchaseResult.Success:
App.Overlay.Toast("Mua hàng thành công! Đã thêm vào tài khoản.");
break;
case PurchaseResult.Cancelled:
Debug.Log("[Shop] Purchase cancelled by the player");
break;
case PurchaseResult.Pending:
// Deferred by the store (Ask-to-Buy) OR the game handler has not delivered yet.
App.Overlay.Toast("Giao dịch đang được xử lý, vật phẩm sẽ được cộng khi hoàn tất.");
break;
case PurchaseResult.Failed:
App.Overlay.Toast("Giao dịch thất bại. Vui lòng kiểm tra lại phương thức thanh toán.");
break;
case PurchaseResult.NotReady:
App.Overlay.Toast("Hệ thống cửa hàng chưa sẵn sàng.");
break;
}
}
private void OnRestoreClicked()
{
if (_restoring) return;
_restoring = true;
App.Iap.Restore(success =>
{
_restoring = false;
// success only means the store restore request worked, not that something was restored.
App.Overlay.Toast(success
? "Đã gửi yêu cầu khôi phục. Các gói đã mua sẽ được áp dụng."
: "Khôi phục thất bại, vui lòng thử lại sau.");
});
}
}
}
10. Các lỗi phổ biến & So sánh Đúng vs Sai
// 1. GRANTING INSIDE THE PURCHASE CALLBACK
App.Iap.Purchase("coins_100", result => {
if (result == PurchaseResult.Success) {
// BUG: Success only arrives AFTER the grant handler already delivered the goods,
// so this grants twice. It also never runs for orders re-delivered on the next launch.
wallet.AddGold(100);
}
});
// 2. HANDLER RETURNS TRUE BEFORE SAVING
App.Iap.SetGrantHandler(productId => {
wallet.AddGold(100);
return true; // BUG: not saved yet. A crash right after this loses the gold, but the SDK
// already marked the order granted and confirmed it at the store.
});
// 3. REGISTERING THE HANDLER TOO LATE
void OpenShopPopup() {
// BUG: orders left pending by a previous session stay undelivered until the player
// opens the shop, and the Purchase callback reports Pending in the meantime.
App.Iap.SetGrantHandler(MyHandler);
}
// 1. THE PURCHASE CALLBACK ONLY UPDATES THE UI
App.Iap.Purchase("coins_100", result => {
// OK: show a toast / reset the buy button
if (result == PurchaseResult.Success) {
ShowCongratulationsPopup();
}
});
// 2. SAVE FIRST, THEN RETURN TRUE
App.Iap.SetGrantHandler(productId => {
int newGold = wallet.Gold + 100;
bool saved = wallet.TrySave(newGold); // OK: persist before committing / returning true
if (saved) wallet.Gold = newGold;
return saved;
});
// 3. REGISTER AT STARTUP
public EconomyService() {
// OK: load state, then register in the economy service created at game start.
// Pending orders from earlier sessions are delivered right away.
App.Iap.SetGrantHandler(MyHandler);
}
11. Checklist kiểm tra hệ thống IAP
PurchaseGrantHandler được đăng ký trong pha khởi tạo dịch vụ kinh tế của game, không để tới lúc mở Shop mới gán.Assets/Resources/IAPProductCatalog.json trùng khớp hoàn toàn với các Product ID đã tạo trên Google Play Console và App Store Connect (SDK không hỗ trợ Store ID Override, nên hai mã phải giống nhau).removeAdsProductId trong CoreConfig.asset khớp với mã sản phẩm Non-Consumable đã thiết lập.App.Iap.Restore() trong màn hình Shop hoặc Settings khi chạy trên hệ điều hành iOS để vượt qua kiểm duyệt App Store.true, an toàn khi bị gọi lại cho cùng một đơn, và UI không gọi Purchase/Restore lần hai khi lần đầu chưa trả callback.