01
背景與動機
在Navigator 2.0
推出之前,Flutter
主要通過Navigator 1.0
和其提供的 API(如push()
,?pop()
,?pushNamed()
等)來管理頁面路由。然而,Navigator 1.0
存在一些局限性,如難以實現復雜的頁面操作(如移除棧內中間頁面、交換頁面等)、不支持嵌套路由以及無法滿足全平臺(尤其是Web
平臺)的新需求。因此,Flutter
官方團隊決定對路由系統進行改造,推出了Navigator 2.0
。?
02
主要特性
聲明式API?
Navigator 2.0
提供的聲明式API
使得路由管理更加直觀和易于理解。開發者只需聲明頁面的配置信息,而無需編寫復雜的導航邏輯代碼。這種方式不僅減少了代碼量,還提高了代碼的可讀性和可維護性。嵌套路由?
Navigator 2.0
滿足了嵌套路由的需求場景,允許開發者在應用中創建嵌套的路由結構。這使得應用的結構更加清晰,同時也提高了頁面導航的靈活性。全平臺支持?
Navigator 2.0
提供的API
能夠滿足不同平臺(如iOS
、Android
、Web
等)的導航需求,使得開發者能夠更加方便地構建跨平臺的應用。強大的頁面操作能力?
Navigator 2.0
提供了更加豐富的頁面操作能力,如移除棧內中間頁面、交換頁面等。這些操作在Navigator 1.0
中很難實現或需要編寫復雜的代碼,而在Navigator 2.0
中則變得簡單直接。
03
核心組件
Router?在
Navigator 2.0
中,Router
組件是路由管理的核心。它負責根據當前的路由信息(RouteInformation
)和路由信息解析器(RouteInformationParser
)來構建和更新UI
。Router
組件接收三個主要參數:1.routeInformationProvider:提供當前的路由信息;
2.routeInformationParser:將路由信息解析為路由配置;
3.routerDelegate:根據路由配置構建和更新
UI
。RouteInformationProvider?
RouteInformationProvider
是一個提供當前路由信息的組件。它通常與平臺相關的路由信息源(如瀏覽器的URL
、Android
的Intent
等)集成,以獲取當前的路由信息。RouteInformationParser?
RouteInformationParser
負責將RouteInformation
解析為RouteConfiguration
。這個過程允許開發者根據路由信息的格式(如URL
)來定義如何將其映射到應用內的路由配置。RouterDelegate?
RouterDelegate
是與UI
構建緊密相關的組件。它必須實現RouterDelegate
接口,并提供兩個主要方法:?1.build(BuildContext context):根據當前的路由配置構建
UI
;2.setNewRoutePath(List?configuration):設置新的路由路徑,并更新
UI
;3.Future?popRoute() :實現后退邏輯。
04
簡單實例
首先通過MaterialApp.router()
來創建MaterialApp
:
class MyApp extends StatelessWidget?{??@override??Widget build(BuildContext context)?{??final routerDelegate?=?MyRouterDelegate();??final routeInformationParser?=?MyRouteInformationParser();??return?MaterialApp.router(??title:?'Flutter Navigator 2.0 Demo',??theme:?ThemeData(??primarySwatch:?Colors.blue,??),??routerDelegate:?routerDelegate,??routeInformationParser:?routeInformationParser,??);??}??
}
需要定義一個RouterDelegate
對象和一個RouteInformationParser
對象。其中根據路由配置構建和更新UI
,RouteInformationParser
負責將RouteInformation
解析為RouteConfiguration
。?RouterDelegate
可以傳個泛型,定義其currentConfiguration
對象的類型。
class MyRouterDelegate extends RouterDelegate<String>??with PopNavigatorRouterDelegateMixin<String>,?ChangeNotifier?{??final GlobalKey<NavigatorState>?navigatorKey?=?GlobalKey<NavigatorState>();??private List<String>?_pages?=?['/home'];??@override??Widget build(BuildContext context)?{??return?Navigator(??key:?navigatorKey,??pages:?_pages.map((route)?=>?MaterialPage(??key:?Key(route),??child:?generatePage(route),??)).toList(),??onPopPage:?(route,?result)?{??if?(!route.didPop(result))?{??return?false;??}??_pages.removeLast();??notifyListeners();??return?true;??},??);??}??@override??Future<void>?setNewRoutePath(String path)?async?{??if?(!_pages.contains(path))?{??_pages.add(path);??notifyListeners();??}??}??Widget generatePage(String route)?{??switch?(route)?{??case?'/home':??return?HomePage();??case?'/details':??//?這里可以傳遞參數,例如?DetailsPage(arguments:?someData)??return?DetailsPage();??default:??return?NotFoundPage();??}??}??@override??String get currentConfiguration?=>?_pages.last;??
}
其中build()
一般返回的是一個Navigator
對象,popRoute()
實現后退邏輯,setNewRoutePath()
實現新頁面的邏輯。定義了一個_pages
數組對象,記錄每個路由的path
,可以理解為是一個路由棧,這個路由棧對我們來說非常友好,在有復雜的業務邏輯時,我們可以自行定義相應的棧管理邏輯。currentConfiguration
返回的是棧頂的page
信息。創建一個類繼承RouteInformationParser
,主要的作用是包裝解析路由信息,這里有一個最簡單的方式,如下:
class MyRouteInformationParser extends RouteInformationParser<String>?{??@override??Future<String>?parseRouteInformation(RouteInformation routeInformation)?{??final uri?=?Uri.parse(routeInformation.location);??return?SynchronousFuture(uri.path);??}??@override??RouteInformation restoreRouteInformation(String configuration)?{??return?RouteInformation(location:?configuration);??}??
}
好的,接下來我們看一下調用:
class HomePage extends StatelessWidget?{??@override??Widget build(BuildContext context)?{??return?Scaffold(??appBar:?AppBar(title:?Text('Home')),??body:?Center(??child:?ElevatedButton(??onPressed:?()?{??Router.of(context).routerDelegate.setNewRoutePath("/details");},??child:?Text('Go to Details'),??),??),??);??}??
}??class DetailsPage extends StatelessWidget?{??@override??Widget build(BuildContext context)?{??return?Scaffold(??appBar:?AppBar(title:?Text('Details')),??body:?Center(??child:?Text('This is Details Page'),??),??);??}??
}?class NotFoundPage extends StatelessWidget?{??@override??Widget build(BuildContext context)?{??return?Scaffold(??appBar:?AppBar(title:?Text('Not Found')),??body:?Center(??child:?Text('Page not found'),??),??);??}??
}
非常簡單,直接調用Router.of(context).routerDelegate.setNewRoutePath()
即可。
到此為止,一個使用Navigator2.0
的最簡單的路由實例就完成了。和Navigator1.0
相比,看上去繁雜了不少。但是可以根據業務需求自定義路由棧進行管理,大大的提升了靈活性。接來看我們看一下Navigator2.0
是如何對路由進行實現的。
05
源碼簡析
我們在使用Navigator2.0
時,是通過MaterialApp.router()
創建的MaterialApp
對象,之前章節提到過,傳了RouteInformationParser
和RouterDelegate
這兩個對象。當傳遞了RouterDelegate
對象時,_MaterialAppState
中的_usesRouter
會被設置為true
。
bool get _usesRouter?=>?widget.routerDelegate?!=?null?||?widget.routerConfig?!=?null;
在build()
時,通過WidgetsApp.router()
方法創建了一個WidgetsApp
對象:
if?(_usesRouter)?{return?WidgetsApp.router(key:?GlobalObjectKey(this),routeInformationProvider:?widget.routeInformationProvider,routeInformationParser:?widget.routeInformationParser,routerDelegate:?widget.routerDelegate,routerConfig:?widget.routerConfig,backButtonDispatcher:?widget.backButtonDispatcher,builder:?_materialBuilder,title:?widget.title,onGenerateTitle:?widget.onGenerateTitle,textStyle:?_errorTextStyle,color:?materialColor,locale:?widget.locale,localizationsDelegates:?_localizationsDelegates,localeResolutionCallback:?widget.localeResolutionCallback,localeListResolutionCallback:?widget.localeListResolutionCallback,supportedLocales:?widget.supportedLocales,showPerformanceOverlay:?widget.showPerformanceOverlay,checkerboardRasterCacheImages:?widget.checkerboardRasterCacheImages,checkerboardOffscreenLayers:?widget.checkerboardOffscreenLayers,showSemanticsDebugger:?widget.showSemanticsDebugger,debugShowCheckedModeBanner:?widget.debugShowCheckedModeBanner,inspectorSelectButtonBuilder:?_inspectorSelectButtonBuilder,shortcuts:?widget.shortcuts,actions:?widget.actions,restorationScopeId:?widget.restorationScopeId,);}
在_WidgetsAppState
中根據routerDelegate
設置了成員變量_usesRouterWithDelegates
的值:
bool get _usesRouterWithDelegates?=>?widget.routerDelegate?!=?null;
在build()
時會創建一個Router
對象,其中Router
繼承了StatefulWidget
:
@overrideWidget build(BuildContext context)?{Widget??routing;if?(_usesRouterWithDelegates)?{routing?=?Router<Object>(restorationScopeId:?'router',routeInformationProvider:?_effectiveRouteInformationProvider,routeInformationParser:?widget.routeInformationParser,routerDelegate:?widget.routerDelegate!,backButtonDispatcher:?_effectiveBackButtonDispatcher,);}?
......}
在上一章節的實例中我們可得知,頁面的切換都是依靠RouterDelegate
對象進行的。每當切換到新的頁面時,都會調用setNewRoutePath()
方法,因此我們來看一下setNewRoutePath()
是什么時候被調用的,有兩處。第一處:
void?_handleRouteInformationProviderNotification()?{_routeParsePending?=?true;_processRouteInformation(widget.routeInformationProvider!.value,?()?=>?widget.routerDelegate.setNewRoutePath);}
_RouteSetter<T>?_processParsedRouteInformation(Object??transaction,?ValueGetter<_RouteSetter<T>>?delegateRouteSetter)?{return?(T data)?async?{if?(_currentRouterTransaction?!=?transaction)?{return;}await delegateRouteSetter()(data);if?(_currentRouterTransaction?==?transaction)?{_rebuild();}};}
我們看看_handleRouteInformationProviderNotification
的調用時機:
@overridevoid?initState()?{super.initState();widget.routeInformationProvider?.addListener(_handleRouteInformationProviderNotification);widget.backButtonDispatcher?.addCallback(_handleBackButtonDispatcherNotification);widget.routerDelegate.addListener(_handleRouterDelegateNotification);}
我們可以看到在initState()
時,也就是在Router
被初始化的時候由widget.routeInformationProvider
來監聽一些狀態實現新頁面的切換。我們來看一下routeInformationProvider
。RouteInformationProvider
在我們自己沒有創建的情況下,系統會默認為我們創建一個PlatformRouteInformationProvider
對象。它實際上是個ChangeNotifier
。系統會監聽每一幀的信號發送,調用其父類routerReportsNewRouteInformation()
方法,我們看看它的實現:
@overridevoid routerReportsNewRouteInformation(RouteInformation routeInformation,?{RouteInformationReportingType?type?=?RouteInformationReportingType.none})?{final bool replace?=type?==?RouteInformationReportingType.neglect?||(type?==?RouteInformationReportingType.none?&&_equals(_valueInEngine.uri,?routeInformation.uri));SystemNavigator.selectMultiEntryHistory();SystemNavigator.routeInformationUpdated(uri:?routeInformation.uri,state:?routeInformation.state,replace:?replace,);_value?=?routeInformation;_valueInEngine?=?routeInformation;}
其中SystemNavigator.selectMultiEntryHistory()
的實現如下:
///?Selects the multiple-entry?history?mode.//////?On web,?this switches the browser?history?model to one that tracks all///?updates to?[routeInformationUpdated]?to form a?history?stack.?This is the///?default.//////?Currently,?this is ignored on other platforms.//////?See also://////??*?[selectSingleEntryHistory],?which?forces the?history?to only have one///????entry.static Future<void>?selectMultiEntryHistory()?{return?SystemChannels.navigation.invokeMethod<void>('selectMultiEntryHistory');}
這個方法是由各個平臺自行實現的。從注釋中我們可得知如果是在Web
平臺下,它會切換成history
模式,并從history stack
中追蹤所有的變化。在history
發生變化時,會發送信號給Flutter
層等待處理。SystemNavigator.routeInformationUpdated()
方法是用來更新路由的,我們先不做分析。接著我們回到PlatformRouteInformationProvider
,看看它什么時候會執行notifyListeners()
方法:
@overrideFuture<bool>?didPushRouteInformation(RouteInformation routeInformation)?async?{assert(hasListeners);_platformReportsNewRouteInformation(routeInformation);return?true;}
void _platformReportsNewRouteInformation(RouteInformation routeInformation)?{if?(_value?==?routeInformation)?{return;}_value?=?routeInformation;_valueInEngine?=?routeInformation;notifyListeners();}
在監聽到有push
路由的情況下時,會調用notifyListeners()
,從而實現頁面的切換。我們再來看第二處調用setNewRoutePath()
的地方:
@overridevoid?didChangeDependencies()?{_routeParsePending?=?true;super.didChangeDependencies();//?The super.didChangeDependencies may have parsed the route information.//?This can happen?if?the didChangeDependencies is triggered by state//?restoration or first build.if?(widget.routeInformationProvider?!=?null?&&?_routeParsePending)?{_processRouteInformation(widget.routeInformationProvider!.value,?()?=>?widget.routerDelegate.setNewRoutePath);}_routeParsePending?=?false;_maybeNeedToReportRouteInformation();}
void _processRouteInformation(RouteInformation information,?ValueGetter<_RouteSetter<T>>?delegateRouteSetter)?{assert(_routeParsePending);_routeParsePending?=?false;_currentRouterTransaction?=?Object();widget.routeInformationParser!.parseRouteInformationWithDependencies(information,?context).then<void>(_processParsedRouteInformation(_currentRouterTransaction,?delegateRouteSetter));}
parseRouteInformationWithDependencies()
方法中調用的parseRouteInformation()
其實就是我們自定義RouteInformationParser
來進行的實現。
Future<T>?parseRouteInformationWithDependencies(RouteInformation routeInformation,?BuildContext context)?{return?parseRouteInformation(routeInformation);}
看到當其與父的依賴關系被改變的時候會調用setNewRoutePath()
。大概率就是App
初始化的時候被調用一次。
06
根據狐友業務的Web端實踐?
我們的Flutter
團隊會承擔一些運營活動的H5
需求。在實現時我們對路由有如下需求:
1.可以根據業務自由的管理路由棧;
2.分享鏈接只能分享出去默認入口鏈接,不希望中間的路由鏈接被分享出去;
3.不管有多少個路由頁面,history
始終不變,在響應瀏覽器返回鍵時不響應路由棧的pop
操作。
在之前使用Navigator1.0
時體驗并不太好,一個是不夠靈活,另外還需對分享出去的鏈接做處理。因此我們利用Navigator2.0
設計了一套新的路由:
MyRouterDelegate delegate?=?MyRouterDelegate();@overrideWidget build(BuildContext context)?{return?MaterialApp.router(debugShowCheckedModeBanner:?false,routeInformationParser:?MyRouteParser(),routerDelegate:?delegate,);}
Parser
實現非常簡單:
class MyRouteParser extends RouteInformationParser<RouteSettings>?{@override///parseRouteInformation()?方法的作用就是接受系統傳遞給我們的路由信息?routeInformationFuture<RouteSettings>?parseRouteInformation(RouteInformation routeInformation)?{//?Uri uri?=?Uri.parse(routeInformation.location??"/");return?SynchronousFuture(RouteSettings(name:?routeInformation.location));}@override///恢復路由信息RouteInformation restoreRouteInformation(RouteSettings configuration)?{return?RouteInformation(location:?configuration.name);}
}
Delegate
的實現如下:
import?'package:ai_chatchallenge/router/exit_util.dart';
import?'package:ai_chatchallenge/router/navigator_util.dart';
import?'package:ai_chatchallenge/router/my_router_arg.dart';
import?'package:flutter/material.dart';import?'route_page_config.dart';class MyRouterDelegate extends RouterDelegate<RouteSettings>with PopNavigatorRouterDelegateMixin<RouteSettings>,?ChangeNotifier?{///頁面棧List<Page>?_stack?=?[];//當前的界面信息RouteSettings _setting?=?RouteSettings(name:?RouterName.rootPage,arguments:?BaseArgument()..name?=?RouterName.rootPage);//重寫navigatorKey@overrideGlobalKey<NavigatorState>?navigatorKey;MyRouterDelegate()?:?navigatorKey?=?GlobalKey<NavigatorState>()?{//初始化兩個方法?一個是push頁面?另一個是替換頁面NavigatorUtil().registerRouteJump(RouteJumpFunction(onJumpTo:?(RouteSettings setting)?{//?_setting?=?setting;//?changePage();addPage(name:?setting.name,?arguments:?setting.arguments);},?onReplaceAndJumpTo:?(RouteSettings setting)?{if?(_stack.isNotEmpty)?{_stack.removeLast();}_setting?=?setting;changePage();},?onClearStack:?()?{_stack.clear();_setting?=?RouteSettings(name:?RouterName.rootPage,arguments:?BaseArgument()..name?=?RouterName.rootPage);changePage();},?onBack:?()?{if?(_stack.isNotEmpty)?{_stack.removeLast();if?(_stack.isNotEmpty)?{_setting?=?_stack.last;}?else?{_setting?=?RouteSettings(name:?RouterName.rootPage,arguments:?BaseArgument()..name?=?RouterName.rootPage);}changePage();}}));}@overrideRouteSettings??get currentConfiguration?{return?_stack.last;}@overrideFuture<bool>?popRoute()?{if?(_stack.length?>?1)?{_stack.removeLast();_setting?=?_stack.last;changePage();//非最后一個頁面return?Future.value(true);}//最后一個頁面確認退出操作return?_confirmExit();}Future<bool>?_confirmExit()?async?{bool result?=?ExitUtil.doubleCheckExit(navigatorKey.currentContext!);//?bool result?=?await ExitUtil.backToDesktop();return?!result;}void addPage({required name,?arguments})?{_setting?=?RouteSettings(name:?name,?arguments:?arguments);changePage();}@overrideWidget build(BuildContext context)?{return?WillPopScope(//解決物理返回建無效的問題onWillPop:?()?async?=>?!await navigatorKey.currentState!.maybePop(),child:?Navigator(key:?navigatorKey,pages:?_stack,onPopPage:?_onPopPage,),);}///?按下返回的回調bool _onPopPage(Route<dynamic>?route,?dynamic result)?{debugPrint("這里的試試");if?(!route.didPop(result))?{return?false;}return?true;}changePage()?{int index?=?getCurrentIndex(_stack,?_setting!);List<Page>?tempPages?=?_stack;if?(index?!=?-1)?{//?要求棧中只允許有一個同樣的頁面的實例?否則開發模式熱更新會報錯//?要打開的頁面在棧中已存在,則將該頁面和它上面的所有頁面進行出棧tempPages?=?tempPages.sublist(0,?index);//?或者刪除之前存在棧里的頁面,重新創建//?tempPages.removeAt(index);}Page page;if?(_setting?.arguments is BaseArgument)?{if?((_setting?.arguments as BaseArgument).name?==?RouterName.rootPage)?{_stack.clear();}}?else?{if?(_setting?.name?==?RouterName.rootPage)?{_stack.clear();}}page?=?buildPage(name:?_setting?.name,?arguments:?_setting?.arguments);tempPages?=?[...tempPages,?page];NavigatorUtil().notify(tempPages,?_stack);_stack?=?tempPages;notifyListeners();}@overrideFuture<void>?setInitialRoutePath(RouteSettings configuration)?{return?super.setInitialRoutePath(_setting);}@overrideFuture<void>?setNewRoutePath(RouteSettings configuration)?async?{if?(configuration.arguments is BaseArgument)?{if?((configuration.arguments as BaseArgument).name?==RouterName.rootPage)?{_stack.clear();}}?else?{if?(configuration.name?==?RouterName.rootPage)?{_stack.clear();}}addPage(name:?configuration.name,?arguments:?configuration.arguments);}
}
其中_stack
是我們的路由棧,_setting
是RouteSettings
,每執行一個新的路由跳轉,都會創建一個RouteSettings
對象并賦值給_setting
,最終在插入_stack
里。buildPage()
的實現如下:
//建造頁面
buildPage({required name,?arguments})?{return?MaterialPage(child:?getPageChild(name:?name,?arguments:?arguments),arguments:?arguments,name:?name,key:?ValueKey(arguments is BaseArgument???(arguments as BaseArgument).name?:?name));
}
其中MaterialPage
繼承了Page
。getPageChild()
實現如下:
Widget getPageChild({required name,?arguments})?{Widget page;Map??arg;if?(arguments is Map)?{arg?=?arguments;}if?(arguments is BaseArgument)?{switch?((arguments as BaseArgument).name)?{case?RouterName.rootPage:page?=?TestHomePage();break;case?RouterName.testChild1Page:page?=?TestChildPage1(argument:?arguments.arguments as TestChild1PageArgument,);break;case?RouterName.testChild2Page:page?=?TestChildPage2();break;default:page?=?TestHomePage();}}?else?{page?=?TestHomePage();}return?page;
}class RouterName?{static const rootPage?=?"/";static const testChild1Page?=?"/testChild1Page";static const testChild2Page?=?"/testChild2Page";
}
我們可以看到,在真正返回Widget
時,我們并沒有使用傳入的name
參數,而是BaseArgument
的name
參數,這是為什么呢?這是在于我們為了實現無論頁面怎么跳轉,從頭到尾瀏覽器只保留一個history
,因此我們在頁面跳轉時RouteSettings
的name
并不發生變化,通過其arguments
里面的參數變化返回不同的Widget
。這樣在路由跳轉時,其實MaterialPage
由于name
一直會被直接復用,從而不會創建新的MaterialPage
也就不會產生history
。?NavigatorUtil
是由業務調用的,創建跳轉方法的抽象類,提供了onJumpTo()
,onReplaceAndJumpTo()
,onClearStack()
,onBack()
四個方法供業務調用,我們可以看一下onJumpTo()
的實現:
@overridevoid onJumpTo({required name,Object??stackArguments,Map<String,?dynamic>??historyArgMap,BuildContext??context})?{var arg?=?BaseArgument();arg.name?=?name;arg.arguments?=?stackArguments;RouteSettings settings?=RouteSettings(name:?RouterName.rootPage,?arguments:?arg);return?_function!.onJumpTo!(settings);}
可以看到在創建RouteSettings
對象時,name
為RouterName.rootPage
,arg
時由業務傳的真正的跳轉頁面相關的參數。我們看一下業務的調用:
@overrideWidget build(BuildContext context)?{return?Scaffold(body:?Container(child:?Column(children:?[Text("TestHomePage"),Text("history length is?:?"?+?window.history.length.toString()),Text("href:?"?+?WebUtil.get().getWindow().location.href),TextButton(onPressed:?()?{var arg?=?TestChild1PageArgument()..isSuccess?=?"false";NavigatorUtil().onJumpTo(name:?RouterName.testChild1Page,stackArguments:?arg,historyArgMap:?arg.toJson(),context:?context);},child:?Text("Go to TestChildPage1"))],),),);}
@overrideWidget build(BuildContext context)?{return?Scaffold(body:?Container(child:?Column(children:?[Text("TestChildPage1"),Text("history length is?:?"?+?window.history.length.toString()),Text("href:?"?+?WebUtil.get().getWindow().location.href),TextButton(onPressed:?()?{NavigatorUtil().onJumpTo(name:?RouterName.testChild2Page,?context:?context);},child:?Text("Go to TestChildPage2")),TextButton(onPressed:?()?{NavigatorUtil().onBack();},child:?Text("Back to TestHomePage")),],),),);}
@overrideWidget build(BuildContext context)?{return?Scaffold(body:?Container(child:?Column(children:?[Text("TestChildPage2"),Text("history length is?:?"?+?window.history.length.toString()),Text("href:?"?+?WebUtil.get().getWindow().location.href),TextButton(onPressed:?()?{NavigatorUtil().onBack();},child:?Text("Back to TestChild1page")),TextButton(onPressed:?()?{NavigatorUtil().onClearStack();},child:?Text("Back to Root")),],),),);}
我們看一下截圖展示:
在這個過程中href
不會發生變化,history
也不會發生變化,完全符合我們的預期。
07
總結
Flutter
的Navigator 2.0
引入了聲明式的API
,使頁面路由管理更加靈活和強大。相較于Navigator 1.0
,Navigator 2.0
支持更復雜的路由操作,如嵌套路由和動態路由配置。它使用不可變的Page
對象列表來表示路由歷史,與Flutter
的不可變Widgets
設計理念一致。Navigator 2.0
還支持命名路由,通過簡單的路由名稱即可實現頁面跳轉,大大簡化了路由管理的復雜度。此外,它還提供了更豐富的路由回調和狀態管理功能,使開發者能夠更輕松地構建復雜的Flutter
應用。