Unity接GooglePlay In-App Billing坑还是蛮多的,各种坑。
这次介绍的是第二种,使用安卓源生方式接入,因为该方式一劳永逸,新项目可以很快就完成接入。
为什么不用第一种呢?直接导入IAP插件,然后设置参数,就可以很快实现了,该方式可以直接参考google文档。
虽然该方式只需要导入插件,然后进行一些参数的设置,但是此方式特别麻烦的一点是需要在Unity中开启Service,但是开启后又得填一大堆信息,巨麻烦无比,而且Unity的网络简直不能看,要么是打不开,要么是卡半死,而且网站老变,以及没有完整的文档。这些都是很恶心人的事情,甚至还要创建组织啥的,反正谁用谁恶心。
----------------------------------分割线------------------------------------------------------------------------
接入之前需要的储备知识是:Unity如何与Android交互
我们的目标是导出自己封装的jar(里面封装了接口供Unity调用,也就是桥接层),以及找到谷歌官方提供的Billing V4插件(aar)。
因为从2021.8.2起,谷歌要求必须接入V3版以上的插件。所以我们这次干脆接了最新的V4.
接入文档可以参考指南:从 AIDL 迁移到 Google Play 结算库的迁移指南
implementation("com.android.billingclient:billing:4.0.0") 注意:plugins这边要改为'com.android.library',导出才会是aar,否则是apk.
plugins { id 'com.android.library' } android { compileSdkVersion 30 defaultConfig { //applicationId "com.egogame.iapforgoogleplay" minSdkVersion 19 targetSdkVersion 30 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'com.google.android.material:material:1.3.0' implementation 'org.jetbrains:annotations:15.0' testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' implementation("com.android.billingclient:billing:4.0.0") }
package com.egogame; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import android.app.Activity; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.widget.Toast; public class BaseMainActivity{ public static String UNITY_GO_NAME="IAP"; public static final String LOG_TAG = "EgoGameLog"; protected Handler uiHandler = new Handler(Looper.getMainLooper()); //unity项目启动时的上下文 private Activity unityActivity; private Context context; public void Init(final String goName,final String googlePlayPublicKey){ PrintLog("Init:"+goName+"===="+googlePlayPublicKey); uiHandler.post(new Runnable() { @Override public void run() { UNITY_GO_NAME=goName; OnInitHandle(googlePlayPublicKey); } }); } public void PrintLog(final String message,final Boolean toast){ android.util.Log.d(LOG_TAG, message); uiHandler.post(new Runnable() { @Override public void run() { if(toast) Toast.makeText(getActivity(), message, Toast.LENGTH_LONG).show(); } }); } public boolean IsIAPSupported(){ return false; } final public void RequstProduct(final String idsJson){ uiHandler.post(new Runnable() { @Override public void run() { String[] realProducts=null; try { JSONObject jObject=new JSONObject(idsJson); JSONArray jArray=jObject.getJSONArray("productIds"); realProducts=new String[jArray.length()]; for (int i = 0; i < jArray.length(); i++) { realProducts[i]=jArray.getString(i); } } catch (Exception e) { PrintLog("RequstProduct数据传输错误:"+e.getMessage()); } if(realProducts!=null){ OnRequstProduct(realProducts); }else{ RequestProductsFail("数据解析错误:"+idsJson); } } }); } final public void BuyProduct(final String productJson){ uiHandler.post(new Runnable() { @Override public void run() { try { JSONObject jObject=new JSONObject(productJson); String productId=jObject.getString("productId"); boolean isConsumable=jObject.getBoolean("isConsumable"); OnBuyProduct(productId,isConsumable); } catch (Exception e) { PrintLog("BuyProduct数据传输错误:"+e.getMessage()); } } }); } protected void OnInitHandle(String googlePlayPublicKey){ } protected void OnRequstProduct(String[] productId){ } protected void OnBuyProduct(String productId,boolean isConsumable){ } protected void BuyComplete(String productId){ PrintLog("购买成功:"+productId); SendUnityMessage("ProductBuyComplete", productId); } protected void BuyCancle(String productId){ PrintLog("购买取消:"+productId); SendUnityMessage("ProductBuyCancled", productId); } protected void BuyFail(String productId,String error){ PrintLog("购买失败:"+productId+"原因:"+error); try { JSONObject jObject=new JSONObject(); jObject.put("productId", productId); jObject.put("error", error); SendUnityMessage("ProductBuyFailed", jObject.toString()); } catch (JSONException e) { PrintLog("BuyFail数据错误:"+e.getMessage()); } } protected void RequestProductsFail(String message){ SendUnityMessage("ProductRequestFail", message); } protected void RecieveProductInfo(ArrayList<SkuItem> skuItems,ArrayList<String> invalidProductIds){ JSONObject jsonObject=new JSONObject(); try { JSONArray skuArray=new JSONArray(); JSONObject tmpObj = null; for (int i = 0; i < skuItems.size(); i++) { SkuItem skuItem=skuItems.get(i); tmpObj = new JSONObject(); tmpObj.put("productId" , skuItem.productId); tmpObj.put("title" , skuItem.title); tmpObj.put("desc" , skuItem.desc); tmpObj.put("price" , skuItem.price); tmpObj.put("formatPrice" , skuItem.formatPrice); tmpObj.put("priceCurrencyCode" , skuItem.priceCurrencyCode); tmpObj.put("skuType" , skuItem.skuType); skuArray.put(tmpObj); } JSONArray invalidArray=new JSONArray(); for (int i = 0; i < invalidProductIds.size(); i++) { invalidArray.put(invalidProductIds.get(i)); } jsonObject.put("skuItems", skuArray); jsonObject.put("invalidIds", invalidArray); } catch (JSONException e) { PrintLog("Json数据错误:"+e.getMessage()); } String info=jsonObject.toString(); PrintLog("当前产品信息:"+info); SendUnityMessage("RecieveProductInfos", info); } public void PrintLog(String message){ PrintLog(message,false); } public void SendUnityMessage(String func,String value){ CallUnity(UNITY_GO_NAME, func, value); } public Activity CurrentActivity(){ return getActivity(); } /** * Android调用Unity的方法 * @param gameObjectName 调用的GameObject的名称 * @param functionName 方法名 * @param args 参数 * @return 调用是否成功 */ boolean CallUnity(String gameObjectName, String functionName, String args){ try { Class<?> classtype = Class.forName("com.unity3d.player.UnityPlayer"); Method method =classtype.getMethod("UnitySendMessage", String.class,String.class,String.class); method.invoke(classtype,gameObjectName,functionName,args); return true; } catch (ClassNotFoundException e) { System.out.println(e.getMessage()); } catch (NoSuchMethodException e) { System.out.println(e.getMessage()); } catch (IllegalAccessException e) { System.out.println(e.getMessage()); } catch (InvocationTargetException e) { } return false; } /** * 利用反射机制获取unity项目的上下文 * @return */ Activity getActivity(){ if(null == unityActivity) { try { Class<?> classtype = Class.forName("com.unity3d.player.UnityPlayer"); Activity activity = (Activity) classtype.getDeclaredField("currentActivity").get(classtype); unityActivity = activity; context = activity; } catch (ClassNotFoundException e) { System.out.println(e.getMessage()); } catch (IllegalAccessException e) { System.out.println(e.getMessage()); } catch (NoSuchFieldException e) { System.out.println(e.getMessage()); } } return unityActivity; } }
package com.egogame; public class SkuItem { public String productId; public String title; public String desc; public String price; public String formatPrice;//格式化价格,包括其货币符号 public String priceCurrencyCode;//货币代码 public String skuType;//内购还是订阅 }
package com.egogame; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchasesUpdatedListener; import com.android.billingclient.api.SkuDetails; import com.android.billingclient.api.SkuDetailsParams; import com.android.billingclient.api.SkuDetailsResponseListener; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; public class MainActivity extends BaseMainActivity implements PurchasesUpdatedListener,BillingClientStateListener { private static final long RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L; private static final long RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L; // 15 mins private static final Handler handler = new Handler(Looper.getMainLooper()); private BillingClient billingClient; private String[] cacheRequestList; private Map<String, SkuDetails> skuDetailsLiveDataMap=new HashMap<>(); private boolean isConsumable; private String buyProductId; private boolean billingSetupComplete = false; // how long before the data source tries to reconnect to Google play private long reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS; @Override protected void OnInitHandle(String googlePlayPublicKey) { super.OnInitHandle(googlePlayPublicKey); if (googlePlayPublicKey.contains("CONSTRUCT_YOUR")) { throw new RuntimeException("Please put your app's public key in MainActivity.java. See README."); } billingClient = BillingClient.newBuilder(CurrentActivity()).setListener(this).enablePendingPurchases().build(); billingClient.startConnection(this); } private void retryBillingServiceConnectionWithExponentialBackoff() { handler.postDelayed(() -> billingClient.startConnection(this), reconnectMilliseconds); reconnectMilliseconds = Math.min(reconnectMilliseconds * 2, RECONNECT_TIMER_MAX_TIME_MILLISECONDS); } @Override protected void OnRequstProduct(String[] productId) { super.OnRequstProduct(productId); List<String> skuList = new ArrayList<>(); skuList.addAll(Arrays.asList(productId)); cacheRequestList=productId; SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP); billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() { @Override public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetailsList) { int responseCode = billingResult.getResponseCode(); PrintLog("onSkuDetailsResponse:"+billingResult+" code:"+GetResponseText(responseCode)); switch (responseCode){ case BillingClient.BillingResponseCode.OK: RecieveProducts(skuDetailsList); break; default: RequestProductsFail("Failed to query inventory: " + billingResult.getDebugMessage()); PrintLog("Failed to query inventory: "+billingResult.getDebugMessage()); break; } } }); } private String GetResponseText(int responseCode){ switch (responseCode){ case BillingClient.BillingResponseCode.OK: return "OK"; case BillingClient.BillingResponseCode.SERVICE_TIMEOUT: return "SERVICE_TIMEOUT"; case BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED: return "FEATURE_NOT_SUPPORTED"; case BillingClient.BillingResponseCode.USER_CANCELED: return "USER_CANCELED"; case BillingClient.BillingResponseCode.SERVICE_DISCONNECTED: return "SERVICE_DISCONNECTED"; case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE: return "SERVICE_UNAVAILABLE"; case BillingClient.BillingResponseCode.BILLING_UNAVAILABLE: return "BILLING_UNAVAILABLE"; case BillingClient.BillingResponseCode.ITEM_UNAVAILABLE: return "ITEM_UNAVAILABLE"; case BillingClient.BillingResponseCode.DEVELOPER_ERROR: return "DEVELOPER_ERROR"; case BillingClient.BillingResponseCode.ERROR: return "ERROR"; case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED: return "ITEM_ALREADY_OWNED"; case BillingClient.BillingResponseCode.ITEM_NOT_OWNED: return "ITEM_NOT_OWNED"; default: return "UnKnown"; } } private void RecieveProducts(List<SkuDetails> skuDetailsList){ ArrayList<SkuItem> skuItems=new ArrayList<SkuItem>(); ArrayList<String> invaildIds=new ArrayList<String>(); PrintLog("cacheRequestList:"+cacheRequestList); int length=cacheRequestList.length; if(cacheRequestList!=null && length>0){ for(int i=0;i<length;i++){ String productId=cacheRequestList[i]; if(!TextUtils.isEmpty(productId)){ SkuDetails detail=null; for (SkuDetails skuDetails : skuDetailsList) { if(skuDetails.getSku()==productId){ detail=skuDetails; break; } } if(detail==null){ PrintLog("未找到该产品信息:"+productId); invaildIds.add(productId); continue; } skuDetailsLiveDataMap.put(productId,detail); String price=detail.getPrice(); String formatPrice=price; SkuItem skuItem=new SkuItem(); skuItem.productId=productId; skuItem.title=detail.getTitle(); skuItem.desc=detail.getDescription(); skuItem.price=price; skuItem.formatPrice=formatPrice; skuItem.priceCurrencyCode=detail.getPriceCurrencyCode(); skuItem.skuType=detail.getType(); skuItems.add(skuItem); } } } RecieveProductInfo(skuItems,invaildIds); } @Override public boolean IsIAPSupported() { return true; } @Override protected void OnBuyProduct(String productId, boolean isConsumable) { super.OnBuyProduct(productId, isConsumable); SkuDetails skuDetails=skuDetailsLiveDataMap.get(productId); if(null!=skuDetails){ buyProductId=productId; this.isConsumable=isConsumable; BillingFlowParams purchaseParams = BillingFlowParams.newBuilder() .setSkuDetails(skuDetails) .build(); billingClient.launchBillingFlow(CurrentActivity(), purchaseParams); }else{ BuyFail(productId,"Can not find SkuDetails:"+productId); PrintLog("未请求商品数据,请先请求:"+productId); } } @Override public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> list) { int responseCode = billingResult.getResponseCode(); PrintLog("BillingResult [" + GetResponseText(responseCode) + "]: " + billingResult.getDebugMessage()); switch (responseCode) { case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED: case BillingClient.BillingResponseCode.OK: FlowFinish(true,null,list); break; case BillingClient.BillingResponseCode.USER_CANCELED: String productId=buyProductId; buyProductId=null; BuyCancle(productId); break; default: FlowFinish(false,billingResult.getDebugMessage(),list); break; } } private void FlowFinish(Boolean isSuccess,String message,List<Purchase> purchases){ if(isSuccess){ if(buyProductId!=null){ String productId=buyProductId; buyProductId=null; String purchaseToken=null; for (Purchase purchase : purchases) { for (String skus : purchase.getSkus()) { //需要校验付款状态 if(skus.contains(productId) && purchase.getPurchaseState()==Purchase.PurchaseState.PURCHASED){ purchaseToken=purchase.getPurchaseToken(); break; } if(purchaseToken!=null) break; } } if(isConsumable){ if(purchaseToken==null){ CallBackBuyFail("unknown purchaseToken:"+productId); }else { ConsumeParams consumeParams = ConsumeParams.newBuilder() .setPurchaseToken(purchaseToken) .build(); billingClient.consumeAsync(consumeParams,(billingResult, token) -> { if(billingResult.getResponseCode()==BillingClient.BillingResponseCode.OK){ BuyComplete(productId); }else{ CallBackBuyFail(billingResult.getDebugMessage()); } }); } }else{ BuyComplete(productId); } } }else{ if(buyProductId!=null){ CallBackBuyFail(message); } } } private void CallBackBuyFail(String message){ String productId=buyProductId; buyProductId=null; BuyFail(productId, message); PrintLog("Error purchasing: " + message); } @Override public void onBillingServiceDisconnected() { PrintLog("onBillingServiceDisconnected"); billingSetupComplete = false; retryBillingServiceConnectionWithExponentialBackoff(); } @Override public void onBillingSetupFinished(@NonNull BillingResult billingResult) { int responseCode = billingResult.getResponseCode(); String debugMessage = billingResult.getDebugMessage(); PrintLog("onBillingSetupFinished: " + debugMessage+"("+ GetResponseText(responseCode)+")",true); switch (responseCode) { case BillingClient.BillingResponseCode.OK: // The billing client is ready. You can query purchases here. // This doesn't mean that your app is set up correctly in the console -- it just // means that you have a connection to the Billing service. reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS; billingSetupComplete = true; break; case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE: case BillingClient.BillingResponseCode.BILLING_UNAVAILABLE: PrintLog("Billing Service Unavailable:"+debugMessage,true); break; default: retryBillingServiceConnectionWithExponentialBackoff(); break; } } }
#if UNITY_ANDROID using System; using UnityEngine; using System.Collections; using System.Collections.Generic; using Newtonsoft.Json; public class GooglePlay_IAPBridge{ class BuyProductData { public string productId; public bool isConsumable; } private AndroidJavaObject javaObject; private GooglePlay_IAPBridge() { if (Application.platform != RuntimePlatform.Android) return; javaObject = new AndroidJavaObject("com.egogame.MainActivity"); } private volatile static GooglePlay_IAPBridge _instance = null; private static readonly object lockHelper = new object(); public static GooglePlay_IAPBridge getInstance() { if(_instance == null) { lock(lockHelper) { if(_instance == null) _instance = new GooglePlay_IAPBridge(); } } return _instance; } public void Init(string goName,string publicKey){ Debug.Log ("[GooglePlay_IAPBridge]Init:" + goName + "=====" + publicKey); if (Application.platform != RuntimePlatform.Android) return; javaObject.Call ("Init", goName, publicKey); } public bool IsIAPSupported(){ if (Application.platform != RuntimePlatform.Android) return false; return javaObject.Call<bool> ("IsIAPSupported"); } public void RequestProducts(string jsonData) { Debug.Log ("[GooglePlay_IAPBridge]RequstProduct:" + jsonData); if (Application.platform != RuntimePlatform.Android) return; javaObject.Call ("RequstProduct", jsonData); } public void BuyProduct(string productId,bool isConsumable){ BuyProductData buyProductData=new BuyProductData(); buyProductData.productId = productId; buyProductData.isConsumable = isConsumable; string jsonData = JsonConvert.SerializeObject(buyProductData); Debug.Log ("[GooglePlay_IAPBridge]BuyProduct:" + jsonData); if (Application.platform != RuntimePlatform.Android) return; javaObject.Call ("BuyProduct", jsonData); } } #endif这里面我们直接 javaObject = new AndroidJavaObject("com.egogame.MainActivity");实例化我们jar封装好的类,即可直接调用public方法。请注意:因为Android和Unity线程不一样,所以jar处理时需要规避线程的同步性。
using UnityEngine; using System.Collections.Generic; using Newtonsoft.Json; public class IAPBridge{ class RequestSkuData { public string[] productIds; } public static void InitIAp(string goName,string publicKey=""){ Debug.Log("[IAPBridge]InitIAp:" + goName + "==" + publicKey); #if UNITY_IPHONE if(Application.platform==RuntimePlatform.IPhonePlayer){ iOS_IAPBridge.InitIAPManager(goName); } #elif UNITY_ANDROID if(Application.platform==RuntimePlatform.Android){ GooglePlay_IAPBridge.getInstance().Init(goName,publicKey); } #endif } public static bool IAPEnabeld(){ #if UNITY_IPHONE if(Application.platform==RuntimePlatform.IPhonePlayer){ return iOS_IAPBridge.IsProductAvailable(); } #elif UNITY_ANDROID if(Application.platform==RuntimePlatform.Android){ return GooglePlay_IAPBridge.getInstance().IsIAPSupported(); } #endif return false; } public static void RequstProducts(List<string> productIds){ RequestSkuData data=new RequestSkuData(); data.productIds = productIds.ToArray(); string jsonData = JsonConvert.SerializeObject(data); Debug.Log("[IAPBridge]RequstProducts:"+jsonData); #if UNITY_IPHONE iOS_IAPBridge.RequstProductInfo(jsonData); #elif UNITY_ANDROID if (Application.platform == RuntimePlatform.Android){ GooglePlay_IAPBridge.getInstance().RequestProducts(jsonData); } #endif } public static void SendBuyProduct(string productId,bool isConsumable){ Debug.Log(string.Format("[IAPBridge]SendBuyProduct:{0} isConsumable:{1}",productId,isConsumable)); #if UNITY_IPHONE if(Application.platform==RuntimePlatform.IPhonePlayer){ iOS_IAPBridge.BuyProduct(productId); } #elif UNITY_ANDROID if (Application.platform == RuntimePlatform.Android){ GooglePlay_IAPBridge.getInstance().BuyProduct(productId, isConsumable); } #endif } public static void RestoreProduct(){ Debug.Log("[IAPBridge]Restore!"); #if UNITY_IPHONE if(Application.platform==RuntimePlatform.IPhonePlayer){ iOS_IAPBridge.Restore(); } #endif } }