|
Flutter For Web多端一体化开发和原理分析
Flutter For Web多端一体化开发和原理分析
肖鹏@贝壳找房
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2021年11月25日 20:52
一、Flutter for Web发展现状2019年Google I/O大会上,Google首次在flutter 1.5版本中加入对于web的支持;2021年flutter 2.0版本 web正式进入stable通道。Flutter官方的roadmap中提到2021年在web方向更专注于性能的提升,来证明Flutter在web上也可以提供高性能的体验。Flutter for Web最适合的应用场景是当开发者已经完成Flutter Mobile代码,需要移植到web端。Flutter官方提出的Flutter for Web另外一个使用的场景就是PWA即Progressive Web Application。相对于blog等静态web页面,Flutter for Web更适用于页面元素和交互更加丰富的页面,它也给我们提供了丰富的控件来使用。在Flutter应用方面,Google官方内部正在使用Flutter for Web开发一些网页,包括我们常用的Flutter Dev Tools也是使用Flutter for Web进行开发的。国内除了贝壳找房之外,还有阿里、美团等公司在进行Flutter for Web方向的研究。贝壳找房Flutter团队从2020年开始调研Flutter for Web,并将Flutter for Web运用于客户端容灾降级(见:《Flutter for Web在贝壳找房容灾降级中的应用》)。从2021年开始,我们也开始了Flutter for Web的多端一体化建设,主要包括基础能力补齐、性能优化与监控、构建与部署等。下面结合我们的经验来介绍如何使用Flutter for Web进行多端一体化开发,并对其中的原理进行分析。二、Flutter for Web原理介绍下面我们从接入、编译、部署、渲染等几个方面来看一下如何使用Flutter for Web进行多端一体化开发及其中的原理。2.1 让你的工程支持Flutter for WebFlutter 2.0之前的版本由于web还处于beta通道,需要通过:flutterchannelbeta将本地的Flutter版本切换到beta之后才可以运行在web上。本文以Flutter 2.2.2版本为例,如果想让自己的Flutter代码运行在浏览器上,Flutter 2.0以后新创建的工程是默认支持web的,如果你的工程使用老版本创建的,需要执行:fluttercreate.来支持web。重新打开工程后我们发现工程中多了一个web目录包含以下文件:Web├──favicon.png├──icons│├──Icon-192.png│└──Icon-512.png├──index.html└──manifest.json这些文件最终都会编译到我们的web产物中。添加web的支持后,我们可以将代码运行在Chrome或者启动Web Server在浏览器中访问。当我们调用flutter build web之后,在我们的build目录就会得到如下产物:其中assets文件夹中包含了我们app中的图片,字体等;main.dart.js中包括了所有的Flutter web sdk和我们的业务代码。flutter_service_worker.js.map,浏览器在做source mapping时会用到,我们可以通过--no-source-maps选项来关闭这个文件的创建。那么Flutter编译器是如何将我们写的dart代码编译成js代码的呢?下面我们来了解一下Flutter for Web的两种编译模式。2.2 Flutter for Web的两种编译模式Flutter官方给我们提供了dartdevc和dart2js两个编译器。我们不仅可以将代码直接运行在Chrome浏览器,也可以将Flutter代码编译为js文件部署在服务端。如果代码运行在Chrome浏览器,flutter_tools会使用dartdevc编译器进行编译,dartdevc是支持增量编译的,开发者可以像调试Flutter Mobile代码一样使用hot reload来提升调试效率。Flutter for Web调试也是非常方便的,编译后的代码是默认支持source map,当运行在web浏览器时,开发者是不用关心生成的js代码是怎样的。如下图,开发者可以使用Chrome自带的开发者工具在dart代码中打断点,当执行到相应的js代码时会断到dart代码中。如果需要编译成release产物部署在服务器,需要运行flutter build web命令调用dart2js编译器进行编译。下面我们就以dart2js为例来了解一下整个编译流程是怎样的。2.2.1 Dart2js前后端编译流程首先我们来回顾一下Flutter Mobile的编译流程:Native编译的前端部分会将源码编译成app.dill中间文件,后端编译会将中间文件进一步编译成安卓/iOS的so/framework。Flutter for Web的编译主要通过dart2js来完成,dart2js中包括了web的前端和后端编译,前端编译和native的编译流程类似,都会生成dill中间文件,主要的差异点是使用了不同的dart sdk,并且针对AST做的转换也有所不同;后端编译部分则差异比较大。下面我们来具体看一下Flutter代码是如何被编译成js文件的。2.2.1.1 Dart2js前端编译在调用flutter build web命令后会将项目的main.dart传入编译流程,最终输出的是中间文件app.dill。flutter_tools首先会将传入的参数进行组装,然后调用dart2jsSnapshot。dart2jsSnapshot是dart-sdk中dart2js.dart的快照,我们需要下载dart-sdk来查看相应的源码。dart2js.dart代码的位置在dart-sdk/pkg/compiler/lib/src/dart2js.dart这个路径下。调用dart2jsSnapshot的参数如下:--libraries-spec=/Users/beike/flutter/bin/cache/flutter_web_sdk/libraries.json--native-null-assertions-Ddart.vm.product=true-DFLUTTER_WEB_AUTO_DETECT=true--no-source-maps-o/Users/beike/build_path_to_dill/app.dill--packages=.packages--cfe-only/Users/beike/path_to_main/main.dart--no-source-maps参数就是我们上文提到的是否生成sourcemap的选项;--cfe-only参数代表只完成前端编译,生成kernel文件后就不继续下面的后端编译流程。完整的参数列表我们可以在dart-sdk/pkg/compiler/lib/src/options.dart查看。前端编译的主要逻辑在kernel/loader.dart的load()方法中。主要代码如下:Futureload(UriresolvedUri){[省略部分代码]initializedCompilerState=fe.initializeCompiler(initializedCompilerState,target,_options.librariesSpecificationUri,dependencies,_options.packageConfig,explicitExperimentalFlags:_options.explicitExperimentalFlags,nnbdMode:_options.useLegacySubtypingfe.NnbdMode.Weak:fe.NnbdMode.Strong,invocationModes:_options.cfeInvocationModes,verbosity:verbosity);component=awaitfe.compile(initializedCompilerState,verbose,fileSystem,onDiagnostic,resolvedUri);[省略部分代码]api.BinaryOutputSinkdillOutput=_compilerOutput.createBinarySink(_options.outputUri);BinaryOutputSinkAdapterirSink=newBinaryOutputSinkAdapter(dillOutput);BinaryPrinterprinter=newBinaryPrinter(irSink);printer.writeComponentFile(component);[省略部分代码]}前端编译主要分为两步,第一步通过dart2js的compile方法生成Component,第二步是将component写入文件。Component是代码静态语法树的根节点,通过对Component进行遍历,可以找到app中所有的Library,Library中包含了库中定义的所有的方法节点、变量节点等。在compile方法中最终会调用到kernel_target.dart中的buildComponent()方法,该方法的实现如下:FuturebuildComponent({boolverify:false})async{if(loader.first==null)returnnull;returnwithCrashReporting(()async{ticker.logMs("Buildingcomponent");awaitloader.buildBodies();finishClonedParameters();loader.finishDeferredLoadTearoffs();loader.finishNoSuchMethodForwarders();ListmyClasses=collectMyClasses();loader.finishNativeMethods();loader.finishPatchMethods();finishAllConstructors(myClasses);runBuildTransformations();if(verify)this.verify();installAllComponentProblems(loader.allComponentProblems);returncomponent;},()=>loader.currentUriForCrashReporting);}其中buildBodies()对每一个Library进行词法分析和语法分析,把dart源码中的每一个Library解析保存在Component中;runBuildTransformations()方法是对Component做一些转换主要包括evaluate constants,add constant coverage 和lower value classes,主要是对代码中的常量做处理,对dart中对js的调用做转换等。BinaryPrinter会对Component进行语法树的遍历,将Component中每一个node按照一定格式写入到dill文件。如果想查看dill文件的内容,可以使用dart-sdk/pkg/vm/bin/dump_kernel.dart将dill转化为可读格式。命令如下:/path_to_flutter_SDK/dart-sdk/bin/dart/path_to_dart_SDK/pkg/vm/bin/dump_kernel.dart/ path_to_dill/app.dill/ path_to_output/out.dill.txt2.2.1.2 Dart2js后端编译Dart2js后端编译是将前端编译生成的dill文件通过编译生成js代码。和前端编译一样,首先通过flutter_tools调用到dart2jsSnapshot。调用的参数如下:--libraries-spec=/Users/beike/flutter/bin/cache/flutter_web_sdk/libraries.json--native-null-assertions-Ddart.vm.product=true-DFLUTTER_WEB_AUTO_DETECT=true--no-source-maps-O1-o/Users/beike/path_to_js/main.dart.js/Users/beike/path_to_dill/app.dill其中O1代表优化等级,dart2js支持O0-O4共5中不同的优化,O0代表不做任何优化,包括内联调用优化、运行时调用优化和全局类型推断优化,O4的优化程度最高。通过优化可以减少产物的大小并且优化代码的性能。Dart2js的后端编译主要包括以下代码:KernelResultresult=awaitkernelLoader.load(uri);[省略部分代码]JsClosedWorldclosedWorld=selfTask.measureSubtask("computeClosedWorld",()=>computeClosedWorld(rootLibraryUri,libraries));[省略部分代码]GlobalTypeInferenceResultsglobalInferenceResults=performGlobalTypeInference(closedWorld);[省略部分代码]generateJavaScriptCode(globalInferenceResults);首先,编译器会将传入的dill通过BinaryBuilder加载到Component中并存储在KernelResult中;computeClosedWorld()方法会将第一步解析出来的所有Library解析成JsClosedWorld,JsClosedWorld代表了通过closed-world语义编译之后的代码。它的结构如下:classJsClosedWorldimplementsJClosedWorld{staticconstStringtag='closed-world';@overridefinalNativeDatanativeData;@overridefinalInterceptorDatainterceptorData;@overridefinalBackendUsagebackendUsage;@overridefinalNoSuchMethodDatanoSuchMethodData;FunctionSet_allFunctions;finalMapmixinUses;Map_liveMixinUses;finalMaptypesImplementedBySubclasses;finalMap_subtypeCoveredByCache={};//TODO(johnniwinther):Canthisbederivedfrom[ClassSet]sfinalSetimplementedClasses;finalSetliveInstanceMembers;///Membersthatarewritteneitherdirectlyorthroughasetterselector.finalSetassignedInstanceMembers;@overridefinalSetliveNativeClasses;@overridefinalSetprocessedMembers;[省略部分代码]}通过传入的app入口,也就是main()函数,我们能够知道什么方法被调用,哪些类被初始化,哪些语言特性被使用到等。从结构我们可以看出JsClosedWorld就是用来存储这些信息的。这些信息将决定后续的编译流程如何优化,代码如何生成。然后,对于JsClosedWorld进行代码优化,包括上面代码中的performGlobalTypeInference()等。最终,generateJavaScriptCode()方法会将上边返回的结果通过JSBuilder生成最终的js AST。简单了解了Flutter for Web的编译模式和编译流程之后,下面我们看一下如何部署Flutter for Web产物。2.3 部署flutter build web之后的产物我们可以直接部署到服务上,官方建议的服务包括Firebase Hosting、Github Pages和Google Cloud Hosting等。下面我们以Firebase为例来看下如何部署Flutter Web产物。1. 执行下面命令安装Firebase CLI,如已安装可跳过。curl-sLhttps://firebase.tools|bash2. 使用如下命令与Firebase账号进行关联,如已关联可跳过。firebaselogin3. 使用如下命令进行初始化项目目录firebaseinit4. 将Flutter Web的产物复制到上一步初始化的目录中并执行如下命令进行部署firebasedeploy部署完成后,通过控制台输出的URL就可以访问Flutter Web页面部署的地址。2.4 Service WorkerFlutter for Web默认支持Service worker。如果想禁用Service Worker,在编译时加上--pwa-strategy=none参数即可。Service worker是和JavaScript主线程执行在不同线程的woker,可以拦截和修改资源访问,更细粒度的缓存资源。它的生命周期包括注册、安装和激活,提供了回调方法在这几个生命周期进行一些自定义任务。Service worker提供了message和fetch两个回调方法。Message用于service worker和JavaScript线程进行通信;fetch可以对发出的fetch进行拦截,在拦截方法中实现自己的缓存逻辑。比如我们通过flutter build web命令生成的flutter_service_worker.js中fetch方法的实现如下:self.addEventListener("fetch",(event)=>{if(event.request.method!=='GET'){return;}varorigin=self.location.origin;varkey=event.request.url.substring(origin.length+1);//RedirectURLstotheindex.htmlif(key.indexOf('v=')!=-1){key=key.split('v=')[0];}if(event.request.url==origin||event.request.url.startsWith(origin+'/#')||key==''){key='/';}//IftheURLisnottheRESOURCElistthenreturntosignalthatthe//browsershouldtakeover.if(!RESOURCES[key]){return;}//IftheURListheindex.html,performanonline-firstrequest.if(key=='/'){returnonlineFirst(event);}event.respondWith(caches.open(CACHE_NAME).then((cache)=>{returncache.match(event.request).then((response)=>{//Eitherrespondwiththecachedresource,orperformafetchand//lazilypopulatethecache.returnresponse||fetch(event.request).then((response)=>{cache.put(event.request,response.clone());returnresponse;});})}));});其中RESOURCES默认缓存了我们App中使用到的资源,当去拉取这些资源的时候,会默认返回缓存中的资源,当没有命中缓存再去请求网络资源。2.5 渲染2.5.1 CanvasKit和HTMLFlutter for Web默认支持以下两种渲染器:CanvasKitHTML默认情况下,当app运行在手机浏览器中时会以HTML模式渲染,运行在桌面浏览器时将会使用CanvasKit进行渲染。如果要指定渲染模式,在编译时可以指定--web-renderer参数为html或者canvaskit。如:flutterbuildweb--web-rendererhtmlCanvasKit和HTML渲染器在性能方面也各有优缺点:CanvasKit以WASM为编译目标,使用WebGL进行渲染,有更好的性能,但是在加载时会额外加载一个2MB的wasm文件,这就导致加载时会有较长的等待时间。HTML渲染器使用 HTML,CSS,Canvas 和 SVG 元素进行渲染,相对CanvasKit渲染器,HTML的加载更快。2.5.2 HTML元素生成过程下面我们用一个例子来看下Image是如何被加载出来的。我们在dart代码中定义了一个FadeInImage widget:FadeInImage.assetNetwork(fit:BoxFit.fill,placeholder:this.placeholderPath,image:this.imgUrl,height:75,width:124,);当调度任务调用到handleDrawFrame()方法之后,会调用到BitmapCanvas的drawImage()方法:html.HtmlElement_drawImage(ui.Imageimage,ui.Offsetp,SurfacePaintDatapaint){[省略部分代码]imgElement=_reuseOrCreateImage(htmlImage);[省略部分代码]finalStringcssTransform=float64ListToCssTransform(transformWithOffset(_canvasPool.currentTransform,p).storage);imgElement.style..transformOrigin='000'..transform=cssTransform..removeProperty('width')..removeProperty('height');rootElement.append(imgElement);_children.add(imgElement);returnimgElement;}方法中会创建img元素,然后修改img的css样式,最终将imgElement添加到rootElement,也就是当前的flt-canvas元素中。生成的html如下:三、 Flutter for Web开发技巧3.1 使用Navigation 2.0实现路由Flutter从1.22版本开始支持Navigation 2.0,相对于1.0版本,2.0能够更加灵活的对路由进行操作。对于Flutter Web页面, navigation 2.0能够保持路由状态与浏览器中的URL保持一致,并且能很好的支持浏览器的回退操作。Navigation 2.0各个类之间的交互如下:其中比较重要的两个类是:RouteInformationParser和RouterDelegate。RouteInformationParser负责对route信息进行解析;RouterDelegate负责接收route的变化,然后去rebuild Router并通知listener。下面我们使用navigation 2.0来实现一个列表和跳转的Flutter for Web页面,我们希望能够在路由跳转时更新浏览器的URL,并且能够通过类似于"/lesson/1"的路由跳转到相应的课程页面。首先,我们在main.dart中有以下代码:voidmain(){runApp(LessonsApp());}classLessonsAppextendsStatefulWidget{@overrideStatecreateState()=>_LessonsAppState();}class_LessonsAppStateextendsState{LessonRouterDelegate_routerDelegate=LessonRouterDelegate();LessonRouteInformationParser_routeInformationParser=LessonRouteInformationParser();@overrideWidgetbuild(BuildContextcontext){returnMaterialApp.router(title:'LessonsApp',routerDelegate:_routerDelegate,routeInformationParser:_routeInformationParser,);}}我们在App的根Widget中使用MaterialApp.router()指定了routerDelegate和routeInformationParser。其中LessonRouteInformationParser的实现如下:classLessonRouteInformationParserextendsRouteInformationParser{//解析URL@overrideFutureparseRouteInformation(RouteInformationrouteInformation)async{finaluri=Uri.parse(routeInformation.location);//Handle'/'if(uri.pathSegments.length==0){returnLessonRoutePath.home();}if(uri.pathSegments.length==2){if(uri.pathSegments[0]!='lesson')returnLessonRoutePath.unknown();varremaining=uri.pathSegments[1];varid=int.tryParse(remaining).toString();if(id==null)returnLessonRoutePath.unknown();returnLessonRoutePath.details(id);}//HandleunknownroutesreturnLessonRoutePath.unknown();}我们实现了parseRouteInformation()方法来对route解析,其中LessonRoutePath是我们定义的类来存储route的信息。LessonRouterDelegate的实现如下:classLessonRouterDelegateextendsRouterDelegatewithChangeNotifier,PopNavigatorRouterDelegateMixin{finalGlobalKeynavigatorKey;Lesson_selectedLesson;boolshow404=false;LessonRouterDelegate():navigatorKey=GlobalKey();//置空后导航栏url就没有了LessonRoutePathgetcurrentConfiguration{if(show404){returnLessonRoutePath.unknown();}return_selectedLesson==nullLessonRoutePath.home()essonRoutePath.details(_selectedLesson.id);}@overrideWidgetbuild(BuildContextcontext){returnNavigator(key:navigatorKey,pages:[//影响页面顺序和返回按钮MaterialPage(key:ValueKey('LessonsListPage'),childearningHistoriesPage(pushDetail:pushDetial,),),if(show404)MaterialPage(key:ValueKey('UnknownPage'),child:UnknownScreen())elseif(_selectedLesson!=null)MaterialPage(key:ValueKey('LessonsDetailPage'),childessonDetailsScreen(id:_selectedLesson.id),),//LessonDetailsPage(lesson:_selectedLesson)],onPopPageroute,result){if(!route.didPop(result)){returnfalse;}//Updatethelistofpagesbysetting_selectedLessontonull_selectedLesson=null;show404=false;notifyListeners();returntrue;},);}voidpushDetial(Stringid){_selectedLesson=Lesson(id);notifyListeners();}}build()方法中根据不同的状态来控制Navigator中的页面,比如,当用户选择一门课程时就会在pages的最上层添加一个的Widget。通过以上代码实现的效果如下:我们发现跳转到页时导航栏的URL没有更改,我们只需要复写RouteInformationParser的restoreRouteInformation()方法即可。@overrideRouteInformationrestoreRouteInformation(LessonRoutePathpath){if(path.isUnknown){returnRouteInformation(location:'/404');}if(path.isHomePage){returnRouteInformation(location:'/');}if(path.isDetailsPage){returnRouteInformation(location:'/lesson/${path.id}');}returnnull;}如果要实现在地址栏输入URL跳转到相应的页面,需要实现RouterDelegate的setNewRoutePath()方法。@overrideFuturesetNewRoutePath(LessonRoutePathpath)async{if(path.isUnknown){_selectedLesson=null;show404=true;return;}if(path.isDetailsPage){if(int.parse(path.id)throwUnimplementedError('logEvent()hasnotbeenimplemented.');}然后各个平台的实现需要去继承这个抽象类并且实现其中的方法,比如在web中我们可以这样实现:classWebLogextendsLog{staticvoidlogEvent(StringeventName,Mapparams)async{[省略部分实现]}}这样当我们需要调用埋点方法的时候直接调用logEvent()方法的就可以,不用像下面代码一样根据平台去做判断调用各自的实现。if(kIsWeb){[调用web实现]}else{[调用native实现]}在定义了以上plugin以后,现在我们来看如何实现我们的web_log.dart。3.3 Dart与JavaScript互调Dart为我们提供了dart:js库来使dart和js交互。使用dart:js库我们可以在dart代码中创建js的实例,调用js代码,读写js中对象的属性等。我们在埋点库建设中遇到了一个问题,对于native的埋点,我们可以通过Flutter的platform channel来调用native原有的埋点能力,web端我们也有web团队开发的比较成熟的埋点库,那如何去复用这种能力呢?这时候我们就用到了dart与js的互调能力。我们web端原有的埋点库有以下方法来向服务器上报埋点:window.ULOG.send=function(evtid,param){returnnewPromise((resolve,reject)=>{[省略部分实现]resolve(response);}};为了能够调用到ULOG的send方法,我们可以通过js_util的getProperty()方法来获取到ULOG,然后通过js_util提供的callMethod方法进行方法调用,代码如下:import'dart:js_util/js_util.dart'asjs_util;import'dart:html'ashtml;classWebLogextendsLog{staticvoidlogEvent(StringeventName,Mapparams)async{ObjectULOG=js_util.getProperty(html.window,'ULOG');js_util.callMethod(ULOG,'send',[eventName,params]);}}这样我们就可以调用到我们js中原有的ULOG.send()方法。我们的web埋点库除了负责将请求发出去之外,还需要关心请求返回的数据,这时候就使用到了js_util中的promiseToFuture,promiseToFuture可以将js中的promise转换为dart中的future,并接收返回的值。我们WebLog中修改后的代码如下:classWebLog{dynamiclogEvent(StringeventName,Mapparams)async{ObjectULOG=js_util.getProperty(html.window,'ULOG');dynamicpromise=js_util.callMethod(ULOG,'send',[eventName,params]);if(promise==null){returnnull;}Futurefuture=js_util.promiseToFuture(promise);dynamicret=awaitfuture;returnret;}}除了dart调用js之外,我们也可能会用到js调用dart。如下面代码,我们在dart侧定义了bar()方法,我们可以通过调用js_util 的setProperty ()方法将bar()设置为window的一个属性foo,当js调用window的foo时就会调用到bar()方法。classHello{staticexampleMethod(){if(js_util.hasProperty(html.window,'foo')==false){js_util.setProperty(html.window,"foo",js.allowInterop(bar()));}}staticbar(){print('Callingfunctionbar');}}四、总结本文首先讲述了Flutter for Web的发展现状和应用场景。然后从配置、编译、部署及渲染分别进行了介绍。然后结合埋点库的例子一起学习了如何开发Federated plugin,完成js与dart的互调。在多端一体化的探索中,贝壳找房Flutter团队还做了加载优化、性能优化、性能监控等工作,将在后续的文章中与大家分享。
预览时标签不可点
Flutter15移动端37大前端69Flutter · 目录#Flutter上一篇Flutter Navigator局部页面切换实践下一篇贝壳Flutter组件库 — Bruno正式开源!关闭更多小程序广告搜索「undefined」网络结果
|
|