最近在用Flutter寫一個新聞客戶端, 新聞詳情頁中的內容 須要用Flutter的本地Widget和WebView共同展現 . 好比標題/上方的視頻播放器是用本地Widget展現, 新聞內容的富文本文字使用webview展現html, 這樣就要求標題/視頻播放器與webview能夠 組合滑動.android
ps: 若是把新聞詳情頁都用html畫出, 就不用考慮組合滑動的問題.git
轉載請標明出處: juejin.im/post/5c997f…github
找一個能夠與本地組件共存的webview控件是首要任務, 如下是我測試過的幾個庫:web
flutter_WebView_plugin
: 不能夠inline;webView_flutter
: 可能支持, 可是尚未發佈;flutter_inappbrowser
: 能夠實現組合佈局, 因此選用了此庫, 連接 github.com/pichillilor…另外, 若是僅是展現html靜態頁面, 能夠嘗試如下幾個庫, 不用看我這個麻煩的解決辦法了:算法
選定flutter_inappbrowser
後開始實現, 初步代碼以下:app
@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一塊的. 嘗試如下兩種辦法iphone
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的高度.async
在android中不會有這個破問題, 給webview
設置wrap_content
就能夠了, 可是在Flutter中我沒有找到相似佈局方式. (有大哥知道的話麻煩告訴我一下下啊)ide
其餘嘗試的方法就不說了, 最後我採用的辦法是: 經過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端給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高度只是大概, 後面機型適配時會有調整.
flutter_inappbrowser
如何加載html字符串:InAppWebView( initialData: InAppWebViewInitialData(' htmlContent '))
複製代碼
static Future<String> decodeStringFromAssets(String path) async {
ByteData byteData = await PlatformAssetBundle().load(path);
String htmlString = String.fromCharCodes(byteData.buffer.asUint8List());
return htmlString;
}
複製代碼