在 IOS 客户端上,为了方便开发且我们小伙伴们前端技术栈也会更熟悉一些,所以 IOS 客户端的架构我选择了用 WKWebView + Vue3 来开发客户端的 UI 部分。

当然既然选择了 WebVIew 的方式来写,有几个点肯定是绕不开的,首先就是目前的 SPA 单页应用无法直接通过 file://index.html 文件加载方式部署,然后就需要在客户端启一个 http 静态服务用于加载服务(此处也有坑需要注意,由于 IOS “优秀”的后台墓碑机制,你肯定绕不开的是切换到前台需要重启服务),此外前端部分与客户端交互或者客户端与前端代码部分互相调用需要用到 JSbridge(相关的博客我后面有时间再补一篇,咕咕咕),当然我踩的坑还远不止如此,比如 Webview 加载动画(远程服务访问 and 弱网情况)、多窗口模式、老生常谈的 Cookie 配置和跨容器共享、自签证书的配置等等…

当然,最大的问题还得是 WKWebview 白屏问题了,这玩意从第一次发版本就一直有小伙伴给我报 BUG 说又出现白屏了,期间也多次治理,我也从最初的完全不知道从哪里下手到目前能抽丝剥茧的分析问题。当然我写这篇博客的最主要原因是分享一下自己的思路,也能给大家提供一些视角或者有遇到这个问题的朋友们提供一些调查方向。

静态服务被后台墓碑机制干掉了

刚开始的时候会发现客户端在后台恢复到前台的时候,打开客户端二级页面出现白屏,通过 Safari 调试服务发现静态资源全部请求失败。但是很神奇的是,在开发调试过程中又没这个问题(IOS debug 模式不会关闭后台),当然,处理方式也比较简单,通过 UIWindowSceneDelegate.sceneDidBecomeActive 获得应用恢复到前台事件的时候重启一下静态 HTTP 服务。

WKWebView processDidTerminate 以及 WKWebView 内存占用过高被嘎掉

这个部分应该算是 WKWebView 错误事件回调,参考官方公开文档实现

WKUIDelegate.webViewDidClose 事件,通过该回调可以检测到 DOM 被关闭的一些事件(没有啥更多的信息,感觉有点鸡肋)。

class WebViewManager: NSObject, WKUIDelegate {
    …
    // webView 被关闭
    func webViewDidClose(_ webView: WKWebView) {
        Logger().debug("WebViewManager: <\(webView.url?.absoluteString ?? "nil")> webViewDidClose")
    }
    …
}

WKNavigationDelegate.webView didFail 和 didFailProvisionalNavigation 事件,这里主要是针对网络请求失败或者导航时候遇到的一些错误处理(注意:Css 和 JavaScript 以及网络请求加载的网络失败错误并不算导航失败,所以不会回调到这里),导航失败的话,我们倒是能够获取到一些失败原因,并且根据这些原因进行一些处理

class WebViewManager: NSObject, WKUIDelegate, WKNavigationDelegate {
    …
    var processDidTerminateCount = 0 // WKWebView 进程异常计数

    func webView(_ webview: WKWebView, didFinish _: WKNavigation!) {
        Logger().debug("WebViewManager: webView didFinish url:\(webview.url?.absoluteString ?? "nil") estimatedProgress:\(webview.estimatedProgress) backList:\(webview.backForwardList.backList.count)")
        processDidTerminateCount = 0 // 清空失败计数
    }

