Flutter 實現webview與原生元件組合滑動

YouCii發表於2019-03-26

前言

歡迎關注我的GithubCSDN.

最近在用Flutter寫一個新聞客戶端, 新聞詳情頁中的內容 需要用Flutter的本地Widget和WebView共同展示 . 比如標題/上方的視訊播放器是用本地Widget展示, 新聞內容的富文字文字使用webview展示html, 這樣就要求標題/視訊播放器與webview可以 組合滑動.

ps: 如果把新聞詳情頁都用html畫出, 就不用考慮組合滑動的問題.

轉載請標明出處: juejin.im/post/5c997f…

找到支援與本地元件共存的webview控制元件

找一個可以與本地元件共存的webview控制元件是首要任務, 以下是我測試過的幾個庫:

  • flutter_WebView_plugin : 不可以inline;
  • webView_flutter: 可能支援, 但是還沒有釋出;
  • flutter_inappbrowser: 可以實現組合佈局, 所以選用了此庫, 連結 github.com/pichillilor…

另外, 如果僅是展示html靜態頁面, 可以嘗試以下幾個庫, 不用看我這個麻煩的解決辦法了:

初步實現組合佈局

選定flutter_inappbrowser後開始實現, 初步程式碼如下:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Column(
        children: <Widget>[
          Text('Title'),
          Expanded( // 注意必須加這個, 否則webview沒有高度
            child: InAppWebView(initialUrl: 'https://juejin.im/timeline'),
          ),
        ],
      ),
    );
  }
複製程式碼

這樣會構建一個text和webview組合的介面, 不過這裡webview自帶滾動條, 滾動時是不帶著title一塊的. 嘗試以下兩種辦法

  1. 包裹SingleChildScrollView: 介面會消失不見, 因為Scrollview根據子佈局處理高度, 而Expanded又要根據父佈局處理高度, 所以互相依賴導致整個頁面無法繪製.
    body: SingleChildScrollView(
            child: Column(
              children: <Widget>[
                Text('Title'),
                Expanded(
                  child: InAppWebView(initialUrl: 'https://juejin.im/timeline'),
                ),
              ],
            ),
          ),
    複製程式碼
  2. 包裹SingleChildScrollView, 去掉Expanded: AppBar可以顯示了, 但是InAppWebView沒有高度了.
    body: SingleChildScrollView(
            child: Column(
              children: <Widget>[
                Text('Title'),
                InAppWebView(initialUrl: 'https://juejin.im/timeline'),
              ],
            ),
          ),
    複製程式碼

這兩種方式都不行, 歸根到底是不知道InAppWebView的高度, 所以才需要使用與SingleChildScrollView相沖突的Expanded, 所以這個問題變為了 如何獲取WebView的高度.

獲取WebView的高度

在android中不會有這個破問題, 給webview設定wrap_content就可以了, 但是在Flutter中我沒有找到類似佈局方式. (有大哥知道的話麻煩告訴我一下下啊)

其他嘗試的方法就不說了, 最後我採用的辦法是: 通過JS注入拿到html內容的高度回撥. 實現方法如下:

class TestState extends State<Test> {
  InAppWebViewController _controller;
  double _htmlHeight = 200; // 目的是在回撥完成之前先展示出200高度的內容, 提高使用者體驗

  static const String HANDLER_NAME = 'InAppWebView';

  @override
  void dispose() {
    super.dispose();
    _controller?.removeJavaScriptHandler(HANDLER_NAME, 0);
    _controller = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: SingleChildScrollView(
        child: Column(
          children: <Widget>[
            Text('Title'),
            Container( // 使用可提供高度的Container包裹WebView, 設定為回撥的高度
              height: _htmlHeight,
              child: InAppWebView(
                initialUrl: 'https://juejin.im/timeline',
                onWebViewCreated: (InAppWebViewController controller) {
                  _controller = controller;
                  _setJSHandler(_controller); // 設定js方法回掉, 拿到高度
                },
                onLoadStop: (InAppWebViewController controller, String url) {
                  // 頁面載入完成後注入js方法, 獲取頁面總高度    
                  controller.injectScriptCode("""
                  window.flutter_inappbrowser.callHandler('InAppWebView', document.body.scrollHeight));
                """);
                },
              ),
            )
          ],
        ),
      ),
    );
  }

  void _setJSHandler(InAppWebViewController controller) {
    JavaScriptHandlerCallback callback = (List<dynamic> arguments) async {
      // 解析argument, 獲取到高度, 直接設定即可(iphone手機需要+20高度)
      double height = HtmlUtils.getHeight(arguments);
      if (height > 0) {
        setState(() {
          _htmlHeight = height;
        });
      }
    };
    controller.addJavaScriptHandler(HANDLER_NAME, callback);
  }
}
複製程式碼

以上方法可以精確獲取到webview高度, 實現webview與本地Widget組合滑動的要求.

Android端一個問題

以上方法實現後我是一陣竊喜, 趕忙測試了一下, 結果發現一個嚴重問題: Android端給webview設定超出5500左右的高度時, App會閃退. 閃退時AndroidStudio不會展示錯誤日誌, 通過flutter run --verbose命令執行可以獲取到錯誤資訊, 大體看了下是Flutter渲染的問題, 先反饋給官方以及flutter_inappbrowser作者了.

然後自己簡單測試發現, 給Column的child新增了多個webview沒什麼問題, 哪怕這幾個webview的內容相加絕對超出了5500高度. 所以有了思路: 切分html, 分為多個webview共同展示, 然後分別注入JS獲取高度.

注意!注意! 我們的使用場景是: 要展示的內容 = assets儲存的html外殼 + 介面獲取到的新聞內容段落, 而不是一個url. 以上解決思路僅適用於載入html的場景, 而不是url.

