Friday, July 25, 2025

Flutter WebView 集成支付网关(PG)完全指南(在没有SDK的情况下)

在为移动应用添加支付功能时,大多数开发者会使用支付网关(PG)提供的原生SDK。但是,由于项目需求或特定PG的政策,有时会遇到没有提供Flutter专用SDK的尴尬情况。本文将详细分享在没有Flutter SDK的情况下,我们如何利用WebView成功实现与国内支付网关的集成,以及在此过程中遇到的技术难题和解决方案。

1. 无SDK的PG集成:设计基于WebView的架构

没有Flutter SDK意味着我们无法通过原生代码直接控制PG提供的支付流程。替代方案是在应用内展示PG提供的“网页支付页面”,而实现这一目标最可靠的技术就是WebView

为了实现稳定的支付处理,我们设计了如下的数据流和架构:

  1. [Flutter App] 请求支付: 当用户在应用中点击“支付”按钮时,应用将商品信息(名称、价格)和订单信息发送到我们的后端服务器。
  2. [后端服务器] 向PG请求支付准备: 服务器根据从应用收到的信息,生成一个唯一的订单号(orderId),并携带此信息调用PG的支付准备API。
  3. [PG服务器] 返回支付页面URL: PG服务器验证请求后,为该笔交易生成一个唯一的网页支付页面URL,并将其返回给我们的后端服务器。
  4. [后端服务器] 将URL传递给App: 后端服务器再将从PG收到的支付页面URL传递给Flutter应用。
  5. [Flutter App] 在WebView中加载支付页面: 应用将收到的URL加载到WebView中并呈现给用户。从此刻起,用户将在PG提供的网页环境中完成所有支付步骤(如输入卡信息、身份验证等)。
  6. [PG服务器 → 后端服务器] 支付结果通知 (Webhook): 用户完成支付后(无论成功、失败还是取消),PG服务器会异步地将支付结果通知到我们预先配置好的后端服务器特定URL(回调/Webhook URL)。这种服务器到服务器(Server-to-Server)的通信是唯一可信的支付结果来源。
  7. [PG Web → Flutter App] 支付完成后重定向: 当网页支付页面的所有流程结束后,PG会将WebView重定向到我们指定的“结果页”URL(例如:https://my-service.com/payment/result?status=success)。应用通过捕获这个特定的URL导航,关闭WebView,并向用户展示相应的结果页面。

在这个架构中,服务器负责与PG进行安全通信、验证支付数据以防篡改以及管理最终状态。而应用则负责提供用户界面,并通过WebView充当PG支付页面的中介。这看似简单,但真正的问题源于本地支付环境的特殊性。

2. 最大的难题:WebView与外部支付应用(App-to-App)的集成

许多地区的PG网页支付不仅仅是输入卡信息那么简单。为了增强安全性和便利性,它们严重依赖于调用各种外部应用的“应用到应用(App-to-App)”流程。

  • 信用卡App: 直接调用各信用卡公司的官方App(如招商银行的掌上生活、浦发银行的浦大喜奔等)进行身份验证和支付。
  • 快捷支付App: 调用支付宝、微信支付等快捷支付应用。
  • 安全/认证App: 调用独立的认证应用,如银行的U盾App等。

这些外部应用不是通过标准的http://https://链接启动的,而是使用一种称为自定义URL SchemeAndroid Intent的特殊地址格式。例如:

  • alipays://:用于调用支付宝的Scheme。
  • weixin://:用于调用微信的Scheme。
  • intent://...#Intent;scheme=...;end:用于调用特定应用的Android Intent地址。

问题在于,Flutter的官方WebView插件webview_flutter默认情况下无法处理这些非标准(non-HTTP)URL。WebView会将其识别为无效地址,并显示“找不到页面”的错误,或者干脆没有任何反应。解决这个问题是我们项目成败的最大障碍。

3. 解决方案:使用`navigationDelegate`拦截URL

解决这个问题的关键在于webview_flutter提供的navigationDelegate。这个强大的功能允许开发者拦截WebView内发生的所有页面导航请求,并执行自定义逻辑。

我们的策略很明确:

  1. 设置navigationDelegate来监控所有的URL加载请求。
  2. 如果请求的URL不是标准的http/https,而是非标准Scheme,我们就阻止WebView的默认导航行为(返回NavigationDecision.prevent)。
  3. 分析拦截到的URL,判断它是用于Android的intent还是用于iOS的自定义Scheme。
  4. 调用相应的原生代码或辅助包(如url_launcher)来直接启动外部应用。

首先,这是Flutter端WebView组件的基本骨架代码。


import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'dart:io' show Platform;

class PaymentWebViewScreen extends StatefulWidget {
  final String paymentUrl;
  const PaymentWebViewScreen({Key? key, required this.paymentUrl}) : super(key: key);

  @override
  State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
}

class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
  late final WebViewController _controller;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('支付')),
      body: WebView(
        initialUrl: widget.paymentUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController webViewController) {
          _controller = webViewController;
        },
        navigationDelegate: (NavigationRequest request) {
          // 1. 检测最终的支付完成/取消/失败URL
          if (request.url.startsWith('https://my-service.com/payment/result')) {
            // TODO: 处理支付结果后关闭当前WebView页面
            Navigator.of(context).pop('支付尝试完成');
            return NavigationDecision.prevent; // 阻止WebView导航到该URL
          }

          // 2. 处理外部应用调用URL(非http)
          if (!request.url.startsWith('http://') && !request.url.startsWith('https://')) {
            if (Platform.isAndroid) {
              _handleAndroidIntent(request.url);
            } else if (Platform.isIOS) {
              _handleIosUrl(request.url);
            }
            return NavigationDecision.prevent; // 阻止WebView的默认行为至关重要
          }

          // 3. 其他所有http/https URL均正常加载
          return NavigationDecision.navigate;
        },
      ),
    );
  }

  // 处理Android Intent的逻辑(详情如下)
  void _handleAndroidIntent(String url) {
    // ...
  }

  // 处理iOS Custom Scheme的逻辑(详情如下)
  void _handleIosUrl(String url) {
    // ...
  }
}

