Flutter For Web多端一体化开发和原理分析
Flutter For Web多端一体化开发和原理分析 https://mp.weixin.qq.com/s/rxapVTJIGfnqCNzB3S7C4Q
Flutter For Web多端一体化开发和原理分析
一、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 Web
Flutter 2.0之前的版本由于web还处于beta通道,需要通过:
flutter channel beta
将本地的Flutter版本切换到beta之后才可以运行在web上。
本文以Flutter 2.2.2版本为例,如果想让自己的Flutter代码运行在浏览器上,Flutter 2.0以后新创建的工程是默认支持web的,如果你的工程使用老版本创建的,需要执行:
flutter create .
来支持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()方法中。主要代码如下:
Future load(Uri resolvedUri) {
[省略部分代码]
initializedCompilerState = fe.initializeCompiler(
initializedCompilerState,
target,
_options.librariesSpecificationUri,
dependencies,
_options.packageConfig,
explicitExperimentalFlags: _options.explicitExperimentalFlags,
nnbdMode: _options.useLegacySubtyping
? fe.NnbdMode.Weak
: fe.NnbdMode.Strong,
invocationModes: _options.cfeInvocationModes,
verbosity: verbosity);
component = await fe.compile(initializedCompilerState, verbose,
fileSystem, onDiagnostic, resolvedUri);
[省略部分代码]
api.BinaryOutputSink dillOutput =
_compilerOutput.createBinarySink(_options.outputUri);
BinaryOutputSinkAdapter irSink =
new BinaryOutputSinkAdapter(dillOutput);
BinaryPrinter printer = new BinaryPrinter(irSink);
printer.writeComponentFile(component);
[省略部分代码]
}
前端编译主要分为两步,第一步通过dart2js的compile方法生成Component,第二步是将component写入文件。Component是代码静态语法树的根节点,通过对Component进行遍历,可以找到app中所有的Library,Library中包含了库中定义的所有的方法节点、变量节点等。
在compile方法中最终会调用到kernel_target.dart中的buildComponent()方法,该方法的实现如下:
Future buildComponent({bool verify: false}) async {
if (loader.first == null) return null;
return withCrashReporting(() async {
ticker.logMs("Building component");
await loader.buildBodies();
finishClonedParameters();
loader.finishDeferredLoadTearoffs();
loader.finishNoSuchMethodForwarders();
List myClasses = collectMyClasses();
loader.finishNativeMethods();
loader.finishPatchMethods();
finishAllConstructors(myClasses);
runBuildTransformations();
if (verify) this.verify();
installAllComponentProblems(loader.allComponentProblems);
return component;
}, () => 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.txt
2.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的后端编译主要包括以下代码:
KernelResult result = await kernelLoader.load(uri);
[省略部分代码]
JsClosedWorld closedWorld = selfTask.measureSubtask("computeClosedWorld",
() => computeClosedWorld(rootLibraryUri, libraries));
[省略部分代码]
GlobalTypeInferenceResults globalInferenceResults =
performGlobalTypeInference(closedWorld);
[省略部分代码]
generateJavaScriptCode(globalInferenceResults);
-
首先,编译器会将传入的dill通过BinaryBuilder加载到Component中并存储在KernelResult中;
-
computeClosedWorld()方法会将第一步解析出来的所有Library解析成JsClosedWorld,JsClosedWorld代表了通过closed-world语义编译之后的代码。它的结构如下:
class JsClosedWorld implements JClosedWorld {
static const String tag = 'closed-world';
@override
final NativeData nativeData;
@override
final InterceptorData interceptorData;
@override
final BackendUsage backendUsage;
@override
final NoSuchMethodData noSuchMethodData;
FunctionSet _allFunctions;
final Map<classentity, Set> mixinUses;
Map<classentity, List> _liveMixinUses;
final Map<classentity, Set> typesImplementedBySubclasses;
final Map<classentity, Map> _subtypeCoveredByCache =
<classentity, Map>{};
// TODO(johnniwinther): Can this be derived from [ClassSet]s?
final Set implementedClasses;
final Set liveInstanceMembers;
/// Members that are written either directly or through a setter selector.
final Set assignedInstanceMembers;
@override
final Set liveNativeClasses;
@override
final Set processedMembers;
[省略部分代码]
}
通过传入的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 -sL https://firebase.tools | bash
2. 使用如下命令与Firebase账号进行关联,如已关联可跳过。
firebase login
3. 使用如下命令进行初始化项目目录
firebase init
4. 将Flutter Web的产物复制到上一步初始化的目录中并执行如下命令进行部署
firebase deploy
部署完成后,通过控制台输出的URL就可以访问Flutter Web页面部署的地址。
2.4 Service Worker
Flutter 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;
}
var origin = self.location.origin;
var key = event.request.url.substring(origin.length + 1);
// Redirect URLs to the index.html
if (key.indexOf('?v=') != -1) {
key = key.split('?v=')[0];
}
if (event.request.url == origin || event.request.url.startsWith(origin + '/#') || key == '') {
key = '/';
}
// If the URL is not the RESOURCE list then return to signal that the
// browser should take over.
if (!RESOURCES[key]) {
return;
}
// If the URL is the index.html, perform an online-first request.
if (key == '/') {
return onlineFirst(event);
}
event.respondWith(caches.open(CACHE_NAME)
.then((cache) => {
return cache.match(event.request).then((response) => {
// Either respond with the cached resource, or perform a fetch and
// lazily populate the cache.
return response || fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
})
})
);
});
其中RESOURCES默认缓存了我们App中使用到的资源,当去拉取这些资源的时候,会默认返回缓存中的资源,当没有命中缓存再去请求网络资源。
2.5 渲染
2.5.1 CanvasKit和HTML
Flutter for Web默认支持以下两种渲染器:
-
CanvasKit
-
HTML
默认情况下,当app运行在手机浏览器中时会以HTML模式渲染,运行在桌面浏览器时将会使用CanvasKit进行渲染。
如果要指定渲染模式,在编译时可以指定--web-renderer参数为html或者canvaskit。如:
flutter build web --web-renderer html
CanvasKit和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.Image image, ui.Offset p, SurfacePaintData paint) {
[省略部分代码]
imgElement = _reuseOrCreateImage(htmlImage);
[省略部分代码]
final String cssTransform = float64ListToCssTransform(
transformWithOffset(_canvasPool.currentTransform, p).storage);
imgElement.style
..transformOrigin = '0 0 0'
..transform = cssTransform
..removeProperty('width')
..removeProperty('height');
rootElement.append(imgElement);
_children.add(imgElement);
return imgElement;
}
方法中会创建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中有以下代码:
void main() {
runApp(LessonsApp());
}
class LessonsApp extends StatefulWidget {
@override
StatecreateState() => _LessonsAppState();
}
class _LessonsAppState extends State{
LessonRouterDelegate _routerDelegate = LessonRouterDelegate();
LessonRouteInformationParser _routeInformationParser =
LessonRouteInformationParser();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Lessons App',
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
);
}
}
我们在App的根Widget中使用MaterialApp.router()指定了routerDelegate和routeInformationParser。
其中LessonRouteInformationParser的实现如下:
class LessonRouteInformationParser
extends RouteInformationParser{
//解析URL
@override
FutureparseRouteInformation(
RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location);
// Handle '/'
if (uri.pathSegments.length == 0) {
return LessonRoutePath.home();
}
if (uri.pathSegments.length == 2) {
if (uri.pathSegments[0] != 'lesson') return LessonRoutePath.unknown();
var remaining = uri.pathSegments[1];
var id = int.tryParse(remaining).toString();
if (id == null) return LessonRoutePath.unknown();
return LessonRoutePath.details(id);
}
// Handle unknown routes
return LessonRoutePath.unknown();
}
我们实现了parseRouteInformation()方法来对route解析,其中LessonRoutePath是我们定义的类来存储route的信息。
LessonRouterDelegate的实现如下:
class LessonRouterDelegate extends RouterDelegate
with ChangeNotifier, PopNavigatorRouterDelegateMixin{
final GlobalKeynavigatorKey;
Lesson _selectedLesson;
bool show404 = false;
LessonRouterDelegate() : navigatorKey = GlobalKey();
//置空后导航栏url就没有了
LessonRoutePath get currentConfiguration {
if (show404) {
return LessonRoutePath.unknown();
}
return _selectedLesson == null
? LessonRoutePath.home()
: LessonRoutePath.details(_selectedLesson.id);
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
//影响页面顺序和返回按钮
MaterialPage(
key: ValueKey('LessonsListPage'),
child: LearningHistoriesPage(
pushDetail: pushDetial,
),
),
if (show404)
MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
else if (_selectedLesson != null)
MaterialPage(
key: ValueKey('LessonsDetailPage'),
child: LessonDetailsScreen(id: _selectedLesson.id),
),
// LessonDetailsPage(lesson: _selectedLesson)
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
// Update the list of pages by setting _selectedLesson to null
_selectedLesson = null;
show404 = false;
notifyListeners();
return true;
},
);
}
void pushDetial(String id) {
_selectedLesson = Lesson(id);
notifyListeners();
}
}
build()方法中根据不同的状态来控制Navigator中的页面,比如,当用户选择一门课程时就会在pages的最上层添加一个详情的Widget。
通过以上代码实现的效果如下:
我们发现跳转到详情页时导航栏的URL没有更改,我们只需要复写RouteInformationParser的restoreRouteInformation()方法即可。
@override
RouteInformation restoreRouteInformation(LessonRoutePath path) {
if (path.isUnknown) {
return RouteInformation(location: '/404');
}
if (path.isHomePage) {
return RouteInformation(location: '/');
}
if (path.isDetailsPage) {
return RouteInformation(location: '/lesson/${path.id}');
}
return null;
}
如果要实现在地址栏输入URL跳转到相应的页面,需要实现RouterDelegate的setNewRoutePath()方法。
@override
Future<void> setNewRoutePath(LessonRoutePath path) async {
if (path.isUnknown) {
_selectedLesson = null;
show404 = true;
return;
}
if (path.isDetailsPage) {
if (int.parse(path.id) < 0) {
show404 = true;
return;
}
_selectedLesson = Lesson(path.id);
} else {
_selectedLesson = null;
}
show404 = false;
}
这样,我们就实现了一套Flutter for Web的路由,最终的效果如下:
3.2 使用Federated plugin来支持多平台
在客户端的开发中,Flutter官方为我们提供了plugin模板来支持native与Flutter的通信。针对多端一体化的开发,Flutter官方提出了Federated plugin的概念。
Federated plugin支持为不同的平台提供不同的实现,它可以把不同平台的不同实现拆分到不同的包里。而且Federated plugin易于扩展,在不影响原有实现的基础上就可以将你的plugin扩展到新的平台。
比如我们要实现一个同时支持android、iOS和web三端的埋点库plugin。Plugin的yaml文件需要按照下面这种方式定义:
flutter:
plugin:
platforms:
android:
package: com.beike.log
pluginClass: LogPlugin
ios:
pluginClass: LogPlugin
web:
pluginClass: WebLog
fileName: web_log.dart
由上面配置可以看到,android和iOS需要各端单独实现,web则是由一个dart文件实现。
实现过程中我们可以定义一个抽象类来定义我们的埋点库会调用的方法,比如我们的埋点库定一个抽象类Log:
abstract class Log extends PlatformInterface {
[省略部分实现]
static void logEvent(String eventName, Map params) async =>
throw UnimplementedError('logEvent() has not been implemented.');
}
然后各个平台的实现需要去继承这个抽象类并且实现其中的方法,比如在web中我们可以这样实现:
class WebLog extends Log {
static void logEvent(String eventName, Map params) 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) {
return new Promise((resolve, reject) => {
[省略部分实现]
resolve(response);
}
};
为了能够调用到ULOG的send方法,我们可以通过js_util的getProperty()方法来获取到ULOG,然后通过js_util提供的callMethod方法进行方法调用,代码如下:
import 'dart:js_util/js_util.dart' as js_util;
import 'dart:html' as html;
class WebLog extends Log {
static void logEvent(String eventName, Map params) async {
Object ULOG = 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中修改后的代码如下:
class WebLog {
dynamic logEvent(String eventName, Map params) async {
Object ULOG = js_util.getProperty(html.window, 'ULOG');
dynamic promise = js_util.callMethod(ULOG, 'send', [eventName, params]);
if (promise == null) {
return null;
}
Future future = js_util.promiseToFuture(promise);
dynamic ret = await future;
return ret;
}
}
除了dart调用js之外,我们也可能会用到js调用dart。
如下面代码,我们在dart侧定义了bar()方法,我们可以通过调用js_util 的setProperty ()方法将bar()设置为window的一个属性foo,当js调用window的foo时就会调用到bar()方法。
class Hello {
static exampleMethod() {
if (js_util.hasProperty(html.window, 'foo') == false) {
js_util.setProperty(html.window, "foo", js.allowInterop(bar()));
}
}
static bar() {
print('Calling function bar');
}
}
四、总结
本文首先讲述了Flutter for Web的发展现状和应用场景。然后从配置、编译、部署及渲染分别进行了介绍。然后结合埋点库的例子一起学习了如何开发Federated plugin,完成js与dart的互调。
在多端一体化的探索中,贝壳找房Flutter团队还做了加载优化、性能优化、性能监控等工作,将在后续的文章中与大家分享。