Flutter WebView 如何通过 POST 发送 form data
结论
当请求头声明为 application/x-www-form-urlencoded 时,请求体不能使用 jsonEncode(map),而要编码为表单格式:
key1=value1&key2=value2
在 Dart 中,可以通过 Uri(queryParameters: ...) 生成编码后的表单请求体,再由 WebView 直接发起 POST 请求。支付服务器返回的页面会保留在 WebView 的正常浏览上下文中,JavaScript、Cookie、页面跳转和表单交互也能继续工作。
问题原因
当前代码发送的其实是 JSON:
utf8.encode(jsonEncode(map))
例如:
{"amount":"100","orderId":"123"}
请求头声明的却是表单数据:
Content-Type: application/x-www-form-urlencoded
服务器通常期望收到这样的请求体:
amount=100&orderId=123
请求头和请求体的格式不一致,可能导致服务器无法解析支付参数、签名校验失败,或返回异常页面。
先通过 Dio 获取 HTML,再把 HTML 字符串放进 WebView,也不等于让 WebView 直接打开支付页面。这个过程可能丢失原始 URL、Cookie、重定向上下文、相对资源路径和浏览器提交过程。结果可能是支付页面能够显示,但后续操作无法正常进行。
直接通过 WebView 发送 POST
下面的示例适用于使用 WebViewController 的新版 webview_flutter API:
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class PaymentPage extends StatefulWidget {
const PaymentPage({super.key});
@override
State<PaymentPage> createState() => _PaymentPageState();
}
class _PaymentPageState extends State<PaymentPage> {
late final WebViewController controller;
final Map<String, dynamic> paymentData = {
'orderId': 'ORDER_123',
'amount': '100.00',
'currency': 'INR',
// 添加 PayGoal 要求的其他字段
};
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) {
debugPrint('Navigate to: ${request.url}');
return NavigationDecision.navigate;
},
onWebResourceError: (error) {
debugPrint(
'WebView error: ${error.errorCode} '
'${error.description} '
'${error.url}',
);
},
),
);
_loadPaymentPage();
}
Future<void> _loadPaymentPage() async {
final formFields = paymentData.map(
(key, value) => MapEntry(key, value.toString()),
);
final formBody = Uri(queryParameters: formFields).query;
await controller.loadRequest(
Uri.parse('https://uat.paygoal.in/order/v1/payment'),
method: LoadRequestMethod.post,
headers: const {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: Uint8List.fromList(utf8.encode(formBody)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: WebViewWidget(controller: controller),
),
);
}
}
如果项目使用的是问题中展示的旧版 WebView API,改法相同:不要调用 jsonEncode(map)。
final formFields = map.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
final formBody = Uri(queryParameters: formFields).query;
controller.loadRequest(
WebViewRequest(
uri: Uri.parse('https://uat.paygoal.in/order/v1/payment'),
headers: const {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: WebViewRequestMethod.post,
body: Uint8List.fromList(
utf8.encode(formBody),
),
),
);
假设参数如下:
final map = {
'orderId': 'ORDER 123',
'amount': '100.00',
};
编码后的请求体类似:
orderId=ORDER+123&amount=100.00
编码过程会正确转义特殊字符,因此不能用简单的字符串拼接代替。
如果 Android 无法正确处理 POST 请求头
部分 webview_flutter 版本或底层 Android WebView 无法正确处理带有自定义 Header 的 POST 请求,实际行为取决于插件版本和平台实现。
对于标准的 application/x-www-form-urlencoded 表单,可以让 WebView 加载一个自动提交的 HTML 表单。POST 请求由页面本身发起,这种方式通常更接近支付网关提供的普通网页接入流程。
import 'dart:convert';
String escapeHtml(String value) {
return const HtmlEscape(HtmlEscapeMode.attribute).convert(value);
}
Future<void> submitPaymentForm(
WebViewController controller,
Map<String, dynamic> paymentData,
) async {
const actionUrl =
'https://uat.paygoal.in/order/v1/payment';
final inputs = paymentData.entries.map((entry) {
final name = escapeHtml(entry.key);
final value = escapeHtml(entry.value.toString());
return '<input type="hidden" name="$name" value="$value">';
}).join();
final html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
</head>
<body>
<form id="paymentForm"
method="post"
action="$actionUrl">
$inputs
</form>
<script>
document.getElementById('paymentForm').submit();
</script>
</body>
</html>
''';
await controller.loadHtmlString(
html,
baseUrl: 'https://uat.paygoal.in/',
);
}
字段名和字段值都必须经过 HTML 转义。如果参数中包含引号、& 或 <,未转义的内容可能破坏页面结构,也可能造成注入风险。
支付页面仍不能运行时的检查项
1. 确认字段类型和签名规则
支付接口通常会严格校验金额、订单号、商户编号、回调地址和签名等字段。生成签名时使用的原始值、字段顺序和字符编码,都必须符合 PayGoal 的接入文档。
不要再次对已经编码的值执行 URL 编码,否则会出现双重编码:
%2F → %252F
2. 不要手动设置错误的 Content-Length
请求体长度应由 WebView 或底层网络实现计算。错误的 Content-Length 可能导致服务器只能读取到部分数据。
3. 保持 JavaScript 可用
支付页面通常依赖 JavaScript:
controller.setJavaScriptMode(JavaScriptMode.unrestricted);
如果页面使用弹窗、新窗口、文件选择或第三方应用跳转,还需要处理相应的 WebView 平台能力。
4. 监听支付结果 URL
支付结束后,网关通常会跳转到成功、失败或取消地址。可以在导航回调中识别这些地址:
onNavigationRequest: (request) {
final uri = Uri.parse(request.url);
if (uri.host == 'example.com' &&
uri.path == '/payment/success') {
Navigator.of(context).pop(true);
return NavigationDecision.prevent;
}
if (uri.host == 'example.com' &&
uri.path == '/payment/failure') {
Navigator.of(context).pop(false);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
判断条件应使用支付平台或后端配置的回调地址,不能只根据页面标题或 HTML 文本判断结果。
5. 不要把前端跳转当作最终支付凭证
WebView 中出现”支付成功”只能用于更新界面。订单的最终状态仍应由服务端通过 PayGoal 的回调通知或订单查询接口确认,不能仅凭客户端的跳转结果更新订单。
优先使用”编码后的表单请求体 + WebView POST”。如果当前插件或 Android 平台无法可靠地附加 POST 请求头,再使用自动提交的 HTML <form>。无论采用哪种方式,实际请求体都必须符合 application/x-www-form-urlencoded 格式,不能发送 JSON。
备注:内容仅供参考。
