打通前后端逻辑,客户端Flutter代码一天上线
一、前沿
? 隨著閑魚的業(yè)務(wù)快速增長(zhǎng),運(yùn)營(yíng)類的需求也越來(lái)越多,其中不乏有很多界面修改或運(yùn)營(yíng)坑位的需求。閑魚的版本現(xiàn)在是每2周一個(gè)版本,如何快速迭代產(chǎn)品,跳過(guò)窗口期來(lái)滿足這些需求?另外,閑魚客戶端的包體也變的很大,企業(yè)包的大小,iOS已經(jīng)到了94.3M,Android也到了53.5M。Android的包體大小,相比2016年,已經(jīng)增長(zhǎng)了近1倍,怎么能將包體大小降下來(lái)?首先想到的是如何動(dòng)態(tài)化的解決此類問(wèn)題。
? 對(duì)于原生的能力的動(dòng)態(tài)化,Android平臺(tái)各公司都有很完善的動(dòng)態(tài)化方案,甚至Google還提供了Android App Bundles讓開發(fā)者們更好地支持動(dòng)態(tài)化。由于Apple官方擔(dān)憂動(dòng)態(tài)化的風(fēng)險(xiǎn),因此并不太支持動(dòng)態(tài)化。因此動(dòng)態(tài)化能力就會(huì)考慮跟Web結(jié)合,從一開始基于 WebView 的 Hybrid 方案 PhoneGap、Titanium,到現(xiàn)在與原生相結(jié)合的 React Native 、Weex。
? 但Native和JavaScript Context之間的通訊,頻繁的交互就成了程序的性能瓶頸。于此同時(shí)隨著閑魚Flutter技術(shù)的推廣,已經(jīng)有10多個(gè)頁(yè)面用Flutter實(shí)現(xiàn),上面提到的幾種方式都不適合Flutter場(chǎng)景,如何解決這個(gè)問(wèn)題Flutter的動(dòng)態(tài)化的問(wèn)題?
二、動(dòng)態(tài)方案
我們最初調(diào)研了Google的動(dòng)態(tài)化方案CodePush。
2.1 CodePush
? CodePush是谷歌官方推出的動(dòng)態(tài)化方案,目前只有在Android上面實(shí)現(xiàn)了。Dart VM在執(zhí)行的時(shí)候,加載isolate_snapshot_data?和isolate_snapshot_instr?2個(gè)文件,通過(guò)動(dòng)態(tài)更改這些文件,就達(dá)到動(dòng)態(tài)更新的目的。官方的Flutter源碼當(dāng)中,已經(jīng)有相關(guān)的提交來(lái)做動(dòng)態(tài)更新的內(nèi)容,具體內(nèi)容可以參考?ResourceExtractor.java。
? 根據(jù)官方給出的Guide,我們這邊也做了相關(guān)的測(cè)試,patch的包體大小會(huì)很大(939kb)。為了降低包體大小,還可以通過(guò)增量的修改snapshot文件的方式來(lái)更新。通過(guò)bsdiff生成的snapshot的差異文件,2個(gè)文件分別可以縮小到48kb和870kb。
? 目前看來(lái),CodePush還不能做到很好的工程化。而且如何管理patch文件,需要制定baseline和patch文件的規(guī)則。
2.2 動(dòng)態(tài)模板
? 動(dòng)態(tài)模板,就是通過(guò)定義一套DSL,在端側(cè)解析動(dòng)態(tài)的創(chuàng)建View來(lái)實(shí)現(xiàn)動(dòng)態(tài)化,比如LuaViewSDK、Tangram-iOS和Tangram-Android。這些方案都是創(chuàng)建的Native的View,如果想在Flutter里面實(shí)現(xiàn),需要?jiǎng)?chuàng)建Texture來(lái)橋接;Native端渲染完成之后,再將紋理貼在Flutter的容器里面,實(shí)現(xiàn)成本很高,性能也有待商榷,不適合閑魚的場(chǎng)景。
? 所以我們提出了閑魚自己的Flutter動(dòng)態(tài)化方案,前面已經(jīng)有同事介紹過(guò)方案的原理:《做了2個(gè)多月的設(shè)計(jì)和編碼,我梳理了Flutter動(dòng)態(tài)化的方案對(duì)比及最佳實(shí)現(xiàn)》,下面看下具體的實(shí)現(xiàn)細(xì)節(jié)。
三、模板編譯
自定義一套DSL,維護(hù)成本較高,怎么能不自定義DSL來(lái)實(shí)現(xiàn)模板下發(fā)?閑魚的方案就是直接將Dart文件轉(zhuǎn)化成模板,這樣模板文件也可以快速沉淀到端側(cè)。
3.1 模板規(guī)范
? 先來(lái)看下一個(gè)完整的模板文件,以新版我的頁(yè)面為例,這個(gè)是一個(gè)列表結(jié)構(gòu),每個(gè)區(qū)塊都是一個(gè)獨(dú)立的Widget,現(xiàn)在我們期望將“賣在閑魚”這個(gè)區(qū)塊動(dòng)態(tài)渲染,對(duì)這個(gè)區(qū)塊拆分之后,需要3個(gè)子控件:頭部、菜單欄、提示欄;因?yàn)檫@3部分界面有些邏輯處理,所以先把他們的邏輯內(nèi)置。
內(nèi)置的子控件分別是MenuTitleWidget、MenuItemWidget和HintItemWidget,編寫的模板如下:
@override Widget build(BuildContext context) {return new Container(child: new Column(children: <Widget>[new MenuTitleWidget(data), // 頭部new Column( // 菜單欄children: <Widget>[new Row(children: <Widget>[new MenuItemWidget(data.menus[0]),new MenuItemWidget(data.menus[1]),new MenuItemWidget(data.menus[2]),],)],),new Container( // 提示欄child: new HintItemWidget(data.hints[0])),],),); }中間省略了樣式描述,可以看到寫模板文件就跟普通的widget寫法一樣,但是有幾點(diǎn)要注意:
? 模板寫好之后,就要考慮怎么在端上渲染,早期版本是直接在端側(cè)解析文件,但是考慮到性能和穩(wěn)定性,還是放在前期先編譯好,然后下發(fā)到端側(cè)。
3.2 編譯流程
? 編譯模板就要用到Dart的Analyzer庫(kù),通過(guò)parseCompilationUnit函數(shù)直接將Dart源碼解析成為以CompilationUnit為Root節(jié)點(diǎn)的AST樹中,它包含了Dart源文件的語(yǔ)法和語(yǔ)義信息。接下來(lái)的目標(biāo)就是將CompilationUnit轉(zhuǎn)換成為一個(gè)JSON格式。
? 上面的模板解析出來(lái)build函數(shù)孩子節(jié)點(diǎn)是ReturnStatementImpl,它又包含了一個(gè)子節(jié)點(diǎn)InstanceCreationExpressionImpl,對(duì)應(yīng)模板里面的new Container(…),它的孩子節(jié)點(diǎn)中,我們最關(guān)心的就是ConstructorNameImpl和ArgumentListImpl節(jié)點(diǎn)。ConstructorNameImpl標(biāo)識(shí)創(chuàng)建節(jié)點(diǎn)的名稱,ArgumentListImpl標(biāo)識(shí)創(chuàng)建參數(shù),參數(shù)包含了參數(shù)列表和變量參數(shù)。
定義如下結(jié)構(gòu)體,來(lái)存儲(chǔ)這些信息:
class ConstructorNode {// 創(chuàng)建節(jié)點(diǎn)的名稱String constructorName;// 參數(shù)列表List<dynamic> argumentsList = <dynamic>[];// 變量參數(shù)Map<String, dynamic> arguments = <String, dynamic>{}; }遞歸遍歷整棵樹,就可以得到一個(gè)ConstructorNode樹,以下代碼是解析單個(gè)Node的參數(shù):
ArgumentList argumentList = astNode;for (Expression exp in argumentList.arguments) {if (exp is NamedExpression) {NamedExpression namedExp = exp;final String name = ASTUtils.getNodeString(namedExp.name);if (name == 'children') {continue;}/// 是函數(shù)if (namedExp.expression is FunctionExpression) {currentNode.arguments[name] =FunctionExpressionParser.parse(namedExp.expression);} else {/// 不是函數(shù)currentNode.arguments[name] =ASTUtils.getNodeString(namedExp.expression);}} else if (exp is PropertyAccess) {PropertyAccess propertyAccess = exp;final String name = ASTUtils.getNodeString(propertyAccess);currentNode.argumentsList.add(name);} else if (exp is StringInterpolation) {StringInterpolation stringInterpolation = exp;final String name = ASTUtils.getNodeString(stringInterpolation);currentNode.argumentsList.add(name);} else if (exp is IntegerLiteral) {final IntegerLiteral integerLiteral = exp;currentNode.argumentsList.add(integerLiteral.value);} else {final String name = ASTUtils.getNodeString(exp);currentNode.argumentsList.add(name);} }端側(cè)拿到這個(gè)ConstructorNode節(jié)點(diǎn)樹之后,就可以根據(jù)Widget的名稱和參數(shù),來(lái)生成一棵Widget樹。
四、渲染引擎
端側(cè)拿到編譯好的模板JSON后,就是解析模板并創(chuàng)建Widget。先看下,整個(gè)工程的框架和工作流:
工作流程:
對(duì)于Native測(cè),主要負(fù)責(zé)模板的管理,通過(guò)橋接輸出到Flutter側(cè)。
4.1 模板獲取
模板獲取分為2部分,Native部分和Flutter部分;Native主要負(fù)責(zé)模板的管理,包括下載、降級(jí)、緩存等。
程序啟動(dòng)的時(shí)候,會(huì)先獲取模板列表,業(yè)務(wù)方需要自己實(shí)現(xiàn),Native層獲取到模板列表會(huì)先存儲(chǔ)在本地?cái)?shù)據(jù)庫(kù)中。Flutter側(cè)業(yè)務(wù)代碼用到模板的時(shí)候,再通過(guò)橋接獲取模板信息,就是我們前面提到的JSON格式的信息,Flutter也會(huì)有緩存,已減少Flutter和Native的交互。
4.2 Widget創(chuàng)建
Flutter側(cè)當(dāng)拿到JSON格式的,先解析出ConstructorNode樹,然后遞歸創(chuàng)建Widget。
創(chuàng)建每個(gè)Widget的過(guò)程,就是解析節(jié)點(diǎn)中的argumentsList和arguments?并做數(shù)據(jù)綁定。例如,創(chuàng)建HintItemWidget需要傳入提示的數(shù)據(jù)內(nèi)容,new HintItemWidget(data.hints[0]),在解析argumentsList時(shí),會(huì)通過(guò)key-path的方式從原始數(shù)據(jù)中解析出特定的值。
解析出來(lái)的值都會(huì)存儲(chǔ)在WidgetCreateParam里面,當(dāng)遞歸遍歷每個(gè)創(chuàng)建節(jié)點(diǎn),每個(gè)widget都可以從WidgetCreateParam里面解析出需要的參數(shù)。
/// 構(gòu)建widget用的參數(shù) class WidgetCreateParam {String constructorName; /// 構(gòu)建的名稱dynamic context; /// 構(gòu)建的上下文Map<String, dynamic> arguments = <String, dynamic>{}; /// 字典參數(shù)List<dynamic> argumentsList = <dynamic>[]; /// 列表參數(shù)dynamic data; /// 原始數(shù)據(jù) }? 通過(guò)以上的邏輯,就可以將ConstructorNode樹轉(zhuǎn)換為一棵Widget樹,再交給Flutter Framework去渲染。
至此,我們已經(jīng)能將模板解析出來(lái),并渲染到界面上,交互事件應(yīng)該怎么處理?
4.3 事件處理
在寫交互的時(shí)候,一般都會(huì)通過(guò)GestureDector、InkWell等來(lái)處理點(diǎn)擊事件。交互事件怎么做動(dòng)態(tài)化?
? 以InkWell組件為例,定義它的onTap函數(shù)為openURL(data.hints[0].href, data.hints[0].params)。在創(chuàng)建InkWell時(shí),會(huì)以O(shè)penURL作為事件ID,查找對(duì)應(yīng)的處理函數(shù),當(dāng)用戶點(diǎn)擊的時(shí)候,會(huì)解析出對(duì)應(yīng)的參數(shù)列表并傳遞過(guò)去,代碼如下:
... final List<dynamic> tList = <dynamic>[]; // 解析出參數(shù)列表 exp.argumentsList.forEach((dynamic arg) {if (arg is String) {final dynamic value = valueFromPath(arg, param.data);if (value != null) {tList.add(value);} else {tList.add(arg);}} else {tList.add(arg);} });// 找到對(duì)應(yīng)的處理函數(shù) final dynamic handler =TeslaEventManager.sharedInstance().eventHandler(exp.actionName); if (handler != null) {handler(tList); } ...五、 效果
新版我的頁(yè)面添加了動(dòng)態(tài)化渲染能力之后,如果有需求新添加一種組件類型,就可以直接編譯發(fā)布模板,服務(wù)端下發(fā)新的數(shù)據(jù)內(nèi)容,就可以渲染出來(lái)了;動(dòng)態(tài)化能力有了,大家會(huì)關(guān)心渲染性能怎么樣。
5.1 幀率
在加了動(dòng)態(tài)加載邏輯之后,已經(jīng)開放了2個(gè)動(dòng)態(tài)卡片,下圖是新版本我的頁(yè)面近半個(gè)月的的幀率數(shù)據(jù):
從上圖可以看到,幀率并沒(méi)有降低,基本保持在55-60幀左右,后續(xù)可以多添加動(dòng)態(tài)的卡片,觀察下效果。
注:因?yàn)槲业捻?yè)面會(huì)有本地的一些業(yè)務(wù)判斷,從其他頁(yè)面回到我的tab,都會(huì)刷新界面,所以幀率會(huì)有損耗。
? 從實(shí)現(xiàn)上分析,因?yàn)槊總€(gè)卡片,都需要遍歷ConstructorNode樹來(lái)創(chuàng)建,而且每個(gè)構(gòu)建都需要解析出里面的參數(shù),這塊可以做一些優(yōu)化,比如緩存相同的Widget,只需要映射出數(shù)據(jù)內(nèi)容并做數(shù)據(jù)綁定。
5.2 失敗率
現(xiàn)在監(jiān)控了渲染的邏輯,如果本地沒(méi)有對(duì)應(yīng)的Widget創(chuàng)建函數(shù),會(huì)主動(dòng)拋Error。監(jiān)控?cái)?shù)據(jù)顯示,渲染的流程中,還沒(méi)有異常的情況,后續(xù)還需要對(duì)橋接層和native層加錯(cuò)誤埋點(diǎn)。
六、展望
? 基于Flutter動(dòng)態(tài)模板,之前需要走發(fā)版的Flutter需求,都可以來(lái)動(dòng)態(tài)化更改。而且以上邏輯都是基于Flutter原生的體系,學(xué)習(xí)和維護(hù)成本都很低,動(dòng)態(tài)的代碼也可以快速的沉淀到端側(cè)。
? 另外,閑魚正在研究UI2Code的黑科技,不了解的老鐵,可以參考閑魚大神的這篇文章《重磅系列文章!UI2CODE智能生成Flutter代碼——整體設(shè)計(jì)篇》。可以設(shè)想下,如果有個(gè)需求,需要?jiǎng)討B(tài)的顯示一個(gè)組件,UED出了視覺(jué)稿,通過(guò)UI2Code轉(zhuǎn)換成Dart文件,再通過(guò)這個(gè)系統(tǒng)轉(zhuǎn)換成動(dòng)態(tài)模板,下發(fā)到端側(cè)就可以直接渲染出來(lái),程序員都不需要寫代碼了,做到自動(dòng)化運(yùn)營(yíng),看來(lái)以后程序員失業(yè)也不是沒(méi)有可能了。
? 基于Flutter的Widget,還可以拓展更多個(gè)性化的組件,比如內(nèi)置動(dòng)畫組件,就可以動(dòng)態(tài)化下發(fā)動(dòng)畫了,更多好玩的東西等待大家來(lái)一起探索。
原文鏈接
本文為云棲社區(qū)原創(chuàng)內(nèi)容,未經(jīng)允許不得轉(zhuǎn)載。
總結(jié)
以上是生活随笔為你收集整理的打通前后端逻辑,客户端Flutter代码一天上线的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 2019五个最棒的机器学习课程
- 下一篇: 如何“神还原”数据中心? 阿里联合NTU