Flutter学习

Flutter 实现webview与原生组件组合滑动

2019-07-31  本文已影响44人  锐心凌志

前言

最近在用Flutter写一个新闻客户端, 新闻详情页中的内容 需要用Flutter的本地Widget和WebView共同展示 . 比如标题/上方的视频播放器是用本地Widget展示, 新闻内容的富文本文字使用webview展示html, 这样就要求标题/视频播放器与webview可以 组合滑动.

ps: 如果把新闻详情页都用html画出, 就不用考虑组合滑动的问题.

找到支持与本地组件共存的webview控件
找一个可以与本地组件共存的webview控件是首要任务, 以下是我测试过的几个库:

flutter_WebView_plugin : 不可以inline;
webView_flutter: 可能支持, 但是还没有发布;
flutter_inappbrowser: 可以实现组合布局, 所以选用了此库, 链接 https://github.com/pichillilorenzo/flutter_inappbrowser
另外, 如果仅是展示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自带滚动条, 滚动时是不带着title一块的.
尝试以下两种办法

包裹SingleChildScrollView: 界面会消失不见, 因为Scrollview根据子布局处理高度, 而Expanded又要根据父布局处理高度, 所以互相依赖导致整个页面无法绘制.

body: SingleChildScrollView(
        child: Column(
          children: <Widget>[
            Text('Title'),
            Expanded(
              child: InAppWebView(initialUrl: 'https://juejin.im/timeline'),
            ),
          ],
        ),
      ),

包裹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即可解决此问题.

大功告成!

附:
flutter_inappbrowser如何加载html字符串:

InAppWebView( initialData: InAppWebViewInitialData(' htmlContent '))

解析asset文件为字符串:

static Future<String> decodeStringFromAssets(String path) async {
    ByteData byteData = await PlatformAssetBundle().load(path);
    String htmlString = String.fromCharCodes(byteData.buffer.asUint8List());
    return htmlString;
}

项目中发现使用上述方法也存在一些问题,主要就是分块展示web view后会存在滑动卡顿问题

后期项目采用了webView_flutter 插件,使用后也发现Android端会有web view高度超过5000后崩溃的问题,然后通过修改webView_flutter插件源码,其中Android端实现测量web view高度以及滑动到底部到事件回调,ios端实现测量web view高度,然后通过控制web view和CustomScrollView滑动来实现组合滑动

我们需要监听webview的滚动事件,但WebView没有直接监听滑动的方法,看WebView的源码则会发现有一个protected void onScrollChanged(int l, int t, int oldl, int oldt)方法。
这个方法。是受到保护的所以我们无法直接使用,所以我们写一个加强的WebView,重写onScrollChanged方法并利用接口回调。

public class NewWebView extends WebView{

    private OnScrollChangeListener mOnScrollChangeListener;
    
    public NewWebView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    
    @Override 
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        // webview的高度
        float webcontent = getContentHeight() * getScale();
        // 当前webview的高度
        float webnow = getHeight() + getScrollY();
        if (Math.abs(webcontent - webnow) < 1) {
            //处于底端 
            mOnScrollChangeListener.onPageEnd(l, t, oldl, oldt);
        } else if (getScrollY() == 0) {
            //处于顶端
            mOnScrollChangeListener.onPageTop(l, t, oldl, oldt);
        } else { 
            mOnScrollChangeListener.onScrollChanged(l, t, oldl, oldt); 
        } 
    }
    
    public void setOnScrollChangeListener(OnScrollChangeListener listener) {
        this.mOnScrollChangeListener = listener; 
    }
    
    public interface OnScrollChangeListener {
        
        public void onPageEnd(int l, int t, int oldl, int oldt);
        
        public void onPageTop(int l, int t, int oldl, int oldt);
        
        public void onScrollChanged(int l, int t, int oldl, int oldt); 
    
    }
    
}

使用:

public class NewsActivity extends AppCompatActivity {

    private NewsWebView mWebView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_news);
        mWebView = (NewsWebView) findViewById(R.id.web_view);
        mWebView.setOnScrollChangeListener(new NewsWebView.OnScrollChangeListener() {
            @Override
            public void onPageEnd(int l, int t, int oldl, int oldt) {
                Logs.d("已经到达地端");
            }

            @Override
            public void onPageTop(int l, int t, int oldl, int oldt) {
                Logs.d("已经到达顶端");
            }

            @Override
            public void onScrollChanged(int l, int t, int oldl, int oldt) {
            }
        });

上一篇 下一篇

猜你喜欢

热点阅读