跳到主要内容
教程目录

WKWebView 混合开发:H5 打包 iOS App,WKWebView 实现 JS 与原生通信

最后更新

WKWebView 混合开发的关键是两条通道:JS 调原生用 window.webkit.messageHandlers.<名字>.postMessage,要返回值就用 WKScriptMessageHandlerWithReply 让它返回 Promise;原生调 JS 用 evaluateJavaScript,要等异步结果用 callAsyncJavaScript。下面用 KXBuild 的 WKWebView 模板新建工程,把 H5 打包进 App 或加载远程地址,再真机调试、打出 ipa。你需要 Apple 开发者账号、一台 iPhone 和数据线,并已安装 KXBuild,API 截至 2026 年 10 月。

H5 打包进 App 还是加载远程 URL?

WKWebView 可以显示打包在 App 里的网页,也可以直接打开服务器上的网址。两种方式可以混用,例如首页和核心流程本地打包,活动页走远程。

对比项 本地 H5(打包进 App) 远程 URL
首屏速度 快,不依赖网络 取决于网络和服务器
离线可用 可以 不行,要自己做断网提示
更新网页 重新打包、提交审核 服务器上改完即生效
加载方式 loadFileURL 或自定义 scheme load(URLRequest)
审核关注点 和普通 App 一样 审核员更容易认为是「网站套壳」,见审核与 4.2 条款
适合 工具类、离线表单、需要稳定体验的核心页面 内容频繁变化的资讯、活动页

审核指南 2.5.2 不允许 App 下载代码来新增或改变 App 的功能,远程页面也要守这条规矩,不要用远程网页上线审核时没有的功能。

第 1 步:怎么用 WKWebView 模板新建工程?

KXBuild 内置了「iOS 应用(WKWebView 套壳 / H5)」模板,整个界面是一个 WKWebView,默认加载打包的 www 目录,带进度条、返回手势和 JS 与原生互调的示例。以下两种做法任选其一。

  • VS Code 插件。命令面板运行 KXBuild: 新建项目,模板选「iOS 应用(WKWebView 套壳 / H5)」。
  • 命令行。运行下面的命令,工程名只能用字母、数字和下划线。
1kxbuild project create -t app_webview -n H5Shell -b com.example.h5shell -o D:\work

生成的工程结构如下,部署目标是 iOS 15。

1H5Shell/
2  H5Shell.xcodeproj/
3  H5Shell/
4    AppDelegate.swift
5    SceneDelegate.swift        根视图控制器设为 WebViewController
6    WebViewController.swift    WKWebView 和 JS 桥
7    Info.plist                 已带 NSAllowsArbitraryLoadsInWebContent
8    Assets.xcassets/
9    www/                       网页目录,index.html、app.js、style.css

www 是文件夹引用。 整个目录原样复制进 App,子目录结构保留。往里面加文件、建子目录都不用改工程文件,下次构建自动带上。

已有用 UIKit 写的工程,也可以照下文手写一个 WebViewController,不必用模板。各项说明见 KXBuild 内置模板。

第 2 步:怎么加载打包在 App 里的 H5?

把前端构建产物放进 www

Vue、React 这类项目先构建出静态文件,再把 dist 目录里的内容复制到 www。资源路径要用相对路径。 构建工具默认的 /assets/... 绝对路径在 file:// 下会指向手机根目录,页面会白屏。Vite 在配置里加 base: './',Vue CLI 设 publicPath: './',路由用 hash 模式。

1npm run build
2Remove-Item -Recurse -Force D:\work\H5Shell\H5Shell\www\*
3Copy-Item -Recurse D:\work\myweb\dist\* D:\work\H5Shell\H5Shell\www\

用 loadFileURL 打开

模板里的写法如下。第二个参数授权 WKWebView 读取整个 www 目录,页面引用的 css、js、图片才能加载。只授权 index.html 一个文件,其他资源全部 404。

1guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else { return }
2webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())

页面用了 fetch 或 history 路由时改用自定义 scheme

