• uniapp之ios开发及支付整体流程爬坑记录


    前言

    在写这篇记录的时候,关于ios的支付已经对接的差不多了,下一步就是测试好了直接发版,总共花了好几周的时间,从0到1对于首次做ios支付来说,确实很很多坑。

    其实业务层面很简单,甚至比安卓支付还简单,因为支付的整体流程uniapp那边已经提供好了,甚至可以直接套模板。主要坑在于不了解ios内购这套东西,及其细节处理。

    该APP使用的是uv-ui组件库,uv-ui 破釜沉舟之兼容vue3+2、nvue、app、h5、小程序等多端基于uni-app和uView2.x的生态框架,支持单独导入,开箱即用,利剑出击。

    准备工作

    最重要的就是准备证书、描述文件等环节。现在uniapp开发ios没有这两样东西,不能真机运行。还需要区分测试证书和正式证书,本地真机运行只能使用测试证书,正式证书只能打包上传后台到TestFlight中下载测试或提App Store审核(即时到了这步,内购也只能使用沙箱环境支付,非正式支付。只有App Store审核通过后才能走真实支付,这里建议开发灰度测试功能,后续会详细讲解)。

    注册账号及创建APP等准备工作都是产品去做的,所以对此流程可能会有遗漏,所以只记录大概我所了解。

    1. 创建你的 Apple ID:https://developer.apple.com/account
    2. 创建一个App应用:
      • 登录iTunes Store,点击我的App
      • 新建一个App(如果App已经创建,直接点击App进入就行了)
      • 填写App的基本信息
    3. 创建证书、描述证书等(Certificates, Identifiers & Profiles):Apple后台uniapp申请证书引导
      • 需要mac电脑
      • 证书及描述文档要分别创建开发版(Development)和发行版(Distribution),参考uniapp申请证书引导
      • 本地调试使用Development,打包上线审核使用Distribution
    4. 最终需要的东西:
      • Bundle ID(AppID)- 创建证书的时候会填写,类似安卓的证书名(推荐反域名+app标识
      • 证书 - xx.p12文件
      • 描述文件 - xx.mobileprovision文件
      • 证书私钥密码 - 创建证书的时候填写的密码
    5. 首次运行需要进行基座签名
    6. 如果有ios内购项目,添加内购项目:
      • 点击我的App进入App Store
      • 选择功能/App内购买项目
      • 创建App内购买项目:类型、产品 ID等信息
      • 最后须知:ios内购的模式是充值,只能是创建固定的金额进行支付,并且平台抽成30%
    7. 添加沙盒账号
      • 回到iTunes Store首页,点击用户和访问权限 进入 用户和访问
      • 点击沙箱测试员
      • 添加沙箱测试账号信息:沙箱账号的邮件地址是需要没有注册过Apple ID的邮箱,知道这点很重要
      • 总结:沙箱测试账号的作用,在后面测试支付的时候会让输入账号和密码,这时候就需要用到这个沙箱账号了,否则其他账号密码是没用的

    真机运行

    1. 完成上面的所有准备,就可以直接使用hbuilderX运行到苹果手机上面
    2. 总之,在使用hbuilderX运行到真机的时候,提示缺什么,我们就需要按照上面的方法准备什么

    正式开发

    业务开发
    1. 使用app-nvue技术开发ios,90%的代码与安卓都是通用的,毕竟多平台跨端开发,只是有些兼容性问题,需要单独处理而已,具体问题具体分析。
    2. 具体开发的内容就不做详细的介绍,接下来把ios内购买项目做简单的记录。
    ios内购开发
    1. 参考文档:uniapp之苹果应用内支付,在开发之前一定要对该文档进行通读和了解,很多开发代码合流程都在这里面。
    2. 后端需要准备两个接口:
      • 接口1:生成业务订单号,前端需要获取后做相关关联
      • 接口2:最后一步,在服务器端请求苹果服务器验证票据
    3. 前端开发步骤:
      • 写好充值页面
      • 创建公共文件iap.js,封装的支付相关处理逻辑,方便后续调用,代码是现成的,直接到示例代码复制
      • 确认充值相关逻辑,完整示例代码,该示例里面需要完善两个接口的逻辑。注意:示例代码可能造成丢单情况,需要配合本地缓存进行处理,参考下面的完整示例。
      • 为了方便理解,我把开发中的完整代码贴在下面:

    充值页面混入pay.ios.js:

    import { Iap, IapTransactionState } from "@/common/js/iap.js"
    export default {
    	data() {
    		return {
    			title: "iap",
    			loadingIOS: false,
    			disabled: true,
    			productId: "",
    			productList: [],
    			isError: false
    		}
    	},
    	methods: {
    		async payInitIOS() {
    			uni.showLoading({
    				mask: true,
    				title: '苹果验证中,请稍等'
    			});
    			this.isError = false;
    			// 创建实例
    			this._iap = new Iap({
    				products: [this.productId] // 苹果开发者中心创建
    			})
    			try {
    				// 初始化,获取iap支付通道
    				await this._iap.init();
    				// 从苹果服务器获取产品列表
    				this.productList = await this._iap.getProduct();
    				this.productList[0].checked = true;
    				this.productId = this.productList[0].productid;
    				// 填充产品列表,启用界面
    				this.disabled = false;
    			} catch (e) {
    				this.isError = true;
    				uni.showModal({
    					title: "init",
    					content: e.message,
    					showCancel: false
    				});
    			} finally {
    				if (this._iap._ready && !this.isError) {
    					this.restore();
    				} else {
    					uni.hideLoading();
    				}
    			}
    		},
    		async restore() {
    			// 检查上次用户已支付且未关闭的订单,可能出现原因:首次绑卡,网络中断等异常
    			// 在此处检查用户是否登陆
    			// uni.showLoading({
    			// 	mask: true,
    			// 	title: '苹果验证中,请稍等'
    			// });
    			try {
    				// 从苹果服务器检查未关闭的订单,可选根据 username 过滤,和调用支付时透传的值一致
    				const transactions = await this._iap.restoreCompletedTransactions({
    					username: ''
    				});
    				if (!transactions.length) {
    					return;
    				}
    				// 开发者业务逻辑,从服务器获取当前用户未完成的订单列表,和本地的比较
    				// 此处省略
    				for (let i = 0; i < transactions.length; i++) {
    					const transaction = transactions[i];
    					switch (transaction.transactionState) {
    						case IapTransactionState.purchased:
    							this.isError = true;
    							// 用户已付款,在此处请求开发者服务器,在服务器端请求苹果服务器验证票据
    							uni.showLoading({
    								mask: true,
    								title: '您有一笔订单正在处理中...'
    							})
    							const order_sn = transaction.payment.username || uni.getStorageSync('IOSPAYORDERID');
    							if(!order_sn) {
    								this.isError = false;
    								return await this._iap.finishTransaction(transaction);
    							}
    							let result = await this.validatePaymentResult({
    								product_id: transaction.payment.productid,
    								order_sn: order_sn,
    								receipt: transaction.transactionReceipt, // 不可作为订单唯一标识
    								transactionIdentifier: transaction.transactionIdentifier
    							}, 0);
    							// 验证通过,交易结束,关闭订单
    							if (result) {
    								await this._iap.finishTransaction(transaction);
    							}
    							break;
    						case IapTransactionState.failed:
    							this.isError = false;
    							// 关闭未支付的订单
    							await this._iap.finishTransaction(transaction);
    							break;
    						default:
    							break;
    					}
    				}
    			} catch (e) {
    				// 为了兼容高版本机型在取消订单时候出现的错误,重启后不存在
    				if(e.code == -100 && e.errMsg.indexOf("本地没有响应要移除的事务")>-1){
    					this.isError = false;
    					return;
    				}
    				this.isError = true;
    				uni.showModal({
    					title: `restore${e.errCode}`,
    					content: e.message,
    					showCancel: false
    				});
    			} finally {
    				if (!this.isError) {
    					this.paymentIOS();
    				} else {
    					uni.hideLoading();
    				}
    			}
    		},
    		async paymentIOS() {
    			if (this.loadingIOS == true) {
    				return;
    			}
    			this.loadingIOS = true;
    			uni.showLoading({
    				mask: true,
    				title: '支付处理中...'
    			});
    			try {
    				// 从开发者服务器创建订单
    				const orderId = await this.createOrder({
    					productId: this.productId
    				});
    				// orderId存在本地,防止丢失
    				uni.setStorageSync('IOSPAYORDERID', orderId);
    				// 请求苹果支付
    				const transaction = await this._iap.requestPayment({
    					productid: this.productId,
    					username: orderId,
    					manualFinishTransaction: true,
    					quantity: 1
    				});
    				// 在此处请求开发者服务器,在服务器端请求苹果服务器验证票据
    				await this.validatePaymentResult({
    					product_id: this.productId,
    					order_sn: transaction.payment.username || orderId,
    					receipt: transaction.transactionReceipt, // 不可作为订单唯一标识
    					transactionIdentifier: transaction.transactionIdentifier
    				});
    				// 验证成功后关闭订单
    				await this._iap.finishTransaction(transaction);
    				// 支付成功
    				this.paySccuess();
    			} catch (e) {
    				uni.$uv.toast('支付取消或失败');
    			} finally {
    				this.loadingIOS = false;
    				uni.hideLoading();
    			}
    		},
    		createOrder({ productId }) {
    			return new Promise((resolve, reject) => {
    				this.getOrderInfo({ product_id: productId }).then(res => {
    					resolve(res.order_no);
    				})
    			})
    		},
    		/**
    		 * 充值,e.code = 201 或 then返回均代表 处理成功
    		 * @param {Object} data 订单数据
    		 */
    		validatePaymentResult(data, type = 1) {
    			return new Promise((resolve, reject) => {
    				const fn = (loading = 1) => {
    					this.validatePayment(data, loading).then(res => {
    						// 处理成功
    						uni.hideLoading();
    						if (type == 0) {
    							this.successTip();
    						}
    						resolve(true);
    					}).catch(e => {
    						if (e.code == 201) { //处理成功-订单已更新
    							this.successTip();
    							uni.hideLoading();
    							resolve(true);
    						} else {
    							setTimeout(() => {
    								fn(0);
    							}, 3000)
    						}
    					})
    				}
    				fn(type == 0 ? 0 : 1);
    			});
    		},
    		applePriceChange(e) {
    			this.productId = e.detail.value;
    		},
    		successTip() {
    			uni.showModal({
    				title: '温馨提示',
    				content: '您的待处理订单已经处理成功,充值金额已到您的账户余额中,请注查收!',
    				showCancel: false,
    				confirmText: '我知道了'
    			});
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209

    测试支付

    1. 其实ios内购在整个业务逻辑并不复杂,不需要像其他支付进行轮询监听等逻辑,ios内部已经做好了这些事情。
    2. 开始测试就需要对ios这个后台有所了解,我也是第一次接触,所以更多的时间是摸索后台怎么设置,我就讲讲我到底经历了哪些问题:
      • 第一步实例化支付就失败了,后来发现是内购项目未创建,必须先创建购买项目,代码中需要使用产品ID。
      • 购买项目创建好后,实例化等逻辑可以走通了,支付之前会弹出一个让输入账号密码的弹窗。一开始我以为是输入自己的AppleID和密码,试了下输入后提交就失败了。在这里也卡了不少时间,后来发现这里是输入沙箱账号,后来创建了沙箱账号成功支付。
      • 测试过程中,发现有丢单情况,可能是由于网络或者后端验证失败等其他原因,导致最后断单了,只有重启APP才能补单,但是有些机型发现请求苹果支付this._iap.requestPayment传的username参数也会丢失,导致补单的时候和我们的订单关联不上,所以后端无法做最后的验证票据,我们的处理方式是配合本地缓存进行处理,如果只是单个订单支付,就可以这样处理,这里的坑在后面专门进行说明,上述完整示例代码中也有体现。
      • 沙箱环境测试通过,接下来想使用正式支付。开始使用TestFlight测试(测试人员可以在这个软件上安装app进行测试);后来发现在发布App Store审核上线之前,都只能进行沙箱环境支付,这个确实有点坑。经过咨询官方给的解决方案:设置几个账号进行灰度测试,以前没有这个概念,现在终于明白了,灰度测试是这样使用的,经过商量就开发了灰度测试功能,就是固定几个账号才能在上线后支付,等支付没问题后,再放开所有账号支付权限。

    开发过程中遇到的坑

    首次开发ios及其内购买项目,遇到坑是正常的,感谢这次机会,至少让我得到了成长,接下来就讲讲整个ios开发遇到了哪些坑:

    坑一:本地没有响应要移除的事务

    如果输入沙箱账号和密码支付后未完成后续验证,杀掉APP进程,重启APP进行补单。这时候肯定会检测到未支付的订单,就需要手动关闭订单this._iap.finishTransaction。但是某些苹果机型一直反馈错误信息:undefined.Payment_appleiap:本地没有响应要移除的事务,https://ask.dcloud.net.cn/article/282

    原因分析:在6s机型没有这问题,在7等机型会有这个问题,导致支付流程不能往下执行

    解决方案:捕捉到此错误,然后就当正确的逻辑处理,在上述完整示例代码中也有体现

    catch (e) {
    	// 为了兼容高版本机型在取消订单时候出现的错误,重启后不存在
    	if(e.code == -100 && e.errMsg.indexOf("本地没有响应要移除的事务")>-1){
    		this.isError = false;
    		return;
    	}
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    坑二:不能真实支付

    本地只能沙箱账号进行支付测试,怎么办?

    解决方案:根据uni官方的回复,灰度测试,设置几个固定账号进行上线后测试,其他账号暂不支持支付。官方回答:https://ask.dcloud.net.cn/question/179074?notification_id-1321394__rf-false__item_id-254173#!answer_254173

    坑三:丢单+补单

    输入密码支付过程中,杀掉进程,会造成丢单情况

    原因分析:由于网络或者用户主动关闭APP等情况,支付流程断掉,如果根据username进行订单关联,可能有些机型在补单的时候丢失该值,最终导致丢单,这在ios是正常情况

    解决方案:

    1. 根据业务需求,配合本地缓存将订单记录,在补单的时候好做对应
    2. 可以不使用订单号,据说ios没得订单号的概念,直接后端进行验证,这种方案我们没试过
    3. 这里有个keep客户端开发也遇到丢单的情况,经过多次测试修改,最终的流程和我们的处理方案一致,这个很有参考意义:根治顽疾:Keep客户端 In-App Purchase 掉单踩坑指南
    坑四:打包上传

    打包上传到iTunes Store,versionCode每次上传都得高于上一次,versionName可以不变

    上传到iTunes Store的工具推荐(必须mac):通过 Transporter App 上传 App 的二进制文件

    坑五:打开APP苹果手机发烧严重

    同一套代码,在安卓机没问题。但是在ios发现发烧很严重,打开APP就开始发烧。

    原因分析:1. 开始以为是本地基座的问题,其实仔细想想不会是这个问题,uniapp不会这么拉胯;2. 经过代码排查,发现是因为image标签使用了@load,我们APP中恰好有很多图片展示,这应该是ios这边的机制比较耗CPU,导致发热严重。

    解决方案:去掉image上的@load,取消图片加载效果,只做图片失败效果

    坑六:uniapp打包提示:打包时未添加OAuth模块

    原因分析:代码中使用了uni.preLogin相关,但是ios并未涉及相关模块,所以在ios端屏蔽掉就OK了。

    解决方案:参考文档:http://www.codingwhy.com/view/12174.html

    坑七:审核多次驳回
    1. 提审后支付报错:Payment_appleiap:Failed to return order information
      http://www.60day.cn/article/show/1367
    上线后首次进入APP首页无网络?

    监听网络状态

    uni.onNetworkStatusChange(function (res) {
    	console.log(res.isConnected);
    	console.log(res.networkType);
    });
    
    • 1
    • 2
    • 3
    • 4
    坑八:沙箱环境被封IP

    这是怎么回事呢?我们公司有个需求就是测试苹果退款功能,这个基本上都是后端实现。本地测试次数太多,然后苹果那边就把IP给禁了,在再怎么调取支付等功能都失败。

    解决办法:换IP

  • 相关阅读:
    mybatis-plugin插件执行原理
    那些你面试必须知道的webpack知识点
    DDD的简单落地实现
    CPU超高问题排查
    2024年山东高企申报注意事项
    mediapipe流水线分析 二
    【MySQL】导入 JSONL 数据到 MySQL数据库
    HTML基础
    # ODS及DWD层自动化构建##, 220731,
    Java面试题:Java中垃圾回收机制是如何工作的?请描述几种常见的垃圾回收算法
  • 原文地址:https://blog.csdn.net/qq_42961150/article/details/133686574