Unity

Unity IAP Codeless 없이 수동 IAPManager 작성

washble2 2026. 3. 9. 16:41

처음에 Unity에 Codeless를 사용해서 IAP를 사용하고 있었는데

처음 Scene이 IAP Button들이 있는 곳이 아니다보니 Android에서 Codeless초기화 때 작동시켜주는 Restore를 얻지 못해서

결국 수동으로 IAP를 관리해주는 Manager를 만들었습니다.

 

Manager사용 예시는 다음 게시글에 올리겠습니다.

 

 

  • IDetailedStoreListener를 상속받아서 IStoreController, IExtensionProvider를 받을 수 있게 해주었습니다.
  • 구매관련은 IStoreController를 사용, Restore는 IExtensionProvider를 사용하였습니다.
  • 구매 후에는 ProcessPurchase여기로 Callback이 들어오는데 이때 정확한 delegate를 작동하기 위해 purchaseEventsMap에 mapping해두었습니다.
  • ReplayRestoredPurchases함수를 통해 비소모, 구독 상품들의 구매복원을 가능하도록 했습니다.
  • RestorePurchases는 Codeless의 BaseIAPButton.cs를 참고하여 작성하였습니다. 임의의 조건으로 저렇게 작성한 것이 아니기에 수정 때는 조심하세요.
  • 만약 Codeless관련 된 Component가 어딘가에 붙어 있다면 IAP Catalog에 Automatically initialize UnityPurchasing을 체크 해제해도 Codeless를 Initilize하려하니 조심하세요.

 

 

 

전체코드

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Extension;

[DefaultExecutionOrder(-100)]
public class IAPManager : Singleton<IAPManager>, IDetailedStoreListener
{
    /// <summary>
    /// Occurs when the restoration of transactions is completed.
    /// </summary>
    public event Action<bool, string> OnTransactionsRestored;
    
    /// <summary>
    /// Maps product IDs to their respective purchase event callbacks.
    /// </summary>
    private Dictionary<string, PurchaseEvents> purchaseEventsMap
        = new Dictionary<string, PurchaseEvents>(50);
    private struct PurchaseEvents
    {
        // Invoked on successful purchase.
        public Action<Product> OnPurchaseCompleteEvent;
        // Invoked on purchase failure with details.
        public Action<Product, PurchaseFailureDescription> OnPurchaseFailedEvent;
        // Invoked when product data is retrieved.
        public Action<Product> OnPurchaseFetchedEvent;
    }

    /// <summary>
    /// Automatically confirms consumable items.
    /// </summary>
    [SerializeField] private bool consumeConfirm = false;
    
    public IStoreController StoreController;
    private IExtensionProvider extensionProvider;

    protected override void Awake()
    {
        base.Awake();
        
        transform.SetParent(null);
        DontDestroyOnLoad(gameObject);
    }

    private void Start()
    {
        ConfigurationBuilder builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
        ProductCatalog catalog = ProductCatalog.LoadDefaultCatalog();
        foreach (ProductCatalogItem product in catalog.allProducts)
        {
            builder.AddProduct(product.id, product.type);
        }
        UnityPurchasing.Initialize(this, builder);
    }

    public void AddIAPListener
        (string productId, Action<Product> purchaseCompleteEvent,
        Action<Product, PurchaseFailureDescription> purchaseFailedEvent,
        Action<Product> purchaseFetchedEvent)
    {
        purchaseEventsMap[productId] = new PurchaseEvents
        {
            OnPurchaseCompleteEvent = purchaseCompleteEvent,
            OnPurchaseFailedEvent = purchaseFailedEvent,
        };

        Product product = StoreController.products.WithID(productId);
        if (product is not null)
        {
            purchaseFetchedEvent.Invoke(product);
        }
        else
        {
            Debug.LogError($"Product {productId} not found in storeController.products.");
        }
    }
    
    public void RemoveIAPListener(string productId)
    {
        if (purchaseEventsMap.ContainsKey(productId))
        {
            purchaseEventsMap.Remove(productId);
        }
    }

    /// <summary>
    /// Type of event fired after a successful fetching the product information from the store.
    /// </summary>
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log($"IAP Initialized {(controller is not null ? "Complete" : "Failed")}");
        StoreController = controller;
        extensionProvider = extensions;
        
        if(StoreController is null) { return; }
        
