公司近期将收费的功能排期了,由于项目做的是线上教育,提供的服务属于虚拟物品。根据iOS官方的规定,虚拟物品交易只能使用iOS应用内支付,其他类似微信、支付宝官方都是明文规定不允许存在的。(注:虚拟物品才有此规定,且iOS官方收税30%;而有实体物品交易的,官方允许在提供应用内支付的前提下,提供其他支付方式供用户选择。)
结合相关平台规定,我们最终确定支付方式为:Android端使用微信支付,iOS使用IAP应用内支付。
不得不说我们这一代程序员是幸运的,得益于国内移动支付的迅猛发展,微信支付的流程闭环比iOS完善了N倍(iOS的槽点一篇文章都写不完,稍后我再来吐);同时微信官方所提供的服务,至少在国内网络中,可以认定为是百分百可靠的。
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:fluwx/fluwx.dart' as fluwx;
class WechatPayment {
StreamSubscription _wxPay;
/// 关闭微信消息订阅
void wxSubscriptionClose() => _wxPay?.cancel();
/// 调起微信支付面板
/// 这里的WxPayModel是业务层的数据,即后台返回的有关微信支付订单的信息
void wxPay(WxPayModel wxPayModel, {VoidCallback onWxPaying, VoidCallback onSuccess, Function(String data) onError}) async {
// 跳转微信支付前,告诉页面进入微信支付,页面层可以做一些关闭加载框等的操作
onWxPaying?.call();
// 一些异常情况的处理
if (!await fluwx.isWeChatInstalled) return onError?.call('请安装微信完成支付或使用苹果手机支付');
if (wxPayModel.appId != Config.WX_APP_ID) return onError?.call('AppID不一致,请联系管理员');
// 此方法笔者没有做单例,因此支付前尝试注销监听,避免重复回调
_wxPay?.cancel();
// 支付回调
_wxPay = fluwx.weChatResponseEventHandler.listen((event) {
_wxPay?.cancel();
if (event is fluwx.WeChatPaymentResponse) {
if (event.isSuccessful) {
return onSuccess?.call();
} else {
return onError?.call(event.errCode == -1 ? '系统错误,请联系管理员' : '您取消了支付');
}
}
});
// 发起支付
fluwx.payWithWeChat(
appId: wxPayModel.appId,
partnerId: wxPayModel.partnerId,
prepayId: wxPayModel.prepayId,
packageValue: wxPayModel.packageValue,
nonceStr: wxPayModel.nonceStr,
timeStamp: wxPayModel.timeStamp,
sign: wxPayModel.sign,
signType: wxPayModel.signType,
extData: wxPayModel.extData,
);
}
}
页面端是这样调用的
WechatPayment paymentUtils = new WechatPayment();
paymentUtils.wxPay(
state.model.wxPayModel,
onError: (String err) {
if (!mounted) return;
// 微信支付错误,设置支付状态为false,弹框即可
_isPaying = false;
SchedulerBinding.instance.addPostFrameCallback((_) {
CommonUtils.showToast(err, backgroundColor: Theme.of(context).errorColor);
});
},
onSuccess:(){
_isPaying = true;
},
onWxPaying: () {
// 启动微信支付,设置支付状态为true,关闭加载框
_isPaying = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.pop(context);
});
},
);
但是需要注意,微信的回调是异步的,并且有很多种情况是接收不到回调的,以下是确定收不到会调的情况。
微信调起支付页面时,其实是跳转到新的应用,对于我们的应用而言是触发了前后台切换的生命周期。
因此在检测到应用返回前台,并且支付状态还在进行中时,可以证明是收不到微信的支付状态回调,需要特殊处理下。
收不到的情况有:
// ① 弹出支付框后使用系统返回键关闭;
// ② 进入微信支付密码框后不输入使用系统导航切回app或者系统返回键返回;
// ③ 进入微信后直接返回桌面再回到应用;
// ④ 弹出支付框后锁屏再开屏;
// ⑤ 弹出支付款后下拉任务栏;
// ⑥ 输入密码成功后,直接返回桌面或者使用系统导航或者使用返回键返回app
// ⑦ 退出微信登录,进行支付后直接登录微信,在登录过程中回到app
// ⑧ 在系统应用管理中双开微信后,调起支付后不点击任一个微信端,而是点击取消
现在主流的做法是再支付页面监听app的生命周期,即由后台切回前台的时候,检测下状态,若还在支付中,直接进入查询结果页面,由后台去检验订单,拿到结果显示即可。(后台主动查询理论上还是存在微信服务端延时的问题,因此后台进行查询的时候,建议采取轮询机制,若是没有支付成功的话,延时5秒后再确认下更保险)
class _XXXPageState extends State with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this); //添加观察者
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this); //销毁观察者
super.dispose();
}
/// 应用状态监听
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
{
if (Platform.isAndroid && _isPaying) {
_isPaying = false;
// 监听到时安卓设备并且支付还在进行中,程序员要根据业务做一下处理
break;
}
default:
break;
}
super.didChangeAppLifecycleState(state);
}
}
到此,微信支付很愉快的解决了,以上代码是抽象出来的工具类,可以直接使用;但是不涉及任何业务流程的开发,这个需要使用者自己去补充。
综上,微信支付流程主线可简单粗暴总结为:服务端生成订单 → 客户端调起支付 → 客户端通知服务端核验订单 → 客户端拿到最终结果 → 客户端final支付。
整个过程形成闭环,有理有据,数据都由后端去操作安全合理。(最重点是前端工作量简直不要太少)。
可是,iOS就不一样了,简直不要太恶心!
① 支付数据完全依赖前端应用,很难跟业务后台的订单系统一一对应;
② 针对①的问题,IAP支付支持传递skPayment对象,里面的applicationUsername经常用来保存系统的OrderId;
但是应用支付成功后收到的回调中,applicationUsername却偶尔会出现为null的情况,没有了对应关系,就没办法核销业务系统中的订单从而为用户充值;
③ iOS支付回调非常不稳定,有时延迟严重;且没有任何注定查询的方法;
④ iOS应用内支付有很多异常情况要处理,最常见的就是没有登录、没有同意最新的iOS支付协议等,都会发送给app支付失败的回调;
但是当用户登录或是同意后,iOS系统又会触发新的支付,导致旧的附带业务订单号的支付无效,莫名又多出一个没有订单号的新支付;
⑤ 国内网上资料极度缺乏,基本都是19年以前的,Flutter的文章更是少的可怜,可参考性不强。
⑥ 测试文档对于中断购买的测试流程有巨坑,后面菜单一定不要错过~
通过查看文档和不断调试,我们发现:
① 支付错误的回调,基本能马上收到;
② 上面流程说到IAP支付需要手动结束支付流程。同时iOS规定不能对同一个skuId重复发起多次支付的,只要当前skuId有没有final的支付,再次发起都会失败;
② 无论支付成功或失败,只要app没有主动对当前支付进行final,每次启动app后,app都会收到这个支付信息的通知;
③ 关于applicationUsername,只有在支付完成马上收到回调的情况下,回调信息才会有这个信息;到②中的情况,肯定不会返回applicationUsername;
④ 没有applicationUsername就意味着订单对不上,因此我们需要进行凑单机制。
综上,我们对异常处理有了确定方案:
① app发起支付后,需要将业务OrderId和skuId进行持久化存储(即卸载应用都不会删除的数据);
②只要持久化存储不为空,启动app就需要马上启动监听,以接收iOS系统的订单推送;
③ 支付出错可以final当前支付,但是支付成功必须明确接收到iOS推送并且后台核验成功后,才能final,并删除持久化存储。
最终,结合到业务系统和特殊情况的处理后,支付流程应该如下:
同时还需要做一些特殊处理:
import 'dart:async';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
// iOS支付单一实例
final iOSPayment = IOSPayment();
class IOSPayment {
/// 单例模式
static final IOSPayment _iosPayment = IOSPayment.init();
factory IOSPayment() {
return _iosPayment;
}
IOSPayment.init();
// 应用内支付实例
InAppPurchaseConnection purchaseConnection = InAppPurchaseConnection.instance;
FlutterSecureStorage storage = new FlutterSecureStorage();
// iOS订阅监听
StreamSubscription> subscription;
/// 判断是否可以使用支付
Future isAvailable() async => await purchaseConnection.isAvailable();
// 开始订阅
void startSubscription() async {
if (subscription != null) return;
print('>>> start subscription');
// 支付消息订阅
Stream purchaseUpdates = purchaseConnection.purchaseUpdatedStream;
subscription = purchaseUpdates.listen(
(purchaseDetailsList) {
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.pending) {
print('>>> pending');
// 业务代码略:有订单开始支付,向外部发出通知,并记录到缓存中;
} else {
if (purchaseDetails.status == PurchaseStatus.error) {
print('>>> error');
// 业务代码略:有订单支付错误,向外部发出通知
// 下面是删除
String value = await storage.read(key: purchaseDetails.productID);
String orderId = value.split('¥')[0];
writeStorage(purchaseDetails.productID, orderId, 'cancel');
finalTransaction(purchaseDetails);
} else if (purchaseDetails.status == PurchaseStatus.purchased) {
print('>>> purchased');
String orderId = purchaseDetails.skPaymentTransaction.payment.applicationUsername;
if (orderId == null || orderId.isEmpty) {
// 如果applicationUsername为空,执行凑单
orderId = await foundRecentOrder(purchaseDetails.productID);
}
if (orderId.isEmpty) {
// 凑单失败,找不到业务单号,结束
finalTransaction(purchaseDetails);
BlocProvider.of(Application.navigatorState.currentContext).add(IosPayFailureEvent(errorMessage: '支付出错啦,请稍后再试~'));
return;
}
// 业务代码略:支付成功,向外部发出通知
// 业务代码略:开始核验订单,核验结果由外部监听
);
}
}
});
},
onDone: () {
stopListen();
},
onError: (error) {
stopListen();
},
);
}
/// 检查sku是否有对应商品
Future checkProductBySku(String sku, {Function(String err) onError}) async {
if (!await isAvailable()) {
onError?.call('无法连接AppStore,请稍后再试');
return false;
}
ProductDetailsResponse appStoreProducts = await purchaseConnection.queryProductDetails([sku].toSet());
if (appStoreProducts.productDetails.length == 0) {
onError?.call('没有找到相关产品,请联系管理员');
return false;
}
return true;
}
/// 启动支付
void iosPay(String sku, String orderId, {Function(String err) onError}) async {
// 获取商品列表
ProductDetailsResponse appStoreProducts = await purchaseConnection.queryProductDetails([sku].toSet());
// 发起支付
purchaseConnection
.buyNonConsumable(
purchaseParam: PurchaseParam(
productDetails: appStoreProducts.productDetails.first,
applicationUserName: orderId,
),
)
.then((value) {
if (value) {
// 只要能发起,就写入
writeStorage(sku, orderId, 'pending');
}
}).catchError((err) {
onError?.call('当前商品您有未完成的交易,请等待iOS系统核验后再次发起购买。');
print(err);
});
}
writeStorage(String key, String value, String status) {
storage.write(key: key, value: '$value¥$status');
}
// 关闭交易
void finalTransaction(PurchaseDetails purchaseDetails) async {
await purchaseConnection.completePurchase(purchaseDetails);
// 每完成一张订单进行缓存的清除
if (!await checkStorage()) {
stopListen();
}
}
// 凑单机制
Future foundRecentOrder(String sku) async {
String orderId = '';
String values = await storage.read(key: sku);
if (values != null) {
orderId = values.split('¥')[0];
}
return orderId;
}
// 校验是否还有缓存
Future checkStorage() async {
Map remainingValues = await storage.readAll();
return remainingValues.isNotEmpty;
}
// 关闭监听
stopListen() async {
subscription?.cancel();
subscription = null;
}
}
页面调用时,建议启用定时器,**因为iOS回调不稳定,所以监听到应用回到前台时开始30秒计时;30秒内没有收到支付回调,需要做对应提示,这一块也是存业务流程,我这里不做代码展示。**下面代码是如何调用上面工具类的:
iOSPayment.startSubscription();
iOSPayment.iosPay(
state.skuId,
state.model.orderId,
onError: (String err) {
if (!mounted) return;
// 支付遇到错误,马上停止定时器,并且关掉弹框
},
);
// 应用启动时
if (Platform.isIOS && await iOSPayment.checkStorage()) {
// 启动订阅:支付缓存未清除完毕、机型可使用应用内支付
iOSPayment.startSubscription(needDelayed: true);
}
#### 设置测试
通过[登录App Store Connect](https://help.apple.com/app-store-connect/#/devcd5016d31)启用对Sandbox Apple ID的中断购买,然后:
1. 在“用户和访问”中,单击边栏中沙箱下的“测试器”。在右侧,您可以查看您的Sandbox Apple ID。
2. 选择您要为其启用中断购买的Sandbox Apple ID。如果已启用,则会在“中断购买”列下看到一个复选标记。
3. 在出现的对话框中,选择“此测试仪的中断购买”。
#### 开始测试
1. 在测试设备上,使用已中断购买的沙盒Apple ID登录。
2. 在您的应用中,选择“购买”或“订阅”进行应用内购买。
3. 观察到系统显示付款单。
4. 在您的代码中,验证付款队列在状态下是否收到新交易。
5. 在设备上,验证付款单。
6. 在您的代码中,观察到付款失败。付款队列在状态中接收更新的交易。
7. 检查您的代码调用是否将其从队列中删除。
8. 在设备上,观察到系统显示“条款和条件”,从而中断了购买(因为您已配置了沙盒环境)。
9. 在设备上,点击以同意条款和条件。
10. 在您的代码中,验证付款队列接收到的新交易处于与失败交易相同且数量相同的状态.
11. 在您的代码中,验证收据。检查您的应用是否提供了服务或产品,然后致电。
12. 在设备上,用户应观察到购买成功。
也就是说在Apple后台把沙盒测试账号设置为中断即可。但是无论我怎么同意,收到的还是支付失败的订阅。其实是因为文档写漏了,中断后app弹出同意协议弹框,也就是上面第8步,这个时候必须在后台把中断测试关了,然后再执行第九步。(就是这么狗血,官方文档不给力,网上也没有任何资料,最后还是在官方论坛,看到某个QA的评论才找到的灵感。。。这里也感谢公司大佬花了半天专门找这方面的资料。)
##写在最后
感谢大家孜孜不倦看到最后,这篇长文希望能帮助开发支付的小伙伴少踩一些坑。
IAP的支付确实是很坑,但如果站在iOS开发者的角度来看。其实也能理解:他们是做手机系统的,他们能保证系统内部的所有支付流程,根本不care开发者的业务逻辑。
但无论如何,这种方式对于开发者,确实是极度不友善的;另外,还有一种流程,app发起支付后,只要有回调就马上final,成功就发给后台,由后台去执行凑单机制,这种对于前端其实更合理,毕竟数据存在客户端永远是不够安全,但是这样app就有可能对同一个skuId疯狂发起购买,后台凑单时,就做不到一一对应。有利有弊吧~~~