[譯] Flutter 應用架構 101:Vanilla, Scoped Model, BLoC

Flutter 提供了一種現代的響應式框架,豐富的元件集和工具,但是還沒有如同 Android 中應用架構指南一樣的東西。
的確,沒有任何終極架構方案能滿足所有需求,但我們面對的事實是,我們正在開發的大多數移動應用至少具有以下的某些功能:
- 從網路請求資料/向網路上傳資料。
- 遍歷,轉換,準備資料並呈現給使用者。
- 向資料庫傳送資料/從資料庫獲取資料。
考慮到這一點,我建立了一個示例應用,使用三種不同的架構方法解決完全相同的問題。
在螢幕中央向用戶顯示“載入使用者資料”按鈕。當用戶單擊該按鈕時,將非同步載入資料,並使用載入指示器替換該按鈕。資料載入完成後,載入指示器將替換為資料。
讓我們開始吧。

資料
為了簡單起見,我建立了類 Repository
,其中包含模擬非同步網路呼叫的方法 getUser()
,並返回帶有硬編碼值的 Future<User>
物件。 如果您不熟悉 Dart 中的 Futures 和非同步程式設計,可以通過這個教程或閱讀文件來了解更多相關資訊。
class Repository { Future<User> getUser() async { await Future.delayed(Duration(seconds: 2)); return User(name: 'John', surname: 'Smith'); } } 複製程式碼
class User { User({ @required this.name, @required this.surname, }); final String name; final String surname; } 複製程式碼
Vanilla
讓我們按照大多數開發人員閱讀 Flutter 官方文件後的方式構建應用。
使用 Navigator
導航到 VanillaScreen
頁面。
由於元件的狀態可能會在其生命週期中多次更改,因此我們應該繼承 StatefulWidget
。實現有狀態元件還需要具有類 State
。類 _VanillaScreenState
中的欄位 bool _isLoading
和 User _user
表示元件的狀態。在呼叫 build(BuildContext context)
方法之前,這兩個欄位都已初始化。 建立元件狀態物件後,將呼叫 build(BuildContext context)
方法來構建 UI。關於如何構建表示元件當前狀態的所有決策都在 UI 宣告程式碼中做出。
body: SafeArea( child: _isLoading ? _buildLoading() : _buildBody(), ) 複製程式碼
當用戶單擊“載入使用者詳細資訊”按鈕時,為了顯示進度指示器,我們執行以下操作。
setState(() { _isLoading = true; }); 複製程式碼
呼叫 setState()
會通知框架該物件的內部狀態已經發生改變,並有可能影響此子樹中的使用者介面,這會導致框架為此 State 物件安排構建。
這意味著在呼叫 setState()
方法後,框架再次呼叫 build(BuildContext context)
方法, 並重建整個元件樹 。由於 _isLoading
現在設定為 true
,因此呼叫 _buildLoading()
而不是 _buildBody()
,並在螢幕上顯示載入指示器。與當我們處理來自 getUser()
的回撥並呼叫 setState()
來重新分配 _isLoading
和 _user
欄位的情況相同。
widget._repository.getUser().then((user) { setState(() { _user = user; _isLoading = false; }); }); 複製程式碼
優點
- 學習簡單,易於理解。
- 不需要第三方庫。
缺點
- 元件的狀態的每次改變都會重建整個元件樹。
- 它打破了單一責任原則。元件不僅負責構建 UI,還負責資料載入,業務邏輯和狀態管理。
- 關於如何表示當前狀態的決策是在 UI 宣告程式碼中做出的。如果我們的狀態複雜一些,程式碼可讀性會降低。
Scoped Model
Scoped Model是第三方包,未包含在 Flutter 框架中。 這是 Scoped Model 開發人員的描述:
一組實用程式,允許您輕鬆地將資料模型從父元件傳遞到其後代。此外,它還會在模型更新時重建使用該模型的所有子項。該庫最初是從 Fuchsia 程式碼庫中提取的。
讓我們使用 Scoped Model 構建相同的頁面。首先,我們需要通過 pubspec.yaml
在 dependencies
下新增 scoped_model
依賴項來安裝 Scoped Model 包。
scoped_model: ^1.0.1 複製程式碼
讓我們看一下 UserModelScreen
元件,並將其與之前未使用 Scoped Model 構建的示例進行比較。由於我們想讓我們的模型可用於所有元件的後代,我們應該使用通用的 ScopedModel
包裝它並提供元件和模型。
class UserModelScreen extends StatefulWidget { UserModelScreen(this._repository); final Repository _repository; @override State<StatefulWidget> createState() => _UserModelScreenState(); } class _UserModelScreenState extends State<UserModelScreen> { UserModel _userModel; @override void initState() { _userModel = UserModel(widget._repository); super.initState(); } @override Widget build(BuildContext context) { return ScopedModel( model: _userModel, child: Scaffold( appBar: AppBar( title: const Text('Scoped model'), ), body: SafeArea( child: ScopedModelDescendant<UserModel>( builder: (context, child, model) { if (model.isLoading) { return _buildLoading(); } else { if (model.user != null) { return _buildContent(model); } else { return _buildInit(model); } } }, ), ), ), ); } Widget _buildInit(UserModel userModel) { return Center( child: RaisedButton( child: const Text('Load user data'), onPressed: () { userModel.loadUserData(); }, ), ); } Widget _buildContent(UserModel userModel) { return Center( child: Text('Hello ${userModel.user.name} ${userModel.user.surname}'), ); } Widget _buildLoading() { return const Center( child: CircularProgressIndicator(), ); } } 複製程式碼
在前面的示例中,當元件的的狀態發生更改時,重建了整個元件樹。但我們真的需要重建整個頁面嗎?例如,AppBar 根本不應該改變,因此重建它沒有意義。理想情況下,我們應該只重建那些更新的元件。Scoped Model 可以幫助我們解決這個問題。
ScopedModelDescendant<UserModel>
元件用於在元件樹中查詢 UserModel
。只要 UserModel
通知發生了更改,它就會自動重建。
另一個改進是 UserModelScreen
不再負責狀態管理和業務邏輯。
我們來看看 UserModel
程式碼。
class UserModel extends Model { UserModel(this._repository); final Repository _repository; bool _isLoading = false; User _user; User get user => _user; bool get isLoading => _isLoading; void loadUserData() { _isLoading = true; notifyListeners(); _repository.getUser().then((user) { _user = user; _isLoading = false; notifyListeners(); }); } static UserModel of(BuildContext context) => ScopedModel.of<UserModel>(context); } 複製程式碼
現在 UserModel
儲存並管理狀態。為了通知監聽器(並重建後代)發生了更改,應呼叫 notifyListeners()
方法。
優點
- 業務邏輯,狀態管理和 UI 程式碼分離。
- 簡單易學。
缺點
notifyListeners()
BLoC
BLoC( B usiness L ogic C omponents)是 Google 開發人員推薦的模式。它利用流功能來管理和廣播狀態更改。
對於 Android 開發人員:您可以將 Bloc
物件視為 ViewModel
,將 StreamController
視為 LiveData
。這將使以下程式碼非常簡單,因為您已經熟悉了這些概念。
class UserBloc { UserBloc(this._repository); final Repository _repository; final _userStreamController = StreamController<UserState>(); Stream<UserState> get user => _userStreamController.stream; void loadUserData() { _userStreamController.sink.add(UserState._userLoading()); _repository.getUser().then((user) { _userStreamController.sink.add(UserState._userData(user)); }); } void dispose() { _userStreamController.close(); } } class UserState { UserState(); factory UserState._userData(User user) = UserDataState; factory UserState._userLoading() = UserLoadingState; } class UserInitState extends UserState {} class UserLoadingState extends UserState {} class UserDataState extends UserState { UserDataState(this.user); final User user; } 複製程式碼
當狀態改變時,不需要額外的方法呼叫來通知訂閱者。
我建立了 3 個類來表示頁面的可能狀態:
UserInitState UserLoadingState UserDataState
以這種方式廣播狀態更改允許我們擺脫 UI 宣告程式碼中的所有邏輯。在使用 Scoped Model 的示例中,我們仍在檢查 UI 宣告程式碼中的 _isLoading
是否為 true
,以決定我們應該呈現哪個元件。在 BLoC 的示例中,我們正在廣播頁面的狀態, UserBlocScreen
元件的唯一責任是呈現此狀態的 UI。
class UserBlocScreen extends StatefulWidget { UserBlocScreen(this._repository); final Repository _repository; @override State<StatefulWidget> createState() => _UserBlocScreenState(); } class _UserBlocScreenState extends State<UserBlocScreen> { UserBloc _userBloc; @override void initState() { _userBloc = UserBloc(widget._repository); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Bloc'), ), body: SafeArea( child: StreamBuilder<UserState>( stream: _userBloc.user, initialData: UserInitState(), builder: (context, snapshot) { if (snapshot.data is UserInitState) { return _buildInit(); } if (snapshot.data is UserDataState) { UserDataState state = snapshot.data; return _buildContent(state.user); } if (snapshot.data is UserLoadingState) { return _buildLoading(); } }, ), ), ); } Widget _buildInit() { return Center( child: RaisedButton( child: const Text('Load user data'), onPressed: () { _userBloc.loadUserData(); }, ), ); } Widget _buildContent(User user) { return Center( child: Text('Hello ${user.name} ${user.surname}'), ); } Widget _buildLoading() { return const Center( child: CircularProgressIndicator(), ); } @override void dispose() { _userBloc.dispose(); super.dispose(); } } 複製程式碼
與前面的示例相比, UserBlocScreen
程式碼變得更加簡單。我們使用 StreamBuilder
監聽狀態更改。 StreamBuilder
是一個 StatefulWidget
,它基於與 Stream
互動的最新快照來構建自身。