file:// 下的页面没有正常的来源,用 fetch、XHR 读取本地 JSON,或者加载 type="module" 的脚本,都可能因为跨域限制失败;history 模式的前端路由一刷新就找不到文件。这时用 WKURLSchemeHandler 注册一个自己的 scheme,例如 app://local/,由原生代码把 www 里的文件当成网站返回。

 1import Foundation
 2import WebKit
 3import UniformTypeIdentifiers
 4
 5/// 用自定义 scheme(app://local/)提供 www 目录里的文件,
 6/// 页面的 fetch、XHR、ES module 和 history 路由都能像在网站上一样工作。
 7final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
 8    private let root: URL
 9
10    init(root: URL) {
11        self.root = root.standardizedFileURL
12    }
13
14    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
15        guard let url = urlSchemeTask.request.url else {
16            urlSchemeTask.didFailWithError(URLError(.badURL))
17            return
18        }
19        var path = url.path
20        if path.isEmpty || path == "/" { path = "/index.html" }
21        var file = root.appendingPathComponent(String(path.dropFirst())).standardizedFileURL
22
23        // 前端路由的地址(没有扩展名、文件不存在)回退到 index.html。
24        if file.pathExtension.isEmpty && !FileManager.default.fileExists(atPath: file.path) {
25            file = root.appendingPathComponent("index.html")
26        }
27        // 不允许用 ../ 读到 www 以外的文件。
28        guard file.path.hasPrefix(root.path), let data = try? Data(contentsOf: file) else {
29            urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
30            return
31        }
32
33        let mime = UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
34        let headers = ["Content-Type": mime, "Content-Length": String(data.count)]
35        guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
36                                             headerFields: headers) else {
37            urlSchemeTask.didFailWithError(URLError(.cannotParseResponse))
38            return
39        }
40        urlSchemeTask.didReceive(response)
41        urlSchemeTask.didReceive(data)
42        urlSchemeTask.didFinish()
43    }
44
45    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
46}

注册要在创建 WKWebView 之前,对已经创建好的 WKWebView 不生效。scheme 不能用 http、https、file 这类 WebKit 自己处理的名字。

1let www = Bundle.main.url(forResource: "www", withExtension: nil)!
2let config = WKWebViewConfiguration()
3config.setURLSchemeHandler(AppSchemeHandler(root: www), forURLScheme: "app")
4let webView = WKWebView(frame: .zero, configuration: config)
5webView.load(URLRequest(url: URL(string: "app://local/index.html")!))

用了自定义 scheme 后,前端构建可以保留绝对路径 /assets/...,history 路由也能正常刷新。

怎么让 WKWebView 加载远程 URL?

把 WebViewController 里的 startURL 改成你的网址即可。

1let startURL: URL? = URL(string: "https://m.example.com/")

开发阶段可以让 App 直接打开电脑上的前端开发服务器,改完网页保存就能在手机上刷新看效果,不用每次重新构建。

  1. 前端开发服务器监听局域网地址,例如 Vite 用 npm run dev -- --host,记下终端里打印的 http://192.168.x.x:5173。
  2. startURL 临时改成这个地址,按 F5 装到手机。
  3. 手机和电脑连同一个 Wi-Fi。Windows 防火墙放行这个端口的入站连接,App 第一次访问局域网时 iOS 会询问是否允许查找本地网络上的设备,点「允许」。
1New-NetFirewallRule -DisplayName "Vite 5173" -Direction Inbound -Protocol TCP -LocalPort 5173 -Action Allow

开发服务器是 http 地址,能打开是因为模板的 Info.plist 带了 ATS 例外,见 ATS 与 http 资源。上线前把 startURL 改回 nil 或正式的 https 地址。

完整的 WebViewController 怎么写?

