API 使用模式
本文处理的是在 C# 中调用 SDK API 时反复遇到的模式。不是单个 API 具体做什么, 而是无论调用哪个 API 都同样适用的规则进行了汇总。
API 原文在哪里
Runtime/SDK/的 C# 表面由客户端 SDK(@apps-in-toss/web-framework)的类型定义自动生成。当前 有 85 个 API,分布在 24 个分类中。
Unity IntelliSense
单个 API 的说明、参数、返回值。上位 SDK 的 JSDoc 已迁移为 C# XML 注释
平台政策、控制台设置、服务器联动等客户端 SDK 官方文档
这套文档集
上面两者都没有的 Unity 特有情况
本仓库的文档之所以不单独放 API 参考,是因为那会变成上位文档的手写副本。C# 表面会在每次更新 SDK 时重新生成,但手写的 Markdown 不会,因此时间一久必然会偏离。相反, IntelliSense 始终是最新的,而本文只写上位文档没有覆盖的内容——async/await、 Awaitable与 Task的分支, timeoutMs, AITException.ErrorCode,Mock(Editor mock、devtools)、IL2CPP stripping。
根据 SDK 版本,C# 表面如何变化,可以在 API 变更历史中查看。
基本模式
SDK API 是异步的。 await等待结果时不会阻塞 Unity 主线程。
using AppsInToss;
using UnityEngine;
public class Example : MonoBehaviour
{
async void Start()
{
// 使用 await 关键字等待异步结果
string deviceId = await AIT.GetDeviceId();
Debug.Log($"Device ID: {deviceId}");
}
}重要: 有一个例外。应用内支付的
ProcessProductGrant回调是唯一同步bool返回的。原因和正确结构见下方 应用内支付:发放批准与服务器验证 一节。
Awaitable 和 Task
即使是同一个 API,随着 Unity 版本不同,返回类型也不同。
6000.0 及以上
Awaitable, Awaitable<T>
及以下
Task, Task<T>
await以它们消费的代码在两边都能直接运行,所以大多数情况下无需在意。只有在 显式写出返回类型时 才会分岔。
如果需要同时支持两个版本且必须有返回类型,就用条件编译分开。
参考:
Task.WhenAll只在TaskUnity 6 及以上Awaitable可用,在别的版本没有。若要在 Unity 6 及以上同时推进多个 API,就用下面的方法。
调用多个 API
顺序调用就直接 await 接着写下去。
如果彼此独立,就先全部启动,之后再分别等待,这样往返会重叠。此方式在 Awaitable与 Task 两边都同样有效。
超时
所有异步 API 的最后一个参数都接收 timeoutMs。默认值是 0只在 无限等待。
这个超时 只放弃 C# 侧的等待。 桥另一侧的 JavaScript 和平台任务仍可能继续执行,晚到的结果会被丢弃。因此,给有副作用的 API(支付、分享、权限请求等)设置超时时,不要把“超时 = 未执行”当成定论。
AITClientTimeoutException只在 继承自 AITException,因此现有的 catch (AITException) 块可以直接接住。只有想单独处理超时时才先捕获它。 ErrorCode是 TIMEOUT。
应用内支付:发放批准与服务器验证
IAPCreateOneTimePurchaseOrder / IAPCreateSubscriptionPurchaseOrder传给它的 ProcessProductGrant 回调会将发放与否 bool同步返回为 。关键是不要在这个回调里验证——回调要立即批准,服务器验证和实际发放则在覆盖层关闭后继续进行。核心 再 onEvent中进行。
这个回调不是可选的
ProcessProductGrant是可空字段,即使不指定也能编译, 但如果不指定,所有支付都会被当作发放失败。
JS 桥会把这个回调 始终 传给平台,所以如果在 C# 中没有注册处理器,SDK 就会在每次支付完成时自动返回 false。此时 Console 会留下如下错误:
接入支付流程时,先填这个字段。
为什么必须同步
支付覆盖层显示期间 visibilityState = hidden,因此 requestAnimationFrame会停止,单靠它运行的 Unity WebGL player loop 也会一起停止。所以在回调里 await的一个 continuation 会等待覆盖层关闭后才会到来的帧,而覆盖层又在等待该回调的响应,于是形成死锁。实机测量中,这个环路 持续了 115 秒 之后 "{应用名} 出现问题了。请申请退款" 页面出现了(支付成功后 30 秒内 true 没有响应就可能显示),而立即批准的支付在覆盖层 1.5 秒后关闭并正常完成。把返回类型固定为 bool,就是为了在编译阶段阻止这种 await 形态。
有两本账
回调返回值和我方服务器的发放记录是 两本不同的账。
ProcessProductGrant 返回值
支付是否已消耗
Toss
30 秒(无帧)
我方服务器的发放记录
是否已交付商品
开发商
无截止,可重试
验证不是阻止 第一本账, 回调是回答“我已接收支付消耗”的地方,而验证和发放则留到之后从容进行。
所以,放进这个回调里的代码其实几乎就定成一行。
第 1 步回调立即批准
这个回调一旦被调用,就已经表示应用判定支付成功了。回调带来的信息只有 OrderId 一个,因此在这里也无法重新验证什么。
第 2 步验证与发放在 onEvent 中
服务器验证的 只有两个时点可以调用。
正常流程下
onEvent——覆盖层刚关闭后。如果连这也错过了, 应用启动时的台词(第 3 步)。
onEvent之所以是第一个有效时点,是因为那是 OrderId和活着的 player loop 同时存在的最早瞬间。下面是实机测量的某次支付时间线。
onEvent开始之后,帧就以正常速度运行了, await可以尽情使用WaitForSecondsRealtime(0.2f)(在 202ms 完成)。
注意:
SuccessEvent.Data里没有Sku。商品是什么,只能通过当初开始购买时传入的sku闭包捕获,或者由服务器OrderId查询。
服务器验证什么
不能直接相信客户端发来的 OrderId。开发商服务器会通过 订单状态查询 API直接向 Toss 确认。
必须使用 mTLS 证书(服务器之间通信)。证书和用户认证头的说明请参考 认证文档。
x-toss-user-key如果在头部里放入通过 Toss 登录获得的 userKey ,就只会响应该用户的订单 。不填的话会查询所有订单,因此若要防止拦截并重用其他用户的OrderId,就必须一并发送这个头。响应里的
sku可以确认实际支付的商品。不要信任客户端告诉你的 SKU。
响应 status是这个 API 的核心。
PURCHASED
支付和商品发放都已完成
PAYMENT_COMPLETED
支付已完成,但 商品发放失败
REFUNDED
退款完成
FAILED / ORDER_IN_PROGRESS / NOT_FOUND
支付失败 / 进行中 / 订单不存在
前两个值也就是 ProcessProductGrant 返回值的结果。 true返回的订单是 PURCHASED,否则的订单是 PAYMENT_COMPLETED。
详细规范请见 官方 IAP 文档。
第 3 步 应用启动时的未交付台词
不能保证第 2 步一定会执行。若在回调发送 true后应用立刻退出, onEvent就收不到 IAPGetPendingOrders中也不会出现。
回收这种情况的是 IAPGetCompletedOrRefundedOrders。在应用启动或回到前台时扫一遍,找出我方服务器还没发放的订单。
如果没有这第 3 步,第 1 步的立即批准就会变得危险。 这三者是一组。
重要:退款只能通过轮询得知。支付或退款发生时,并不会提供通知开发商服务器的 webhook。即使用户完成了退款,在应用再次运行并触发这段台词之前,开发商也无从得知。若要回收已退款订单的商品,就必须把已发放订单的
OrderId保存在服务器上,并通过订单状态查询 API 定期检查。
false 什么时候返回
官方文档说明, true对于 false 之外的响应,退款提示页面 可能会显示。 (我直接测量的是无响应路径,尚未确认显式 false时是否也会出现同样的画面。)因此 false是 只有在真的无法把这个商品发给用户时 才使用——比如在支付过程中,已拥有的非消耗品在别的设备上被获取,因而现在能断定无法发放的时候。
“因为没把握,所以先 false”这种说法不成立。那样会让应用每次支付都弹出退款提示。把握应通过 1~3 步获得, false而不是通过
参考:旧版 Toss 应用里会忽略返回值。
processProductGrant在不支持的版本(Android 5.231.1 以下 / iOS 5.230.0 以下)中,桥接会回退到旧支付路径,此时回调返回值不会传到平台,而是被丢弃。编写依赖返回值的逻辑时,请把这一区间考虑进去。
错误处理
如果 API 调用失败 继承自 AITException会抛出这个异常。
Message
string
人类可读的错误消息
ErrorCode
string
错误代码。如果平台未提供,则为空字符串
APIName
string
失败的 API 名称。若未知则为空字符串
IsPlatformUnavailable
bool
是否因缺少平台桥接而产生的错误
ErrorCode若要据此分支,请注意值可能为空。
IsPlatformUnavailable
这个标志不是作为单独字段传递的,而是 根据错误消息来判定。如果下面字符串中任意一个出现 true就会变为 true。
__GRANITE_NATIVE_EMITTER
没有原生 emitter
ReactNativeWebView
在 Toss 应用 WebView 外部运行中
不是常量处理器
该 API 没有桥接处理器
无法读取 undefined 的属性
window.AppsInToss尚未初始化
true如果是这样,就不是代码 bug,而是 运行环境问题。这在普通浏览器或开发环境中很常见,因此在上报错误时,最好将此情况降为较低严重性或过滤掉。
按运行环境的行为
WebGL 构建 + Apps in Toss 应用
实际原生 API 调用
WebGL 构建 + 普通浏览器
大多会失败。若 devtools 开启(Dev Server),则以 mock 响应
Unity Editor
Editor mock 调用
其他平台(Windows、macOS 等)
Editor mock 调用
Editor mock 与构建配置无关。 Runtime/SDK/的各个 API #if UNITY_WEBGL && !UNITY_EDITOR分叉成,因此如果不是 WebGL 构建 在编译时 只会保留 mock 路径。
如有需要,可以按运行环境分支。
若要确认实际原生行为,必须构建为 WebGL 并在 Apps in Toss 应用中运行。在 Editor 中无论做什么都是 mock。
模拟
被称为“Mock”的有 两种,并且它们的行为不同。
在哪里
Runtime/SDK/的 C#
@apps-in-toss/devtools(npm 包,在浏览器中打开构建产物时运行)
什么
所有 SDK API
60 多个 SDK API + 可操控状态的浮动面板
何时
不是 WebGL 构建时(在编译时决定)
在普通浏览器中打开通过 Dev Server 运行的构建时
如何关闭
无法关闭
AIT > Configuration的 devtools 设置,或服务器运行时的环境变量 AIT_DEVTOOLS=0
Editor mock
在 Unity Editor 和非 WebGL 平台上调用 API 时,会记录日志并返回默认值。由于不会抛出异常,所以在 Editor 中游戏逻辑不会停止。
string
空字符串 ""
bool
false
数组
空数组
类类型
default,也就是 null
取消订阅 Action
仅记录日志的函数。 SafeAreaInsetsSubscribe仅 null
类类型为 null会以该形式传来,这一点很重要。在 Editor 中 result.SomeField如果直接读取 空引用异常如果要在 Editor 中也运行这段逻辑,请加入 null 检查。返回数组的 API 会返回空数组,因此 foreach 是安全的。 foreach是安全的。
devtools
@apps-in-toss/devtools是 @apps-in-toss/web-framework 仅限 3.x 是开发工具。运行 Dev Server 时,vite 插件会 @apps-in-toss/web-framework 将 import 别名到 mock 实现,在没有 Toss 应用的普通浏览器中,60 多个 SDK API 会以 mock 方式运行。同时屏幕上会出现浮动面板,可以直接操作登录状态·广告结果·存储值等 mock 状态。
面板默认开启。若要关闭整个 devtools(或仅面板) AIT > Configuration请更改 devtools 设置——因为构建产物保持不变, 只需重启服务器即可生效。如果像 CI 或临时确认那样,只想在不改设置的情况下临时关闭一次,可以用服务器运行环境变量 AIT_DEVTOOLS=0进行覆盖。
在 devtools 关闭的状态下(例如在普通浏览器中打开但禁用了 devtools 的情况)调用 SDK API 时, IsPlatformUnavailable是 true为 继承自 AITException会发生。
相关文档
这有帮助吗?