一個 Demo 入門 Flutter
Flutter 是 Google 研發的一套移動端開發框架,也是 Google 正在研發的下一代作業系統 Fuchsia 的 App 開發框架(Web 和 Desktop 也都在進行積極的嘗試),前幾天剛釋出了 1.0 正式版。關於 Flutter 的原理和介紹可以參考美團的ofollow,noindex" target="_blank">這篇文章 。
本文希望通過一個 Demo 來更深入地瞭解 Flutter 的佈局、狀態管理等細節。這個 Demo 可以獲取豆瓣的 Top 250 電影,以自定義列表形式呈現,可以收藏/取消收藏,可以點選檢視詳情頁。
原始碼:https://github.com/lzyy/flutter-demo-topmovies
360x640/uoeUgoq2-EigTzi7.mp4?tag=6" rel="nofollow,noindex" target="_blank">這裡 可以檢視最終的效果。
目錄劃分
接到這個需求後首先要考慮的是目錄結構應該怎麼劃分,這也是架構的一部分,我是這麼分的:
1 2 3 4 5 6 7 8 blocs/ widgets/ models/ pages/ routes/ services/ main.dart env.dart
BLoC
BLoC是 Business Logic of Components 的縮寫,也就是負責所有業務邏輯的,跟ViewModel的職能挺像的。是一個純潔的 Dart 類,跟 UI 完全解耦,更加詳細的說明可以參見這篇文章
Widgets
這個目錄下面放的是所有的 Widget,Widget 位於 Flutter Framework 的最上層,用來描述 UI 元素的 Layout / Animation 等,或者非 UI 元素(如 DataProvider),最終這些 Widget 會被組裝起來形成 Page。
Models
服務端的 JSON 過來後,需要轉換成對應的 Model 方便使用,Model 不需要包含業務邏輯,但可以有一些簡單的 Model 相關的操作,比如 CRUD。
Pages
通常一個 App 會有多個頁面組成,這些頁面就可以放到這個目錄下。
Routes
雖然 Flutter 也支援直接 push 一個 Widget,但這樣不方便頁面管理,對於像「外部的 URL 可以直接跳轉到某個頁面」這樣的功能處理起來也會麻煩些。因此,需要有一個地方可以對 URL 進行註冊。
Services
這個是放 Remote API 相關的,理論上來說,加一層 Repository 抽象會更加合適,出於方便,就去掉了這一層。
main.dart
入口檔案,用來初始化 App
env.dart
用來設定一些環境變數,類似於 Config。
設定好了目錄後,接下來進行任務分解,首先要完成的就是佈局,進行佈局之前需要有資料來源,方便模擬,最好跟正式介面一致,那就先來完成這一項工作。
設定資料介面
我們希望從模擬環境到真實環境只需改一個配置即可,為了達到這個目的,我們先把協議定好,到時只要換一個實現就行。這裡會用到兩個介面
1 2 3 4 5 6 import 'package:topmovies/models/movie.dart'; abstract class API { Future<MovieEnvelope> getMovieList(int start); Future<Movie> getMovie(String movieID); }
然後新建一個 Mock API 類來實現這個介面
1 2 3 4 5 6 7 8 9 10 11 12 class MockAPI extends API { @override Future<Movie> getMovie(String movieID) { return createMockMovieWithTitle('我是電影 $movieID'); } @override Future<MovieEnvelope> getMovieList(int start) { // 現在還用不著 return null; } }
然後在env.dart裡新建一個 Env 類
1 2 3 4 5 import 'services/api.dart'; class Env { static API apiClient; }
其實就是提供一個全域性的 apiClient 注入介面,App 初始化時,設定好 apiClient,使用時不需要關心是哪個 apiClient 例項,這樣也方便單元測試。
經過這兩步後,資料介面就準備好了,接下來需要設定 BLoC。
設定首頁的 BLoC
BLoC 其實就是 ViewModel,有了 API 實現之後,接下來就要讓 Widget 可以用上這些資料,這就是 BLoC 做的事。
Widget 告訴 BLoC 發生了什麼,BLoC 告訴 Widget 哪些資料更新了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class MoviesBloc extends BlocBase { final BehaviorSubject<MovieEnvelope> _movieEnvelope = BehaviorSubject(); var _currentStart = 0; Observable<MovieEnvelope> get movieEnvelope => _movieEnvelope.stream; MoviesBloc() { _getMovies(); } _getMovies() { Env.apiClient.getMovieList(_currentStart).then((movieEnvelope) { var newMovieEnvelope = movieEnvelope; if (_movieEnvelope.value != null) { newMovieEnvelope.movies = _movieEnvelope.value.movies ..addAll(movieEnvelope.movies); } _movieEnvelope.add(newMovieEnvelope); _currentStart = movieEnvelope.movies.length; }); } }
對於 Widget 來說,只要 listenbloc.movieEnvelope就能第一時間拿到最新的 movie 資料,然後把它們展示出來即可。
StreamBuilder
如果直接用setState的話,使用姿勢是在 state 的initState時 listenbloc.movieEnvelope,當收到新的內容時,setState,這樣系統就會 rebuild widget,然後用上最新的資料。Flutter 很貼心地提供了便捷的類StreamBuilder,只要告訴它stream,那麼當這個 stream 有新資料時,itemBuilder就會自動 rebuild。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Movies extends StatefulWidget { @override State<StatefulWidget> createState() { return _MoviesState(); } } class _MoviesState extends State<Movies> { @override Widget build(BuildContext context) { final bloc = BlocProvider.of<MoviesBloc>(context); return StreamBuilder<MovieEnvelope>( stream: bloc.movieEnvelope, builder: (context, snapshot) { // snapshot.data 就是最新的內容 // 返回一個新的 widget 即可 }) } }
注意到這裡有一個BlocProvider,這是何物?它其實也是個 Widget(是的,Flutter 的 Widget 並不侷限於 UI)。
為什麼通過of方法能拿到這個 bloc?因為在構建 Tree 時,BlocProvider套在了當前檢視的上層(只要從當前節點向上追溯能找到就行),就像這樣:
1 2 3 4 5 6 7 8 9 10 11 class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( bloc: MoviesBloc(), child: MaterialApp( title: 'Douban Movie Top 250', home: Home(), )); } }
而這個of方法也很簡單:
1 2 3 4 5 6 static T of<T extends BlocBase>(BuildContext context) { final type = _typeOf<BlocProvider<T>>(); // 這句話的意思就是從當前 context 向上找,找到第一個該型別的例項為止,找不到就返回 null BlocProvider<T> provider = context.ancestorWidgetOfExactType(type); return provider.bloc; }
BLoC 差不多有了之後,接下來就可以進入佈局階段了。
首頁佈局
Flutter 提供了兩種列表佈局方式:ListView和GridView,如果頁面裡除了列表還有其他元素(比如頂部的 SlideView 等),就需要用到CustomScrollView或者NestedScrollView,首頁除了列表沒有其他元素,同時一行可能包含多個檢視,因此我們選擇GridView 。
GridView 的構建可以使用GridView.builder, 它需要提供幾個關鍵資訊:
1 2 3 4 5 GridView.builder( gridDelegate: // 提供最終的佈局資訊,x,y,width.height itemCount: // 一共有多少元素 itemBuilder: (context, index) {} // 每個 item 具體長啥樣 );
如果是固定的每行幾列或固定寬度,且每個 item 的呈現形式幾乎一樣,可以直接使用自帶的SliverGridDelegateWithFixedCrossAxisCount或SliverGridDelegateWithMaxCrossAxisExtent,我們這個 case 中,每一行的列表不都是一樣的,因此不能直接拿來用,這就需要進入到高階模式了,自己實現gridDelegate。SliverGridDelegate的核心方法是SliverGridLayout getLayout(SliverConstraints constraints);也就是返回一個SliverGridLayout,系統拿到這個 layout 之後,就知道該怎麼佈局了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 abstract class SliverGridLayout { /// 針對某個 scroll 的偏移量,最小的 index 是多少 int getMinChildIndexForScrollOffset(double scrollOffset); /// 針對某個 scroll 的偏移量,最大的 index 是多少 int getMaxChildIndexForScrollOffset(double scrollOffset); /// 給一個 index,告訴我它的 x,y,width,height SliverGridGeometry getGeometryForChildIndex(int index); /// 這些 childcount 一共能產生多大的偏移量 /// 知道了這個資訊後,系統就可以展示滾動條的長短了 double computeMaxScrollOffset(int childCount); }
具體實現這裡就不貼出來了,感興趣的可以在原始碼裡看。還是有點小複雜的,尤其是加上 loadmore 的邏輯後,不過知道了系統想要什麼,想盡辦法滿足它就是了。
載入更多
第一次請求的佈局是完成了,如何實現載入更多的效果呢?在 iOS 中會通過判斷是否拉到了底部來觸發載入更多的邏輯,在 Flutter 中我們可以換一種方式來達到效果。
什麼時候需要載入更多?當前檢視的 item 都展示完了的時候,而在展示 item 時,builder 會傳入一個index,用來告知當前 item 處於哪個index,我們可以把這個資訊告訴 BLoC。比如第一頁一共展示 20 部電影,當 BLoC 收到 index 19 時,就知道這 20 部都已經被展示過了,就可以通過 API 去拿更多的資料,等拿到後,跟原先的資料進行合併,然後作為新的值告訴 widget,widget 的 StreamBuilder 發現有新資料後,自動重新整理 widget,這樣新的電影就被展示出來了。
稍微複雜的一點是載入更多時,需要展示一個 indicator,當沒有更多資料時,又是另一個樣式,這又該如何處理呢?可以思考下。
Real API 接入
前面這幾步都走完後,列表的佈局應該沒問題了,接下來就要接入真實的資料了。這個接入過程還是挺簡單的,新建一個實現了API的類,然後在 App 入口處,用它替換掉 MockAPI
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class RealAPI extends API { @override Future<MovieEnvelope> getMovieList(int start) async { var client = HttpClient(); var request = await client.getUrl(Uri.parse( 'https://api.douban.com/v2/movie/top250?start=$start&count=40')); var response = await request.close(); var responseBody = await response.transform(utf8.decoder).join(); Map data = json.decode(responseBody); return MovieEnvelope.fromJSON(data); } @override Future<Movie> getMovie(String movieID) async { // 暫時還用不到,先忽略 return null; } }
Dart 內建了對非同步請求的支援,分為兩大塊,async + await + Future和Stream + async* + yield,前者用來處理單次或少量的非同步請求,後者用來實現非同步的iterator,也就是資料可能會源源不斷地冒出來。
在這個例子中,通過 await 來拿非同步資料就可以了,拿到之後把它轉換為 model(方便起見,錯誤處理就先忽略)。 然後在入口處設定
1 2 3 4 void main() { Env.apiClient = RealAPI(); runApp(MyApp()); }
item 的內容展示
這塊是個細緻活,對於 Widget 元素的使用可以參考官方的這個教程 ,需要提一點的是LayoutBuilder這個 widget,預設 child widget 是拿不到 parent 的佈局資訊的,但有時又需要拿它來進行一些計算,這時就可以在外面套一層LayoutBuilder,它的builder屬性是一個 function,會傳一個BoxConstraints進來,通過它就能拿到 parent 的佈局資訊。
收藏影片
點選影片 title 旁邊的..., 如果是 iOS 平臺,則彈出一個ActionSheet,Android 則彈出BottomSheet,選擇收藏的話,這個...會變成紅色。
如果要針對不同平臺進行不同的展示,可以使用Platform.isIOS來區分(這個類在dart:iopackage 下),關於ActionSheet和BottomSheet的使用,檢視相應的 API 即可。比較麻煩的是...的變色處理,倒不是變色麻煩,而是要讓這個狀態得到保持,不然下次再滑到該 item 時,又會回到原先的顏色。
大多數的教程裡,對這部分的處理都是更新 list 中該 item 對應的 model,然後讓更新後的 list 觸發 StreamBuilder 重新渲染 widget,我覺得這樣實在有點小題大做了。我的做法是給每個 item 配一個對應的 bloc,item 的 model 和狀態都儲存在這個 bloc 中,在這個例子中,當某個 movie item widget 需要更新狀態時,從它對應的 bloc 中拿即可。
如果這時要新加一個 Feature,當收藏電影時,頂部 AppBar 的右邊要有對應的數字顯示。在 Google 官方的Demo 裡是直接在 widget 的onTapcallback 裡呼叫另一個 bloc 的方法(CartBloc.addition),這樣其實把業務邏輯也耦合到了 UI 裡面(如果 CartBloc 的 addition 邏輯變了,或者在新增到 Cart 的同時,還要更新使用者狀態等,就需要在這個 callback 裡調整這些邏輯),因此更好的方法是自己的 bloc 處理完後向上拋 Notification,外層接收到 Notification 後再去更新其他 Bloc。就好像要進行跨部門溝通時,最好讓共同的上級知道這件事,或者由他來協調。
這裡簡單說下 Flutter 裡的 Notification 機制,它不像 iOS 裡的NotificationCenter可以全域性接收,而是隻有在 widget 上層路徑中的NotificationListener才能收到通知,這樣可以避免通知氾濫的情況。而且使用時,必須繼承系統的Notification,這樣每一個通知就是一個特定類,閱讀程式碼或排查起來也會很方便。
至此,首頁的電影列表頁基本完成了,該進入詳情頁了,在這之前,還有一件事情要做,那就是 Router 的註冊。
Router 註冊
MaterialApp自帶了 Router 註冊,乍一看還挺方便的,不過有一個坑就是不支援通過 URL 傳遞動態引數,比如/movie/1024,必須完全匹配才可以。如果真要實現 pattern 匹配就要設定onGenerateRoute引數,當通過 Navigator 進行 push 時,這個引數對應的方法就會被觸發,可以在這個方法裡面進行 URL 的解析。
我在這裡實現了個簡單的通過 URL 註冊 Widget 的方法,URL 支援 pattern,比如/movie/{id}就能匹配/movie/1024,使用時,通過 URL 來拿 widget,再把這個 widget push 出去即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 class Router { static var _routerEntry = _Router(name: ''); // param should wrap with {}, eg: /movie/{id} static register(String pattern, WidgetBuilder widgetBuilder) { final patternSections = pattern.split('/'); var routerEntry = _routerEntry; for (int i = 0; i < patternSections.length; i++) { final _pattern = patternSections[i]; final _router = _Router(name: _pattern); if (i == patternSections.length - 1) { _router.widgetBuilder = widgetBuilder; } routerEntry.addChild(_router); routerEntry = _router; } } static Widget getWidget(String url, BuildContext context, {Map params}) { final urlSections = url.split('/'); var routerEntry = _routerEntry; Widget widget; Map urlParams = {}; for (int i = 0; i < urlSections.length; i++) { final _urlSection = urlSections[i]; var found = false; for (_Router _router in routerEntry.children) { if (_router.name == _urlSection || _router.name.startsWith('{')) { if (_router.name.startsWith('{')) { final param = _router.name.substring(1, _router.name.length - 1); urlParams[param] = _urlSection; } found = true; routerEntry = _router; if (i == urlSections.length - 1) { if (_router.widgetBuilder != null) { widget = _router.widgetBuilder(context, urlParams, params: params); } } } } if (!found) { break; } } return widget; } }
路由的註冊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Routes { static String root = '/'; static String detail = '/movie/{id}'; static configureRoutes() { Router.register(root, (BuildContext context, Map urlParams, {Map params}) { return Home(); }); Router.register(detail, (BuildContext context, Map urlParams, {Map params}) { return MoviePage( movieID: urlParams['id'], movie: params != null && params['movie'] != null ? params['movie'] : null, ); }); } }
詳情頁-佈局選擇
詳情頁主要分為 4 個部分,頂部的海報圖,中間的概要、橫向可滾動的影人列表頁和 Tab 標籤,以及最下面的列表頁。有兩個方案可以選,NestedScrollView和CustomScrollView,前者分為 header 和 body 兩部分,可以在裡面套 scrollView,最開始選擇了這個方案,後來發現TabbarView怎麼都處理不好,如果把它單獨放到 body 裡,那麼所有剩下的部分就都要放到 header 裡,雖然可行,但跑起來會發現,TabbarView裡的 scroll offset 是錯的,一部分內容會「鑽」進 Tabbar 裡,而且底部空了很大一部分。如果把除了海報圖,剩下的部分都放到 body 裡,也不行,被迫只能選擇CustomScrollView。
CustomScrollView方案的一個難點是處理TabbarView,因為不能直接把它放到slivers裡,於是換了個思路,拋棄TabbarView手動實現 tabbar 點選之後,下面的內容切換效果。
詳情頁的核心程式碼大概像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xfff4f4f4), body: BlocProvider( bloc: bloc, child: (() { if (movie == null) { return Center( child: Text('loading'), ); } else { return NotificationListener<TabSwitchNotification>( onNotification: (notification) { setState(() { currentTabType = notification.tabContentType; }); }, child: DefaultTabController( length: 2, child: SafeArea( top: false, child: CustomScrollView( slivers: <Widget>[ MovieHero(), SliverToBoxAdapter( child: MovieSummary(), ), SliverToBoxAdapter( child: MovieActors(), ), SliverPadding( padding: EdgeInsets.all(7.0), ), MovieReviewTabbar(), MovieReviewTabbarContent( tabContentType: currentTabType, ), ], ))), ); } }()), )); }
對於 tabbar 來說,需要提供一個 controller,要麼通過屬性設定,要麼外面包一層,這裡選擇了後者,所以可以看到最外面是DefaultTabController,這樣就可以在裡面通過DefaultTabController.of(context)來拿到這個 controller,進而瞭解當前選中的 tab,以及控制 tab 的選中情況。
普通的 Widget 要通過SliverToBoxAdapter轉換才能被放到 slivers 裡面,slivers 其實就是CustomScrollView的組成部分。通過上面的程式碼,我們可以知道這個 scrollview 是由哪幾部分組成的,非常清晰。
詳情頁-頂部海報效果
這個效果看上去滿複雜的,實現起來很簡單,只要使用SliverAppBar 這個 Widget 即可。這裡 有比較詳細的介紹。
詳情頁-影片介紹
這裡主要是使用 Text Widget,一個難點是預設內容是截斷和收起的,當點選按鈕後,展開。如果是在 iOS 裡,需要分別計算兩者的高度,然後呼叫reloadRowsAtIndexPaths方法,有點麻煩,Flutter 裡很簡單,widget 自己更新內容即可,不需要考慮高度的計算,也不要顯式地呼叫 reload 方法。
詳情頁-影人列表
這是一個橫向滑動列表,把ListView的scrollDirection設定為horizontal即可
詳情頁-Tabbar
Tabbar 需要有一個 controller,我們在外面包了一層DefaultTabController,這裡就不需要操心了。比較坑的一點是,TabBar這個 Widget 沒有提供onTap方法,只能通過監聽 controller 來獲取 tab 的變化,不過這樣也好,管理起來更方便。
PS: 這裡的 Tabbar 是需要吸頂的,因此要用SliverPersistentHeader包一下。
詳情頁-點評列表
點評列表是一個SliverList,配置起來還算簡單,那麼點選 tab 切換內容這個該如何實現呢?可以思考下。
小結
我個人很喜歡 Flutter, 用它來寫 GUI 感覺非常自然和舒服。不需要藉助 JSX 或 XML,用程式語言就能搞定,方便 Local Reasoning,不需要在 JSX / XML 和 Code 之間來回切換。同時通過 Widget 來配置檢視的方式也很方便,GUI 非常適合這種宣告式程式設計。
剛開始接觸 Flutter 時,容易被那一大堆的類搞暈,其實瞭解了核心理念後,啃透幾個 Demo,就會慢慢找到感覺。自己再多寫寫,踩踩坑,就熟練了。
至於效能方面,debug 跟 release 模式還是會有些差距,因此如果在 debug 模式下發現不夠流暢,可以切換到 release 模式再試下。
我也是接觸 Flutter 不久,如果有不對的地方歡迎指正,如果能給你帶來些幫助,備感榮幸。