下面这份代码可以直接替换模板里的 WebViewController.swift,在模板基础上补了几件实际项目都会遇到的事。

  • 两个消息通道。native 只发不收,nativeAsync 带返回值。
  • 用弱引用转发消息。WKUserContentController 会强引用 handler,直接 add(self, name:) 会让控制器永远释放不了。
  • 校验消息来源,只接受本地页面和你自己域名发来的调用。
  • Debug 包把网页的 console 转发到原生日志,并打开 isInspectable。
  • 处理 target="_blank" 链接、电话和邮件链接、网页进程被回收后的白屏。
  1import UIKit
  2import WebKit
  3import os
  4
  5private let webLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "H5Shell", category: "web")
  6
  7/// 弱引用转发。WKUserContentController 会强引用 handler,直接 add(self) 会让控制器永远释放不了。
  8final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
  9    weak var target: WKScriptMessageHandler?
 10
 11    init(_ target: WKScriptMessageHandler) {
 12        self.target = target
 13    }
 14
 15    func userContentController(_ userContentController: WKUserContentController,
 16                               didReceive message: WKScriptMessage) {
 17        target?.userContentController(userContentController, didReceive: message)
 18    }
 19}
 20
 21/// 带返回值的版本,JS 端 postMessage 会得到一个 Promise。
 22final class WeakReplyHandler: NSObject, WKScriptMessageHandlerWithReply {
 23    weak var target: WKScriptMessageHandlerWithReply?
 24
 25    init(_ target: WKScriptMessageHandlerWithReply) {
 26        self.target = target
 27    }
 28
 29    func userContentController(_ userContentController: WKUserContentController,
 30                               didReceive message: WKScriptMessage,
 31                               replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
 32        guard let target else {
 33            replyHandler(nil, "页面容器已释放")
 34            return
 35        }
 36        target.userContentController(userContentController, didReceive: message, replyHandler: replyHandler)
 37    }
 38}
 39
 40final class WebViewController: UIViewController, WKNavigationDelegate,
 41                               WKScriptMessageHandler, WKScriptMessageHandlerWithReply {
 42
 43    /// 远程首页;为 nil 时加载 App 里打包的 www/index.html。
 44    let startURL: URL? = nil
 45    /// 允许调用原生接口的远程域名。
 46    private let trustedHosts: Set<String> = ["m.example.com"]
 47
 48    private var webView: WKWebView!
 49
 50    override func viewDidLoad() {
 51        super.viewDidLoad()
 52        view.backgroundColor = .systemBackground
 53
 54        let contentController = WKUserContentController()
 55        contentController.add(WeakMessageHandler(self), name: "native")
 56        contentController.addScriptMessageHandler(WeakReplyHandler(self), contentWorld: .page, name: "nativeAsync")
 57        #if DEBUG
 58        contentController.add(WeakMessageHandler(self), name: "console")
 59        contentController.addUserScript(WKUserScript(source: Self.consoleBridge,
 60                                                     injectionTime: .atDocumentStart,
 61                                                     forMainFrameOnly: false))
 62        #endif
 63
 64        let config = WKWebViewConfiguration()
 65        config.userContentController = contentController
 66        config.allowsInlineMediaPlayback = true
 67
 68        webView = WKWebView(frame: view.bounds, configuration: config)
 69        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
 70        webView.navigationDelegate = self
 71        webView.allowsBackForwardNavigationGestures = true
 72        #if DEBUG
 73        if #available(iOS 16.4, *) {
 74            webView.isInspectable = true
 75        }
 76        #endif
 77        view.addSubview(webView)
 78
 79        NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive),
 80                                               name: UIApplication.didBecomeActiveNotification, object: nil)
 81        loadStartPage()
 82    }
 83
 84    private func loadStartPage() {
 85        if let url = startURL {
 86            webView.load(URLRequest(url: url))
 87            return
 88        }
 89        guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else {
 90            webLog.error("www/index.html 没有打包进 App")
 91            return
 92        }
 93        // 授权读取整个 www 目录,页面里的相对路径 css、js、图片才能加载。
 94        webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())
 95    }
 96
 97    /// 只接受本地页面和信任域名发来的消息。
 98    private func isTrusted(_ message: WKScriptMessage) -> Bool {
 99        let origin = message.frameInfo.securityOrigin
100        // 打包在 App 里的页面:file:// 或上文自定义的 app://
101        if origin.protocol == "file" || origin.protocol == "app" { return true }
102        return origin.protocol == "https" && trustedHosts.contains(origin.host)
103    }
104
105    // MARK: - JS 调原生:不需要返回值
106
107    func userContentController(_ userContentController: WKUserContentController,
108                               didReceive message: WKScriptMessage) {
109        if message.name == "console" {
110            webLog.debug("[H5] \(String(describing: message.body), privacy: .public)")
111            return
112        }
113        guard message.name == "native", isTrusted(message),
114              let body = message.body as? [String: Any],
115              let action = body["action"] as? String else { return }
116
117        switch action {
118        case "share":
119            let text = body["text"] as? String ?? ""
120            let sheet = UIActivityViewController(activityItems: [text], applicationActivities: nil)
121            sheet.popoverPresentationController?.sourceView = view
122            present(sheet, animated: true)
123        case "haptic":
124            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
125        default:
126            webLog.error("未知的 action:\(action, privacy: .public)")
127        }
128    }
129
130    // MARK: - JS 调原生:需要返回值
131
132    func userContentController(_ userContentController: WKUserContentController,
133                               didReceive message: WKScriptMessage,
134                               replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
135        guard isTrusted(message),
136              let body = message.body as? [String: Any],
137              let action = body["action"] as? String else {
138            replyHandler(nil, "非法调用")
139            return
140        }
141
142        switch action {
143        case "deviceInfo":
144            let device = UIDevice.current
145            let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
146            replyHandler(["model": device.model,
147                          "system": "\(device.systemName) \(device.systemVersion)",
148                          "appVersion": version], nil)
149        case "getItem":
150            let key = body["key"] as? String ?? ""
151            replyHandler(UserDefaults.standard.string(forKey: "h5." + key), nil)
152        case "setItem":
153            guard let key = body["key"] as? String, let value = body["value"] as? String else {
154                replyHandler(nil, "缺少 key 或 value")
155                return
156            }
157            UserDefaults.standard.set(value, forKey: "h5." + key)
158            replyHandler(true, nil)
159        default:
160            replyHandler(nil, "未知的 action:\(action)")
161        }
162    }
163
164    // MARK: - 原生调 JS
165
166    /// 调用页面里的 window.onNativeEvent({ event, data })。
167    func sendEvent(_ event: String, data: [String: Any] = [:]) {
168        let payload: [String: Any] = ["event": event, "data": data]
169        guard JSONSerialization.isValidJSONObject(payload),
170              let json = try? JSONSerialization.data(withJSONObject: payload),
171              let text = String(data: json, encoding: .utf8) else { return }
172        webView.evaluateJavaScript("window.onNativeEvent && window.onNativeEvent(\(text))") { _, error in
173            if let error {
174                webLog.error("evaluateJavaScript 失败:\(error.localizedDescription, privacy: .public)")
175            }
176        }
177    }
178
179    /// 调用页面里的异步函数并拿到结果,参数由 WebKit 负责转换,不用自己拼字符串。
180    func readCartCount() async -> Int {
181        do {
182            let result = try await webView.callAsyncJavaScript(
183                "return await window.app.getCartCount(userId)",
184                arguments: ["userId": "u_1001"],
185                contentWorld: .page)
186            return (result as? NSNumber)?.intValue ?? 0
187        } catch {
188            webLog.error("callAsyncJavaScript 失败:\(error.localizedDescription, privacy: .public)")
189            return 0
190        }
191    }
192
193    @objc private func appDidBecomeActive() {
194        sendEvent("resume")
195        Task {
196            let count = await readCartCount()
197            webLog.info("购物车数量:\(count)")
198        }
199    }
200
201    // MARK: - WKNavigationDelegate
202
203    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
204                 decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
205        guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else {
206            decisionHandler(.cancel)
207            return
208        }
209        // 电话、邮件、App Store 链接交给系统打开。
210        if ["tel", "mailto", "sms", "itms-apps"].contains(scheme) {
211            UIApplication.shared.open(url)
212            decisionHandler(.cancel)
213            return
214        }
215        // target="_blank" 的链接没有目标 frame,默认什么都不会发生,改为在当前页打开。
216        if navigationAction.targetFrame == nil {
217            webView.load(navigationAction.request)
218            decisionHandler(.cancel)
219            return
220        }
221        decisionHandler(.allow)
222    }
223
224    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
225                 withError error: Error) {
226        webLog.error("页面加载失败:\(error.localizedDescription, privacy: .public)")
227        sendEvent("loadError", data: ["message": error.localizedDescription])
228    }
229
230    /// 网页进程被系统回收后页面会变白,重新加载即可。
231    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
232        webView.reload()
233    }
234
235    // MARK: - 调试:把 console 转发到原生日志
236
237    private static let consoleBridge = """
238    (function () {
239      var h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.console;
240      if (!h) return;
241      function send(level, args) {
242        var text = Array.prototype.map.call(args, function (a) {
243          if (typeof a === 'string') return a;
244          try { return JSON.stringify(a); } catch (e) { return String(a); }
245        }).join(' ');
246        h.postMessage(level + ': ' + text);
247      }
248      ['log', 'info', 'warn', 'error'].forEach(function (level) {
249        var original = console[level];
250        console[level] = function () {
251          send(level, arguments);
252          original.apply(console, arguments);
253        };
254      });
255      window.addEventListener('error', function (e) {
256        send('error', [e.message + ' @ ' + e.filename + ':' + e.lineno]);
257      });
258      window.addEventListener('unhandledrejection', function (e) {
259        send('error', ['unhandled rejection: ' + String(e.reason)]);
260      });
261    })();
262    """
263}

