PAYATHON 2026

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

架构老王

结论

getDetails() 应传入订阅的 product ID:

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

goldplan1 是 base plan ID,通常不能作为独立商品 ID 传给 getDetails()。Google Play 会根据已启用的 base plan、offer、地区和测试账号资格等信息,决定具体采用哪个 base plan。

目前最需要处理的不是 setTimeout,而是代码吞掉了 getDetails() 抛出的所有异常。无论是商品配置、账号资格、发布状态,还是返回数据的处理出了问题,都会进入同一个 catch。由于页面和控制台都没有输出实际异常,最终看起来就像“没有任何内容”。

先显示真实错误

不要用一个没有诊断信息的 catch 包住整个流程。至少应记录错误名称、消息和堆栈,并把结果显示在页面上:

window.checkDigitalGoodsApi = async () => {
    const billingDiv = document.getElementById(
        'divGoogleDigitalGoodsAndBilling'
    );
    const digitalGoodsOnlyDiv = document.getElementById(
        'divGoogleDigitalGoodsOnly'
    );
    const neitherDiv = document.getElementById('divNeither');
    const productDetailsDiv = document.getElementById('divProductDetails');

    const showError = (message) => {
        console.error(message);

        if (productDetailsDiv) {
            productDetailsDiv.innerHTML =
                `<pre style="white-space: pre-wrap; color: red;"></pre>`;

            productDetailsDiv
                .querySelector('pre')
                .textContent = message;
        }
    };

    if (!('getDigitalGoodsService' in window)) {
        billingDiv.hidden = true;
        digitalGoodsOnlyDiv.hidden = true;
        neitherDiv.hidden = false;
        return;
    }

    let service;

    try {
        service = await window.getDigitalGoodsService(
            'https://play.google.com/billing'
        );
    } catch (error) {
        billingDiv.hidden = true;
        digitalGoodsOnlyDiv.hidden = false;
        neitherDiv.hidden = true;

        console.error('getDigitalGoodsService failed:', error);
        return;
    }

    billingDiv.hidden = false;
    digitalGoodsOnlyDiv.hidden = true;
    neitherDiv.hidden = true;

    try {
        const items = await service.getDetails(['goldmonthly1']);

        console.log('getDetails result:', items);
        console.log('Serialized result:', JSON.stringify(items, null, 2));

        if (!items || items.length === 0) {
            showError(
                'getDetails() returned no products for product ID "goldmonthly1".'
            );
            return;
        }

        productDetailsDiv.replaceChildren();

        for (const item of items) {
            const container = document.createElement('section');
            const title = document.createElement('h3');
            const description = document.createElement('p');
            const price = document.createElement('p');

            title.textContent = item.title ?? item.itemId ?? 'Subscription';
            description.textContent = item.description ?? '';

            /*
             * Digital Goods API 的 price 通常是结构化金额,不一定是字符串。
             * 先查看实际返回对象,再按当前运行环境的字段处理。
             */
            price.textContent =
                `Price: ${formatDigitalGoodsPrice(item.price)}`;

            container.append(title, description, price);
            productDetailsDiv.append(container);
        }
    } catch (error) {
        const message = [
            'service.getDetails() failed.',
            `name: ${error?.name ?? 'UnknownError'}`,
            `message: ${error?.message ?? String(error)}`,
            error?.stack ? `stack: ${error.stack}` : ''
        ].filter(Boolean).join('\n');

        showError(message);
    }
};

function formatDigitalGoodsPrice(price) {
    if (price == null) {
        return 'Unavailable';
    }

    if (typeof price === 'string') {
        return price;
    }

    if (price.currency && price.value != null) {
        try {
            return new Intl.NumberFormat(undefined, {
                style: 'currency',
                currency: price.currency
            }).format(Number(price.value));
        } catch {
            // 继续使用原始值
        }
    }

    return JSON.stringify(price);
}

这段代码也避免了把服务端返回的标题和描述直接写入 innerHTML。改用 textContent 更安全,内容中的特殊字符也不会破坏 HTML。