這個思路的核心在於如何切分html內容, 需要保證切分後的html是標籤閉合的, 即不是切在了某標籤內部. 使用此切分方案的前提是: body內部的html標籤不會有超大範圍的div包裹, 否則單個標籤內容就超過高度了. 可用的html示例:

<html>
  <head></head>
    <body>
        <!-- 並列小組合, 沒有超大範圍的div等標籤的包裹 -->
        <p style.. > asdasdasd </p>
       	<div style.. > 
       	    <img ... />
       	    <p> ... </p>
       	</div> 
       	<p> asdasdas </p>
    </body>
</html>
複製程式碼

下面是我實現的切分html的演算法:

  // 剪下過長的html, 考慮到較差機型以及其他誤差, 定為4000
  // @return String 剪下後的html
  static List<String> cutHtml(String htmlString) {
    htmlString = _getBody(htmlString);

    List<String> htmlList = List();
    if (Platform.isAndroid && _calculateHeightOfHtml(htmlString) > 4000) {
      // html總高度
      double totalHeight = _calculateHeightOfHtml(htmlString);
      // 切為幾段('~/'整除, /.toInt)
      int childNum = totalHeight ~/ 4000 + (totalHeight % 4000 == 0 ? 0 : 1);
      // 每段html的長度
      int childLength = htmlString.length ~/ childNum;
      // 切一刀後的兩段html
      String resultHtml = '', remainHtml = htmlString;

      int labelStack = 0;
      while (childNum > 0 && remainHtml.length > 0) {
        if (childLength < remainHtml.length) {
          resultHtml = remainHtml.substring(0, childLength);
          remainHtml = remainHtml.substring(childLength);
        } else {
          resultHtml = remainHtml;
          remainHtml = '';
        }

        labelStack = _checkComplete(resultHtml);
        if (labelStack == 0) {
          htmlList.add(resultHtml);
          childNum--;
        } else {
          // 如果不是閉合的, 把remain裡的n個標籤尾之前的內容剪下到result中
          int tailPosition = 0;
          do {
            tailPosition = _getTailPositionOfTail(remainHtml, tailPosition);
            if (tailPosition == -1) {
              throw Exception('html style error: no label tail');
            }
            labelStack--;
          } while (labelStack != 0);

          resultHtml = resultHtml + remainHtml.substring(0, tailPosition);
          remainHtml = remainHtml.substring(tailPosition);

          htmlList.add(resultHtml);
          childNum--;
        }
      }
    } else {
      htmlList.add(htmlString);
    }

    return htmlList;
  }

  // 自startPosition開始向後找到第一個尾標籤, 返回該尾標籤的下一位位置, 以便substring
  static int _getTailPositionOfTail(String remainHtml, int startPosition) {
    int frontTailPosition = remainHtml.length;
    String frontTailName;
    for (String tailLabel in _tailLabels) {
      int current = remainHtml.indexOf(tailLabel, startPosition);
      if (current != -1 && current < frontTailPosition) {
        frontTailPosition = current;
        frontTailName = tailLabel;
      }
    }
    return frontTailPosition + frontTailName.length;
  }

  // 未閉合的標籤數目 --> 時間複雜度過高, O(11n)
  static int _checkComplete(String resultHtml) {
    // 這裡沒有使用stack, 而是簡單的計數, 是預設正確的html格式, 而且只有_headLabels內的標籤型別
    int labelStack = 0;
    for (int i = 0; i < resultHtml.length; i++) {
      String label = _startWithLabelHead(resultHtml, i);
      if (label != null) {
        labelStack++;
        i += label.length - 1;
      } else {
        label = _startWithLabelTail(resultHtml, i);
        if (label != null) {
          labelStack--;
          i += label.length - 1;
        }
      }
    }
    return labelStack;
  }

  // 以_labelsHead內的字串開頭
  static String _startWithLabelHead(String resultHtml, int startPosition) {
    for (String label in _headLabels) {
      if (resultHtml.startsWith(label, startPosition)) {
        return label;
      }
    }
    return null;
  }

  // 以_labelsTail內的字串開頭
  static String _startWithLabelTail(String resultHtml, int startPosition) {
    for (String label in _tailLabels) {
      if (resultHtml.startsWith(label, startPosition)) {
        return label;
      }
    }
    return null;
  }

  // 去除body及以外的標籤, 露出並列的子標籤
  // <html>
  //   <head></head>
  //     <body>
  //    	 ...
  //     </body>
  // </html>
  static String _getBody(String htmlString) {
    if (htmlString.contains('<body>')) {
      htmlString = htmlString.substring(htmlString.indexOf('<body>') + 6);
      htmlString = htmlString.substring(0, htmlString.indexOf('</body>'));
    }
    return htmlString;
  }

  // 待檢測的標籤
  static final _headLabels = {'<div', '<img', '<p', '<strong', '<span'};
  static final _tailLabels = {'</div>', '</img>', '</p>', '</strong>', '</span>', '/>'};
複製程式碼

通過以上演算法, 拿到了切分好的htmlList, 然後在PageState中使用多個webview分別載入, 分別注入js即可解決此問題.

大功告成!

ps. 這裡的使用的4000高度只是大概, 後面機型適配時會有調整.

附:

  1. flutter_inappbrowser如何載入html字串:
    InAppWebView( initialData: InAppWebViewInitialData(' htmlContent '))
    複製程式碼
  2. 解析asset檔案為字串:
    static Future<String> decodeStringFromAssets(String path) async {
        ByteData byteData = await PlatformAssetBundle().load(path);
        String htmlString = String.fromCharCodes(byteData.buffer.asUint8List());
        return htmlString;
    }
    複製程式碼

相關文章