这份代码和上一节的 AppSchemeHandler 都用 iOS 15 SDK 部署目标编译通过,Swift 5 和 Swift 6 语言模式下都没有警告。trustedHosts 改成你自己的域名,m.example.com 只是示例。

WKWebView 里 JS 怎么调原生?

原理

用 add(_:name:) 注册一个名字,WebKit 就会在页面里生成 window.webkit.messageHandlers.<名字>.postMessage(消息)。消息体可以是字符串、数字、布尔、数组和普通对象,到了 Swift 里分别是 String、NSNumber、Array 和 Dictionary。函数、DOM 节点、undefined 字段传不过去。

方式 注册 JS 端得到 适合
WKScriptMessageHandler add(_:name:) 没有返回值 分享、震动、打点、关闭页面
WKScriptMessageHandlerWithReply(iOS 14 起) addScriptMessageHandler(_:contentWorld:name:) Promise 读取设备信息、读写本地存储、调用需要等结果的原生能力

带返回值的通道里,replyHandler(值, nil) 让 Promise 以这个值完成,值为 nil 时 JS 拿到 undefined。replyHandler(nil, "错误说明") 让 Promise 被拒绝,JS 端 catch 到的 Error 的 message 就是这段文字。

replyHandler 最多调用一次,一次都不调用的话,Promise 也会以错误结束。返回值只能是数字、字符串、日期、数组、字典和 NSNull。

