简介:面向iOS开发者的文件操作与WKWebView实战资料包,适合移动端初中级开发者系统学习。内容从iOS沙盒机制讲起,分别剖析Documents、Library、Caches、tmp四个目录的定位:Documents存放需备份的重要数据并同步iCloud,Library保存偏好设置,Caches存放可再生成的临时数据,tmp保存生命周期较短的文件;随后基于FileManager类给出文件创建、读取、复制、移动、删除、属性获取等常用操作的Objective-C与Swift实现示例。WKWebView部分涵盖WebKit框架导入、WKWebViewConfiguration配置、网页加载、WKNavigationDelegate导航状态监听,以及通过WKUserContentController注册脚本处理器实现JavaScript与原生代码双向通信的完整流程,并提示跨域与脚本注入等安全注意事项。资源共224个文件,以Objective-C源码为主(39个.m与39个.h),另含storyboard/xib界面、plist配置、png图片素材等,压缩包仅600KB,便于快速下载。当前已有471人学习浏览,包内包含完整Xcode工程及Git相关文件,目录结构贴近真实项目,能帮助读者把理论知识点直接映射到可运行的示例代码中。
1. iOS 文件操作与 WKWebView:混合开发绕不开的这两件事
做 iOS 混合开发的人一定有过这种经历:H5 页面需要下载一个 PDF 到本地,原生层不知道文件该往哪写;WebView 加载到一半,登录态突然丢了,Cookie 没同步过去。文件操作和 WKWebView 是 iOS 里两个最基础也最磨人的模块,它们本身各自有一套规则,一旦要放在同一个 App 里协作,坑就连着坑。这套资料包把两件事从头拆到尾:沙盒目录怎么选、FileManager 怎么上手、WKWebView 怎么配置、原生和 JS 怎么通信、Cookie 怎么同步,全是能直接跑起来的代码工程。适合刚转 iOS 或正在做 Hybrid 方案的工程师,下面的内容会直接告诉你每个参数怎么调、每个坑怎么绕。
2. 沙盒文件操作:目录选型、FileManager 用法与路径规范
2.1 沙盒目录结构与存储选型
iOS 的每个 App 都运行在自己的沙盒里,别的进程访问不了你的目录,你也访问不了别人的。沙盒内部有四个主要目录:Documents、Library、tmp,其中 Library 下面又分 Caches、Preferences 等子目录。这四个地方各司其职,存错了位置轻则审核被拒,重则用户数据被系统清掉。
Documents 是持久化存储的主目录,用户产生的数据、需要备份的数据都放这里。比如一个笔记 App 的草稿、一个文件管理器的用户文档。iTunes 备份会带上它,所以不适合放体积大、可再生的东西。
Library/Caches 放缓存文件,图片缓存、音频缓存、WebView 的离线资源都可以放这里。系统在磁盘空间紧张时会清空 Caches,所以它不可靠,但速度快。Library/Preferences 放着 App 的偏好设置(UserDefaults 的数据就落在这里),一般不需要手动操作。tmp 目录存放临时文件,比如正在下载的临时片段、解压的中间产物,系统会随时清理。
2.2 FileManager 遍历与读写:一套代码搞定
拿到沙盒路径后,实际的文件操作全靠 FileManager。先看一个通用封装:
import Foundation enum SandboxPath { static var documents: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } static var caches: URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] } static var tmp: URL { URL(fileURLWithPath: NSTemporaryDirectory()) } } func createDirectoryIfNeeded(at url: URL) { let fm = FileManager.default if !fm.fileExists(atPath: url.path) { do { try fm.createDirectory(at: url, withIntermediateDirectories: true) print("目录创建成功: \(url.path)") } catch { print("目录创建失败: \(error.localizedDescription)") } } }这里用URL(fileURLWithPath:)而不是字符串拼接,是因为 URL 自带路径标准化能力,能避免后续字符串拼接时多一个少一个斜杠的问题。withIntermediateDirectories: true表示如果父目录不存在就一并创建,写文件前习惯性调用这个方法,能省掉一大半"directory not found"的错误。
2.3 文件的写入、读取与删除
写入文件时最常见的需求是把 Data 类型写到磁盘,或者把 JSON 序列化后存下来:
func saveJSONToFile(dict: [String: Any], fileName: String) { let fileURL = SandboxPath.documents.appendingPathComponent("\(fileName).json") guard let data = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted) else { print("JSON 序列化失败") return } do { try data.write(to: fileURL) print("文件写入成功: \(fileURL.path)") } catch { print("文件写入失败: \(error.localizedDescription)") } } func readJSONFromFile(fileName: String) -> [String: Any]? { let fileURL = SandboxPath.documents.appendingPathComponent("\(fileName).json") guard let data = FileManager.default.contents(atPath: fileURL.path) else { print("文件不存在: \(fileURL.path)") return nil } do { let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] return dict } catch { print("JSON 解析失败: \(error.localizedDescription)") return nil } }contents(atPath:)是同步读取,适合小文件。如果处理大文件,建议换成Data(contentsOf:)配合InputStream分段读,防止内存峰值。删除文件用FileManager.default.removeItem(at:),删除前要确认文件存在,否则会抛 "file doesn't exist" 异常。
2.4 移动、复制与文件属性
移动文件是下载功能的常见收尾动作——先下载到 tmp,再移到 Documents:
func moveFile(from fromURL: URL, to toURL: URL) { let fm = FileManager.default do { if fm.fileExists(atPath: toURL.path) { try fm.removeItem(at: toURL) // 覆盖前先删旧文件 } try fm.moveItem(at: fromURL, to: toURL) print("移动成功") } catch { print("移动失败: \(error)") } } func getFileSize(at url: URL) -> Int64 { let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) return (attributes?[.size] as? Int64) ?? 0 }这里removeItem是为了覆盖同名文件,因为moveItem不会自动覆盖。文件大小在下载进度条和缓存清理场景下都要用到,注意它返回的是 Int64,转成可读的字符串可以配合 ByteCountFormatter。
3. WKWebView 基础配置:从初始化到导航回调的完整链路
3.1 WKWebView 初始化与关键参数
WKWebView 的初始化不像 UIWebView 那样直接 new 一个,它必须带配置对象。配置对象决定了 JS 是否能弹窗、是否能使用 localStorage、媒体播放策略等:
import WebKit func makeWebView(frame: CGRect) -> WKWebView { let configuration = WKWebViewConfiguration() // 偏好设置 let preferences = WKPreferences() preferences.javaScriptEnabled = true preferences.minimumFontSize = 12.0 // 是否允许 JS 直接打开新窗口(window.open) configuration.preferences = preferences // 允许 localStorage 持久化 configuration.websiteDataStore = WKWebsiteDataStore.default() // 用户内容控制器:后面注入 JS 和注册原生回调都用它 let userContentController = WKUserContentController() configuration.userContentController = userContentController let webView = WKWebView(frame: frame, configuration: configuration) webView.navigationDelegate = self webView.uiDelegate = self webView.allowsBackForwardNavigationGestures = true return webView }allowsBackForwardNavigationGestures开启后用户可以从屏幕边缘滑动返回上一页,这是 iOS 端 H5 体验最接近原生导航的关键参数。minimumFontSize不推荐改太大,否则页面里小字号说明文字会被放大到变形,一般保持默认就行。
3.2 页面加载与导航代理回调
加载页面很简单,load(_:)或loadHTMLString(_:baseURL:)就够:
extension ViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { // 开始加载:显示 loading } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // 加载完成:隐藏 loading,注入 JS 或调用 JS 可以在这里做 } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { // 导航失败,展示错误页 } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { let url = navigationAction.request.url if url?.scheme == "http" || url?.scheme == "https" { decisionHandler(.allow) } else { decisionHandler(.cancel) } } }decidePolicyFor是拦截跳转的关键回调。除了过滤协议,还能做 URL 拦截——比如遇到appscheme://的链接就调起原生页面。这里必须调用decisionHandler,不调会导致页面卡死,这是一个很容易被忽略的强制约定。
3.3 进度条、返回与 JS 执行
加载进度条是 H5 页面体验的标配,WKWebView 自带estimatedProgress属性,配合 KVO 就能做:
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "estimatedProgress" { let progress = webView.estimatedProgress progressView.progress = Float(progress) } }执行 JS 用evaluateJavaScript(_:completionHandler:):
webView.evaluateJavaScript("document.title") { result, error in if let title = result as? String { print("页面标题: \(title)") } else if let error = error { print("JS 执行失败: \(error.localizedDescription)") } }注意evaluateJavaScript的 completionHandler 不在主线程,如果要在里面刷新 UI,必须手动切到主队列。
4. 原生与 H5 的桥接:WKScriptMessageHandler 与消息收发实战
4.1 注入 JS 与注册原生回调
桥接的核心是WKUserContentController,原生代码通过它注册一个处理器,H5 页面调用window.webkit.messageHandlers.xxx.postMessage()时就会触发原生的回调方法:
class JSBridge: NSObject, WKScriptMessageHandler { weak var webView: WKWebView? func setup(with webView: WKWebView) { self.webView = webView let config = webView.configuration config.userContentController.removeScriptMessageHandler(forName: "nativeBridge") config.userContentController.add(self, name: "nativeBridge") } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "nativeBridge" else { return } // message.body 是 H5 传来的参数,通常是字典或字符串 print("接受到 JS 消息: \(message.body)") } }H5 端对应的调用代码是:
// H5 页面内 window.webkit.messageHandlers.nativeBridge.postMessage({ action: "openCamera", params: { source: "avatar" } });注册前先removeScriptMessageHandler是为了防止重复注册。消息体可以是任意可 JSON 序列化的对象,但过大(超过几百 KB)会影响传递效率,建议只传 ID 和必要参数,大数据走文件缓存或 Base64 后分片。
4.2 原生调用 JS 与回调传参
原生反向调 JS 最典型的场景是让 H5 刷新某个数据:
func callJSUpdateData(jsonString: String) { let jsCode = "window.updateData && window.updateData(\(jsonString))" webView.evaluateJavaScript(jsCode) { result, error in if let error = error { print("调用 JS 失败: \(error)") } else { print("调用 JS 成功: \(result ?? "")") } } }这里拼 JS 字符串时要注意 JSON 转义,如果 JSON 里有"或换行符,直接拼进去会语法报错。稳妥的做法是用JSONSerialization把字典转成 JSONString,再交给evaluateJavaScript。window.updateData &&的写法是在调用前先判断函数是否存在,H5 页面还没注入完的时候就不至于白报错。
4.3 线程、锁与内存泄漏
桥接最隐蔽的坑是内存泄漏。WKScriptMessageHandler是强引用,如果 ViewController 强持有 WebView、WebView 又通过add(_:name:)强持有 ViewController,就会形成循环引用,控制器永远 dealloc 不了。
deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "nativeBridge") webView.removeObserver(self, forKeyPath: "estimatedProgress") }removeScriptMessageHandler必须在 deinit 或者页面退出时调用。我在开发时习惯把 JSBridge 单独抽成一个类而不是让 ViewController 直接实现协议,这样桥接的生命周期可以单独控制,控制器退出时手动释放桥接,不会互相拖累。
5. 避坑指南:文件乱写与 WebView 回调的六个常见问题
5.1 文件写到 tmp 却被系统清空
现象:App 启动后发现下载好的 PDF 不见了,打印 tmp 路径时文件确实存在,但重启后就消失。
原因:tmp 目录里的内容系统在 App 不活跃时随时可能清理,特别是当磁盘空间不足时,tmp 是第一个被动的。
解决:文件完成下载后立刻从 tmp 移动到 Documents 或 Library/Caches,不要在图省事的心态下把临时下载目录当持久化目录用。移动时注意先判断目标路径是否有同名文件,有就先删掉或改名。
5.2 WKWebView 内存泄漏,控制器无法释放
现象:反复进入和退出 H5 页面,App 内存持续增长,Debug 内存图里能看到 ViewController 被 WebView 带着不释放。
原因:WKScriptMessageHandler的强引用把控制器和 WebView 绑成了循环引用。
解决:一是添加removeScriptMessageHandler的时机提前,不要在 deinit 才移除,而是每次页面viewWillDisappear时移除;二是 controller 弱引用 WebView,JSBridge 类声明weak var webView,双管齐下,基本能根治。
5.3 evaluateJavaScript 在页面未加载完成时静默失败
现象:进入页面后立即调用evaluateJavaScript("window.someFunc()"),没任何报错,但 H5 端函数根本没执行。
原因:WebView 的 HTML 还在加载解析,JS 上下文尚未就绪,此时调用不会触发错误回调,直接静默丢弃。
解决:把 JS 调用统一放到didFinish navigation之后。如果必须在加载中途调用,先轮询检查webView.isLoading或监听document.readyState。实战里我把所有原生调 JS 的请求包在一个队列里,页面加载完成时统一 flush,避免 H5 在等待原生回调时白屏。
5.4 自定义 scheme 跳转被 WebView 拦掉
现象:H5 页面里点击了一个scheme://openNative的链接,WebView 直接显示无法打开的空白页。
原因:decidePolicyFor navigationAction里默认把所有非 http/https 的 scheme 都 cancel 了,但 after cancel 之后还需要自己处理路由跳转。
解决:在 cancel 分支里解析 URL scheme 并做对应处理:
if url?.scheme == "myscheme" { // 解析 myscheme://openPage?page=userCenter // 跳转到对应原生页面 decisionHandler(.cancel) } else { decisionHandler(.allow) }这里有个血泪教训:scheme 解析同时支持//和://两种写法,H5 端经常混用,统一在原生层做兼容处理。
5.5 同步 Cookie 失败导致 H5 登录态丢失
现象:在原生层登录成功后,接着加载 H5 页面,H5 却提示未登录,但打开 Safari 里的同一页面是登录过的。
原因:WKWebView 的 Cookie 存储默认分离,原生网络请求的 Cookie 不会自动写入 WKWebView 的存储空间。
解决:在 WebView 初始化前,把需要携带的 Cookie 手动放进WKWebsiteDataStore。代码会在下一章详细给出,核心是拿到HTTPCookieStorage里的 cookie,再写入WKWebsiteDataStore.default(),顺序不能反。
5.6 loadHTMLString 时相对路径资源全部丢失
现象:用loadHTMLString加载本地 HTML,里面的img src="./images/a.png"全部裂掉,控制台报 404。
原因:loadHTMLString没有 baseURL,页面不知道去哪里找相对资源。
解决:加载时给 baseURL 指定本地目录:
let html = try? String(contentsOf: htmlFileURL, encoding: .utf8) webView.loadHTMLString(html ?? "", baseURL: htmlFileURL.deletingLastPathComponent())这样 HTML 里的相对路径就能正确解析到同一目录下的图片和 CSS 了。
6. 进阶技巧:Cookie 同步与自定义协议加载离线包
最后一章给两个实际项目里最用得上的技巧:Cookie 同步和离线路由加载。
Cookie 同步的核心代码:
func syncCookiesToWebView() { let cookieStore = WKWebsiteDataStore.default().httpCookieStore let sharedCookies = HTTPCookieStorage.shared.cookies ?? [] for cookie in sharedCookies { cookieStore.setCookie(cookie) { // 每条 cookie 单独写入 } } }注意写入顺序:先确认原生登录接口已经完成了HTTPCookieStorage.shared的写入,再执行这个同步方法,否则会同步到旧 cookie。同步时机选在viewDidLoad里面 WebView 加载请求之前,不要放在didFinish回调里,太晚了。
自定义协议加载离线资源用WKURLSchemeHandler:
class LocalSchemeHandler: NSObject, WKURLSchemeHandler { func webView(_ webView: WKWebView, start task: WKURLSchemeTask) { let url = task.request.url guard let components = url?.pathComponents else { return } // 从 app 内目录读取与路径匹配的文件 let filePath = localBasePath + components.joined(separator: "/") let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) let response = URLResponse(url: url!, mimeType: "application/octet-stream", expectedContentLength: data?.count ?? 0, textEncodingName: nil) task.didReceive(response) if let data = data { task.didReceive(data) } task.didFinish() } func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {} }注册方式:configuration.setURLSchemeHandler(LocalSchemeHandler(), forURLScheme: "appres"),然后 H5 里的资源路径写成appres://page/index.html就能直接走本地文件。这套方案比把 HTML 文件和图片资源塞进 bundle 再用loadFileURL灵活得多,离线包直接解压到 Documents 后按文件名映射,完全不需要改 H5 端的资源路径。
从那次因为临时目录导致用户 PDF 丢失的事故之后,我每次处理下载类需求都强制走一遍同样的流程:先选目录、再写文件、最后验证路径,一步都不跳。这套资料包里附带了一个模拟项目 X 的完整工程,文件操作和 WebView 桥接的代码都直接嵌在里面,比对着文档调参要顺手得多。希望帮到你。
本文还有配套的精品资源,点击获取