3.1. Android:攻克`intent://`与MethodChannel的应用

在Android上,intent:// scheme是最具挑战性的。这个URL包含了复杂的信息,如目标应用的包名,以及当应用未安装时的备用URL(通常是Google Play商店链接)。仅用Dart代码来解析和执行它几乎是不可能的,绝对需要原生Android代码的帮助。为此,我们使用了Flutter的MethodChannel(方法通道)

Flutter (Dart) 端代码

首先,添加url_launcher包。虽然它不能直接处理intent://,但在启动像market://这样的简单scheme或备用URL时非常有用。


flutter pub add url_launcher

现在,我们来具体实现_handleAndroidIntent函数。我们将以intent://开头的URL传递给原生端,并尝试用url_launcher处理其他scheme。


import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';

// ... 在 _PaymentWebViewScreenState 类内部 ...

// 定义与Android原生代码通信的通道
static const MethodChannel _channel = MethodChannel('com.mycompany.myapp/payment');

Future<void> _launchAndroidIntent(String url) async {
  if (url.startsWith('intent://')) {
    try {
      // 将intent URL传递给原生代码并请求执行
      await _channel.invokeMethod('launchIntent', {'url': url});
    } on PlatformException catch (e) {
      // 原生调用失败时(例如:无法处理的intent)
      debugPrint("启动intent失败: '${e.message}'.");
    }
  } else {
    // 对于其他scheme(例如:market://, alipays:// 等)
    // 尝试用url_launcher启动
    _launchUrl(url);
  }
}

// 使用url_launcher的通用函数
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // 无法启动URL(应用未安装等)
    debugPrint('无法启动 $url');
  }
}

// 从navigationDelegate调用的最终函数
void _handleAndroidIntent(String url) {
  _launchAndroidIntent(url);
}

原生Android (Kotlin) 端代码

现在,在你的android/app/src/main/kotlin/.../MainActivity.kt文件中,添加接收MethodChannel调用并处理intent的代码。


package com.mycompany.myapp

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import java.net.URISyntaxException

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.mycompany.myapp/payment"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "launchIntent") {
                val url = call.argument<String>("url")
                if (url != null) {
                    launchIntent(url)
                    result.success(null) // 通知Flutter调用已处理
                } else {
                    result.error("INVALID_URL", "URL is null.", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun launchIntent(url: String) {
        val intent: Intent?
        try {
            // 1. 将intent URL字符串解析为Android Intent对象
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
            
            // 2. 检查是否存在可以处理此Intent的应用
            val packageManager = context.packageManager
            val existPackage = intent.`package`?.let {
                packageManager.getLaunchIntentForPackage(it)
            }
            
            if (existPackage != null) {
                // 3. 如果应用已安装,则启动它
                startActivity(intent)
            } else {
                // 4. 如果应用未安装,则导航到备用URL(通常是应用商店URL)
                val fallbackUrl = intent.getStringExtra("browser_fallback_url")
                if (fallbackUrl != null) {
                    val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse(fallbackUrl))
                    startActivity(marketIntent)
                }
            }
        } catch (e: URISyntaxException) {
            // 处理格式错误的URI
            e.printStackTrace()
        } catch (e: Exception) {
            // 处理其他异常
            e.printStackTrace()
        }
    }
}

这段原生代码从Flutter接收intent://字符串,将其解析为标准的Android Intent对象,检查目标应用是否已安装,然后启动它或通过browser_fallback_url重定向到应用商店。这完全解决了Android上的应用间集成问题。

3.2. iOS:自定义URL Scheme与`Info.plist`的重要性

iOS上的情况比Android要简单一些。它主要使用像alipays://weixin://这样的简单自定义scheme,而不是复杂的intent。在大多数情况下,url_launcher包足以处理它们。