同一个名字只能注册一次。 按 WebKit 的接口说明,同一个 content world 里一个名字只能对应一个 handler,两种 handler 之间也算冲突,重复注册会抛出异常让 App 崩溃。所以示例里两个通道用了不同的名字 native 和 nativeAsync。

JS 端封装

把调用集中到一个 bridge.js 里,业务代码不直接碰 window.webkit。在桌面浏览器里调试页面时 window.webkit 不存在,封装里先判断一下,页面不会报错。

 1// www/bridge.js:页面和原生之间的唯一入口
 2(function () {
 3  function handler(name) {
 4    return window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers[name];
 5  }
 6
 7  window.bridge = {
 8    // 是否运行在 App 里,浏览器里调试时为 false
 9    get inApp() {
10      return !!handler('native');
11    },
12
13    // 不需要返回值:分享、震动、打点
14    post(action, data = {}) {
15      const h = handler('native');
16      if (!h) {
17        console.warn('不在 App 中,忽略', action);
18        return;
19      }
20      h.postMessage({ action, ...data });
21    },
22
23    // 需要返回值:返回 Promise,原生出错时 reject
24    async call(action, data = {}) {
25      const h = handler('nativeAsync');
26      if (!h) throw new Error('不在 App 中:' + action);
27      return h.postMessage({ action, ...data });
28    },
29  };
30
31  // 原生通过 evaluateJavaScript 推送事件
32  const listeners = {};
33  window.bridge.on = function (event, fn) {
34    (listeners[event] = listeners[event] || []).push(fn);
35  };
36  window.onNativeEvent = function (payload) {
37    (listeners[payload.event] || []).forEach((fn) => fn(payload.data));
38  };
39})();

