PAYATHON 2026

iOS 从 UIWebView 迁移到 WKWebView 实现 JavaScript POST

支付老李

结论

evaluateJavaScript 返回的 result 不是表单 POST 的响应数据。JavaScript 函数没有显式 return 时,result 为 nil 是正常现象。

在 WKWebView 中,也不能依赖 WKNavigationAction.request.httpBody 获取网页表单产生的 POST body。由于 WKWebView 采用多进程架构,导航代理通常只能读取 URL、请求方法等信息,POST body 不一定会暴露给 App。

处理 3D Secure 1.x 时,可以采用以下流程:

  1. App 在 WKWebView 中向 ACSURL 提交 PaReq、TermUrl 和 MD。
  2. 用户在 ACS 页面完成验证。
  3. ACS 通过 HTTP POST 将 PaRes 和 MD 发送到你控制的 TermUrl。
  4. 服务器解析并校验数据,再返回结果页面或跳转地址。
  5. App 通过 URL 跳转或 WKScriptMessageHandler 获取验证结果,然后继续支付流程。

不要在正式环境中把 https://www.apple.com 之类的任意第三方地址设为 TermUrl。这个地址是 ACS 完成验证后的回调接收端,第三方服务器不会替你可靠地保存和返回 PaRes。

当前代码为什么得到 nil

代码中有几个相互独立的问题。

1. HTML 中没有表单

示例字符串只包含以下 JavaScript:

function submitForm() {
    document.getElementById('submitButton').style.display = 'none';
    document.getElementById('submitButton').click();
}

其中既没有定义 submitButton,也没有包含向 ACSURL 提交数据的 <form>。所以:

document.getElementById('submitButton')

会返回 null,继续访问 style 时就会抛出 JavaScript 异常。

此外,String(format:) 的格式字符串中没有 %@,传入的 acsurlString、paReqString 和 termURLString 实际上没有插入 HTML。

2. JavaScript 没有返回值

即使下面的函数成功执行,因为没有 return:

function submitForm() {
    document.getElementById('threeDSForm').submit();
}

evaluateJavaScript 的 result 仍然会是 nil。调用时还应检查 error:

webView.evaluateJavaScript("submitForm()") { result, error in
    if let error {
        print("JavaScript error: \(error)")
        return
    }

    print("JavaScript return value: \(String(describing: result))")
}

这里的返回值只是 JavaScript 表达式的计算结果,不是 HTTP POST body,也不是 ACS 返回的 PaRes。

3. WebView 宽度被设置为零

当前代码创建的 frame 宽度是 0.0:

CGSize(
    width: 0.0,
    height: secureViewContainer.frame.size.height
)

即使页面加载成功,用户也看不到验证界面。可以直接使用容器边界,再设置自动布局或 autoresizing:

webView = WKWebView(
    frame: secureViewContainer.bounds,
    configuration: configuration
)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
secureViewContainer.addSubview(webView)

正确构造并提交 ACS 表单

下面的代码适用于典型的 3D Secure 1.x 表单流程。字段名称是否区分大小写,以及是否需要 MD,要以支付服务商的协议为准。

把数据插入 HTML 前必须转义,不能直接拼接服务端返回的内容:

private func escapeHTML(_ value: String) -> String {
    value
        .replacingOccurrences(of: "&", with: "&amp;")
        .replacingOccurrences(of: "\"", with: "&quot;")
        .replacingOccurrences(of: "'", with: "&#39;")
        .replacingOccurrences(of: "<", with: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")
}

然后生成完整页面:

private func loadThreeDSChallenge(
    acsURL: String,
    paReq: String,
    termURL: String,
    md: String
) {
    let html = """
    <!doctype html>
    <html>
    <head>
        <meta name="viewport"
              content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <form id="threeDSForm"
              method="POST"
              action="\(escapeHTML(acsURL))">
            <input type="hidden"
                   name="PaReq"
                   value="\(escapeHTML(paReq))">
            <input type="hidden"
                   name="TermUrl"
                   value="\(escapeHTML(termURL))">
            <input type="hidden"
                   name="MD"
                   value="\(escapeHTML(md))">
            <noscript>
                <button type="submit">继续验证</button>
            </noscript>
        </form>

        <script>
            window.addEventListener("load", function () {
                document.getElementById("threeDSForm").submit();
            });
        </script>
    </body>
    </html>
    """

    webView.loadHTMLString(html, baseURL: nil)
}

页面加载后,load 事件会自动提交表单,通常不需要再在 didFinish 中调用一次 submitForm()。

decidePolicyFor 和 didFinish 分别能做什么

decidePolicyFor

它在导航开始前调用,适合:

  • 检查即将访问的 URL;
  • 拦截服务器返回的完成跳转;
  • 阻止访问不允许的域名;
  • 决定使用 .allow 还是 .cancel。

例如,服务器处理完 PaRes 后跳转到:

https://pay.example.com/3ds/app-complete?transactionId=123

App 可以拦截这个完成地址:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.allow)
        return
    }

    if url.host == "pay.example.com",
       url.path == "/3ds/app-complete" {
        let components = URLComponents(
            url: url,
            resolvingAgainstBaseURL: false
        )

        let transactionID = components?
            .queryItems?
            .first(where: { $0.name == "transactionId" })?
            .value

        decisionHandler(.cancel)
        handleThreeDSCompletion(transactionID: transactionID)
        return
    }

    decisionHandler(.allow)
}