    func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) {
        // 忽略已取消加载页面错误
        if (error as NSError).code == NSURLErrorCancelled {
            return
        }
        Logger().debug("WebViewManager: <\(webView.url?.absoluteString ?? "nil")> webView didFail \(error)")
        if !webView.isLoading {
            webView.reload()
        }
    }

    func webView(_ webView: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: Error) {
        let err = error as NSError
        Logger().debug("WebViewManager: <\(webView.url?.absoluteString ?? "nil")> webView didFailProvisionalNavigation code:\(err.code) \(error)")

        if err.code == -999 {
            // 用户主动(程序主动)取消请求,不需要展示任何错误页面以及不停止加载页面
            return
        } else if err.code == -1200 {
            // 忽略处理 SSL 证书错误展示(避免拦截自签 SSL 证书认证),不需要展示任何错误页面以及不停止加载页面
            return
        } else if let originUrl = webView.url ?? URL(string: err.userInfo["NSErrorFailingURLStringKey"] is String ? err.userInfo["NSErrorFailingURLStringKey"] as! String : "") {
            // 加载失败展示页面 (通过 NSErrorFailingURLStringKey 获取到加载失败的 url)
        }
        webView.stopLoading()
    }
    …
}

除此之外 WKNavigationDelegate 还提供了 webViewWebContentProcessDidTerminate 事件,用于感知 WebContentProcess 是否被终止了。

class WebViewManager: NSObject, WKUIDelegate, WKNavigationDelegate {
    …
    var processDidTerminateCount = 0 // WKWebView 进程异常计数
    // Webkit 进程被终止,抛出异常
    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
        Logger().debug("WebViewManager: <\(webView.url?.absoluteString ?? "nil")> webViewWebContentProcessDidTerminate")
        if processDidTerminateCount < 3 {
            webView.reload()
            processDidTerminateCount += 1
        }
    }
    …
}

不过,就以上 API 还是有很多错误根本就无法感知到,比如说 WKProcess 进程被终止(且无法感知原因),于是就需要整点花活了。有大佬通过挖 WebKit 的开源代码发现其实果子还是藏了私货的,在 WKNavigationDelegate 有一个私有函数 _webView:webContentProcessDidTerminateWithReason 在这里面能够拿到 WKProcess 进程终止的事件通知(不是全部,有很多详细的原因在上层调用栈中就被隐藏了),我这里参考 duckduckgo DistributedNavigationDelegate 的实现

@objc
public enum WKProcessTerminationReason: Int {
    case exceededMemoryLimit = 0
    case exceededCPULimit
    case requestedByClient
    case crash

    public static let userInfoKey: String = "WKProcessTerminationReasonKey"
    var description: String {
        switch self {
        case .exceededMemoryLimit:
            return "ExceededMemoryLimit"
        case .exceededCPULimit:
            return "ExceededCPULimit"
        case .requestedByClient:
            return "RequestedByClient"
        case .crash:
            return "Crash"
        }
    }
}

class WebViewManager: NSObject, WKUIDelegate, WKNavigationDelegate {
    …
    var processDidTerminateCount = 0 // WKWebView 进程异常计数
    @objc(_webView:webContentProcessDidTerminateWithReason:)
    public func webView(_ webView: WKWebView, webContentProcessDidTerminateWith reason: Int) {
        self.webView(webView, processDidTerminateWith: WKProcessTerminationReason(rawValue: reason))
    }

    private func webView(_ webView: WKWebView, processDidTerminateWith reason: WKProcessTerminationReason?) {
        Logger().debug("WebViewManager: <\(webView.url?.absoluteString ?? "nil")> webContentProcessDidTerminate \(reason?.description ?? "nil") title:\(webView.title ?? "nil") reload:\(processDidTerminateCount)")
        if processDidTerminateCount < 3 {
            webView.reload()
            processDidTerminateCount += 1
        }
    }
    …
}

如何检测 WKWebview 白屏?

其实到这里基本在 webview 上能拿的错误信息和 “不能” 拿的错误信息都拿的差不多了,可是小伙伴们还是时不时会出现白屏情况,于是只能先看看能不能实现监测到白屏,然后通过分析相应的白屏原因以及尝试恢复。目前大部分检测白屏的方案有三种,第一种的话通过判断 WebView URL 是否为空,title 是否为空,第二种是通过组件树子孙组件数量判断是否白屏,以及第三种通过快照截图判断是否白屏。(目前我就只实现了第一种方案,后续要是发现还是有白屏情况无法捕捉的话会尝试补充其他两种方案)

 public enum UIWhiteScreenError: Error {
    case Uninitialized
    case EmptyViewInstance
    case EmptyURL
    case EmptyTitle
    case EmptyRender
}

