ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WKWebView文件操作实战:从沙盒加载HTML到原生能力桥接

WKWebView文件操作实战:从沙盒加载HTML到原生能力桥接 简介面向iOS初学者的文件操作与WKWebView综合示例工程主要解决沙盒目录管理、数据持久化以及原生与JavaScript交互等常见问题。压缩包共224个文件以Objective-C源文件.m/.h为主另含Storyboard/XIB界面布局、plist配置、PNG图片及工程配置文件整体仅600KB属于轻量、完整的Xcode工程源码已有466人学习/下载。内容覆盖Documents、Library/Caches、tmp等目录的写入与读取使用FileManager实现文件的创建、复制、移动和删除WKWebView部分则演示加载本地与远程网页、监听导航状态并通过WKUserContentController桥接JavaScript事件实现双向通信。工程目录结构清晰可直接打开对照学习适合iOS初级开发者、面试准备者及需要快速复用文件系统与WebView代码的工程人员。1. 项目背景为什么要把“文件操作”和“WKWebView”放在一起这个压缩包我解压之后看到的其实是一个很典型的 iOS 混合开发工程。当时的需求就一句话App 内置一个网页引擎能加载本地打包好的 HTML 资源同时还要把文件下载、存储、删除、分享这些原生能力暴露给网页去调用。听起来不复杂但真做起来文件操作和 WKWebView 这两块坑都不少而且它们之间还有一堆“接口摩擦”问题。先说清楚这个场景为什么常见。很多 App 内部都有“活动页”、“帮助中心”、“说明书”、“离线报表”之类的模块。如果全部用原生页面堆开发成本和更新成本都高如果实时请求远程 HTML网络差的时候体验一塌糊涂。于是最务实的方案就是把前端打包好的静态资源HTML、CSS、JS、图片、PDF、Excel 模板一起塞进 App 的沙盒里WKWebView 直接加载本地文件需要动态数据再用 JS 桥接调原生接口。这样首屏秒开离线可用前端改版只要换资源包不用等 App 发版。这个项目里我负责的是“文件操作 WKWebView 能力封装”这条链路也就是典型的 iOS 开发基本功组合拳。适合什么人参考呢一种是正在做混合 App 的 iOS 开发另一种是前端想了解原生侧怎么配合的同事。下面我把整个方案的选型、代码实现、踩坑记录都摊开讲。2. 整体设计与方案选型2.1 文件操作的边界什么该由原生做什么该由 JS 做刚开始最容易犯的错就是“什么都往 JS 里塞”。网页确实可以用 Blob、ArrayBuffer 做文件读写但 iOS 的沙盒机制天然限制 WebView 的访问范围JS 拿不到沙盒目录的完整权限。所以我的划分原则很简单资源读取HTML/CSS/JS/图片这些静态资源WKWebView 自己加载不走 JS 桥。数据持久化前端产生的业务数据JSON 配置、用户操作记录通过桥接交给原生写进沙盒。文件下载PDF、Excel、压缩包这类体积较大的文件由原生发起下载并保存JS 只负责“命令”。文件预览与分享预览必须原生能力比如 QuickLook分享必须走系统的 UIActivityViewControllerJS 调不到。这个边界定清楚之后后面所有代码都是围绕它来展开。不然前端写一套 File API原生又写一套两边数据和路径对不上调试起来非常痛苦。2.2 加载本地页面loadFileURL 与 loadHTMLString 的取舍iOS 9 之后 WKWebView 支持loadFileURL:allowingReadAccessToURL:可以直接加载沙盒目录下的 HTML 文件。这个方法的好处是页面里的相对路径./js/app.js、./img/banner.png都能正常解析前提是allowingReadAccessToURL参数要传对。官方对allowingReadAccessToURL的说明挺容易让人误解。很多初学者直接传 HTML 的文件路径结果页面能出来但 CSS、JS 全 404。正确做法是传 HTML 所在的根目录。假设 HTML 在Documents/web/index.html你应该这样写let rootURL documentsDir.appendingPathComponent(web) let htmlURL rootURL.appendingPathComponent(index.html) webView.loadFileURL(htmlURL, allowingReadAccessTo: rootURL)这样 WebView 就能以rootURL为根读取它下面所有子目录的文件。如果你用的是loadHTMLString(_:baseURL:)也能加载但baseURL必须是本地目录的 file URL否则相对路径一样全挂。2.3 为什么不用 NSURLProtocol而选 WKURLSchemeHandler早期的 WebView 要拦截自定义协议、重定向请求大家习惯用 NSURLProtocol。但 iOS 11 之后WKWebView 的网络请求默认不走 NSURLProtocol 的拦截逻辑你注册了也白搭。苹果官方的替代方案就是 WKURLSchemeHandler通过注册自定义 scheme比如localres://来接管特定协议的加载。这个项目里我之所以选择 WKURLSchemeHandler而不是老老实实把 HTML 放进沙盒再loadFileURL是因为当时有个需求前端希望用一套统一的虚拟路径去访问资源不管资源是在本地还是远程这样前端代码不用感知资源位置。简单说就是前端写的资源路径形如localres://assets/xxx.png原生拦截之后去沙盒里找到真实的文件并返回。好处是路径逻辑统一坏处是 WKURLSchemeHandler 的返回机制比较繁琐而且对 POST 请求支持不友好。如果项目只是简单加载一个目录我的建议还是老老实实用loadFileURL别折腾 Handler只有当你有“虚拟路径映射”或“离线资源包动态更新”这类需求量级时才考虑上 WKURLSchemeHandler。3. 核心实现文件操作与 WKWebView 的实战代码3.1 初始化 WebView 与 Handler 注册如果必须要用 WKURLSchemeHandler注册代码大致长这样let config WKWebViewConfiguration() config.setURLSchemeHandler(LocalSchemeHandler(), forURLScheme: localres) let webView WKWebView(frame: .zero, configuration: config)然后在LocalSchemeHandler里实现两个方法func webView(_ webView: WKWebView, start task: WKURLSchemeTask) { guard let url task.request.url else { task.didFailWithError(URLError(.badURL)) return } // 解析出 localres://assets/xxx.png 对应的本地路径 // 读取文件数据调用 task.didReceive(response:data:) // 最后 task.didFinish() } func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) { // 取消任务时清理状态 }这里最大的坑是WKURLSchemeTask 的回调顺序必须严格正确先didReceive响应再didReceive数据最后didFinish。如果中间出错一定要调用didFailWithError。还有一个细节同一个 task 可能被多次回调要避免重复 send 数据否则 WebView 会崩溃或者白屏。我当时给每个 task 加了一个“是否已 finish”的标记实测能挡住大部分异常。3.2 沙盒目录选型别把所有文件都塞进 DocumentsiOS 的沙盒有几个目录选错地方会导致审核问题、备份问题、甚至被系统清理。我在项目里反复强调过下表目录用途备份到 iCloud被系统清理Documents用户可见的文档、需要持久化的业务数据是否Library/Caches临时缓存、可以随时重新下载的资源否可能Library/Application Support需要持久化但无需用户看到的文件是否tmp临时文件App 不运行时也可能被清否是这个项目里我把网页资源放在Library/Application Support/WebRoot因为网页资源是“需要持久化、但用户不应该直接看到”的文件。下载的 PDF 等临时预览文件放在tmp或Library/Caches这样用户点预览、点分享用完即走不会被备份也不占 iCloud 空间。只有用户明确想保存的文档才会写到 Documents。很多开发习惯把所有东西直接扔 Documents时间长了 App 的 Documents 体积膨胀审核时甚至会收到“App 可执行文件或资源体积异常”的提示实际就是你的离线包太肥了。所以目录分类这件事越早定越省心。3.3 NSFileManager 的增删改查与路径拼接文件操作的核心就是 FileManager。下面是这个项目里最常用的几个操作封装// 创建目录注意 withIntermediateDirectories 要传 true不然容易因为父目录不存在而失败 try? FileManager.default.createDirectory( at: webRootDir, withIntermediateDirectories: true ) // 遍历目录 if let files FileManager.default.enumerator(atPath: webRootDir.path) { for case let file as String in files { print(file) } } // 移动文件 try? FileManager.default.moveItem(at: srcURL, to: dstURL) // 删除文件 try? FileManager.default.removeItem(at: fileURL) // 判断是否存在 FileManager.default.fileExists(atPath: fileURL.path)这里我吃过一个亏移动文件时目标路径不能已存在同名文件否则报NSFileWriteFileExistsError。正确做法是先判断目标位置有没有文件有就删掉旧文件再移动。另外enumerator遍历出来的路径是相对路径要拼接上根目录才是完整路径不然访问文件的时候会莫名报 file not found。路径拼接还有一个容易踩坑的点appendingPathComponent会在中间自动处理斜杠但如果你直接字符串拼接webRoot / fileName一旦 fileName 里带了多余斜杠或者..轻则路径错乱重则目录跨越越权。所以能用 URL API 就别用字符串拼路径这是经验之谈。3.4 JS 与原生双向通信文件操作“桥”的实现WKWebView 和 JS 通信有三条常用链路WKScriptMessageHandlerJS 向原生发消息。WKWebView evaluateJavaScript原生向 JS 执行代码。WKScriptMessageHandlerWithReplyiOS 14 之后的“带返回值”消息机制。文件操作桥接最常用的是第一条。前端调用方式类似于window.webkit.messageHandlers.fileBridge.postMessage({action: save, fileName: a.json, content: ...})原生侧// 注册桥 config.userContentController.add(self, name: fileBridge) // 实现 WKScriptMessageHandler func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body message.body as? [String: Any] else { return } let action body[action] as? String switch action { case save: // 处理写文件 break case read: // 处理读文件 break default: break } }这里有个老生常谈但很重要的坑WKScriptMessageHandler 传进去之后没有自动移除会导致 WebView 和控制器循环引用。最典型的现象就是控制器 deinit 不调用内存一直涨。解决办法是在控制器的deinit里手动移除deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: fileBridge) }还有一点要注意WKScriptMessage 的body只能传 JSON 序列化的对象不能直接传 File 对象或 Blob。大文件内容要想传过去建议先转 Base64原生拿到再用 Data 写盘。Base64 字符串会膨胀 33% 左右所以超过 10MB 的文件最好不走桥而是改成原生直接下载或原生从临时目录读取JS 只传文件标识。3.5 原生下载文件并回写沙盒的完整流程这个项目里有个典型的“下载 PDF 并预览”场景。前端点击按钮把文件 URL 传给原生原生用 URLSession 下载存到 tmp再调起预览。let task URLSession.shared.downloadTask(with: url) { tempURL, response, error in guard let tempURL tempURL else { return } let destURL FileManager.default.temporaryDirectory .appendingPathComponent(response.suggestedFilename ?? download.pdf) // 先删后移 try? FileManager.default.removeItem(at: destURL) try? FileManager.default.moveItem(at: tempURL, to: destURL) DispatchQueue.main.async { // 跳转 QLPreviewController let preview QLPreviewController() preview.dataSource self self.present(preview, animated: true) } } task.resume()下载任务必须持有强引用不然 URLSession 的任务可能被提前释放。而且回调不一定在主线程UI 操作要记得DispatchQueue.main.async。文件名最好用response.suggestedFilename服务端传过来的文件名经常带编码问题比如中文名被 URLEncode 成%E4%BD%A0%E5%A5%BD.pdf需要removingPercentEncoding还原。4. 文件预览、分享与外部 App 打开4.1 QLPreviewController 快速预览QuickLook 框架支持的格式很多PDF、图片、Office 文档、txt 都能覆盖。实现QLPreviewControllerDataSource非常轻量extension ViewController: QLPreviewControllerDataSource { func numberOfPreviewItems(in controller: QLPreviewController) - Int { return 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) - QLPreviewItem { return fileURL as NSURL } }注意的是QLPreviewItem协议要求返回的是 NSURLSwift 里直接用fileURL as NSURL即可。预览之前最好确认文件已经完整写入否则 QuickLook 打开一个 0 字节文件会直接黑屏或提示“无法打开”。4.2 系统分享UIActivityViewController分享文件到微信、QQ、备忘录直接走系统分享面板最省心let activityVC UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) // iPad 上必须设置 popover 的 sourceView否则崩溃 if let popover activityVC.popoverPresentationController { popover.sourceView shareButton popover.sourceRect shareButton.bounds } present(activityVC, animated: true)iPad 上 UIActivityViewController 不设置 popover source 会直接崩溃这个坑很经典但新人常踩。另外activityItems传文件 URL 和传字符串的效果不一样传 URL 系统会按文件类型提供对应分享渠道传 Data 有些场景会丢失文件名所以优先传文件 URL。4.3 关于 WKWebView 内直接下载和对应打开方式热词里提到“ios浏览器唤起安装app”以及“本地加载vue打包好的项目”这两个其实都能用到这个方案。如果前端资源包就是 Vue 打包产物那么原生侧只需要把 dist 目录整体复制到沙盒然后用loadFileURL加载index.html即可。Vue Router 如果用了 history 模式在 file 协议下会出问题因为 file:// 没有服务端路由回退解决方法是改用 hash 模式或者把路由基址配成相对路径。还要注意WKWebView 内点击a download链接、window.open默认行为跟 Safari 不一样。Safari 能直接下载WKWebView 内如果不做处理经常点了没反应。我的做法是前端跳转统一走桥接拦截下载链接然后用原生 URLSession 下载。如果你也希望在 WebView 里直接弹出下载要用decidePolicyFor navigationAction的 WKNavigationDelegate 判断navigationAction.targetFrame nil然后用UIApplication.shared.open(url)或者用 SFSafariViewController 接管。5. 常见问题与排查技巧实录5.1 页面白屏或 CSS/JS 加载失败这是 WKWebView 加载本地 HTML 最常见的故障。排查步骤我总结成了一条线先用 Safari 开发者工具直接访问这个 HTML 的 file 路径看 CSS/JS 是否能加载。这能快速区分是资源路径问题还是原生配置问题。确认allowingReadAccessToURL指向的是根目录不是 HTML 文件本身。如果 asset 路径是/assets/app.js开头根路径file 协议下会跑到磁盘根目录去找必然失败。需要前端把资源路径改成相对路径./assets/app.js或者统一用虚拟协议由原生拦截。检查 HTML 里的meta和 Content Security Policy有些 CSP 会禁止加载本地文件。我自己遇到过的典型案例是前端在 PC 上开发时路劲交给 webpack 处理打包后资源用了/static/绝对路径结果塞进沙盒一加载全是白屏。后来让前端把 publicPath 改成相对路径./问题直接解决。5.2 中文文件名和空格导致 404WKWebView 的loadFileURL对中文路径是支持的但你对 URL 做字符串处理或者拼接 cookie、query 参数时中文字符必须addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)。如果你把中文路径直接塞给URL(string:)返回的 URL 可能是 nil然后 WebView 就毫无反应。文件名的空格也容易出问题下载文件保存时最好把空格替换成下划线或者统一 URLEncode避免以后用 QuickLook 预览或者系统分享时表现诡异。5.3 文件下载到一半失败残留文件占空间URLSession 下载失败时临时文件可能不会清理长时间积累会让 Caches 体积越涨越大。我的做法是每次 App 启动时清空tmp目录同时 Caches 目录只保留最近 7 天的离线文件。// 启动时清理 tmp let tmpURL FileManager.default.temporaryDirectory try? FileManager.default.removeItem(at: tmpURL) try? FileManager.default.createDirectory(at: tmpURL, withIntermediateDirectories: true)有争议的是这个清理操作会不会影响正在下载的任务实测下来URLSession 的临时文件和 tmp 目录并不是同一个概念清理tmp不会中断正在进行的下载但保险起见可以放到didFinishLaunching里执行。5.4 大文件写入内存暴涨如果前端通过桥接传 Base64 字符串给原生写文件一个 50MB 的文件光字符串就接近 70MB内存峰值很容易扛不住。我的项目里对这个做了限制JS 传入文件内容超过 8MB 时拒绝写入并提示前端改用“临时目录 原生下载”方案。真正的大文件下载一律走 URLSession 的 downloadTask系统会把数据流式写到磁盘内存占用极小。如果必须由 JS 提供二进制数据建议切成小块分片传输原生边收边写FileHandle追加每次分片控制在 512KB 以内。5.5 WKWebView 内存泄漏与进程回收问题WKWebView 是跨进程架构页面进程崩溃后会出现白屏但 App 主进程不受影响。监听页面进程是否崩溃可以用webViewDidTerminate(_:)方法。如果收到回调一般做法是刷新页面或者重建 WKWebView。func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { webView.reload() }还有一种很隐蔽的泄漏WebView 和控制器互相持有。configuration.userContentController.add(self, name:)会把控制器强引用到 WebContentProcess 里控制器又持有 WebView于是形成一个闭环。除了在deinit移除 messageHandler我在项目里还会把 WebView 用弱引用持有双保险。5.6 不同系统版本 WKWebView 的差异化表现iOS 14 之后 WKWebView 默认允许 网页使用相机、麦克风权限但 iOS 15、16 对权限弹窗的描述文案要求更严格必须在 Info.plist 里写清楚用途否则 WebView 调用这些能力时会直接崩溃或拒绝。iOS 17 之后 WKWebView 有很多行为变更比如localStorage 写入策略、加载本地大文件时的内存行为和旧版本都有细微差异。混合开发的项目还必须真机测试不同系统版本的加载情况。模拟器上数据读写和真机沙盒结构一致但磁盘速度、内存限制表现完全不同本地资源包一旦超过 100MB模拟器正常、真机白屏的情况我也遇到过。后记一点实际操作中的体会这个项目让我最深的感受是文件操作和 WKWebView 看起来是两个独立知识点但真正难的是把两者连起来的那条链路。JS 传给原生什么格式、原生返回什么结构、路径由哪边生成、权限由哪边控制这些边界问题如果一开始不定清楚后面会在各种刁钻场景里反复踩坑。我个人现在会建议所有做混合开发的项目从第一天起就建立一套“路径规则”文档。前端不能随便拼路径原生也不能把沙盒根路径直接暴露给 JS。所有文件操作都必须通过桥接层统一封装前端只传“文件名 动作”路径拼接和权限校验全部在原生侧完成。这样以后不管换前端框架、换 webView 组件、还是资源包上 CDN改动面都很小。最后分享一个小技巧在桥接层里把所有 JS 请求的原生操作都记录一条异步日志输出到沙盒的 log 文件。线上用户反馈文件打不开、白屏时直接把日志拉出来分析能省下大量沟通时间。这个日志文件的读写本身也是文件操作正好继续用项目里封装好的那些方法算是自己写的代码又自举了一次。本文还有配套的精品资源点击获取
返回列表