Code前端首页关于Code前端联系我们

Flutter 是为了实现 Web 视图和原生组件的滑动组合而开发的

terry 2年前 (2023-09-23) 阅读数 72 #移动小程序

Flutter 编写了一个新闻客户端。新闻详情页上的内容必须与Flutter原生Widget和WebView一起显示。例如上面的字幕/视频播放器是使用本地widget来显示的,而新闻内容的富文本是使用webview进行html显示的。这就要求字幕/视频播放器和网页预览可以组合滚动.

ps:如果消息详情页全部是html Draw,就不用考虑组合滚动了问题。

找到一个支持与原生组件共存的 Web 视图控件

找到一个可以与原生组件共存的 Web 视图控件是首要任务。以下是一些库测试:

  • flutter_WebView_plugin:无法嵌入;
  • webView_flutter:可能支持但尚未发布;
  • flutter_inappbrowser:可以实现组合布局,所以我选择了这个库,链接github.com/pichillilor…

另外,如果你只想显示html静态页面,可以尝试以下库,不要烦我解决办法是:

  • html
  • flutter_html
  • flutter_html_view

组合布局的初步实现

选择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有自己的滚动条,滚动时不包含标题。尝试以下两种方法

  1. WrapSingleChildScrollView:界面就会消失。因为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冲突扩展,所以问题就变成了如何获取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

.

然后我简单测试了一下,发现在child列中添加更多的webview是没有问题的,尽管这些webview的内容加在一起肯定超过了5500的高度。所以我有一个主意。 :将html拆分成多个webview一起显示,然后注入JS获取高度

注意!注意力!我们的使用场景是:要显示的内容=存储的资产通过html shell检索的消息内容段落+界面而不是url。上述方案仅适用于html加载场景,不适用于url。

这个思路的核心是如何对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,然后使用几个webview在PageState中分别加载它们并注入js。这个问题是可以解决的。

完成!

狗。这里使用的4000高度只是近似值,后续模型调整时会进行调整。

附:

  1. flutter_inappbrowser如何加载 html 字符串:
    InAppWebView( initialData: InAppWebViewInitialData(' htmlContent '))
    复制代码
  2. 将资源文件解析为字符串:
    static Future<String> decodeStringFromAssets(String path) async {
        ByteData byteData = await PlatformAssetBundle().load(path);
        String htmlString = String.fromCharCodes(byteData.buffer.asUint8List());
        return htmlString;
    }
    

作者:YouCii❀5inb990 a070cd56a86d
来源:掘金
版权属于作者。商业转载请联系作者获得许可。非商业转载请注明来源。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门