1. 项目背景与核心需求拆解
在 iOS 开发中,用 WKWebView 加载 HTML 内容是一个非常高频的场景。无论是展示服务端返回的富文本协议、内嵌活动页面,还是加载本地打包的静态页面,开发者几乎都会遇到同一个问题:页面在手机上显示得乱七八糟。要么内容宽度溢出屏幕,要么图片撑破容器,要么视频播放时全屏乱转,要么用户长按弹出选择菜单甚至触发 3D Touch 预览,体验非常割裂。
这个项目的核心目标,就是解决 WKWebView 加载 HTML 时的四类典型适配问题:屏幕宽度自适应、图片自适应、视频播放自适应、长按事件禁用。它不是一个庞大的框架,而是一套可以直接复用的配置方案和注入脚本组合。适合所有正在做 iOS 原生与 Web 混合开发的工程师,尤其是那些需要快速把 H5 内容嵌入 App、又不想引入重型前端框架的场景。
我做过好几个类似需求,从资讯类 App 的正文渲染,到电商详情页的图文混排,再到教育类 App 的课件展示,几乎每次都会踩一遍这些坑。有些问题看起来简单,但真正要做到“稳”,需要理解 WKWebView 的渲染机制、Viewport 的解析规则、以及 JavaScript 注入的时机。下面我会把这套方案完整拆开,从设计思路到实操细节,再到问题排查,全部讲清楚。
2. 整体方案设计与技术选型考量
2.1 为什么选择 WKWebView 而不是 UIWebView
这个问题其实已经没什么悬念了。UIWebView 从 iOS 8 开始就被 WKWebView 取代,后者在性能、内存占用、JavaScript 引擎效率上都有明显优势。更重要的是,WKWebView 提供了更完善的代理方法和配置项,比如WKUserContentController可以注入脚本,WKNavigationDelegate可以精细控制加载流程。对于需要做自适应和事件拦截的场景,这些能力是必需的。
不过 WKWebView 也有自己的脾气。它运行在独立的进程中,JavaScript 执行和原生通信是异步的,注入时机不对就会失效。所以方案设计的第一原则是:所有配置必须在 WebView 初始化阶段完成,所有脚本必须在页面加载前注入。
2.2 自适应方案的核心思路
自适应这件事,本质上是在解决“HTML 不知道手机屏幕有多宽”的问题。传统 PC 页面默认按 980px 宽度渲染,放到手机上就会缩小到看不清。解决方案是在 HTML 的<head>里插入一个正确的 viewport meta 标签:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">但问题在于,服务端返回的 HTML 往往没有这个标签,或者标签写得不对。我们不可能要求后端为每个页面都适配移动端,所以最稳妥的做法是:在原生侧通过 JavaScript 注入,动态修改或创建 viewport meta 标签。
图片自适应也是同理。很多富文本编辑器生成的<img>标签带着固定的 width 和 height 属性,直接渲染就会溢出。我们需要在页面加载完成后,遍历所有图片,把它们的最大宽度限制为容器宽度,高度设为 auto。
视频自适应稍微复杂一点。<video>标签在不同 iOS 版本上的表现不一致,有些会自动全屏,有些会内联播放。我们需要同时处理playsinline属性和webkit-playsinline属性,并且用 CSS 限制视频容器的宽度。
2.3 禁用长按事件的实现路径
长按事件在 WKWebView 里默认会触发文本选择、链接预览、图片保存菜单等。禁用它的方式有几种:一是通过 CSS 设置-webkit-touch-callout: none和-webkit-user-select: none;二是通过 JavaScript 监听contextmenu事件并阻止默认行为;三是在原生侧通过WKWebView的configuration做一些限制。
我实测下来,CSS + JavaScript 组合拳最稳。CSS 负责静态样式层面的禁用,JavaScript 负责动态拦截。两者缺一不可,因为有些长按行为是系统级别的,单靠 CSS 拦不住。
3. 核心细节解析与实操要点
3.1 Viewport 注入的时机与写法
注入 viewport 的脚本必须在document开始加载时执行,也就是在WKUserScript的injectionTime设置为.atDocumentStart。如果等到.atDocumentEnd才注入,页面可能已经按默认宽度渲染了一遍,会出现闪烁或布局跳动。
脚本内容大概是这样:
(function() { var meta = document.querySelector('meta[name="viewport"]'); if (!meta) { meta = document.createElement('meta'); meta.name = 'viewport'; document.head.appendChild(meta); } meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; })();这里有个细节:user-scalable=no可以禁止用户双指缩放,但有些产品经理会要求允许缩放。如果允许缩放,就把maximum-scale设大一点,比如 5.0。另外,initial-scale=1.0是必须的,否则页面可能以 0.5 倍缩放显示。
注意:如果 HTML 本身已经有 viewport 标签,直接覆盖 content 属性即可,不要重复创建,否则会出现多个 viewport 标签,浏览器解析行为不可预测。
3.2 图片自适应的两种策略
图片自适应有两种做法:一种是纯 CSS,一种是 JavaScript 遍历。纯 CSS 的写法是:
img { max-width: 100% !important; height: auto !important; }但这种方式有个问题:如果图片本身有内联的width和height属性,CSS 的!important可以覆盖,但某些老旧的 HTML 结构里,图片可能被包裹在固定宽度的<table>或<div>里,这时候单纯限制图片宽度不够,还需要处理父容器。
所以我通常会在.atDocumentEnd时机再注入一段 JavaScript,做更彻底的处理:
(function() { var images = document.querySelectorAll('img'); var maxWidth = document.documentElement.clientWidth; for (var i = 0; i < images.length; i++) { var img = images[i]; img.style.maxWidth = '100%'; img.style.height = 'auto'; img.style.display = 'block'; img.style.margin = '0 auto'; if (img.naturalWidth > maxWidth) { img.style.width = '100%'; } } })();这段脚本的关键点在于:先判断图片的原始宽度是否超过屏幕宽度,如果超过就强制设为 100%,否则保持原样。这样可以避免小图被拉伸模糊。
3.3 视频播放自适应的关键属性
视频这块,iOS 10 以后支持playsinline属性,可以让视频在页面内播放而不是自动全屏。但为了兼容更早的版本,还需要加上webkit-playsinline。完整的<video>标签写法是:
<video src="xxx.mp4" playsinline webkit-playsinline controls preload="metadata"></video>如果 HTML 里的视频标签没有这些属性,我们同样可以通过 JavaScript 动态添加:
(function() { var videos = document.querySelectorAll('video'); for (var i = 0; i < videos.length; i++) { var video = videos[i]; video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); video.setAttribute('controls', ''); video.style.maxWidth = '100%'; video.style.height = 'auto'; } })();另外,在原生侧初始化 WKWebView 时,configuration.allowsInlineMediaPlayback = true是必须设置的,否则playsinline属性不生效。
3.4 禁用长按事件的完整方案
禁用长按需要从三个层面入手:
第一层:CSS 禁用选择与呼出菜单
* { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }-webkit-touch-callout控制的是长按链接或图片时弹出的系统菜单,-webkit-user-select控制的是文本选择。两个都设为 none,基本能覆盖大部分场景。
第二层:JavaScript 拦截 contextmenu 事件
document.addEventListener('contextmenu', function(e) { e.preventDefault(); return false; });这个事件在 iOS 上对应的是长按操作,阻止默认行为可以进一步确保不弹出菜单。
第三层:原生侧禁用 3D Touch 预览
在WKWebView的WKNavigationDelegate里,实现webView(_:contextMenuConfigurationForElement:completionHandler:)方法并返回 nil,可以禁用链接预览。不过这个方法只在 iOS 13 以上可用,低版本需要另外处理。
实操心得:如果页面里有输入框,
-webkit-user-select: none会导致用户无法选择文本进行复制粘贴。这时候需要单独给input和textarea设置-webkit-user-select: text,把选择能力还回来。
4. 完整实操流程与核心代码实现
4.1 WebView 初始化配置
先看原生侧的初始化代码。这里我用 Swift 写一个完整的配置示例:
import WebKit class WebViewController: UIViewController { var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() let configuration = WKWebViewConfiguration() configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] let userContentController = WKUserContentController() userContentController.addUserScript(viewportScript()) userContentController.addUserScript(imageAdaptScript()) userContentController.addUserScript(videoAdaptScript()) userContentController.addUserScript(disableLongPressScript()) configuration.userContentController = userContentController webView = WKWebView(frame: view.bounds, configuration: configuration) webView.navigationDelegate = self webView.scrollView.bounces = false webView.scrollView.showsHorizontalScrollIndicator = false view.addSubview(webView) } }这里有几个关键点:allowsInlineMediaPlayback必须为 true,mediaTypesRequiringUserActionForPlayback设为空数组表示视频可以自动播放(如果产品要求手动播放,就保留默认值)。scrollView.bounces = false可以避免页面滚动到顶部或底部时的回弹效果,让体验更接近原生。
4.2 四个注入脚本的完整实现
Viewport 脚本(atDocumentStart)
func viewportScript() -> WKUserScript { let source = """ (function() { var meta = document.querySelector('meta[name="viewport"]'); if (!meta) { meta = document.createElement('meta'); meta.name = 'viewport'; document.head.appendChild(meta); } meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; })(); """ return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) }图片自适应脚本(atDocumentEnd)
func imageAdaptScript() -> WKUserScript { let source = """ (function() { var maxWidth = document.documentElement.clientWidth; var images = document.querySelectorAll('img'); for (var i = 0; i < images.length; i++) { var img = images[i]; img.style.maxWidth = '100%'; img.style.height = 'auto'; img.style.display = 'block'; img.style.margin = '8px auto'; if (img.naturalWidth > maxWidth) { img.style.width = '100%'; } } })(); """ return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) }视频自适应脚本(atDocumentEnd)
func videoAdaptScript() -> WKUserScript { let source = """ (function() { var videos = document.querySelectorAll('video'); for (var i = 0; i < videos.length; i++) { var video = videos[i]; video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); video.setAttribute('controls', ''); video.style.maxWidth = '100%'; video.style.height = 'auto'; video.style.display = 'block'; video.style.margin = '8px auto'; } })(); """ return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) }禁用长按脚本(atDocumentEnd)
func disableLongPressScript() -> WKUserScript { let source = """ (function() { document.addEventListener('contextmenu', function(e) { e.preventDefault(); return false; }); var style = document.createElement('style'); style.innerHTML = '* { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } input, textarea { -webkit-user-select: text; user-select: text; }'; document.head.appendChild(style); })(); """ return WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) }4.3 加载 HTML 的两种方式
加载远程 URL
if let url = URL(string: "https://example.com/article") { let request = URLRequest(url: url) webView.load(request) }加载本地 HTML 字符串
let htmlString = "<html><head></head><body><h1>标题</h1><p>内容</p></body></html>" webView.loadHTMLString(htmlString, baseURL: nil)如果是加载本地 HTML 字符串,baseURL传 nil 的话,图片和视频的相对路径会失效。如果 HTML 里有本地资源引用,需要传一个正确的 baseURL,比如 Bundle 的路径。
4.4 处理页面内跳转与新窗口
WKWebView 默认会在当前 WebView 里打开所有链接。如果页面里有target="_blank"的链接,需要实现WKUIDelegate的webView(_:createWebViewWith:for:windowFeatures:)方法,否则点击没反应。
extension WebViewController: WKUIDelegate { func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if navigationAction.targetFrame == nil { webView.load(navigationAction.request) } return nil } }这段代码的作用是:如果链接要求在新窗口打开,就直接在当前 WebView 加载,避免创建新的 WebView 实例。
5. 常见问题与排查技巧实录
5.1 注入脚本不生效的排查思路
这是最常见的问题。脚本写好了,但页面加载后没有任何变化。排查顺序如下:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 注入时机 | 确认injectionTime设置正确 | viewport 必须用.atDocumentStart,其他用.atDocumentEnd |
| 主框架限制 | 检查forMainFrameOnly参数 | 如果页面有 iframe,设为 true 会导致 iframe 内不生效 |
| 脚本语法 | 在 Safari 开发者工具里看控制台报错 | 字符串转义问题、ES6 语法兼容性问题 |
| 页面加载方式 | 确认是loadHTMLString还是load | loadHTMLString时 baseURL 为 nil 可能导致部分脚本行为异常 |
| 缓存问题 | 清除 WKWebView 缓存后重试 | 旧缓存页面可能没有执行新脚本 |
我踩过最坑的一次是:脚本里用了let和const,结果在 iOS 9 的机器上直接报语法错误,整个脚本不执行。后来全部改成var才解决。虽然现在 iOS 9 已经很少了,但如果你的 App 还要兼容老系统,这一点必须注意。
5.2 图片宽度自适应的边界情况
有些图片是 base64 编码的内联图片,naturalWidth属性在图片未完全解码时可能是 0。这时候判断img.naturalWidth > maxWidth会失效。解决方案是监听图片的load事件,在加载完成后再做宽度判断:
img.addEventListener('load', function() { if (this.naturalWidth > maxWidth) { this.style.width = '100%'; } });另外,如果图片本身是 SVG 格式,naturalWidth可能返回 0 或 undefined,这时候直接用max-width: 100%就够了,不需要额外判断。
5.3 视频播放的兼容性坑
iOS 上视频播放有几个经典问题:
问题一:视频自动全屏,无法内联播放。原因是allowsInlineMediaPlayback没有设为 true,或者playsinline属性没有正确添加。两个条件必须同时满足。
问题二:视频有声音但画面黑屏。这种情况通常是视频编码格式不支持。iOS 对 H.264 支持最好,H.265 需要 iOS 11 以上,VP9 需要 iOS 14 以上。如果视频源是 WebM 格式,iOS 原生不支持,需要转码。
问题三:视频播放时页面滚动卡顿。这是 WKWebView 的渲染机制导致的,视频播放时会占用大量 GPU 资源。解决方案是限制同时播放的视频数量,或者在视频播放时暂停其他动画。
5.4 长按禁用后的副作用处理
禁用长按后,最直接的副作用是:用户无法复制文本了。如果产品要求允许复制,就不能全局禁用-webkit-user-select,而是只针对图片和链接禁用。
我的做法是:给img和a标签单独设置-webkit-touch-callout: none,文本区域保持可选。这样既能防止图片长按弹出保存菜单,又不影响用户复制文字。
img, a { -webkit-touch-callout: none; }另外,如果页面里有contenteditable的富文本编辑器,-webkit-user-select: none会导致无法编辑。这时候需要给[contenteditable]元素单独设置-webkit-user-select: text。
5.5 页面高度与滚动问题
WKWebView 的内容高度是动态的,如果外层用 UITableView 或 UIScrollView 包裹,需要监听scrollView.contentSize的变化来调整高度。可以在webView(_:didFinish:)里通过 JavaScript 获取document.body.scrollHeight,然后更新约束。
document.body.scrollHeight但要注意,图片加载完成后页面高度会变化,所以最好在图片的load事件里也触发一次高度更新。我通常会用setTimeout延迟 0.3 秒再获取一次高度,确保所有图片都渲染完毕。
6. 性能优化与进阶技巧
6.1 减少脚本注入的性能开销
四个脚本分别在.atDocumentStart和.atDocumentEnd注入,每次页面加载都会执行。如果页面内容很多,querySelectorAll遍历所有图片和视频会有性能损耗。优化方式是:只在必要时执行遍历,比如先判断document.images.length > 0再执行。
另外,可以把多个.atDocumentEnd的脚本合并成一个,减少 JavaScript 上下文的切换开销。合并后的脚本大概长这样:
(function() { // 图片自适应 // 视频自适应 // 禁用长按 })();6.2 使用 WKContentRuleList 拦截资源
如果页面里有大量第三方广告或跟踪脚本,可以用WKContentRuleList在原生侧拦截,减少页面加载时间。规则用 JSON 格式定义,比如拦截所有.gif图片:
[{ "trigger": {"url-filter": ".*\\.gif"}, "action": {"type": "block"} }]这种方式比 JavaScript 注入更高效,因为拦截发生在网络层,不会消耗 WebView 的渲染资源。
6.3 内存管理与 WebView 复用
WKWebView 的内存占用比 UIWebView 低,但如果频繁创建和销毁,仍然会导致内存峰值。在列表页里,如果每个 cell 都嵌一个 WebView,滑动时会非常卡顿。解决方案是:复用 WebView 实例,或者用WKProcessPool共享进程池。
let processPool = WKProcessPool() configuration.processPool = processPool不过WKProcessPool在 iOS 15 以后已经被标记为废弃,新版本系统会自动管理进程池,不需要手动设置。
6.4 调试技巧:Safari 开发者工具
WKWebView 支持 Safari 的 Web Inspector,可以在 Mac 上直接调试 iOS 设备里的 WebView。开启方式:设置 > Safari > 高级 > 网页检查器。然后在 Mac 的 Safari 里,开发菜单下会显示连接的设备。
调试时最常用的功能是控制台和元素检查。控制台可以看 JavaScript 报错,元素检查可以看注入的样式是否生效。我每次遇到脚本不生效的问题,第一件事就是打开控制台看有没有报错。
实操心得:如果 Safari 开发者工具里看不到你的 WebView,检查一下 App 是否在 Debug 模式下运行。Release 包默认不允许调试,需要手动开启
isInspectable属性(iOS 16.4 以上)。
7. 不同场景下的方案调整建议
7.1 资讯类 App 的正文渲染
资讯类 App 的 HTML 通常来自服务端富文本编辑器,结构比较规范。重点处理图片自适应和字体大小。可以在 viewport 脚本里额外注入一段设置font-size的代码,让正文字号跟随系统设置:
document.documentElement.style.fontSize = '17px';如果支持夜间模式,还需要注入 CSS 变量来切换背景色和文字颜色。
7.2 电商详情页的图文混排
电商详情页的图片通常有固定的宽高比,而且很多是懒加载的。除了基本的自适应处理,还需要监听图片的load事件来更新页面高度。另外,详情页里可能有视频介绍,需要确保视频不自动播放,避免消耗用户流量。
7.3 教育类 App 的课件展示
课件页面通常包含复杂的交互元素,比如拖拽、点击答题等。禁用长按的时候要特别小心,不能把正常的点击事件也禁用了。我的做法是:只禁用contextmenu事件,不全局设置-webkit-user-select: none,而是针对特定元素设置。
另外,课件里可能有公式图片,这些图片需要保持原始比例,不能被拉伸。可以在图片自适应脚本里加一个判断:如果图片的class包含formula,就跳过宽度调整。
7.4 本地 HTML 与远程 HTML 的差异处理
本地 HTML 加载速度快,但资源路径需要特别注意。如果 HTML 里引用了本地图片,baseURL必须指向正确的目录。远程 HTML 则要考虑网络超时和缓存策略。我通常会给远程请求设置 15 秒超时,并在加载失败时显示一个友好的错误页面。
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 15)cachePolicy设为.returnCacheDataElseLoad可以优先使用缓存,减少流量消耗,同时保证有网时能更新内容。
8. 最后的经验分享
这套方案我在多个项目里反复用过,从 iOS 10 到 iOS 17 都跑过,整体稳定性没问题。但有几个细节是我踩过坑之后才总结出来的,这里再强调一下。
第一,viewport 脚本一定要在.atDocumentStart注入。我试过在.atDocumentEnd注入,结果页面先按 980px 渲染了一遍,然后才缩放到屏幕宽度,用户能看到明显的闪烁。这个体验非常糟糕,一定要避免。
第二,图片自适应的 JavaScript 要处理naturalWidth为 0 的情况。有些图片是懒加载的,初始状态下naturalWidth是 0,直接判断会跳过宽度调整。解决方案是监听load事件,或者在setTimeout里延迟执行。
第三,禁用长按不要一刀切。全局禁用-webkit-user-select会导致输入框无法输入,这个坑我踩过。正确的做法是只对图片和链接禁用呼出菜单,文本区域保持可选。
第四,视频的playsinline属性在 iOS 10 以下不生效。如果你的 App 还要兼容 iOS 9,视频只能全屏播放,没有其他办法。这时候可以考虑用原生播放器替代 WebView 内的视频。
第五,测试的时候一定要用真机。模拟器上的 WKWebView 行为和真机有差异,尤其是视频播放和长按事件。我遇到过模拟器上正常、真机上长按菜单依然弹出的情况,最后发现是模拟器没有 3D Touch 硬件导致的。
这套代码你可以直接复制到项目里用,但建议根据自己的业务场景做调整。比如如果你的页面不需要视频,就可以去掉视频自适应脚本,减少不必要的性能开销。如果页面里有大量图片,可以考虑用IntersectionObserver做懒加载,进一步提升滚动流畅度。