检查 Google Play 中的订阅配置

成功取得 DigitalGoodsService,只能说明当前容器支持 Google Play Billing,不能证明当前用户一定能查询到指定商品。

需要逐项确认:

  1. goldmonthly1 是订阅的 product ID,不是后台显示名称。
  2. 订阅处于可用状态,而不是草稿或已停用状态。
  3. goldplan1 base plan 已启用,并设置了有效价格。
  4. base plan 对测试设备所在的国家或地区可用。
  5. 当前安装包的 application ID/package name 与 Play Console 中的应用一致。
  6. PWA/TWA 的站点来源、应用包和 Digital Asset Links 关联正确。
  7. 测试设备上的 Google Play 登录账号已加入对应的内部测试轨道,并接受了测试邀请。
  8. 应用通过 Google Play 的测试链接安装,而不是通过本地 APK、浏览器直接访问或其他来源启动。
  9. 当前设备上的应用版本属于配置了该订阅的正确 Play Console 应用。
  10. Play Console 中刚完成的商品或测试轨道变更可能还没有传播完成。遇到这种情况,只能稍后重新安装或重试,不能仅根据 API 可用就判断配置已经生效。

即使应用有多个 base plan,getDetails(['goldmonthly1']) 查询的仍是订阅商品。不要假定返回对象一定包含某个指定 base plan 的全部后台字段。Digital Goods API 提供的是经过浏览器统一处理的商品信息,不等同于原生 Play Billing Library 的完整 ProductDetails 模型。

不需要依赖 setTimeout(..., 0)

setTimeout(..., 0) 只是把回调推迟到后续事件循环,不能可靠地保证 Blazor 已完成目标组件的渲染。更稳妥的做法是在组件首次渲染完成后调用 JavaScript:

@inject IJSRuntime JS

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

<div id="divGoogleDigitalGoodsOnly" hidden>
    <h2>Subscriptions</h2>
    <p>Digital Goods API is available but Google Play Billing is not supported!</p>
</div>

<div id="divNeither" hidden>
    <h2>Subscriptions</h2>
    <p>
        For the fully-featured app with paid subscriptions,
        you will need the Google Play store version for Android.
    </p>
</div>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JS.InvokeVoidAsync("checkDigitalGoodsApi");
        }
    }
}

此时 DOM 已完成首次渲染,JavaScript 不再需要 setTimeout。异步回调写成 async () => {} 本身没有问题,问题是 timeout 不能可靠地与 Blazor 生命周期同步。

如何调试 Play Store 安装的版本

“必须通过 Google Play 安装”并不意味着“无法调试”。对于运行在 Chrome、WebView 或 TWA 中的应用,通常可以尝试使用 Android 远程调试:

  1. 在 Android 设备上启用开发者选项和 USB 调试。
  2. 使用 USB 将设备连接到电脑。
  3. 在桌面版 Chrome 中打开:
chrome://inspect/#devices
  1. 启动从 Google Play 安装的测试版本。
  2. 在可检查目标中找到对应页面,点击 inspect。
  3. 查看 Console 和 Network,并检查:
await window.getDigitalGoodsService('https://play.google.com/billing')

以及:

await service.getDetails(['goldmonthly1'])

能否找到可检查目标,取决于实际使用的 Chrome、WebView/TWA 容器及其调试配置。如果发布构建没有出现在 chrome://inspect 中,页面内的错误输出仍然有用。也可以暂时把 JSON.stringify(items, null, 2) 和异常消息显示在测试页面上。

不要只记录 ${error}。部分 DOM 异常经过这种转换后只能得到很少的信息,应分别记录:

console.error(error);
console.error(error?.name);
console.error(error?.message);
console.error(error?.stack);

正确的排查顺序是:先用 goldmonthly1 查询,再输出真实异常和原始返回值,然后确认订阅及 base plan 的可售状态,最后通过远程 DevTools 或页面内诊断,判断问题究竟是商品不可见、调用被拒绝,还是返回字段的处理方式不正确。

备注:内容仅供参考。