熟悉 iOS/macOS Hybrid 混合开发的同学应该都有体会,WKWebView 虽然是苹果作为替代 UIWebView\WebView 而推出的"新"组件,但大部分开发者对它实在“爱不起来”。毕竟对于国内大部分应用开发者来说,在实际使用中 WKWebView 所谓的“优势”未必能体现出来,但带来的“坑”却都着实都不浅。
目前社区或线上可查找的 WKWebView 相关资料,大多比较陈旧且人云亦云、复制粘贴类的居多。少部分真实实践和探索的开发者,或许也因时间或精力的原因,对问题和解决方案未能做详细的阐述。导致目前线上 WKWebView 相关的资料数量不少、但质量不高;且有不少文章存在对问题的背景解释不清,解决方案缺乏有效验证等问题。
我从事端容器领域开发多年,曾在生产环境方案设计上与 WKWebView "对抗"多次。目前混合开发已经是现代 App 标配,一方面是对这么长时间用法经验上的总结,另外一方面也希望能够为还在抗争中的同学提供一些新视角或者解决思路,故准备结合 WebKit 部分源码,将自己对这个组件的理解以及部分问题解决方案整理分享一下。本文尝试说明 3 件事情:
- WKWebView 使用中的典型问题有哪些?
- 为什么会出现这些问题?
- 这些问题的解决办法有哪些?
基础回顾
iOS 端网络设计和 WKWebView 设计特点我们可以通过官方资料来查阅。但为了后面更好的说明问题,下面我们重点回顾下与文章后续内容相关的两个基本知识点:
- iOS 端网络设计与 Cookie 管理
- WKWebView 多进程模型
iOS 网络设计与 Cookie 管理
Cookie 管理是做混合开发过程中经常会涉及到的部分,在应用开发中我们知道可以通过 NSHTTPCookie 和 NSHTTPCookieStorage 来管理应用的 Cookie。但在系统层面 Cookie 是如何管理的、如何与网络层各模块进行联动,这对我们后面分析 WKWebView 中的 Cookie 问题有着至关重要的联系。
根据官方资料,我们可知 iOS 平台下网络相关模块大概关系如下:
从上至下模块依次为:
- WebKit:应用层,客户端 App 以及 WKWebView 处于这一层;
- NSURL:可以理解为对底层 CF 接口的封装扩展层,NSURLConnection、NSURLSession 等处于这一层;
- CFNetwork:iOS 网络模块核心实现层,是网络层设计中最重要的部分。负责网络协议组装发送接收等主要工作,与 CoreFoundation 框架关系紧密;
- BSD socket:基于底层硬件接口的 socket 服务。
CFNetwork 是整个网络系统的核心模块,负责组装请求、处理响应等:
核心内容包含:
- CFURLRequest:包括 URL/header/body 这些请求的信息。CFURLRequest 会进一步转换成 CFHTTPMessage;
- CFHTTPMessage:主要是 HTTP 协议的定义和转换,把每一个请求 request 转换成标准的 HTTP 格式的文本;
- CFURLConnection:主要是处理请求任务。包括 pthread 线程、CFRunloop、请求队列的管理等等。提供了start、cancel 等等操作的 API;
- CFHost:负责 DNS,有 CFHostStartInfoResolution 等函数,基于 dns_async_start 和 getaddrinfo_async_start 等方法;
- CFURLCache/CFURLCredential/CFHTTPCookie:处理 缓存/证书/cookie 相关的逻辑,都有对应的NS类。
从上面分析可知关键信息:iOS Cookie 管理相关模块处于 CFNetwork 这一层中。即对于请求 Response 中的 "set-cookie" 字段,在 CFNetwork 中被消费和处理。
WKWebView 多进程模型
通过官方资料,我们知道 WKWebView 相比 UIWebView 很大的一个变化是"多进程模型":
WKWebView 使用中各种问题的原因,有不少和多进程运行模式有很大的关系。
多进程模型详解
但具体是什么样形态的多进程?我们通过一张简图来说明下:
- WKWebView(WebKit) 包含 3 种进程:UI Process, Networking Process, WebContent Process;
- UI Process:即 App 进程,WKWebView(WebKit) 中部分模块运行在此进程,会负责启动其它进程;
- Networking Process:即网络模块进程,主要负责 WKWebView 中网络请求相关功能;此进程 App 中只会有启动一次,多个 WKWebView 间共享;
- WebContent Process:即 Web 模块进程,主要负责 WebCore, JSCore 相关模块的运行,是 WKWebView 的核心进程。此进程在 App 中会启动多次,每个 WKWebView 会有自己独立的 WebContent 进程;
- 各个进程之间通过 CoreIPC 进程通信。
总的来说:在一个客户端 App 中,多个 WKWebView 使用中会共享一个 UI 进程(与 App 进程共享)、共享一个 Networking 进程、每个 WKWebView 实例独享一个 WebContent 进程。
示例:
此处关于 WebContent Process 和 Networking Process 的启动规则,官方文档并未解释特别清楚,且因为版本迭代等原因,文档与目前最新规则也略有出入。为避免混淆与歧义,下面结合 WebKit 源码稍作分析。
WebContent 进程启动规则
根据官方文档描述:
规则是优先使用创建新进程,当进程上线超过某阈值之后则会共享,在 WebKit 内部由 maximumProcessCount 控制。但是此规则只对 iOS13 之前的系统生效,iOS13 之后的系统,WKWebView 每次创建实例都会启动一个新的 WebContent Porcess。相关实现如下。
iOS13 前:
iOS 13 及以后:
Networking 进程启动规则
Networking 规则相对简单,确保在 App 生命周期内启动一例(Crash 之后会重新创建)。相关代码:
主要问题与解决方案
WKWebView 在生产环境使用中,除去相对简单的使用和适配问题外,容易对开发者和前端同学造成困扰的问题有 4 个:
- 请求代理问题
- Cookie 管理问题
- 全面屏适配问题
- WebContent 进程崩溃问题
下面分别对这 4 例问题产生的原因、可尝试的解决方案以及不同方案下引入的问题做一下说明。
请求代理问题
这一点应该是阻碍 WKWebView 铺开的首要问题。问题背景也相对简单,并非有什么技术实现上的难度,而是苹果官方不希望 WKWebView 请求被应用拦截,美其名曰"为了安全"。但在实际使用场景中,我们又需要对 WebView 的请求进行代理以满足业务和性能诉求,典型场景如:离线包、流量监控等。
官方不支持、业务上又有使用场景,我们只能尝试通过"黑魔法"来解决。目前适用面比较多的解决方案有两个:
- 通过 [WKBrowsingContextController registerSchemeForCustomProtocol:] 来注册代理,为方便简称为代理方案1;
- 通过 [WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 来注册,为方便简称为代理方案 2。
目前两种解决方案的实现方法都有较为丰富的资料和说明,在此不在赘述。
虽然这两种方案某种程度上可以"部分解决问题",但带来的副作用相对也不少,在生产环境中如何取舍还需具体开发同学来取舍。下面分别通过 "代理方案1" 和 "代理方案2" 代指来简单说明下,可以供大家选型时做一个参考。
代理方案 1
此为最早出现的 WKWebView 请求代理方案,可满足 iOS 9 及以后应用使用(目前最新为 iOS 14)。根据之前调研分析,业界大部分有代理需求的 App 采用此方案或变种方案。
1)方案思路
通过 WKBrowsingContextController 将 http(s) 注册到 Networking 的 m_registeredSchemes 数组中。对于数组中的 Scheme,WebKit 在发起请求时会通过 WKCustomProtocol 将请求发送到 App 所在的进程,并由 App 进程来执行发送。
由于从 Networking 进程将数据发送到 App 进程时,WebKit 内有意剥离了 Body 部分(见 WebCoreArgumentCodersMac.mm):
故需要对携带 body 的请求做一些特殊处理。处理方案是通过在 WKWebView 内注入脚本,重写掉 WebView 内请求发送相关方法。在请求发送之前将 body 部分序列化之后通过 bridge 传递到 App 进程暂存。
App 进程代理 WKWebView 请求时,根据规则按需拼接缓存的 body,完成之后再进行发送动作。
2)方案弊端
此方案虽然适用面较广,但是弊端也很明显。主要有两方面:
(1)问题一:无法定向处理、只能一刀切
即如果 App 采用此方案,对其所有 WKWebView 实例的发送的请求都需要代理。如果有 WKWebView 实例中并未注入脚本或者执行代理,则可能导致请求无法发送、发送缺少 body 等问题,常见于一些集成的二方库、三方库中的 WKWebView 实例。
(2)问题二:重写脚本完备性很难保障
由于需要在 JS 层重写请求发送逻辑,比如 form 表单提交、AJAX、Fetch 等接口,重写接口的质量直接决定方案的完备度。且 WKWebView 原设计有不少能力在 c++ 层面实现,仅在 JS 重写无法保证对齐。目前已知的问题有:
- 对于同步请求,此方案目前无法支持;
- 对于流式请求,比如上传场景,目前支持度较差。只能在 JS 侧全量读取之后再进行发送;
- 无法处理 Fetch API Stream 返回值;
- 当使用 Form 表单提交内容包含大块数据时,可能出现丢失、Crash 等情况。
代理方案 2
此方案是基于苹果在 iOS 11 上开放的 [WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 接口做"扩展"来实现。对于 iOS 11.3 以后的设备,此方案具备较好实用性(WebKit 处理了部分 Body 传递问题)。
1)方案思路
- [WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 可以在 WKWebView 实例上注册自定义请求 Scheme。如果 WKWebView 发送的请求匹配注册 Scheme,则会代理到 UI 进程(App 进程) 执行发送动作;
- WKWebView 内部默认不支持注册 http(s) 等标准 Scheme,但是有"黑魔法"可绕过限制;
- 对于 AJAX 发送 BLOB 数据时,也会出现 body 丢失的情况,可以参考 代理方案1 中类似的方案来解决。
2)方案优势
代理方案 2 相对方案 1 两个巨大的优势在于:
- 不用一刀切,配置与 WKWebView 实例绑定:即可以定向处理我们需要处理的 WKWebView 实例,对于 三方库 中的对象,完全可以做到无影响,安全性大大提高;
- 不用重写所有发送请求:大部分情况下请求中的 body 是可以被携带到 App 进程,即我们只需定向处理部分异常即可,健壮性大大提升。
3)方案弊端
此方案除去有 iOS 11.3 的系统版本限制外,在具体运行中也有也有不少很难处理的问题,主要如下:
(1)问题一:多图分片下载情况下,WKWebView 内部存在处理时序存在 BUG
问题表现:在 WKWebView 中加载大图、且大图数据存在存在分片返回时,WKWebView 内部时序处理异常可能导致 图片无法展示、图片展示不完整等问题。具体可结合 WebKit 中对图片加载的流程来简单说明下:
问题即出现在上述 step1, step2, step3 的执行顺序上。在异常情况下,会偶现执行顺序为:step1 -> step3 -> step2, 且 step3 不再被触发(allDataReceived),进而导致图片最终的内容未渲染上屏。
解决方案:目前暂无有效的解决办法,通过配置 suppressesIncrementalRendering 配置为 YES 某种程度上可以缓解问题,但并无法根治且对体验略有影响。
(2)问题二:iOS 12及以下系统系统同步 AJAX 导致 Crash
问题表现:在 WKWebView 中如果出现 Web 页面发送 sync request,则可导致 WebContent 进程崩溃,WKWebView 回调 webViewWebContentProcessDidTerminate,进而导致页面白屏等问题。此问题可明确是 WebKit 内部的 BUG,且已有相关 Fix:
处理方案:对于 Bug1,处理相对比较简答,即在网络请求回调 error 之前优先回调部分空数据,规避掉问题;但是对于 Bug2,目前缺少有效的解决办法。
(3)问题三:301 请求下 SWAP 导致页面无法转场问题
问题表现:如果页面中存在使用 301 做重定向的情况,可能会出现重定向页面无法加载的情况,进而导致页面异常、白屏等问题。
处理方案:关闭 processSwapsOnNavigation,将置为 NO(内部属性)。
总的来说,虽然代理方案 2 相比代理方案 1 有很大的优势,但此方案因为版本限制等原因,目前使用量相比代理方案 1 略低。且与代理方案 1 相比,此方案的优势和劣势都很明显,如多图分片场景下可能导致图片无法展示的问题,目前未找到有效的解决办法。方案 2 是否可替代方案 1 在生产环境使用,还需使用同学自己斟酌。
Cookie 问题
根据官方文档及资料,我们可知 WKWebView 因为其"独立存储",导致 Cookie 和 Cache 与 App 不互通,进而有问题。但是这种表述较为模糊,且实际使用中 WKWebView 与 App 的 Cookie 并非完全隔离,这种模棱两可的表现很难让人搞清楚"通"或"不通"的边界在哪里。
下面首先根据自己对这块的理解,尝试说明下 WKWebView 使用 Cookie 问题到底是什么、以及背后的原因。由于苹果并未对全部代码开源,下有不少内容是自己的理解和推断,无法保证完全正确,仅介绍部分思路和判断,供大家在需要时参考。
Cookie 管理策略
根据上节的背景介绍我们可知,iOS Cookie 相关内容,是在 CFNetwork 这一层由 CFHTTPCookie、CFHTTPCookieStorage 等来管理,是 CFNetwork 模块的一部分。且对于 Session Cookie 和 持久化 Cookie,系统有着不同的管理策略:
- Session Cookie:内存中保存,进程周期内生效。在 iOS 移动端,一个 App 进程 即对应于一个 Session,即 Session Cookie 可在进程内共享;
- 持久化 Cookie:这部分 Cookie 除保存内存以外,还会持久化到磁盘,可多次使用。本地文件存储在 沙箱文件夹/Library/Cookies/Cookies.binarycookies;需要特别注意的是:持久化 Cookie 并非在产生之后立即同步到 Cookies.binarycookies,根据经验会有一个 300ms ~ 3s 的延迟。
WKWebView Cookie 问题
基于上节的 iOS Cookie 管理、结合多进程模型,我们大概可以推断 App 与 WKWebView Cookie 管理模型,见如下简图:
注意:WKHTTPCookieStore 为示意,画到了 Networking 进程,实际情况中此模块分散在 WebContent、Networking 以及 UI Process 中,且各进程中的部分通过 IPC 桥接。
根据上图可以引导出 WKWebView Cookie 相关的 2 个核心点:
1)WKWebView Cookie 问题具体是什么
- 对于 "Session Cookie":App 进程与 WKWebView 进程(WebContent + Networking)之间 完全隔离;
- 对于 "持久化 Cookie":App 进程与 WKWebView 进程(WebContent + Networking)之间 同步存在时差。
2)造成 WKWebView Cookie 问题的根本原因
- App 进程 与 Networking 双进程的设计。
核心目标
在了解 WKWebView 问题以及对应的根本原因之后,如何来处理此问题相对也清晰了:根据是否采用代理了 WKWebView 的网络请求,我们需要不同的处理策略。
- 场景 1 - 未代理 WKWebView 网络请求:Cookie 完全由 Networking 进程管理,WKWebView 内可自闭环。大部分情况下 App 进程也无需感知,如果确实需要感知,可以根据业务场景选择 JS 桥接、强制持久化等方案;
- 场景 2 - 已代理 WKWebView 网络请求:Cookie 大部分是由 App 进程来管理,此时应该采用何种同步策略。
由于场景 1 中我们并未在生产环境中采用,故本文不打算做冒然分析。下面主要聚焦于场景 2 来做进一部分分析。在场景 2 下我们的核心目标:
- 对于 App 进程中产生的 Cookie,能够及时同步到 Networking 进程:主要解决 Reponse 中存在 "Set-Cookie" 情况下,JS 端如何及时读取相关 Cookie 的问题;
- 对于 WebContent 中由 JS 产生的 Cookie,能及时同步到 App 进程:主要解决在 JS 端产生 Cookie 之后,我们如何保证在后续代理的网络请求中可被正常携带的问题。
同步手段
在确认方案之前,我们首先要搞清楚一个问题:客户端侧 Cookie 来源有哪些?
对于 App 进程,Cookie 来源有两个:
- 通过 NSHTTPCookieStorage 写入的;
- 在网络请求 Response Header 中通过 "Set-Cookie" 写入的。
对于 WebContent 进程,主要是 JS 通过 document.cookie 写入的(网络代理之后 Set-Cookie 不会在 WKWebView 进程中生效)。
其次,我们要确认可用做同步的手段有哪些:
对于 iOS 11 之后的系统,苹果已经为我们提供了 WKHTTPCookieStore 对象用来读写、监听 WKWebView 对应的 Cookie,可以直接使用。
对于 iOS 11 之前的系统,需要区分处理一下。
从 App 进程同步到 Networking 进程,简单流程如下:
- 第1步,需要把 Session Cookie 持久化,临时保存(注意需要标识,以供恢复);
<!---->
- 第2步,调用 NSHTTPCookieStorage 内部接口 _saveCookies 触发强制同步;
<!---->
- 第3步,恢复临时保存的 Session Cookie,避免污染。
由于 Networking 进程不会产生 Cookie,所以我们下面要做的是从 WebContent 进程同步 Cookie:处理策略即在 JS 侧重写 document.cookie 方法,在 JS 修改 cookies 时,通过 bridge 将 cookie 传递到 App 进程。
处理方案
在理清楚问题、目标和可用手段之后,我们即可总结出 WKWebView Cookie 相关问题的处理方案:
- 对于 iOS 11 及之后的系统,我们可以通过 HOOK NSHTTPCookieStorage 中读写 Cookie 的接口,并且监听网络请求中 "Set-Cookie" 关键字,在 App 进程 Cookie 发生变化时同步到 WKWebView;同时通过 WKHTTPCookieStore 提供 cookiesDidChangeInCookieStore 能力来监听 WKWebView 中 Cookie 的变化;
- 对于 iOS 11 之前的系统,处理策略类似。但是我们需要过 NSHTTPCookieStorage 接口来做强制同步,并且需要注意恢复 Cookie 的 SessionOnly 属性;同时需要通过在 JS 侧重写 document.cookie 的方式,来感知 WKWebView 中 Cookie 的变化。
特别注意:
采用 iOS 11 之后方案处理时一定要注意,对 WKHTTPCookieStore 的操作会 涉及到 IPC 通信,如果通信过于频繁、通信数据量过大,会产生明显的性能问题。极端情况可能造成 IPC 模块异常,出现所有 WKWebView 都无法加载的情况。比如典型的场景,如果一个页面请求较多、每个请求都带"Set-Cookie"、且业务上为了简单,每次把 App 进程的 Cookie 全量同步到 WKWebView,则 Cookie 过多时,有一定概率(暴力测试可复现)触发 IPC 异常,导致后续所有 WKWebView 实例都无法正常加载,只有 App 杀进程才可恢复。建议在同步 Cookie 时,尽量按需同步变化的部分。
全面屏适配问题
全面屏适配问题相对不复杂,但因为 WKWebView 如 UIWebView 在表现上的不同,导致容易造成一些困扰。
问题是 UIWebView 与 WKWebView 在对前端 viewport-fit 支持表现上略有差别:UIWebView 对 viewport-fit 支持度较好,表现基本与官方文档表述一致。但是 WKWebView 中存在一个潜规则,如果 Web 页面内 body 的高度,在没有超出 WKWebView 组件实际高度时,viewport-fit=cover 可能不生效。
处理办法是在页面中规避掉此类情况,如配置 body height 为 100vh (或其它类似方案)。
WebContent 进程崩溃问题
这是一个出现概率不高,但是缺乏通用、有效解决办法的问题。我们知道 WKWebView 多进程模式下,如果 WebContent 进程因为各种原因出现 Crash,则 WKWebView 会通过 webViewWebContentProcessDidTerminate 回调告诉开发者,一般情况下我们会通过 reload 方法重新加载页面。同时如果用户设备内存紧张,则可能出现系统主动 KILL WebContent 进程的情况。即可能出现 App 进程(前台)正常,但是 WebContent 崩溃、页面重新加载的情况。
绝大部分情况,进入此流程并不一定会对用户操作造成困扰。但是,如果此时造成内存紧张是因为前端触发业务导致,典型如表单中唤起相机上传图片,此流程对用户的影响可能是致命的。即使我们通过 WebView reload 使页面恢复,用户在执行的上传动作也会被打断,导致提交流程出现异常、影响用户的操作。且如果用户设备进入此状态,大部分情况下用户再次操作还会触发同样的流程。
这种情况下,用户无法及时感知到造成问题的根本原因,绝大多数直观反应即为:“App 出现 bug 了!”故从用户角度来看,缺少自动恢复、处理问题的办法。
目前此问题缺乏有效、统一解决办法,一种解决思路是客户端与前端配置,针对核心、可能出现异常的流程,定向设计解决方案。通过端侧的能力来将数据持久化,在类似异常发生之后使用持久化数据恢复现场,尽量在用户无感的情况下保证用户操作流程正常。
总结
以上便是我们在端容器设计开发过程中,WKWebView 使用上遇到的一些典型问题和对应的解决办法。总的来说,目前造成这么不协调的状态,大部分是因为系统平台未能充分考虑开发者诉求、组件设计对历史业务兼容性不佳导致的。当然,现在这种状态必然也不是一种合理状态,未来无论是系统平台方、还是业务方、或者是开发者,当矛盾无法协调时总有一方要进行妥协。在这个时间点来临之前,希望上面总结内容,能够为受此类问题困扰的同学提供一些帮助。
关注我们,每周 3 篇移动干货&实践给你思考!