PAYATHON 2026

Blazor WASM PWA 中 Google Play Billing 无法获取订阅

排障赵师傅

结论

service.getDetails() 应传入订阅的 product ID,即 goldmonthly1。goldplan1 是 base plan ID,表示该订阅商品的定价方案,不是独立的商品 SKU。

当前代码首先要修正错误处理。getDetails() 返回空数组时,代码不会渲染任何内容;抛出异常时,代码直接进入 catch 并切换 DIV,实际错误信息没有显示出来。使用 setTimeout() 还会让 JS interop 提前结束,定时器回调中的异常通常无法传回 Blazor。因此,没有看到 Blazor error 不代表调用已经成功。

显示实际返回结果

暂时不要渲染商品卡片,先把返回值、空结果和异常输出到页面及控制台:

window.checkDigitalGoodsApi = async () => {
    const output = document.getElementById('divProductDetails');

    if (!output) {
        console.error('divProductDetails was not found.');
        return;
    }

    output.textContent = '正在读取订阅信息……';

    try {
        if (!('getDigitalGoodsService' in window)) {
            throw new Error('Digital Goods API is not supported.');
        }

        const service = await window.getDigitalGoodsService(
            'https://play.google.com/billing'
        );

        const requestedIds = ['goldmonthly1'];
        const details = await service.getDetails(requestedIds);

        console.log('Requested product IDs:', requestedIds);
        console.log('getDetails result:', details);
        console.log('Serialized result:', JSON.stringify(details, null, 2));

        if (!Array.isArray(details) || details.length === 0) {
            output.textContent =
                'getDetails() 调用成功,但没有返回商品。请检查商品、base plan、测试账号及发布状态。';
            return;
        }

        output.replaceChildren();

        for (const product of details) {
            const section = document.createElement('section');
            const title = document.createElement('h3');
            const description = document.createElement('p');
            const price = document.createElement('p');

            title.textContent = product.title ?? product.itemId;
            description.textContent = product.description ?? '';

            if (product.price) {
                price.textContent =
                    `Price: ${product.price.value} ${product.price.currency}`;
            } else {
                price.textContent = 'Price information is unavailable.';
            }

            section.append(title, description, price);
            output.append(section);
        }
    } catch (error) {
        console.error('Google Play Billing error:', error);

        const name = error?.name ?? 'Error';
        const message = error?.message ?? String(error);

        output.textContent = `${name}: ${message}`;
    }
};

还要注意,price 通常是一个包含 value 和 currency 的对象。直接使用 ${itmProduct.price},很可能只会得到 [object Object]。这与 title 不显示无关,但价格需要按对象字段读取。

在 Blazor 生命周期中调用

在 Blazor 中,适合调用这段 JavaScript 的位置是组件的 OnAfterRenderAsync。执行到这个生命周期时,组件的 DOM 已经生成:

@inject IJSRuntime JS

<div id="divGoogleDigitalGoodsAndBilling">
    <div id="divProductDetails"></div>
</div>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            try
            {
                await JS.InvokeVoidAsync("checkDigitalGoodsApi");
            }
            catch (JSException ex)
            {
                Console.Error.WriteLine(ex.Message);
            }
        }
    }
}

这样可以删除 JavaScript 中的 setTimeout(),Promise rejection 也有机会通过 JS interop 传回 .NET。

如果确实需要延迟一个事件循环,应在同一个 Promise 链中等待:

await new Promise(resolve => setTimeout(resolve, 0));

不要把全部逻辑放进未被等待的 setTimeout(async () => ...) 回调。外层函数会在定时器执行前返回,之后发生的错误也无法正常传给 Blazor。

使用 goldmonthly1 还是 goldplan1

调用方式应为:

await service.getDetails(['goldmonthly1']);

这里使用的是订阅的 product ID:

goldmonthly1

goldplan1 是该订阅下的 base plan ID,用来描述续订周期、地区和价格等销售方案。如果单独传给 getDetails(),Google Play 会把它当作商品 ID 查询,通常返回空结果。