业务页面里这样用。index.html 里先引入 bridge.js,再引入 app.js。

 1// www/app.js:业务页面里的用法
 2document.getElementById('share').addEventListener('click', () => {
 3  window.bridge.post('share', { text: '推荐一个好用的 App' });
 4});
 5
 6document.getElementById('info').addEventListener('click', async () => {
 7  try {
 8    const info = await window.bridge.call('deviceInfo');
 9    console.log('设备信息', info);
10  } catch (e) {
11    console.error('调用失败', e.message);
12  }
13});
14
15window.bridge.on('resume', () => {
16  console.log('App 回到前台,刷新数据');
17});
18
19// 给原生 callAsyncJavaScript 调用的函数,可以是 async
20window.app = {
21  async getCartCount(userId) {
22    const saved = await window.bridge.call('getItem', { key: 'cart.' + userId });
23    return saved ? JSON.parse(saved).length : 0;
24  },
25};

安全

注册的通道对 WebView 里打开的每一个页面都可见,包括用户点链接跳到的第三方网站和页面里嵌的 iframe。示例里的 isTrusted 检查 message.frameInfo.securityOrigin,只接受本地页面和 trustedHosts 里的 https 域名。读写文件、取用户信息这类敏感接口,再加一层参数校验,不要做成「JS 传什么方法名就执行什么」的通用反射。

原生怎么调用页面里的 JS?

方法 最低版本 特点 适合
evaluateJavaScript(_:completionHandler:) iOS 8 执行一段脚本字符串,参数要自己拼进去 推送事件、调用同步函数
callAsyncJavaScript(_:arguments:in:contentWorld:)(async 版) iOS 15 传函数体和参数字典,会等 Promise 完成后返回结果 调用页面里的 async 函数并拿结果

用 evaluateJavaScript 时,参数一定要先用 JSONSerialization 转成 JSON 再拼进脚本,示例里的 sendEvent 就是这样做的。直接把字符串拼进单引号里,内容里带引号或换行就会出语法错误,内容来自用户输入时还会被注入脚本。

callAsyncJavaScript 的第一个参数是函数体,不是一个函数。示例里写的是 return await window.app.getCartCount(userId),userId 由 arguments 字典传入,WebKit 负责类型转换。函数体返回的 Promise 被拒绝时,Swift 端抛出错误。

原生调 JS 要等页面加载完。在 webView(_:didFinish:) 之后再调,或者像示例一样,让页面在准备好之后先调一次原生,原生再开始推送。

http 资源被 ATS 拦截怎么办?

App 传输安全(ATS)默认只允许 https。模板的 Info.plist 带了下面这项,它只放开 WKWebView 里的网页请求,URLSession 等原生网络请求仍然受 ATS 限制。

1<key>NSAppTransportSecurity</key>
2<dict>
3  <key>NSAllowsArbitraryLoadsInWebContent</key>
4  <true/>
5</dict>

