PAYATHON 2026

Flutter 中如何在 launch_url 浏览器关闭后执行操作

支付老李

结论

launchUrlString() 通过系统外部浏览器打开支付页面后,Flutter 无法可靠监听浏览器何时关闭。await launchUrlString(iframe) 只表示系统已接收并处理该 URL,不会等待浏览器关闭或支付结束。

支付场景可以按以下方式处理:

  1. 以支付网关的服务端 webhook/callback 作为最终支付结果的依据。
  2. 为支付请求配置 return_url,支付结束后由网关跳转到 App Link、Universal Link 或自定义 URL Scheme。
  3. App 收到回跳后,携带 paymentId 向自己的服务端查询支付状态。
  4. App 从后台恢复时也可以查询一次支付状态作为兜底,但这不能证明支付已经成功。
  5. 如果使用 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 为准。

创建支付交易时,如果网关支持,可以设置类似下面的返回地址:

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 通过回跳链接恢复界面,并在收到回跳或重新进入前台后,向服务端查询最终支付状态。

备注:内容仅供参考。