Flutter WebView 弱网超时加载(循环重定向)

2,098 阅读10分钟

本文主要探讨下面两个问题:

  1. 弱网下的加载 H5 的 Loading 控制
  2. 弱网加载后回退,历史堆栈重定向死循环,导致容器无法退出

Q1:Loading 控制

   为了保持业务的内聚和页面跨平台的通用性,我们加载一个 H5 的时候,往往会选择让 H5 页面自己实现导航栏,原生平台会隐藏原生导航栏以全屏的方式加载 H5。这会带来的问题便是如果我们在弱网或网络异常的情况下进入 WebView,我们可能会因为 H5 导航栏没有加载出来而原生导航栏又不存在,导致在 iOS 上无法关闭这个 WebView,如下图 1

   解决这个问题的常见做法就是「让原生容器 WebView 做点防范处理 」。具体来说就是在打开容器后,WebView 开始加载 H5 时,容器显示导航栏 & Loading 框,在 WebView 加载结束后视情况让原生导航栏「保持常驻」或「立即隐藏」,这也是我们十分常见的做法,如下图 2、3 所示:

image.png

   Flutter 项目中,以 flutter_inappwebview 插件为例,看看页面加载的相关回调:

  • onLoadStart - 页面开始加载
  • onLoadStop - 页面结束加载
  • onLoadError - 页面加载失败

   所以理论上只需要在 onLoadStart 回调时 WebView 显示 Loading,在 onLoadStop 和 onLoadError 下结束即可。而事实是:在网络正常的情况下的确可以,但在弱网的环境下却行不通。我们用 Charles 模拟个弱网看看 Log 就知道了。

image.png WebView onLoadStart.png   在弱网下,WebView 容器打开(onCreate)到页面开始加载的回调执行(onLoadStart)中间竟相隔 30s,在此之后我们也会陆续收到 onLoadError & onLoadStop 的回调。

  所以合理的做法应该是:在打开 WebView 的时候就开始显示 Loading,onLoadStop 时结束。而这个 Loading 过程要持续多久?通过调整不同的弱网环境,实验数据基本维持在 [30s, 32s) 这个区间。也就是在网络够弱的情况下,用户得盯着这个 Loading 30s 才能得到一个加载结果。

   那么为了降低等待时长,改善体验,我们就必须让这个 Loading 过程是可控的,通过自定义 Timer 来控制 Loading 时长也是个显而易见的做法。在 Timer 计时结束后,如果页面还处在 Loading 的状态,我们就需要主动将页面状态置为 Error,促使 Loading 框的消失和重新加载网页的入口显示。

/// 启动自定义的计时器
_startLoadingCountdown() {
  _loadingTimer?.cancel();
  _loadingTimer = Timer(const Duration(milliseconds: _loadingInterval), () async {
    // loading 超时后,如果状态依然是 Loading,则将状态置为 failed
    if (_isLoading()) {
      // 重要!立即停止 WebView 加载,触发 onLoadStop 立即回调,而不是等 WebView 自然超时回调
      await _webViewController?.stopLoading();
      setState(() {
        _webPageLoadedResult = 3;
      });
    }
  });
}

   这里有个重要的细节:就是计时结束后,务必要调用 WebViewController.stopLoading()。如果我们仅仅只将 UI 层面将页面状态置为 Error,结束 Loading 的展示并弹出重新加载网页的点击入口,而不是真正中断这个过程,那么 WebView 必定会在未来某个时刻自行执行这次未被中断的回调方法,从而干扰我们 URL 重试加载处理的流程。

   举个例子,设定自定义 Loading Timer 倒计时为 15s,在弱网下打开 WebView 加载 URL,15s 倒计时结束后页面置为 Error,点击「重新加载」,注意: WebView 执行 loadUrl 或 reload 会强制中断上一个正在加载的任务,触发 onLoadStop 回调,并马上执行 onLoadStart;而这个始料未及的 onLoadStop 回调就可能会干扰到你原本设计好的逻辑控制或状态管理(比如你在点击重试加载的时候做了一些状态设置,此时就可能被 onLoadStop 覆盖掉)。所以务必调用 WebViewController.stopLoading()