按 Apple 文档,把 NSAllowsArbitraryLoadsInWebContent 设为 YES,提交审核时要说明理由。如果你的网页和接口都是 https,上线前把这一项删掉。只有个别域名还是 http 时,改用 NSExceptionDomains 只给这些域名开例外,比整体放开更容易说清楚。

Info.plist 在 VS Code 里双击就能用属性列表编辑器修改。

怎么在 iPhone 上调试 WKWebView 里的 H5?

先在桌面浏览器里调好

页面布局、业务逻辑在电脑浏览器里用开发者工具调,效率最高。bridge.js 在浏览器里会走「不在 App 中」的分支,页面照常运行。只在 iPhone 上出现的问题,再用下面的方法。

把 console 转发到原生日志

完整代码里 Debug 包注入了一段用户脚本,把 console.log、warn、error、未捕获的异常和 Promise 拒绝都转发给原生的 Logger。按 F5 运行后,网页的输出和原生日志一起显示在 VS Code「输出 → KXBuild Console」里,带 [H5] 前缀。不调试时用 kxdevice ostrace --subsystem 你的BundleID 也能看到,用法见 iOS 真机日志怎么看。

原生侧的消息处理代码可以直接打断点。在 userContentController(_:didReceive:) 里打一个断点,从页面点一下按钮,就能看到 JS 传进来的消息体,用法见运行与调试。

页面内调试面板

要在手机上看网络请求、DOM、本地存储,在 Debug 包的页面里临时引入 vConsole 或 Eruda 这类页面内调试面板,发布前去掉。

Safari 网页检查器与 isInspectable

手边有 Mac 时,也可以用 Mac 上的 Safari 网页检查器连接手机里的 WKWebView。从 iOS 16.4 起,WKWebView 的 isInspectable 默认是 false,要显式设为 true 才能被检查,示例代码只在 Debug 包里打开它。手机上还要在「设置」里 Safari 浏览器的「高级」页打开「网页检查器」。

第 3 步:怎么真机运行并打包 ipa?

  1. 把 Apple Development 证书和包含这台 iPhone 的描述文件放进证书目录,见证书与签名。
  2. 数据线连上 iPhone,第一次连接按设备准备安装驱动、信任电脑、打开开发者模式。
  3. 在 KXBuild 视图中选中这台 iPhone,按 F5。KXBuild 会构建、签名、安装、启动并接上调试器。

改了 www 里的网页,停止调试后重新按 F5,新的文件才会打包进 App。调试工具栏上的「重启」不会重新构建。

上架用 Release 配置构建,证书目录里要有 Apple Distribution 证书和 App Store 类型的描述文件。以下两种做法任选其一。

  • VS Code 插件。在命令面板或工程的右键菜单运行 KXBuild: 生成 App Store ipa。
  • 命令行。在工程目录运行下面的命令。
1kxbuild build .\H5Shell.xcodeproj -c Release --export app-store

构建完成后 build\H5Shell.ipa 就是可以上传 App Store Connect 的包,上传和提审见发布与上架。

H5 打包的 App 怎么过审核 4.2 最低功能?

App Store 审核指南 4.2 最低功能要求 App 提供超出「重新包装的网站」的功能、内容和界面。只把一个网址装进 WKWebView 的 App,很容易以 4.2 被拒。被拒信的常见说法和回复方法见 App Store 审核被拒原因。

提交前对照下面几点,给 App 加上网页做不到的原生价值。

方向 做法
原生能力 推送通知、相机扫码、相册、定位、通过 Apple 登录、桌面小组件
原生界面 底部标签栏、导航栏、设置页用原生控件实现,网页只负责内容区
离线可用 核心页面打包进 App,断网时有明确的提示和缓存内容
体验细节 下拉刷新、返回手势、加载进度、失败重试,外部链接用系统浏览器打开
账号 支持注册就要支持在 App 内删除账号

