相信很多iOS App的开发者,特别是手游开发者,都接触过苹果支付IAP(In-App Purchase)。相信使用了IAP的App,都经历过“掉单”问题。
什么是“掉单”呢?简言之就是用户付款买金币,钱扣了,金币却没到账。
掉单一旦发生,用户通常会很愤怒地来找客服。然后客服只能找开发人员把金币给用户手动加上。
显然,伤害用户的体验,特别是伤害付费用户的体验,是一件相当糟糕的事情。
我们在微爱App的开发过程中,IAP支付的掉单问题也困扰了我们很久。直到去年第四季度的一次优化,才算是彻底解决了掉单问题。
掉单是如何产生的呢?这需要从IAP支付的技术流程说起。
IAP同国内的支付宝、微信支付都是用于支付的平台接口,但它们在支付的技术流程上却有着本质的不同。
支付宝和微信支付在支付流程上非常相似(顺便说一下,微信支付早期的API设计甚至跟支付宝API在参数命名上都保持一致),如果忽略掉它们之间的细微差别,那么它们的支付流程大体上可以按如下描述:
注:目前的微信支付在最开始还多一步向平台获取prepayid的过程,不过这不是我们讨论的重点,我们暂时忽略它。
而IAP的支付流程完全不同:
那么在上述流程中,可能发生掉单的环节是哪些呢?
在支付宝和微信支付中,如果第4步回调发生错误(比如网络超时、App服务器处理异常),那么就会发生掉单。在前3步,用户已被扣款,但由于回调错误而没有给用户发货。在这个流程中,防止掉单的措施主要是支付宝和微信支付服务器在检测到回调发生失败后,会再次重试回调。通常重试间隔会越来越大,并设定一个最大的重试次数。虽然在连续失败达到最大重试次数之后,最终支付宝和微信支付服务器会放弃回调重试(发生掉单),但这种概率极小。
而在IAP的支付流程中,在用户扣款之后,在第4步和第5步,都很有可能发生错误(比如网络超时、App崩溃、App服务器处理异常、App Store服务器异常),尤其是网络错误,从而发生掉单。第4步主要是客户端到App服务器之间的网络错误,由于移动客户端经常处于弱网环境,所以这种错误就很容易出现。第5步主要是App服务器到App Store服务器之间的网络错误,由于国内的服务器与App Store服务器之间网络延迟通常很高,这种错误也比较容易出现。另外,App Store服务器还偶尔会返回503,这也是造成发货失败的原因之一。
在IAP中能够防止掉单问题的方式,是利用事务机制。IAP中的每次支付行为被抽象成一个事务(SKPaymentTransaction),只有事务被正常结束(finishTransaction:)该次支付行为才算完成。即使一次支付中途被中断,这次事务也并没有丢失。假设支付没有完成App就退出了(比如突然崩溃了),那么当下次App重启之后(调用了addTransactionObserver:),之前被中断的事务会接着进行。
但是,IAP提供的这种基本的事务机制,对于支付流程的完整性只能提供一个比较弱的保证。它的缺点有以下几个:
在任何工程性的系统中,失败和错误都不可避免。好的技术方案不仅能在正常的情况下保证逻辑正确,还应该能保证在系统发生错误的时候让系统有机会从错误状态中恢复。在支付宝和微信支付中,错误恢复主要由平台服务器负责(重试回调),App开发者承担的任务较少;而在IAP中,错误恢复很大程度上要依赖App开发者来完成。App开发者要确保App客户端和App服务器之间有一个更强的通信通道。这样看来,IAP比支付宝或微信支付更容易发生掉单现象,也就不足为奇了。
为了应对IAP支付流程中的上述缺陷,我们在优化中考虑了如下的关键点:
下面分别详细介绍一下这几个点。
首先是自动重试的发货任务。正常情况下,在用户完成付款后(即事务状态变为SKPaymentTransactionStatePurchased时),发货任务被启动。发货任务一旦启动,将会不断重试,直到发货成功。因此,启动后的发货任务可能处于两种不同的状态:
另外,即使是发生连续多次支付行为,程序逻辑也要保证发货任务同时不会启动多次。考虑到这些因素,发货任务的启动逻辑可以按如下设计:
只有当某次发货请求执行成功后,App客户端才调用finishTransaction:将发货任务结束掉,并不再重试;否则,发货任务就等待一段时间重新进入上述启动发货任务的逻辑。
在上述发货任务的实现逻辑中,涉及到异步编程以及如何取消一个异步任务。有关异步编程中需要注意的事项,请参见笔者的系列文章《Android和iOS开发中的异步处理》
第2点是使用App Receipt来代替transactionReceipt。这也正是苹果官方所强烈建议的,并且从iOS 7.0开始transactionReceipt已经被置为过期接口。实际上,App Receipt本身并不仅仅用于IAP订单的验证,还用于App本身的验证。你可以利用App Receipt的客户端本地验证来确保用户只能使用从App Store下载到的你的App版本(实际上这也是苹果希望App开发者去做的)。如果你的App需要付费才能下载,那么这个检验就非常有意义。
当利用App Receipt来验证IAP订单时,我们需要验证的是在App Reciept中所包含的IAP receipt列表(in_app节点)。与iOS 7.0之前的方式相比,这种方式的明显区别是:它包含一个IAP receipt列表而不是仅仅一个IAP receipt。这使它本身带有某种程度的自动修复的特性。如果用户某次支付没有被正确完成也没有后续被成功恢复,那么当他在同一个手机设备上产生下一次支付行为时,App Receipt中就会包含前后两次支付的IAP receipt,这就能让上次失败的订单一并恢复。
第3点,发货任务的重启动不直接依赖IAP的事务机制。按照正常的IAP事务机制,如果用户已经付款成功,但最终没有发货成功(finishTransaction:最终没有被调用),那么下次App启动后在SKPaymentQueue的addTransactionObserver:调用后,paymentQueue:updatedTransactions:会自动被回调,从而使得之前未完成的事务得以继续。但是,这一机制是否一如既往地如苹果宣称的那样值得信赖,我个人是持怀疑态度的。在我们以前使用IAP的过程中,我们总是会碰到一些无法被IAP的事务机制恢复的情况。在iOS平台提供的API中,总是存在一些令人不安的设计(实际上其它平台上也不乏这样的例子),这也算是其中之一。
我们采取的策略是,把自动重试的发货任务的执行状态在客户端持久化下来。当下次App启动时,我们可以依赖之前持久化的发货任务状态来重启发货任务,而不必依赖苹果的事务机制来重启任务。注意:我们之前描述的发货任务的启动逻辑已经可以确保发货任务同时不会启动多次。
需要注意,我们这样一种脱离IAP事务的设计,会影响我们最终对于结束事务时的处理。通常情况下,由paymentQueue:updatedTransactions:回调所启动的发货任务,由回调接口已经传进来了需要处理的SKPaymentTransaction实例,这样在发货成功后打算结束事务时,我们便很自然能拿到需要结束的SKPaymentTransaction实例。
然而,在我们自己控制下启动的发货任务,在任务结束时我们只能拿到transactionIdentifier,没有现成的SKPaymentTransaction实例可以供我们传给finishTransaction:接口。但这算不上一个难题,我们可以遍历SKPaymentQueue的transactions列表,通过对比transactionIdentifier来找到SKPaymentTransaction实例。
经过上述对IAP实现的优化,我们几乎再也没有碰到过毫无缘由的掉单现象。
在这篇文章最后,我再把IAP开发中值得开发者关心的其它一些问题,补充说明一下。
早在2012年7月,IAP曾被俄罗斯的工程师ZonD80破解。比如,这篇早期的文章(http://www.zdnet.com/article/apple-ios-in-app-purchases-hacked-everything-is-free-video/)声称:
Apple iOS in-app purchases hacked; everything is free.
虽然苹果官方宣称在iOS 6中解决了这个漏洞,但有些借此盈利的人手里应该还保留有旧的iOS版本,也许淘宝上那些可以打折为游戏充值的店铺今天仍然在利用这个漏洞(虽然没有直接证据,但很值得怀疑)。
作为iOS开发者,应该尽量做到:
提交IAP支付请求(SKPaymentQueue的addPayment:接口),需要传入一个SKPayment实例。这个实例可以从productIdentifier创建(SKPayment的类方法paymentWithProductIdentifier:),但是这个接口从iOS 5.0开始过期了。
按苹果的建议,应该使用SKPayment的paymentWithProduct:方法来创建SKPayment实例。然而这个接口需要传入一个SKProduct实例。而要获取一个SKProduct实例,必须先使用SKProductsRequest向App Store查询产品信息。苹果建议的购买流程是,先使用SKProductsRequest查询到所有售卖物品的产品信息(以SKProduct实例来表达),然后再展示商店购买页面。
这个获取SKProduct实例的过程,苹果官方之所以如此设计,可能是为了确保App内的商店购买页面对于商品的展示与iTunesConnect后台的配置保持完全一致。但是,SKProductsRequest查询的过程会增加好几秒的耗时(在国内经常在5秒以上)。这会导致商店购买页面本身显示出来非常慢。
因此,最终我们决定还是使用已经过期的SKPayment的paymentWithProductIdentifier:来创建一个SKPayment实例。这样可以做到先将商店购买UI快速展示出来。
用户退款的订单有可能依然在App Receipt中出现,因此App服务器实现验证的时候需要能够识别出已经被退款的订单,不至于给退款的订单发货(甚至发两次货)。
被退款订单的唯一标识是:它带有一个cancellation_date字段。
iOS 7新的App Receipt在验证完毕后,App Store服务器返回的验证结果(status)所表达的含义发生了变化。如果返回status=0,那么只是表示整个App的票据验证通过,并不表示票据中所包含的每个IAP receipt都有效。甚至有可能App Receipt中根本不包含任何IAP receipt,status也可以是0。
另外,由于App Receipt可能包含多个IAP receipt,因此App服务器并不能保证所有IAP receipt一次性都发货成功。
所以,在设计发货请求的响应参数的时候,一定要能够区分出如下几种case:
App服务器在国内连接App Store服务器进行票据验证时,网络延迟较大。一般最低也要200多ms,而在大的时候能超过7s。
因此,如果有条件,建议给App Store服务器的验证请求加上国际代理(比如使用HTTP CONNECT tunneling),降低请求延迟。
总之,IAP和支付宝、微信支付的机制完全不同,它的API之所以这样设计,可能是为了同时支持纯客户端和有服务器的客户端。但IAP目前的这种实现机制,确实给App开发者带来了挑战。它需要我们更加谨慎,每一步的程序逻辑都更多地考虑容错,才能实现出一个稳定的支付方案。