Q2:历史堆栈回退循环重定向,导致无法关闭容器

   现象:在模拟弱网下访问 URL,加载超时后再恢复正常网络,点击「重新加载」加载成功,此时无论怎么点击导航栏 Back 按键,都无法退出 WebView.

   背景:在 Flutter 中我们通常会给 WebView Widget 套一层 WillPopScope 来做 H5 页面的回退或容器关闭的判断处理,此判断依托于 WebViewController.canGoBack() 的结果:

WillPopScope(
    onWillPop: () async {
        bool canGoBack = await _webViewController!.canGoBack();
        if (canGoBack) {
            _webViewController!.goBack();
        }
        return true; // true 表示可以关闭容器
    }
}

canGoBack 为 true 则执行页面回退,而 false 则执行页面容器关闭。所以当页面出现无法关闭的情况,则表明在 WebView 的页面历史堆栈中一直存在可后退访问的页面。页面堆栈不论怎么回退都清不掉?这显然是很诡异的。

   经过一番 Debug,发现主要是因为在弱网下加载一个重定向 URL,并又在网络正常时发起重新加载所致。具体怎么回事?举个例子:这是一个会重定向的 Redirect URL,在浏览器输入访问后,会重定向到 www.baidu.com 。在弱网的情况下访问,首先会因为网页资源获取超时,导致错误提醒页面的弹出,WebView 中断加载过程,此时打印页面堆栈:

> I/PLogger (15643): │ 🐛 _handlePageFinished url https://sourl.cn/QhDhpy
> I/PLogger (15643): │ 🐛 _handlePageFinished historyList : {list: [], currentIndex: -1}

此时历史堆栈为空,继续保持弱网环境,点击「重新加载」,毫无疑问 URL 会因超时再次加载失败,打印其加载过程及页面堆栈:

I/PLogger (20091): │ 🐛 点击重试:https://sourl.cn/QhDhpy
I/PLogger (20091): │ 🐛 _handlePageStart url https://sourl.cn/QhDhpy
I/PLogger (20091): │ 🐛 _handlePageError code -8 and message net::ERR_TIMED_OUT
I/PLogger (20091): │ 🐛 _handlePageFinished url https://sourl.cn/QhDhpy
I/PLogger (20091): │ 🐛 _handlePageFinished historyList : {list: [{originalUrl: https://sourl.cn/QhDhpy, title: 网页无法打开, url: https://sourl.cn/QhDhpy, index: 0, offset: 0}], currentIndex: 0}

关闭弱网环境,在网络恢复正常后,点击「重新加载」,此时页面加载成功,同样打印其记载过程和页面堆栈,当 loadURL 出现重定向时,shouldOverrideUrlLoading 方法会被调用:

I/PLogger (20455): │ 🐛 点击重试:https://sourl.cn/QhDhpy
I/PLogger (20455): │ 🐛 _shouldOverrideUrlLoading uri = https://www.baidu.com/ and request = {url: https://www.baidu.com/} and redirect = true
I/PLogger (20455): │ 🐛 _handlePageFinished url https://www.baidu.com/
I/PLogger (20455): │ 🐛 _handlePageFinished historyList : {list: [{originalUrl: https://sourl.cn/QhDhpy, title: 网页无法打开, url: https://sourl.cn/QhDhpy, index: 0, offset: 0}], currentIndex: 0}
I/PLogger (20455): │ 🐛 _handlePageStart url https://www.baidu.com/
I/PLogger (20455): │ 🐛 _handlePageFinished url https://www.baidu.com/
I/PLogger (20455): │ 🐛 _handlePageFinished historyList : {list: [{originalUrl: https://sourl.cn/QhDhpy, title: 网页无法打开, url: https://sourl.cn/QhDhpy, index: 0, offset: -1}, {originalUrl: https://www.baidu.com/, title: 百度一下, url: https://www.baidu.com/, index: 1, offset: 0}], currentIndex: 1}

注意:最后一行 Log 显示此时历史堆栈出现了两个栈帧,一个是重定向前的 URL,另一个为重定向后的 URL。点击返回键触发页面返回,无论怎么点击返回,都无法关闭 WebView 容器。而如果网络在正常的状态下加载 URL,其历史页面堆栈只会保留重定向后的栈帧,即:

I/PLogger ( 6893): │ 🐛 _handlePageFinished historyList : {list: [{originalUrl: https://www.baidu.com/, title: 百度一下, url: https://www.baidu.com/, index: 0, offset: 0}], currentIndex: 0}

所以相对于正常情况下的历史堆栈,就多出了这个栈帧:

{originalUrl: sourl.cn/QhDhpy, title: 网页无法打开, url: sourl.cn/QhDhpy, index: 0, offset: -1}

即它的存在让 WebViewController.canGoBack() 一直返回 true,从而导致我们陷入回退死循环,而无法关闭 WebView 容器。姑且猜测:这个重定向原 URL 栈帧在每次回退触发访问的时候,WebView 虽然执行了 URL 的重定向,但在结束后却不将其清除。这里需要源码考证,篇幅有限,便不再这里探讨,重点是给出解决方案。

以上弱网的模拟,注意需要在 Flutter HTTP 层面以及设备 WIFI 同时进行配置代理方可。

   先交待点背景,也如上文所提到的,在 Android 中,WebView 加载重定向 URL 的时候,会触发 shouldOverrideUrlLoading 的回调,属于下面 Android 加载 URL 四种情形下的第(2)点:

(1)正常流程

loadUrl() -> shouldInterceptRequest() -> onPageStarted -> onPageFinished

(2)重定向(302)流程

loadUrl() -> shouldOverrideUrlLoading() -> shouldInterceptRequest() -> onPageStarted > onPageFinished

(3)内部跳转(location.href)流程

loadUrl() -> shouldOverrideUrlLoading() -> onPageStarted -> onPageFinished

(4)历史回退栈

shouldInterceptRequest() -> onPageStarted -> onPageFinished

所以,(2)(3)两种情况都会触发 shouldOverrideUrlLoading,并且如果是多次重定向那么 shouldOverrideUrlLoading 也会不断地被调用。为了在 shouldOverrideUrlLoading 中识别到当下是重定向行为,一般会采取两种做法:

方法一:判断 HitTestResult

@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    WebView.HitTestResult hitTestResult = view.getHitTestResult();
    if(hitTestResult == null
        || hitTestResult.getType() == WebView.HitTestResult.UNKNOWN_TYPE) {
        // 当前请求为重定向
    }
    return super.shouldOverrideUrlLoading(view, url);
}

方法二:判断 WebResourceRequest

@RequiresApi(api = Build.VERSION_CODES.N)
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    if (request.isRedirect()) {
        // 当前请求为重定向
    }
    return super.shouldOverrideUrlLoading(view, url);
}

相信你也留意到了,shouldOverrideUrlLoading(WebView view, WebResourceRequest request) 要求 Android API Version >= Build.VERSION_CODES.LOLLIPOP,而 WebResourceRequest.isRedirect 则要求 >= Build.VERSION_CODES.N,我们需要根据实际情况,选择不同的方法。

   交代完这些技术背景,讲讲解决的思路:造成问题的主要原因是在弱网情况下,页面回退栈残留了一个具有重定向动作的栈帧,我们的考虑是要么把它删掉,要么就忽略它。

   上层通过 WebView 取得的页面历史栈其实是从远程进程 Fork 过来的一份数据,如果我们想真正改写这个 List,则需要通过 Hook 的方式进行。这方面的积累不足且存在兼容成本。

// WebView.class 
public WebBackForwardList copyBackForwardList() {
    throw new RuntimeException("Stub!");
}

   所以另辟蹊径,在点击返回键的时候,通过「忽略该重定向栈帧」来实现页面返回或关闭容器。具体来讲就是在出现重定向动作的时候(shouldOverrideUrlLoading),将源 URL 和目标重定向 URL 做一个映射关系进行存储,当 WebView 在执行回退操作时,检查页面回退栈当前栈帧和临近的下一个栈帧之间是否存在重定向关系,如果存在则通过 WebViewController.goBackOrForward(steps: -2) 跨帧回退。注意:如果当前回退栈只存在两个栈帧,并且为重定向关系,则需直接关闭容器。具体代码如下:

步骤一:捕获重定向映射关系

InAppWebView(
    ...
    shouldOverrideUrlLoading: (controller, navigationAction) async {
      var hitTestResult = await controller.getHitTestResult();
      var uri = navigationAction.request.url!;
      // 如果发生了重定向跳转
      if (InAppWebViewHitTestResultType.UNKNOWN_TYPE == hitTestResult?.type) {
        Uri? controllerUri = await controller.getUrl();
        if (controllerUri != null && controllerUri.toString() != uri.toString()) {
          // 记录重定向地址与源地址的映射关系
          _redirectUriMap[uri.toString()] = controllerUri.toString();
        }
      }
      ...
    },
    ...
)

此步骤有些小细节需要进一步说明:

  1. 正常加载重定向 URL 的时候,此时当前页面还未加载成功,shouldOverrideUrlLoading 下 WebViewController.getUrl() 获取当前页面 Url 为空
  2. 网络异常(弱网)下加载失败后重试,此时 shouldOverrideUrlLoading 下 WebViewController.getUrl() 不为空且为重定向源 URL
  3. 重定向源 URL 如果存在多次重定向,shouldOverrideUrlLoading 会被多次调用并将每一级重定向的目标 URL 与源 URL 进行映射存储。比如源 URL:sourl.cn/s3MiUy ,该 URL 指向 www.baidu.com 而浏览器会将 http 重定向为 https,所以该源 URL 存在两级重定向

image.png

image.png

所以最终被存储下来的映射哈希表为:

{
    "http://www.baidu.com": "https://sourl.cn/s3MiUy",
    "https://www.baidu.com": "https://sourl.cn/s3MiUy"
}

步骤二:拦截回退检查

_checkRedirectGoBack() async {
  WebHistory? webHistory = await _webViewController?.getCopyBackForwardList();
  final historyList = webHistory?.list;
  if (historyList != null && historyList.length >= 2) {
    WebHistoryItem currentHistoryItem = historyList.elementAt(historyList.length - 1);
    WebHistoryItem lastHistoryItem = historyList.elementAt(historyList.length - 2);
    Uri? lastUri = lastHistoryItem.url;
    Uri? currentUri = currentHistoryItem.url;
    final redirectOriginUri = _redirectUriMap[currentUri.toString()];
    // 当前页面与上一个相邻页面是否存在重定向绑定关系
    if (redirectOriginUri != null && redirectOriginUri.toString() == lastUri?.toString()) {
      // 仅剩的两个栈帧若存在重定向映射关系,那么就 return true 直接关闭容器即可
      if (historyList.length == 2) {
        return true;
      }
      // Key step : 跨页回退
      await _webViewController!.goBackOrForward(steps: -2);
      return false;
    }
  }
  await _webViewController!.goBack();
  return false;
}
WillPopScope(
    onWillPop: () async {
        bool canGoBack = await _webViewController!.canGoBack();
        if (canGoBack) {
            return await _checkRedirectGoBack();
        }
        return true; // true 表示可以关闭容器
    }
}

其他

  1. 如你所见,基本没看见关于 iOS 的相关介绍和处理,主要是在 iOS 平台上不会出现这个问题,而且在一般的网页加载情况下,重定向映射表也不会有任何存储动作,只有在异常网络条件下加载,才可能对其存值。
  2. Android 中重载 shouldOverrideUrlLoading 进行 URL 重定向的干预有两种方式,这两种方式会影响 WebView 生命周期函数的回调,具体可以自己 Debug 一下:
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    mWebView.loadUrl(request.getUrl().toString());
    return true;
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
    return false;
}
  1. iOS 设备设置 Charles 代理 WebView 无法正常访问一个网页,需要做些特殊配置

(1)修改 FWP 原生部分的代码

// Flutter WebView Plugin - InAppWebView.swift
public func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    ...
    else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
        // 直接 return 
        return completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust))
    }
    ...
}

(2)为 Info.plist 添加配置

<key>NSAppTransportSecurity</key>
 <dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
 </dict>