另外两条也和混合 App 有关。远程网页不能在审核之后加入新功能,见 2.5.2。如果你的 App 是一个平台,允许第三方 H5 小程序运行在里面,要按 4.7 的要求做内容索引、举报和年龄限制,并且未经 Apple 许可不能把原生接口开放给这些第三方页面。

WKWebView 常见问题怎么排查?

现象 原因 改法
打开就是白屏,页面没内容 www 里没有 index.html,或构建产物用了 /assets/ 绝对路径 检查 www 目录,前端构建改成相对路径,或改用自定义 scheme
页面能显示,图片和样式丢失 loadFileURL 只授权了单个文件 allowingReadAccessTo 传 www 目录
fetch 本地 JSON 失败 file:// 页面受跨域限制 改用自定义 scheme,或把数据交给原生读取后通过 bridge 返回
http 接口或图片加载不出来 ATS 拦截 网页内容用 NSAllowsArbitraryLoadsInWebContent 或按域名加例外,最好改成 https
window.webkit 是 undefined 页面在桌面浏览器里运行,或通道名拼错 用 bridge.inApp 判断,核对 Swift 里注册的名字
注册通道时 App 崩溃 同一个名字注册了两次 每个名字只注册一次,带返回值的用另一个名字
控制器退出后不释放 add(self, name:) 被强引用 用弱引用转发的 WeakMessageHandler
evaluateJavaScript 报 SyntaxError 参数直接拼进字符串,内容带引号或换行 先用 JSONSerialization 转 JSON,或改用 callAsyncJavaScript
target="_blank" 链接点了没反应 WKWebView 默认不处理新窗口 在 decidePolicyFor 里改为当前页打开
后台停留久了回来是白屏 网页进程被系统回收 实现 webViewWebContentProcessDidTerminate 并 reload
页面内容被刘海或底部横条挡住 网页没处理安全区域 viewport 加 viewport-fit=cover,CSS 用 env(safe-area-inset-top) 等留出边距
改了网页,手机上没变化 用了调试工具栏的「重启」 停止调试,重新按 F5

表里没有的现象,先按上文把 console 转发到原生日志,再在 userContentController(_:didReceive:) 打断点,看消息有没有到达原生侧。WKWebView 混合开发的问题多数出在路径、ATS 和通道名这三处,逐一核对就能定位。

常见问题

WKWebView 里 JS 怎么调用 Swift 并拿到返回值?

让处理类遵循 WKScriptMessageHandlerWithReply,用 addScriptMessageHandler(_:contentWorld:name:) 注册,JS 端 window.webkit.messageHandlers.名字.postMessage 会返回 Promise。Swift 里调用 replyHandler(值, nil) 完成 Promise,传错误文字则让 Promise 被拒绝。需要 iOS 14 及以上。

Swift 怎么调用页面里的 async 函数?

用 iOS 15 起的 callAsyncJavaScript,第一个参数写函数体,例如 return await window.app.getData(id),参数放在 arguments 字典里。它会等页面返回的 Promise 完成后把结果交给 Swift,Promise 被拒绝时抛出错误。

在 Windows 上怎么调试 WKWebView 里的网页?

先在电脑浏览器里调好页面逻辑。只在手机上出现的问题,把网页的 console 转发到原生日志,在 VS Code「输出 → KXBuild Console」里看,或者在 Debug 包里临时引入 vConsole、Eruda 这类页面内调试面板。

iOS 16.4 以后 Safari 检查不到 WKWebView 怎么办?

从 iOS 16.4 起,WKWebView 的 isInspectable 默认是 false。在 Debug 包里用 if #available(iOS 16.4, *) 把它设为 true,再在手机「设置」里 Safari 浏览器的「高级」页打开网页检查器。

纯网页套壳的 App 能上架吗?

只是把网站装进 WKWebView、没有原生功能的 App,很容易按审核指南 4.2 最低功能被拒。加上推送、扫码、原生导航、离线可用这类网页做不到的能力,能通过与否最终由 Apple 审核决定。