Flutter 中如何在 launch_url 浏览器关闭后执行操作
结论
launchUrlString() 通过系统外部浏览器打开支付页面后,Flutter 无法可靠监听浏览器何时关闭。await launchUrlString(iframe) 只表示系统已接收并处理该 URL,不会等待浏览器关闭或支付结束。
支付场景可以按以下方式处理:
- 以支付网关的服务端 webhook/callback 作为最终支付结果的依据。
- 为支付请求配置
return_url,支付结束后由网关跳转到 App Link、Universal Link 或自定义 URL Scheme。 - App 收到回跳后,携带
paymentId向自己的服务端查询支付状态。 - App 从后台恢复时也可以查询一次支付状态作为兜底,但这不能证明支付已经成功。
- 如果使用 WebView,通常只能可靠监听顶层页面跳转。支付内容位于跨域 iframe 时,普通导航回调一般无法捕获 iframe 内部的 URL 变化。
为什么无法监听外部浏览器关闭
外部浏览器是另一个应用。Flutter 调用 url_launcher 后,操作系统只负责打开 URL,不会向 Flutter 发送某个浏览器标签页已关闭的通知。
所以下面的代码不能用来等待浏览器关闭:
final launched = await launchUrlString(iframe);
// 执行到这里通常只代表 URL 已经交给浏览器,
// 不代表用户已关闭浏览器或完成支付。
html.window.onBeforeUnload 是 Flutter Web 页面自身的浏览器事件,只适用于当前网页即将卸载的情况。Android 或 iOS App 无法用它监听另一个浏览器应用。
推荐的支付流程
较稳妥的流程如下:
App 创建支付订单
↓
服务端向支付网关创建交易
↓
App 打开网关支付页面
↓
网关通过 webhook 通知服务端
↓
服务端验签并更新订单
↓
支付页面跳转到 return_url
↓
App 被唤起并向服务端查询订单状态
App 收到返回 URL,只能说明用户回到了 App,不能证明支付成功。最终状态仍应以服务端查询结果或经过验签的 webhook 为准。
使用 Deep Link 接收支付回跳
创建支付交易时,如果网关支持,可以设置类似下面的返回地址:
myapp://payment/result?paymentId=12345
更推荐使用经过域名验证的 HTTPS App Link 或 Universal Link:
https://example.com/payment/result?paymentId=12345
打开支付页面:
import 'package:url_launcher/url_launcher.dart';
Future<void> openPaymentPage(String iframeUrl) async {
final uri = Uri.parse(iframeUrl);
final opened = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!opened) {
throw StateError('Unable to open payment page');
}
}
App 接收到回跳 URL 后,只提取订单标识,再查询服务端:
Future<void> handlePaymentReturn(Uri uri) async {
if (uri.path != '/payment/result') {
return;
}
final paymentId = uri.queryParameters['paymentId'];
if (paymentId == null || paymentId.isEmpty) {
return;
}
await refreshPaymentStatus(paymentId);
}
Future<void> refreshPaymentStatus(String paymentId) async {
final result = await paymentApi.getPaymentStatus(paymentId);
if (!mounted) {
return;
}
switch (result.status) {
case PaymentStatus.paid:
await markOrderAsPaid(result);
break;
case PaymentStatus.failed:
await showPaymentFailure(result);
break;
case PaymentStatus.pending:
await showPaymentPending();
break;
}
}
Deep Link 的监听方式取决于项目使用的路由或链接插件,相关 API 也可能随版本变化。无论采用哪种实现,都需要处理以下三种情况:
- App 正在运行时收到新链接。
- App 从后台被链接唤醒。
- App 完全关闭后通过链接冷启动。
使用 App 生命周期作为兜底
用户从外部浏览器切回 App 时,通常会触发 AppLifecycleState.resumed。可以在这个阶段重新查询支付状态:
class PaymentPageState extends State<PaymentPage>
with WidgetsBindingObserver {
bool _paymentStarted = false;
bool _querying = false;
String? _paymentId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _paymentStarted) {
_refreshAfterResume();
}
}
Future<void> _refreshAfterResume() async {
final paymentId = _paymentId;
if (paymentId == null || _querying) {
return;
}
_querying = true;
try {
await refreshPaymentStatus(paymentId);
} finally {
_querying = false;
}
}
Future<void> startPayment(
String paymentId,
String paymentUrl,
) async {
_paymentId = paymentId;
_paymentStarted = true;
final opened = await launchUrl(
Uri.parse(paymentUrl),
mode: LaunchMode.externalApplication,
);
if (!opened) {
_paymentStarted = false;
throw StateError('Unable to open payment page');
}
}
}
这个方法只能确认 App 恢复到了前台,无法判断:
- 用户是否关闭了浏览器。
- 用户是否完成支付。
- 用户是否只是临时切换应用。
- 支付是成功、失败还是仍在处理中。
因此,App 恢复到前台后必须查询服务端,并正确处理 pending 状态。必要时可以采用次数有限、间隔逐步增加的轮询。
为什么 WebView 的导航回调没有触发
navigationDelegate、onPageStarted 和类似的 onUrlChanged 回调通常只关注 WebView 顶层页面的导航。
假设最初加载的页面结构如下:
<iframe src="https://payment-gateway.example/checkout"></iframe>
后续变化只发生在 iframe 内部,而 WebView 的顶层 URL 始终不变,Flutter 侧就可能只收到第一次顶层页面加载事件。这也解释了为什么导航回调只调用一次。
如果 iframe 与外层页面不同域,浏览器的同源策略还会阻止外层页面读取 iframe 当前的 URL。Flutter 侧无法通过注入普通 JavaScript 绕过这项限制。
WebView 方案怎样才能工作
支付结束后,网关需要执行顶层页面跳转,而不是只修改 iframe 内部的地址。例如,最终跳转到:
https://example.com/payment/result?paymentId=12345
使用 webview_flutter 时,可以监听顶层页面的跳转。不同版本的控制器初始化方式可能不同,核心判断逻辑如下:
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
final uri = Uri.parse(request.url);
if (uri.host == 'example.com' &&
uri.path == '/payment/result') {
final paymentId = uri.queryParameters['paymentId'];
Navigator.of(context).pop(paymentId);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onPageStarted: (String url) {
debugPrint('Page started: $url');
},
),
)
..loadRequest(Uri.parse(paymentIframe));
调用页面等待 WebView 返回,再查询服务端:
final paymentId = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => PaymentWebView(
paymentIframe: iframeUrl,
),
),
);
if (paymentId != null) {
await refreshPaymentStatus(paymentId);
}
如果支付网关只在跨域 iframe 内导航,又不允许配置顶层 return_url,导航回调仍然无法可靠捕获支付结果。此时可以采用以下方案:
- 使用网关提供的 Flutter SDK 或原生 SDK。
- 要求网关支持 App Link、Universal Link 或自定义 URL Scheme。
- 使用网关提供的服务端 webhook。
- 如果外层页面由自己控制,通过双方约定的
postMessage通信接收支付结果。 - 用户回到 App 后,通过服务端轮询订单状态。
只有支付网关明确支持,或者 iframe 内容也由自己控制时,才能使用 postMessage。
记录更新时的注意事项
支付结果处理必须具备幂等性。webhook、Deep Link、前台恢复和用户手动刷新都可能同时触发状态查询,服务端需要避免重复扣款、重复发货或重复写入记录。
处理时应遵守以下原则:
- 使用服务端生成的唯一
paymentId或订单号关联交易。 - webhook 必须验证签名、交易金额、币种和商户信息。
- 客户端传来的
success参数不能作为支付成功的依据。 - 只允许合法的状态迁移,例如
pending → paid,不能让迟到的失败通知覆盖已经确认的成功状态。 - 查询接口和记录更新接口应支持重复调用。
- 不要在 URL 中携带银行卡信息、密钥或完整支付凭据。
- 使用 WebView 前,应先确认支付网关是否允许。部分网关出于安全验证、3-D Secure 或平台政策原因,要求使用系统浏览器或官方 SDK。
这个问题不需要围绕如何捕获浏览器关闭来解决。应由服务端接收支付通知,App 通过回跳链接恢复界面,并在收到回跳或重新进入前台后,向服务端查询最终支付状态。
备注:内容仅供参考。