同一个订阅配置了多个 base plan 或 offer 时,还要确认当前 Digital Goods API、浏览器和 Google Play Billing 桥接层是否支持这套配置。排查时可以只保留一个已激活的 base plan,暂时移除复杂 offer,先确认基础商品能够返回。具体支持范围可能随 Chrome 和 Google Play Billing 更新而变化,应以项目当前运行环境对应的官方文档为准。

检查 Google Play Console 配置

能够获得 DigitalGoodsService,只能说明浏览器提供了该 API,并找到了相应的支付提供方,无法证明指定商品可以销售给当前用户。

需要检查以下配置:

  1. 订阅 goldmonthly1 已创建并处于可用状态。
  2. goldplan1 已激活,没有停留在草稿或未完成状态。
  3. base plan 已设置价格及可销售的国家或地区。
  4. 当前测试账号所在的国家或地区包含在销售范围内。
  5. Play Console 中的商品修改已经提交并生效。
  6. 当前安装包来自正确的 internal testing track,不是本地安装的 APK、旧版本,也不是在浏览器中直接访问的开发地址。
  7. 测试账号已加入对应测试轨道,并通过测试邀请链接加入测试。
  8. 测试购买流程时,账号还应按照 Play Console 的要求配置为 license tester。
  9. Play Console 中应用的 package name 与实际安装包一致。
  10. 如果应用通过 Trusted Web Activity 承载 PWA,应确认应用包、站点 origin 和 Digital Asset Links 对应当前发布版本。
  11. 设备上的 Chrome、Google Play Store 和 Google Play services 未使用旧版本,账号环境也没有异常。

Play Console 的配置变更可能无法立即同步到所有设备。不要预设具体等待时间,可以换用已经加入测试的账号或设备进行交叉验证。

区分异常和空数组

抛出异常与返回空数组通常指向不同的问题。

如果调用抛出异常:

try {
    const details = await service.getDetails(['goldmonthly1']);
} catch (error) {
    console.error(error.name, error.message, error.stack);
}

应检查运行环境、支付提供方、TWA 配置、账号状态和 API 支持情况。

如果调用成功,但返回空数组:

[]

应检查:

  • 传入的是否为 product ID;
  • 订阅及 base plan 是否已激活;
  • 商品是否在当前地区销售;
  • 当前账号是否属于测试轨道;
  • 商品是否属于当前安装包对应的 Play Console 应用;
  • 多 base plan 或 offer 是否超出当前桥接实现的支持范围。

原代码中的 for...of 遇到空数组时不会执行,也不会报错,所以页面会保持空白。这是出现“没有内容但也没有错误”的原因之一。

调试商店安装版本

从 Google Play 安装应用后,仍然可以尝试调试 JavaScript。对于由 Chrome 承载的 PWA 或 TWA,可按以下步骤操作:

  1. 在 Android 设备上开启开发者选项和 USB 调试。
  2. 使用 USB 将设备连接到开发电脑。
  3. 在桌面版 Chrome 打开:
chrome://inspect/#devices
  1. 找到对应页面、PWA 或 TWA,点击 Inspect。
  2. 查看 Console 和 Network,并在 getDetails() 调用前后设置断点。

页面能否出现在远程调试列表中,取决于实际容器和调试配置。如果无法附加调试器,至少要在测试页面中显示 error.name、error.message、请求的 product ID 和返回数组长度。不要记录购买令牌、账号信息或其他敏感支付数据。

排查顺序

先处理静默 catch 和未等待的 setTimeout(),确定 getDetails() 是抛出异常还是返回空数组。接着固定使用 goldmonthly1,检查订阅和 goldplan1 是否均已激活,并确认已经配置当前地区的价格。之后再核对测试账号、internal testing 安装来源、package name 和 TWA 关联配置。

仅凭 getDigitalGoodsService 是否存在就显示“Google Play Billing is supported”并不准确。应等到服务创建和商品查询完成后,再判断当前环境能否正常使用该订阅商品。

备注:内容仅供参考。