// 检测 WebViewManager 白屏原因
public static func uiWhiteScreenDetectionTask(webview: WKWebView?, handle: (WKWebView?, UIWhiteScreenError?) -> Void = { _, _ in }) {
    guard let webview else {
        handle(webview, .Uninitialized)
        return
    }

    // WebView 实例为空
    if webview.navigationDelegate {
        handle(webview, .EmptyViewInstance)
        return
    }

    // WebView URL 是否为空,title 是否为空
    if webview.url == nil {
        handle(webview, .EmptyURL)
        return
    } else if webview.title == nil || webview.title == "" || webview.title!.isEmpty {
        handle(webview, .EmptyTitle)
        return
    }

    // WebView 是否渲染白屏
    // TODO: 通过组件树子孙组件数量判断是否白屏,通过快照截图判断是否白屏
    // handle(webview, .EmptyRender)
}

然后我在应用每次回到前台时都会调用 uiWhiteScreenDetectionTask 检测是否出现白屏情况,如果出现白屏,要不重新加载要不就是重新构造一个实例。

通过注入 JS 监测 Vue 是否渲染失败

通过上面几种方式,在客户端层面把相应的错误和检测都做的差不多了(我以为就不会出现什么白屏问题了),但是实际上除了客户端这层会出现各种异常导致白屏情况之外,在 UI 层也是可能会出现很多异常情况导致白屏的,比如说 UI 层代码异常导致 Vue 渲染失败,其实作为客户端从纯客户端层面是无法感知 JS 执行失败错误的,但是可以通过 WKUserContentController.addUserScript 注入脚本到网页中,在 JS 层通过如下两个函数做到主动向客户端上报异常,和我之前写的 Electron 日志收集之 Renderer (渲染) 进程错误日志自动收集 相关博客大同小异,就是将 IPC 函数调用替换为 WKScriptMessageHandler 调用就好了。

window.addEventListener('unhandledrejection', function (event) {});
window.addEventListener('error', function (event) {});

除此之外,我们其实还可以在 UI 层通过判断 DOM 树组件数量来判断 Vue 是否渲染成功(React 等其他SPA 框架也可以参考该实现方式),最后在创建 WKWebview 实例时通过 WKUserContentController.addUserScript 将脚本注入到页面中就可以了。

document.onreadystatechange = (e) => {
    if (document.readyState === 'complete') {
        if ((window as any).__VUE__) {
            // 判断是 Vue 页面的话,增加检测脚本
            console.log('[onreadystatechange] readyState complete', ((window as any).app as HTMLElement).childNodes.length, (window as any).app);

            // 如果 DOM 未渲染,提示页面加载失败
            if (!(window as any).app || ((window as any).app as HTMLElement).childNodes.length < 1) {
                const _NAME_LZC_WARN_TAG = '_lzc_c_warn_message'
                if (!document.body.querySelector(`#${_NAME_LZC_WARN_TAG}`)) {
                    // 致敬 Safari 错误提示页面样式
                    let tipsDom = document.createElement("p");
                    tipsDom.id = _NAME_LZC_WARN_TAG;
                    tipsDom.style.padding = "0 50px";
                    tipsDom.style.margin = "auto 0";
                    tipsDom.style.minHeight = "234px";
                    tipsDom.style.maxWidth = "832px";
                    tipsDom.style.color = "rgb(125, 127, 127)";
                    tipsDom.style.font = "-apple-system-short-body";
                    tipsDom.style.textAlign = "center";
                    tipsDom.innerHTML = `打不开来自 ${window.location.host} 的页面,请稍后重试。`;
                    document.body.appendChild(tipsDom);
                }
            }
        }
    }
};

