前言
欢迎关注我的Github和CSDN.
最近在用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静态页面, 可以尝试以下几个库, 不用看我这个麻烦的解决办法了:
- html
- flutter_html
- flutter_html_view
初步实现组合布局
选定flutter_inappbrowser
后开始实现, 初步代码如下:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
Text('Title'),
Expanded( // 注意必须加这个, 否则webview没有高度
child: InAppWebView(initialUrl: 'https://juejin.im/timeline'),
),
],
),
);
}
复制代码
这样会构建一个text和webview组合的界面, 不过这里webview自带滚动条, 滚动时是不带着title一块的. 尝试以下两种办法
- 包裹
SingleChildScrollView
: 界面会消失不见, 因为Scrollview根据子布局处理高度, 而Expanded又要根据父布局处理高度, 所以互相依赖导致整个页面无法绘制.body: SingleChildScrollView( child: Column( children:
[ Text('Title'), Expanded( child: InAppWebView(initialUrl: 'https://juejin.im/timeline'), ), ], ), ), 复制代码 - 包裹
SingleChildScrollView
, 去掉Expanded
: AppBar可以显示了, 但是InAppWebView
没有高度了.body: SingleChildScrollView( child: Column( children:
[ 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: [
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>
<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及以外的标签, 露出并列的子标签
//
//
//
// ...
//
//
static String _getBody(String htmlString) {
if (htmlString.contains('')) {
htmlString = htmlString.substring(htmlString.indexOf('') + 6);
htmlString = htmlString.substring(0, htmlString.indexOf(''));
}
return htmlString;
}
// 待检测的标签
static final _headLabels = {', ', ', ', '};
static final _tailLabels = {'
', '', '', '', '', '/>'};
复制代码
通过以上算法, 拿到了切分好的htmlList, 然后在PageState中使用多个webview分别加载, 分别注入js即可解决此问题.
大功告成!
ps. 这里的使用的4000高度只是大概, 后面机型适配时会有调整.
附:
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; } 复制代码