Flutter 應用程式內購為何一直停留在「Loading」,以及如何透過重試設計修復
Why our Flutter in-app purchases got stuck on "Loading" (and the retry design that fixed it)
Flutter 應用程式的內購價格清單曾因商店連線或商品查詢一次失敗,永久停留在「Loading」;修復方案加入重新連線、排隊處理一次額外查詢、合併部分結果及設有限次數的自動重試。自動重試間隔為 5 秒、30 秒和 2 分鐘,亦可由應用程式恢復、開啟商店、按下購買按鈕或手動觸發重試。此修正已於 Android 和 iOS 的 1.1.1 版本推出。
In our Flutter app (Android and iOS, in_app_purchase 3.3.1) we shipped a bug where the shop's price list stayed on "Loading" forever. The cause was simple: if connecting to the store or querying products failed once, nothing ever tried again until the user restarted the app.
Here is what was wrong, and the retry design we replaced it with.
What was going on
All billing code lives in a Billing singleton with three states: loading, ready and unavailable. The old refresh() looked roughly like this:
Future<void> refresh() async {
final gw = _gw;
if (gw == null || status.value == BillingStatus.loading) return; // not connected, or busy: drop it
status.value = BillingStatus.loading;
try {
if (!await gw.isAvailable().timeout(const Duration(seconds: 15))) {
status.value = BillingStatus.unavailable; return; // one failure ends it
}
final q = await gw.query(ProductIds.all).timeout(const Duration(seconds: 20));
_products = {for (final e in q.found.entries) e.key: e.value}; // full overwrite
} catch (e) { status.value = BillingStatus.unavailable; } // no retry
}
That has four holes:
-
A failed connection at startup is permanent. If creating the gateway threw at launch,
_gwstayednull, and every laterrefresh()returned on the first line. -
Requests during a query were dropped. A refresh that arrived while
loadingwas simply ignored. -
No automatic retry.
isAvailable() == false, a timeout and an exception all ended inunavailable. The only retry was opening the shop screen. - Partial results overwrote good data. A query that returned only some products could erase products we already had.
Temporary store failures are common: the Play Store app updating itself, a network blip. The real problem was that one failure meant "Loading" forever.
The fix
1. Reconnect on the next attempt
If the connection fails at startup we don't give up. ensureStarted() reconnects on every new attempt, and syncs past purchases once it connects.
2. Don't drop requests during a query — queue one more
Future<void> refresh() {
final cur = _inflight;
if (cur != null) { _again = true; return cur; } // run once more after this one
// ...
}
However many times it is called, at most one extra query runs after the current one.
3. Merge results instead of overwriting
_products = {..._products, ...found}; // a partial answer can't erase known products
4. Retry automatically at 5 s → 30 s → 2 min, then stop
static List<Duration> retryDelays =
const [Duration(seconds: 5), Duration(seconds: 30), Duration(minutes: 2)];
void _afterRefresh() {
if (!needsRetry) { _retryStep = 0; return; } // got everything: reset
if (_retryStep >= retryDelays.length) return; // stop after 3
_retryTimer = Timer(retryDelays[_retryStep++], () => unawaited(refresh()));
}
We cap it on purpose. On devices where the store really is unavailable (no Play Store, for example), retrying forever only burns battery and data. Timeouts stay at 15 s for the connection check and 20 s for the query.
5. Let user actions and app resume trigger a retry
If the three automatic tries fail, four other things trigger a retry: app resume, opening the shop, pressing a buy button, and a manual retry.
// main.dart
AppLifecycleListener(onResume: Billing.i.onResume);
// billing.dart
void onResume() { if (needsRetry) unawaited(retryNow()); } // only when products are missing
We don't listen to a connectivity stream. Timers handle the automatic part; everything else comes from the user or from resume. A manual trigger resets the retry schedule to zero. If the buy button is pressed while billing isn't ready, it calls retryNow() and then continues straight into the purchase.
6. Stop saying "Loading"
We split failures into connect, timeout and notFound, and the screen now says "Connecting to the store… / Retry" instead of "Loading". With "Loading", users can't tell whether waiting will help or whether the item is gone. The first time each failure type appears we log an analytics event, so we can see later which failures are common.
Takeaways
- Write billing setup so it can be redone any number of times, not "once at startup".
- Don't drop requests that arrive mid-query; queue exactly one more.
- Merge query results so a partial answer can't delete known products.
- Cap automatic retries; leave the rest to app resume and user actions.
- Don't show a vague state like "Loading" when you know what failed.
This shipped in version 1.1.1 on Android and iOS. We haven't yet verified every real-payment path in production, so if your setup is similar, try it with a Play Console license tester: toggle airplane mode and force-stop the Play Store app while the shop is open.
Disclosure: I'm the developer of this app, **Starlit Alley: Tarot & Saju* (HS Solution). Core readings (saju, tarot, dream reading) are free, with no sign-up and no ads. Google Play*
This post was drafted by an AI agent from our repository and fix notes. The code and numbers are quoted from the actual repository.
來源:DEV Community · dev.to