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

MeFelixWang發表於2019-02-25

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

Flutter 提供了一種現代的響應式框架,豐富的元件集和工具,但是還沒有如同 Android 中應用架構指南一樣的東西。

的確,沒有任何終極架構方案能滿足所有需求,但我們面對的事實是,我們正在開發的大多數移動應用至少具有以下的某些功能:

  1. 從網路請求資料/向網路上傳資料。
  2. 遍歷,轉換,準備資料並呈現給使用者。
  3. 向資料庫傳送資料/從資料庫獲取資料。

考慮到這一點,我建立了一個示例應用,使用三種不同的架構方法解決完全相同的問題。

在螢幕中央向使用者顯示“載入使用者資料”按鈕。當使用者單擊該按鈕時,將非同步載入資料,並使用載入指示器替換該按鈕。資料載入完成後,載入指示器將替換為資料。

讓我們開始吧。

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

資料

為了簡單起見,我建立了類 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 _isLoadingUser _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;
  });
});
複製程式碼

優點

  1. 學習簡單,易於理解。
  2. 不需要第三方庫。

缺點

  1. 元件的狀態的每次改變都會重建整個元件樹。
  2. 它打破了單一責任原則。元件不僅負責構建 UI,還負責資料載入,業務邏輯和狀態管理。
  3. 關於如何表示當前狀態的決策是在 UI 宣告程式碼中做出的。如果我們的狀態複雜一些,程式碼可讀性會降低。

Scoped Model

Scoped Model是 第三方包,未包含在 Flutter 框架中。 這是 Scoped Model 開發人員的描述:

一組實用程式,允許您輕鬆地將資料模型從父元件傳遞到其後代。此外,它還會在模型更新時重建使用該模型的所有子項。該庫最初是從 Fuchsia 程式碼庫中提取的。

讓我們使用 Scoped Model 構建相同的頁面。首先,我們需要通過 pubspec.yamldependencies 下新增 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() 方法。

優點

  1. 業務邏輯,狀態管理和 UI 程式碼分離。
  2. 簡單易學。

缺點

  1. 需要第三方庫。
  2. 隨著模型越來越複雜,在呼叫 notifyListeners() 時很難跟蹤。

BLoC

BLoC(Business Logic Components)是 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 個類來表示頁面的可能狀態:

  1. 當使用者開啟一箇中心帶有按鈕的頁面時,狀態為 UserInitState
  2. 當載入資料顯示載入指示器時,狀態為 UserLoadingState
  3. 當資料載入完成並顯示在頁面上時,狀態為 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 互動的最新快照來構建自身。

優點

  1. 不需要第三方庫。
  2. 業務邏輯,狀態管理和 UI 邏輯分離。
  3. 這是響應式的。不需要額外的呼叫,就像 Scoped Model 的 notifyListeners() 一樣。

缺點

  1. 需要有使用 stream 或 rxdart 的經驗。

原始碼

你可以在這個 github repo 中檢視以上示例的原始碼。


如果發現譯文存在錯誤或其他需要改進的地方,敬請提出。

相關文章