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 直接打开电脑上的前端开发服务器,改完网页保存就能在手机上刷新看效果,不用每次重新构建。
- 前端开发服务器监听局域网地址,例如 Vite 用
npm run dev -- --host,记下终端里打印的http://192.168.x.x:5173。 startURL临时改成这个地址,按 F5 装到手机。- 手机和电脑连同一个 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?
- 把 Apple Development 证书和包含这台 iPhone 的描述文件放进证书目录,见证书与签名。
- 数据线连上 iPhone,第一次连接按设备准备安装驱动、信任电脑、打开开发者模式。
- 在 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 审核决定。