不要把这里的 request.httpBody 作为获取 PaRes 的可靠来源。

didFinish

它表示当前页面已经完成主框架导航,适合:

  • 隐藏加载状态;
  • 更新界面;
  • 执行当前页面中的 JavaScript;
  • 读取你有权限访问的页面状态。

它不会提供 POST body,也不会自动返回 ACS 提交到 TermUrl 的 PaRes。

JavaScript POST 的实际过程

这个过程不是 JavaScript 把结果交给 Native,而是浏览器提交普通的 HTML 表单:

App 生成表单
    ↓ POST PaReq、TermUrl、MD
ACSURL
    ↓ 用户完成验证
ACS POST PaRes、MD
    ↓
你控制的 TermUrl
    ↓ 服务器校验并记录结果
完成页面或完成跳转
    ↓
App 获知流程结束

TermUrl 可以写成任意格式正确的 URL,因为它只是作为隐藏字段传给 ACS。验证完成后,ACS 会尝试向该地址发送 POST。UIWebView 过去可能会把相关 POST body 暴露给代理,但在 WKWebView 中不能继续依赖这种接口行为。

正式环境中的 TermUrl 通常还要满足支付服务商对 HTTPS、域名白名单、会话关联或签名校验的要求。

使用 WKScriptMessageHandler 通知 App

如果 TermUrl 返回的 HTML 页面由你的服务器控制,可以让该页面通知 Native。先配置消息处理器:

final class ThreeDSViewController: UIViewController,
                                  WKNavigationDelegate,
                                  WKScriptMessageHandler {
    private var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let contentController = WKUserContentController()
        contentController.add(self, name: "threeDS")

        let configuration = WKWebViewConfiguration()
        configuration.userContentController = contentController

        webView = WKWebView(
            frame: secureViewContainer.bounds,
            configuration: configuration
        )
        webView.navigationDelegate = self
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        secureViewContainer.addSubview(webView)
    }

    deinit {
        webView?
            .configuration
            .userContentController
            .removeScriptMessageHandler(forName: "threeDS")
    }

    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == "threeDS",
              let payload = message.body as? [String: Any],
              let transactionID = payload["transactionId"] as? String else {
            return
        }

        handleThreeDSCompletion(transactionID: transactionID)
    }

    private func handleThreeDSCompletion(transactionID: String?) {
        // 向自己的服务器查询并确认最终验证状态,
        // 然后继续支付流程。
    }
}

服务器收到并处理 ACS 的 POST 后,可以返回类似的页面:

<!doctype html>
<html>
<body>
<script>
    window.webkit.messageHandlers.threeDS.postMessage({
        status: "completed",
        transactionId: "server-generated-id"
    });
</script>
</body>
</html>

更稳妥的做法是只把交易标识和完成状态传给 App,再由 App 向自己的服务器查询结果。不要根据网页传回的一个布尔值判断支付是否成功,也不要在 URL 查询参数中暴露完整的 PaRes。

安全注意事项

  • ACSURL 应由可信的支付服务端提供,并建议限制允许加载的域名。
  • 不要把 PaReq、PaRes、MD 等值写入普通日志。
  • 不要使用 JavaScript 读取或修改第三方 ACS 页面的 DOM。跨域限制和页面实现变化都会让这种方案失效。
  • TermUrl 应由自己的后端控制,并使用 HTTPS。
  • 必须通过 MD、服务器会话或不可预测的交易标识关联同一笔支付,防止串单和重放。
  • Native 收到完成通知后,仍要向可信服务器确认最终支付状态。
  • WKUserContentController 会保留消息处理器。控制器销毁时要移除 handler,避免循环引用。
  • 如果支付服务商提供官方 3D Secure SDK,应优先使用其 SDK,并参照最新的集成文档。不同 3DS 版本的流程差异较大,不能假设 3D Secure 1.x 的 PaReq 和 PaRes 表单方案同样适用于后续版本。

理解 Native 与网页的交互方式时,可以重点查看 Apple WebKit 文档中的 WKWebView、WKNavigationDelegate、WKUIDelegate、WKUserContentController、WKScriptMessageHandler 和 evaluateJavaScript。需要区分三件事:导航代理负责观察页面导航,evaluateJavaScript 用于获取 JavaScript 返回值,而服务器接收的 HTTP POST 数据应由服务器端处理。

备注:内容仅供参考。