> For the complete documentation index, see [llms.txt](https://developers-apps-in-toss.toss.im/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers-apps-in-toss.toss.im/documentation/api-and-sdk-zh/unity/first-steps/api-usage-patterns.md).

# 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 注释 |
| [Apps in Toss 开发者中心](https://developers-apps-in-toss.toss.im/) | 平台政策、控制台设置、服务器联动等客户端 SDK 官方文档                   |
| 这套文档集                                                          | 上面两者都没有的 Unity 特有情况                             |

本仓库的文档之所以不单独放 API 参考，是因为那会变成上位文档的手写副本。C# 表面会在每次更新 SDK 时重新生成，但手写的 Markdown 不会，因此时间一久必然会偏离。相反， **IntelliSense 始终是最新的**，而本文只写上位文档没有覆盖的内容——async/await、 `Awaitable`与 `Task`的分支， `timeoutMs`, `AITException.ErrorCode`，Mock（Editor mock、devtools）、IL2CPP stripping。

根据 SDK 版本，C# 表面如何变化，可以在 [API 变更历史](https://toss.github.io/apps-in-toss-unity-sdk/docs/changelog/index.html)中查看。

### 基本模式

SDK API 是异步的。 `await`等待结果时不会阻塞 Unity 主线程。

{% code collapsedlinecount="10" %}

```csharp
using AppsInToss;
using UnityEngine;

public class Example : MonoBehaviour
{
    async void Start()
    {
        // 使用 await 关键字等待异步结果
        string deviceId = await AIT.GetDeviceId();
        Debug.Log($"Device ID: {deviceId}");
    }
}
```

{% endcode %}

> **重要**: 有一个例外。应用内支付的 `ProcessProductGrant` 回调是唯一同步 `bool`返回的。原因和正确结构见下方 **应用内支付：发放批准与服务器验证** 一节。

#### Awaitable 和 Task

即使是同一个 API，随着 Unity 版本不同，返回类型也不同。

| Unity 版本   | 返回类型                        |
| ---------- | --------------------------- |
| 6000.0 及以上 | `Awaitable`, `Awaitable<T>` |
| 及以下        | `Task`, `Task<T>`           |

`await`以它们消费的代码在两边都能直接运行，所以大多数情况下无需在意。只有在 **显式写出返回类型时** 才会分岔。

{% code collapsedlinecount="10" %}

```csharp
// ❌ 只会在 Unity 6 及以上编译
public async Awaitable<bool> ProcessPayment(string orderId) { ... }

// ✅ 在任一版本都能编译——不写返回类型
async void ProcessPayment(string orderId) { ... }
```

{% endcode %}

如果需要同时支持两个版本且必须有返回类型，就用条件编译分开。

{% code collapsedlinecount="10" %}

```csharp
#if UNITY_6000_0_OR_NEWER
    public async Awaitable<bool> ProcessPayment(string orderId)
#else
    public async Task<bool> ProcessPayment(string orderId)
#endif
    {
        try
        {
            var result = await AIT.CheckoutPayment(options);
            return result != null;
        }
        catch (AITException)
        {
            return false;
        }
    }
```

{% endcode %}

> **参考**: `Task.WhenAll`只在 `Task`Unity 6 及以上 `Awaitable`可用，在别的版本没有。若要在 Unity 6 及以上同时推进多个 API，就用下面的方法。

#### 调用多个 API

顺序调用就直接 `await` 接着写下去。

{% code collapsedlinecount="10" %}

```csharp
async void InitializeGame()
{
    string deviceId = await AIT.GetDeviceId();
    string platform = await AIT.GetPlatformOS();
    string locale = await AIT.GetLocale();

    Debug.Log($"设备: {deviceId}, 平台: {platform}, 语言: {locale}");
}
```

{% endcode %}

如果彼此独立，就先全部启动，之后再分别等待，这样往返会重叠。此方式在 `Awaitable`与 `Task` 两边都同样有效。

{% code collapsedlinecount="10" %}

```csharp
async void InitializeGameParallel()
{
    // 先全部启动——这里不 await
    var deviceIdOp = AIT.GetDeviceId();
    var platformOp = AIT.GetPlatformOS();
    var localeOp = AIT.GetLocale();

    // 然后分别收取
    string deviceId = await deviceIdOp;
    string platform = await platformOp;
    string locale = await localeOp;

    Debug.Log($"设备: {deviceId}, 平台: {platform}, 语言: {locale}");
}
```

{% endcode %}

### 超时

所有异步 API 的最后一个参数都接收 `timeoutMs`。默认值是 `0`只在 **无限等待**。

{% code collapsedlinecount="10" %}

```csharp
try
{
    string deviceId = await AIT.GetDeviceId(timeoutMs: 3000);
}
catch (AITClientTimeoutException ex)
{
    Debug.LogWarning($"{ex.TimeoutMs}ms 内没有收到响应");
}
```

{% endcode %}

这个超时 **只放弃 C# 侧的等待。** 桥另一侧的 JavaScript 和平台任务仍可能继续执行，晚到的结果会被丢弃。因此，给有副作用的 API（支付、分享、权限请求等）设置超时时，不要把“超时 = 未执行”当成定论。

`AITClientTimeoutException`只在 `继承自 AITException`，因此现有的 `catch (AITException)` 块可以直接接住。只有想单独处理超时时才先捕获它。 `ErrorCode`是 `TIMEOUT`。

### 应用内支付：发放批准与服务器验证

`IAPCreateOneTimePurchaseOrder` / `IAPCreateSubscriptionPurchaseOrder`传给它的 `ProcessProductGrant` 回调会将发放与否 `bool`同步返回为 **。关键是不要在这个回调里验证——回调要立即批准，服务器验证和实际发放则在覆盖层关闭后**继续进行。核心 **再** `onEvent`中进行。

#### 这个回调不是可选的

`ProcessProductGrant`是可空字段，即使不指定也能编译， **但如果不指定，所有支付都会被当作发放失败。**

{% code collapsedlinecount="10" %}

```csharp
// ❌ 能编译，也会弹出支付窗口，但商品不会发放
var options = new IapCreateOneTimePurchaseOrderOptionsOptions { Sku = sku };
```

{% endcode %}

JS 桥会把这个回调 **始终** 传给平台，所以如果在 C# 中没有注册处理器，SDK 就会在每次支付完成时自动返回 `false`。此时 Console 会留下如下错误：

{% code collapsedlinecount="10" %}

```
[AITCore] Nested callback 'processProductGrant' is not registered (id: ...); responding false.
支付已经成功，所以不会发放商品，用户可能会看到
退款通知。请在订单选项中设置 ProcessProductGrant，并返回发放决定
（例如 _ => true）；验证和交付稍后在 onEvent 中进行。
```

{% endcode %}

接入支付流程时，先填这个字段。

#### 为什么必须同步

支付覆盖层显示期间 `visibilityState = hidden`，因此 `requestAnimationFrame`会停止，单靠它运行的 Unity WebGL player loop 也会一起停止。所以在回调里 `await`的一个 continuation 会等待覆盖层关闭后才会到来的帧，而覆盖层又在等待该回调的响应，于是形成死锁。实机测量中，这个环路 **持续了 115 秒** 之后 `"{应用名} 出现问题了。请申请退款"` 页面出现了（支付成功后 30 秒内 `true` 没有响应就可能显示），而立即批准的支付在覆盖层 **1.5 秒**后关闭并正常完成。把返回类型固定为 `bool`，就是为了在编译阶段阻止这种 `await` 形态。

#### 有两本账

回调返回值和我方服务器的发放记录是 **两本不同的账**。

|                           | 记录什么        | 所有权  | 截止       |
| ------------------------- | ----------- | ---- | -------- |
| `ProcessProductGrant` 返回值 | **支付是否已消耗** | Toss | 30 秒（无帧） |
| 我方服务器的发放记录                | **是否已交付商品** | 开发商  | 无截止，可重试  |

验证不是阻止 **第一本账，** 回调是回答“我已接收支付消耗”的地方，而验证和发放则留到之后从容进行。

所以，放进这个回调里的代码其实几乎就定成一行。

#### 第 1 步回调立即批准

{% code collapsedlinecount="10" %}

```csharp
var options = new IapCreateOneTimePurchaseOrderOptionsOptions
{
    Sku = sku,
    ProcessProductGrant = _ => true
};
```

{% endcode %}

这个回调一旦被调用，就已经表示应用判定支付成功了。回调带来的信息只有 `OrderId` 一个，因此在这里也无法重新验证什么。

#### 第 2 步验证与发放在 onEvent 中

服务器验证的 **只有两个时点可以调用**。

1. 正常流程下 **`onEvent`** ——覆盖层刚关闭后。
2. 如果连这也错过了， **应用启动时的台词**（第 3 步）。

`onEvent`之所以是第一个有效时点，是因为那是 **`OrderId`和活着的 player loop 同时存在的最早瞬间**。下面是实机测量的某次支付时间线。

{% code collapsedlinecount="10" %}

```
00:35:48.563  支付覆盖层盖住屏幕      player loop 停止 ─┐
                                                                │ 在这段期间，await
                 ⋮  （用户操作支付 UI）                      │ 不会恢复。
                                                                │ 调用验证会死锁。
00:36:01.413  ProcessProductGrant → 立即 true   [第 1 步]         │
00:36:02.725  覆盖层关闭                     loop 恢复 ──────┘
00:36:02.796  onEvent 到达              (+71ms)  [第 2 步] ← 服务器验证在这里调用
00:36:02.998  验证完成                (+202ms)          await 正常恢复
```

{% endcode %}

`onEvent`开始之后，帧就以正常速度运行了， `await`可以尽情使用`WaitForSecondsRealtime(0.2f)`（在 202ms 完成）。

{% code collapsedlinecount="10" %}

```csharp
_disposer = AIT.IAPCreateOneTimePurchaseOrder(
    onEvent: e =>
    {
        // 支付已经确定，因此可以立即反映到 UI
        ShowPurchaseSuccess(e.Data.DisplayAmount);

        // 验证与发放交给服务器，不等待
        _ = DeliverAsync(e.Data.OrderId);
    },
    options: options,
    onError: err => Debug.LogError(err.Message)
);

async Task DeliverAsync(string orderId)
{
    // 这里帧会正常运行，因此 await 是安全的
    await MyServer.VerifyAndDeliver(orderId);
}
```

{% endcode %}

> **注意**: `SuccessEvent.Data`里没有 `Sku`。商品是什么，只能通过当初开始购买时传入的 `sku`闭包捕获，或者由服务器 `OrderId`查询。

#### 服务器验证什么

不能直接相信客户端发来的 `OrderId`。开发商服务器会通过 **订单状态查询 API**直接向 Toss 确认。

{% code collapsedlinecount="10" %}

```
POST https://apps-in-toss-api.toss.im/api-partner/v1/apps-in-toss/order/get-order-status
{ "orderId": "..." }
```

{% endcode %}

* **必须使用 mTLS 证书**（服务器之间通信）。证书和用户认证头的说明请参考 [认证文档](https://developers-apps-in-toss.toss.im/api/auth)。
* `x-toss-user-key` 如果在头部里放入通过 Toss 登录获得的 userKey **，就只会响应该用户的订单** 。不填的话会查询所有订单，因此若要防止拦截并重用其他用户的 `OrderId`，就必须一并发送这个头。
* 响应里的 `sku`可以确认实际支付的商品。不要信任客户端告诉你的 SKU。

响应 `status`是这个 API 的核心。

| status                                       | 含义                 |
| -------------------------------------------- | ------------------ |
| `PURCHASED`                                  | 支付和商品发放都已完成        |
| `PAYMENT_COMPLETED`                          | 支付已完成，但 **商品发放失败** |
| `REFUNDED`                                   | 退款完成               |
| `FAILED` / `ORDER_IN_PROGRESS` / `NOT_FOUND` | 支付失败 / 进行中 / 订单不存在 |

前两个值也就是 `ProcessProductGrant` 返回值的结果。 `true`返回的订单是 `PURCHASED`，否则的订单是 `PAYMENT_COMPLETED`。

详细规范请见 [官方 IAP 文档](https://developers-apps-in-toss.toss.im/documentation/sdk/domains-api/iap)。

#### 第 3 步 应用启动时的未交付台词

不能保证第 2 步一定会执行。若在回调发送 `true`后应用立刻退出， `onEvent`就收不到 `IAPGetPendingOrders`中也不会出现。

回收这种情况的是 `IAPGetCompletedOrRefundedOrders`。在应用启动或回到前台时扫一遍，找出我方服务器还没发放的订单。

{% code collapsedlinecount="10" %}

```csharp
var completed = await AIT.IAPGetCompletedOrRefundedOrders();
if (completed.Orders == null) return;   // 若平台不支持，error 字段会包含原因

foreach (var order in completed.Orders)
{
    if (order.Status != CompletedOrRefundedOrdersResultOrderStatus.COMPLETED) continue;

    // 是否已发放要根据服务器记录判断。PlayerPrefs 之类的本地记录
    // 会因重装、换机而丢失，不能作为这段台词的判断依据。
    await MyServer.DeliverIfMissing(order.OrderId, order.Sku);
}
```

{% endcode %}

如果没有这第 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`会抛出这个异常。

{% code collapsedlinecount="10" %}

```csharp
using AppsInToss;
using UnityEngine;

public class ErrorHandling : MonoBehaviour
{
    async void CallAPI()
    {
        try
        {
            var result = await AIT.GetDeviceId();
            Debug.Log($"成功: {result}");
        }
        catch (AITException ex)
        {
            Debug.LogError($"API 错误: {ex.Message}");
            Debug.LogError($"错误代码: {ex.ErrorCode}");
        }
        catch (System.Exception ex)
        {
            Debug.LogError($"意外错误: {ex.Message}");
        }
    }
}
```

{% endcode %}

| 属性                      | 类型       | 说明                   |
| ----------------------- | -------- | -------------------- |
| `Message`               | `string` | 人类可读的错误消息            |
| `ErrorCode`             | `string` | 错误代码。如果平台未提供，则为空字符串  |
| `APIName`               | `string` | 失败的 API 名称。若未知则为空字符串 |
| `IsPlatformUnavailable` | `bool`   | 是否因缺少平台桥接而产生的错误      |

`ErrorCode`若要据此分支，请注意值可能为空。

{% code collapsedlinecount="10" %}

```csharp
catch (AITException ex)
{
    switch (ex.ErrorCode)
    {
        case "PAYMENT_CANCELLED":
            Debug.Log("用户取消了支付。");
            break;
        case "PAYMENT_FAILED":
            Debug.LogError("支付处理过程中发生了错误。");
            break;
        case "NETWORK_ERROR":
            Debug.LogError("请检查网络连接。");
            break;
        default:
            Debug.LogError($"未知错误: {ex.Message}");
            break;
    }
}
```

{% endcode %}

#### 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 路径。

如有需要，可以按运行环境分支。

{% code collapsedlinecount="10" %}

```csharp
void Start()
{
#if UNITY_WEBGL && !UNITY_EDITOR
    // 仅限 WebGL 的逻辑
#else
    // 开发·测试用逻辑
#endif
}
```

{% endcode %}

若要确认实际原生行为，必须构建为 WebGL 并在 Apps in Toss 应用中运行。在 Editor 中无论做什么都是 mock。

### 模拟

被称为“Mock”的有 **两种**，并且它们的行为不同。

|      | Editor mock          | devtools                                                         |
| ---- | -------------------- | ---------------------------------------------------------------- |
| 在哪里  | `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 中游戏逻辑不会停止。

{% code collapsedlinecount="10" %}

```
[AIT Mock] 已调用 GetDeviceId
[AIT Mock] 已调用 GetPlatformOS
```

{% endcode %}

| 返回类型          | Mock 返回值                                    |
| ------------- | ------------------------------------------- |
| `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`会发生。

### 相关文档

* [入门](https://developers-apps-in-toss.toss.im/documentation/unity/first-steps/getting-started) — 安装与基本设置
* [广告集成](https://developers-apps-in-toss.toss.im/documentation/unity/add-features/advertising) — 广告 API 使用方法
* [Sentry 集成](https://developers-apps-in-toss.toss.im/documentation/unity/add-features/sentry-integration) — 将错误收集到 Sentry
* [构建配置](https://developers-apps-in-toss.toss.im/documentation/unity/build/build-profiles) — devtools 设置位置
* [问题排查](https://developers-apps-in-toss.toss.im/documentation/unity/first-steps/faq) — 常见卡点


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://developers-apps-in-toss.toss.im/documentation/api-and-sdk-zh/unity/first-steps/api-usage-patterns.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