        foreach (Product product in StoreController.products.all)
        {
            if(purchaseEventsMap.TryGetValue(product.definition.id, out PurchaseEvents purchaseEvents))
            {
                purchaseEvents.OnPurchaseFetchedEvent?.Invoke(product);
            }
        }
    }

    /// <summary>
    /// Type of event fired after a failed fetching the product information from the store.
    /// </summary>
    public void OnInitializeFailed(InitializationFailureReason error)
    {
        Debug.LogError($"IAP Initialization Failed: {error}");
    }

    /// <summary>
    /// Type of event fired after a failed fetching the product information from the store.
    /// </summary>
    public void OnInitializeFailed(InitializationFailureReason error, string message)
    {
        Debug.LogError($"IAP Initialization Failed: {error}: {message}");
    }
    
    /// <summary>
    /// Type of event fired after a failed purchase of a product.
    /// </summary>
    public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
    {
        Debug.LogError($"IAP Purchase Failed {product}: {failureDescription}");
        
        if(purchaseEventsMap.TryGetValue(product.definition.id, out PurchaseEvents purchaseEvents))
        {
            purchaseEvents.OnPurchaseFailedEvent?.Invoke(product, failureDescription);
        }
    }

    /// <summary>
    /// Type of event fired after a failed purchase of a product.
    /// </summary>
    public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
    {
        Debug.LogError($"Purchase failed for product {product.definition.id}: {failureReason}");
    }
    
    /// <summary>
    /// Type of event fired after a successful purchase of a product.
    /// </summary>
    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs purchase)
    {
        Debug.Log($"Purchase successful: {purchase.purchasedProduct.definition.id}");
        
        if(purchaseEventsMap.TryGetValue
               (purchase.purchasedProduct.definition.id, out PurchaseEvents purchaseEvents))
        {
            purchaseEvents.OnPurchaseCompleteEvent?.Invoke(purchase.purchasedProduct);
        }
        
        return consumeConfirm ? PurchaseProcessingResult.Complete : PurchaseProcessingResult.Pending;
    }

    /// <summary>
    /// Type of event fired after a restore transactions was completed.
    /// </summary>
    private void TransactionsRestoredHandler(bool success, string error)
    {
        OnTransactionsRestored?.Invoke(success, error);
    }
    
    /// <summary>
    /// Replays restore completion only for registered products that currently have receipts.
    /// </summary>
    public void ReplayRestoredPurchases()
    {
        if (StoreController is null) { return; }

        foreach (KeyValuePair<string, PurchaseEvents> entry in purchaseEventsMap)
        {
            Product product = StoreController.products.WithID(entry.Key);
            if (product is null)
            {
                UnityEngine.Debug.LogWarning($"ReplayRestoredPurchases product not found: {entry.Key}");
                continue;
            }

            if (!product.hasReceipt)
            {
                UnityEngine.Debug.Log($"ReplayRestoredPurchases no receipt: {entry.Key}");
                continue;
            }

            if (product.definition.type == ProductType.Consumable)
            {
                UnityEngine.Debug.Log($"ReplayRestoredPurchases skip consumable: {entry.Key}");
                continue;
            }

            UnityEngine.Debug.Log($"ReplayRestoredPurchases invoke: {entry.Key}");
            entry.Value.OnPurchaseCompleteEvent?.Invoke(product);
        }
    }   
    
    /// <summary>
    /// For advanced scripted store-specific IAP actions, use this session's <typeparamref name="IStoreExtension"/>s after initialization.
    /// </summary>
    /// <typeparam name="T">A subclass of <typeparamref name="IStoreExtension"/> such as <typeparamref name="IAppleExtensions"/></typeparam>
    /// <returns></returns>
    public T GetStoreExtensions<T>() where T : IStoreExtension
    {
        return extensionProvider.GetExtension<T>();
    }
    
    /// <summary>
    /// Starts the restore purchases process depending on the current platform.
    /// Reference: BaseIAPButton.cs
    /// </summary>
    public void RestorePurchases()
    {
        // Check initialization
        if (extensionProvider is null)
        {
            Debug.LogWarning("IAP is not initialized, cannot start restore process.");
            return;
        }

        // 1. Windows Store (UWP)
        if (Application.platform == RuntimePlatform.WSAPlayerX86 ||
                 Application.platform == RuntimePlatform.WSAPlayerX64 ||
                 Application.platform == RuntimePlatform.WSAPlayerARM)
        {
            Debug.Log("Starting Microsoft Store restore...");
            extensionProvider.GetExtension<IMicrosoftExtensions>().RestoreTransactions();
        }
        // 2. Execute manual restore logic only for iOS or macOS
        else if (Application.platform == RuntimePlatform.IPhonePlayer ||
                Application.platform == RuntimePlatform.OSXPlayer ||
                Application.platform == RuntimePlatform.tvOS
#if UNITY_VISIONOS
                || Application.platform == RuntimePlatform.VisionOS
#endif
                )
        {
            Debug.Log("Starting Apple restore...");
            extensionProvider.GetExtension<IAppleExtensions>().RestoreTransactions(TransactionsRestoredHandler);
        }
        // 3. Google Play (Android specific versions or explicit restore required)
        else if (Application.platform == RuntimePlatform.Android &&
                 StandardPurchasingModule.Instance().appStore == AppStore.GooglePlay)
        {
            Debug.Log("Starting Google Play restore...");
            extensionProvider.GetExtension<IGooglePlayStoreExtensions>().RestoreTransactions(TransactionsRestoredHandler);
        }
        else
        {
            Debug.LogWarning($"{Application.platform} does not support manual restore button.");
        }
    }
}