然而,有一个至关重要的前提条件。自iOS 9起,由于隐私政策的加强,你必须在你的Info.plist文件中将你打算调用的应用的URL scheme列入白名单。如果一个scheme不在此列表中,canLaunchUrl将始终返回false,你将无法启动该应用。

配置`ios/Runner/Info.plist`

打开你的ios/Runner/Info.plist文件,添加LSApplicationQueriesSchemes键,并附上一个包含集成所需所有scheme的数组。你必须查阅你的PG的开发者文档以获取完整的列表。


LSApplicationQueriesSchemes

    weixin
    wechat
    alipay
    alipays
    

Flutter (Dart) 端代码

现在,你可以使用url_launcher来实现_handleIosUrl函数。不需要MethodChannel,仅用Dart代码就足够了。


// ... 在 _PaymentWebViewScreenState 类内部 ...

void _handleIosUrl(String url) {
  _launchUrl(url); // 复用上面创建的通用_launchUrl函数
}

// _launchUrl函数已在上面定义。
// 对于iOS,如果scheme已在Info.plist中注册,canLaunchUrl将返回true。
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    // 需要以externalApplication模式启动,以绕过Safari直接打开应用
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // 如果应用未安装
    // 你可以重定向到PG提供的App Store链接,
    // 或者向用户显示一个对话框。
    // 例如:if (url.startsWith('alipays')) { launchUrl(Uri.parse('APP_STORE_ALIPAY_LINK')); }
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('应用未安装'),
        content: const Text('要继续支付,需要安装一个外部应用。请从App Store安装。'),
        actions: [
          TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('好的')),
        ],
      ),
    );
  }
}

这样,你就可以在iOS上成功启动外部应用了。请记住,Info.plist的配置是这个过程中最关键的部分

4. 返回应用与至关重要的“服务器端最终验证”

在外部应用中完成支付并返回到我们应用的WebView后,PG将重定向到约定的结果页面(例如:https://my-service.com/payment/result?status=success&orderId=...)。我们必须在navigationDelegate中检测到这个URL,以完成支付流程。


// ... 在 navigationDelegate 内部 ...
if (request.url.startsWith('https://my-service.com/payment/result')) {
  // 从URL中解析查询参数以检查结果
  final Uri uri = Uri.parse(request.url);
  final String status = uri.queryParameters['status'] ?? 'fail';
  final String orderId = uri.queryParameters['orderId'] ?? '';

  // 🚨 安全警告:绝不能在此处直接断定支付成功!
  // 来自客户端(App)的此信息可以被轻松篡改。
  // 必须向我们自己的服务器再次确认最终的支付状态。
  
  // 调用服务器API进行最终验证的示例
  // Future<void> verifyPayment() async {
  //    try {
  //       bool isSuccess = await MyApiService.verifyPayment(orderId);
  //       if (isSuccess) {
  //         // 最终成功处理,导航到成功页面
  //       } else {
  //         // 最终失败处理,导航到失败页面
  //       }
  //    } catch (e) {
  //       // 处理通信错误
  //    }
  // }
  
  // 关闭WebView并返回状态
  Navigator.of(context).pop(status); 
  return NavigationDecision.prevent; // 阻止WebView加载此页面
}

最重要的一点是,绝不能仅凭重定向URL的参数(status=success)就将支付最终处理为成功。这是一个严重的安全漏洞,因为恶意用户可以伪造这个URL来在未实际支付的情况下访问付费内容。应用必须向你的后端服务器发出最终验证请求,询问:“这个orderId的支付真的成功了吗?” 然后,服务器使用它从PG收到的webhook数据(在我们架构的第6步中)来确认真实状态,并响应给应用。只有经过这个服务器端的二次验证,一个安全的支付系统才算完成。

5. 结论:核心要点与经验教训

在没有Flutter SDK的情况下集成PG支付无疑是一个充满挑战的过程。特别是Android的intent和iOS的Info.plist配置的复杂性,需要多次试错才能解决。然而,通过有效利用webview_flutternavigationDelegate和特定于平台的原生集成(MethodChannel),我们能够解决所有问题。

从这次经历中我们学到的核心教训如下:

  • 良好的架构是成功的一半: 清晰地定义从支付请求到WebView加载、结果处理和最终验证的整个流程至关重要。明确划分服务器和客户端的角色。
  • URL拦截是关键: webview_flutternavigationDelegate是基于WebView的支付集成的核心。它让你能够控制所有URL加载,以处理外部应用调用和结果。
  • 尊重平台特性: 虽然Flutter是一个跨平台框架,但像外部应用集成这样的功能需要你理解并遵守每个平台的独特机制(Android Intent, iOS Custom Scheme)。
  • 安全第一: 永远不要相信从客户端收到的支付成功信息。始终通过服务器端从PG的webhook接收的数据进行最终的二次验证,以防止欺诈。

我们希望本指南能对其他希望在Flutter中实现支付功能的开发者有所帮助。


0 개의 댓글:

Post a Comment