> 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`）的类型定义自动生成的。当前 **在 24 个类别中有 85 个 API**。

| 在哪里                                                            | 什么                                              |
| -------------------------------------------------------------- | ----------------------------------------------- |
| 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 剥离。

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

### 基本模式

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

```csharp
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 版本下返回类型不同。

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

`await`消费端代码在两边都能直接工作，所以大多数情况下无需在意。只有在 **明确写出返回类型时** 才会分叉。

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

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

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

```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;
        }
    }
```

> **参考**: `Task.WhenAll`是 `Task`只在 `Awaitable`中有，在 Unity 6 及以上要并行执行多个 API 时请使用下面的方法。

#### 调用多个 API

顺序调用就直接接着 `await` 即可。

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

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

如果彼此独立，先全部启动、之后分别等待，就能让往返重叠。这种方式在 `Awaitable`和 `Task` 两边表现一致。

```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}");
}
```

### 超时

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

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

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

`AITClientTimeoutException`是 `继承自 AITException`，因此现有的 `catch (AITException)` 块会照单全收。只有想单独处理超时时才先捕获它。 `ErrorCode`是 `TIMEOUT`。

### 内购：发放批准与服务器验证

`IAPCreateOneTimePurchaseOrder` / `IAPCreateSubscriptionPurchaseOrder`传给 `ProcessProductGrant` 回调对是否发放 `bool`做 **同步返回**。关键是不要在这个回调里做验证——回调里要立即批准，服务器验证和实际发放放到覆盖层关闭 **后** `onEvent`中进行。

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

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

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

JS 桥接会把这个回调 **请始终提供** 传给平台，所以 C# 里没有注册处理器时，SDK 每次支付完成都会自动 `false`作出响应。此时 Console 会留下如下错误：

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

把支付流程接起来时，先填这个字段。

#### 为什么必须同步

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

#### 有两本账

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

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

验证不是拦住第一本账， **而是拦住第二本账。** 回调是回复“已接收支付消费”的地方，验证和发放则在之后从容处理。

因此，这个回调里要写的代码基本就固定成一句。

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

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

这个回调被调用本身就表示应用已经判定支付成功。回调带来的信息只有 `OrderId` 这一个，所以这里也无法再做新的验证。

#### 第 2 步：验证和发放在 onEvent 中

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

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

`onEvent`之所以是第一个有效时点，是因为那是 **`OrderId`和仍然活着的 player loop 同时具备的最早时刻**。下面是一次实机测得的支付时间线。

```
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 正常恢复
```

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

```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);
}
```

> **注意**: `SuccessEvent.Data`中 `Sku`没有。到底是哪件商品，是购买开始时传入的 `sku`抓成闭包，或者由服务器 `OrderId`来查询。

#### 服务器验证什么

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

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

* **必须使用 mTLS 证书**（服务器间通信）。证书和用户认证头说明见 [认证文档](https://developers-apps-in-toss.toss.im/documentation/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`回收这种情况的是

```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);
}
```

没有这个第 3 步，第 1 步的立即批准就会变得危险。 **三者是一组。**

> **重要**: 退款只能通过轮询得知。支付或退款发生时，不会提供通知开发商服务器的 webhook。即使用户拿到了退款，在应用重新运行并执行这段台词之前，开发商也不会知道。若要回收已退款订单的商品，就必须把已发放订单的 `OrderId`保存到服务器，并用订单状态查询 API 定期检查。

#### 什么时候返回 false

官方文档说明 `true`非返回值时，退款 안내 页面 *可能会显示*。 （我实际测到的是无响应路径，尚未确认显式 `false`下是否也会出现同样的画面。）因此 `false`是 **只在真的无法发放这个商品时** 使用——例如，已经持有的非消耗品在结算过程中于另一台设备上获得时这种情况，也就是现在可以断定无法发放时。

“反正不确定，先 `false`”是不成立的。因为那会让每次支付都弹出退款 안내 页面。确定性要靠 1～3 步获取， `false`不是靠

> **参考**: 旧版 Toss App 中会忽略返回值。 `processProductGrant`在不支持该功能的版本（Android 5.231.1 以下 / iOS 5.230.0 以下）中，桥接会回退到旧支付路径，此时回调返回值不会传给平台而是被丢弃。编写依赖返回值的逻辑时请注意这个区间。

### 错误处理

如果 API 调用失败， `继承自 AITException`会抛出。

```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}");
        }
    }
}
```

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

`ErrorCode`在按此值分支时，请注意它可能为空。

```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;
    }
}
```

#### IsPlatformUnavailable

这个标志不是单独作为字段传来的，而是 **通过错误消息判断**。只要包含下面任一字符串， `true`就会成立。

| 判定字符串                                 | 何时                       |
| ------------------------------------- | ------------------------ |
| `__GRANITE_NATIVE_EMITTER`            | 没有 native emitter        |
| `ReactNativeWebView`                  | 正在 Toss App WebView 外运行  |
| `is not a constant handler`           | 该 API 没有桥接处理器            |
| `Cannot read properties of undefined` | `window.AppsInToss`还未初始化 |

`true`如果是这样，就不是代码 bug，而是 **运行环境问题**。这在普通浏览器或开发环境中经常发生，因此在加入错误报告时，最好将这种情况降为较低严重度或过滤掉。

### 按运行环境的行为

| 环境                         | 行为                                          |
| -------------------------- | ------------------------------------------- |
| WebGL 构建 + Apps in Toss 应用 | 实际的原生 API 调用                                |
| WebGL 构建 + 普通浏览器           | 大多失败。如果 devtools 已开启（Dev Server），则以 mock 响应 |
| Unity 编辑器                  | Editor mock 调用                              |
| 其他平台（Windows、macOS 等）      | Editor mock 调用                              |

Editor mock 与构建配置无关。 `Runtime/SDK/`的各个 API `#if UNITY_WEBGL && !UNITY_EDITOR`被分成了两路，因此如果不是 WebGL 构建 **在编译时** 只会保留 mock 路径。

需要的话，可以按运行环境分支。

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

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

### 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 中游戏逻辑不会停止。

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

| 返回类型          | Mock 返回值                                     |
| ------------- | -------------------------------------------- |
| `string`      | 空字符串 `""`                                    |
| `bool`        | `false`                                      |
| 数组            | 空数组                                          |
| 类类型           | `default`，也就是 `null`                         |
| 取消订阅 `Action` | 仅记录日志的函数。 `SafeAreaInsetsSubscribe`只有 `null` |

类类型是 `null`会以 null 返回，这一点很重要。在 Editor 中 `result.SomeField`直接读取的话 `NullReferenceException`会抛出 NullReferenceException。若逻辑也要在 Editor 中运行，请加入 null 检查。返回数组的 API 会返回空数组，因此 `foreach`是安全的。

#### devtools

`@apps-in-toss/devtools`是 `@apps-in-toss/web-framework` **仅限 3.x** 是开发工具。运行 Dev Server 时，vite 插件会 `@apps-in-toss/web-framework` 将 import 别名到 mock 实现，因此无需 토스 应用，在普通浏览器中也能让 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.