什么?vue-router 也会加载失败吗?

其实到这里我以为自己对白屏的问题检测的手段已经够多了,总不会还有我抓不住的白屏问题了吧。现实又啪啪打我脸了,今天早上小方同学拿一个白屏情况给我直接甩我脸上(此处感谢小方同学无数次帮我测试后台运行恢复和白屏问题)。我连上 Safari 调试模式,一看傻眼了,DOM 树中有组件,但是只有一个 div 组件,后面的组件都没渲染出来,通过看代码发现 App.tsx 渲染了,但是 RouterView 组件没渲染啊。然后我刷新页面发现还是加载不成功,有部分资源网络请求失败了,但是 HTML 和 index.js 请求又成功了,不但如此还发现一个 JavaScript 报错如下

IOS 开发之 WKWebview 加载 Vue3 各种白屏问题抽丝剥茧-天真的小窝
IOS 开发之 WKWebview 加载 Vue3 各种白屏问题抽丝剥茧-天真的小窝

我们之前注册路由时,都是使用 import('~/page/home') 方式直接懒加载的页面,然后猜测原因可能是由于 home.js 的构建产物请求加载失败,于是导致这个现象。怎么办?先通过 routers.onError 接口监听到路由加载失败并展示到页面上吧,至少后面能够判断是这个问题导致白屏的。

import { createRouter, createWebHashHistory, RouteRecordRaw, RouterView } from 'vue-router';

const routes: Array<RouteRecordRaw> = [
    ……
];

const routers = createRouter({
    history: createWebHashHistory(),
    routes: routes,
});

// 监听路由加载失败,尝试使用 routers.onError 回调函数监听 RouterView TypeError: Importing a module script failed. 错误
routers.onError((err, to, from) => {
    // 展示页面路由加载失败原因,方便调查白屏原因
    const _NAME_LZC_WARN_TAG = '_lzc_c_warn_message';
    if (!document.body.querySelector(`#${_NAME_LZC_WARN_TAG}`)) {
        // 致敬 Safari 错误提示页面样式
        let tipsDom = document.createElement('p');
        tipsDom.id = _NAME_LZC_WARN_TAG;
        tipsDom.style.padding = '0 50px';
        tipsDom.style.margin = 'auto 0';
        tipsDom.style.minHeight = '234px';
        tipsDom.style.maxWidth = '832px';
        tipsDom.style.color = 'rgb(125, 127, 127)';
        tipsDom.style.font = '-apple-system-short-body';
        tipsDom.style.textAlign = 'center';
        tipsDom.innerHTML = `页面导航失败 ${to.fullPath ?? window.location.href} 因为 ${
            err ?? '未知错误'
        },请尝试 <a href="javascript:window.location.reload();" style="color: color(srgb 0.0003 0.5165 1);">重新加载</a> 或 <a href="/" style="color: color(srgb 0.0003 0.5165 1);">返回主页</a> 。`;
        document.body.appendChild(tipsDom);
    }
});

当然,如果是这个问题导致的话,目前的处理方案就是先将主页移除掉懒加载逻辑(虽然这会增加 index.js 构建产物的大小)。

import { RouteRecordRaw } from 'vue-router';

import HomePage from '~/pages/home';

const routes: Array<RouteRecordRaw> = [
    {
        path: '/',
        component: HomePage,
        // component: () => import('~/pages/home'), // Bin: 懒加载页面方案,可能导致客户端后台恢复时白屏(暂时弃用)
    }
]

后续还需要持续观察,治理白屏是个艰巨且需要长时间作战的任务,因为你不知道用户会怎么使用你的代码。至于上面遇到的部分 JavaScript 和 Css 资源加载失败原因还需要后续继续挖掘,我猜测具体问题要不就是在 Http 静态服务上,再不然就是 Webkit 缓存或者加载(恢复)机制有 BUG