[譯] 在 flutter 中高效地使用 BLoC 模式

LucasTwilight發表於2019-06-05

朋友們,我有好長一段時間沒有寫過 flutter 相關的文章了。在完成了兩篇關於 BLoC 模式的文章之後,我花了一些時間,分析了社群對於這種模式的使用情況,在回答了一些關於 BLoC 模式實現的一些問題之後,我發現大家對於 BLoC 模式存在很多疑惑。所以,我構思了一套方法,大家按照這一套方法來做,就可以正確地實現 BLoC 模式了,這會幫助開發人員在實現的時候避免犯下一些常見的錯誤。所以,我今天向大家介紹一下在使用 BLoC 模式時必須要遵循的 8 個黃金點

[譯] 在 flutter 中高效地使用 BLoC 模式

前提

我心目中的讀者,應該知道 BLoC 模式是什麼,或者使用模式建立了一個應用(至少做過 CTRL + CCTRL + V)。如果你是第一次聽到 BLoC 這個詞,那麼下面三篇文章可以很好地幫助你理解這個模式。

  1. 使用 BLoC 模式構建 Flutter 專案第一部分第二部分

  2. 當 Firebase 遇到了 BLoC 模式

和 BLoC 相遇的故事

我知道,BLoC 模式是一個很難去理解和實現的模式。我看過了很多開發人員的帖子,詢問 哪裡是學習 BLoC 模式的最佳資源呢?讀完了不同的帖子和評論之後,我覺得大家在理解這個問題的阻礙有以下幾點。

  1. 響應式地思考。

  2. 努力瞭解需要建立多少 BLoC 檔案。

  3. 害怕這個模式會造成程式碼複雜度的提升。

  4. 不知道 stream 在什麼時候會被處理掉。

  5. 什麼是 BLoC 模式的完整形式?(這是一個業務邏輯元件)

  6. 更多其他的原因……

但是今天我要列出一些最為重要的點,這些點可以幫助你更加自信及有效地實現 BLoC 模式。現在,就讓我們趕快看看有哪些很棒的點。

每一個頁面都有其自己的 BLoC

這是需要記住的最重要的一個點。每當你建立了一個新的頁面,例如登入頁,註冊頁,個人資料頁等涉及到資料處理的頁面的時候,你必須要為其 建立一個新的 BLoC。不要將全域性 BLoC 用於處理應用中的所有頁面。你可能會認為,如果我們有一個全域性的 BLoC,就可以輕鬆地處理跨頁面的資料了。這很不好,因為你的庫應當將這些公共資料提供給 BLoC。BLoC 僅僅是獲取資料並且將其注入到頁面中,來向使用者展示。

左圖是正確的使用模式

每個 BLoC 必須要有一個 dispose() 方法

這一點比較直接。你建立的每個 BLoC 都應該有一個 dispose() 方法。這個方法是你清理或者關閉你建立的所有 stream 的位置。下面是一個 dispose() 的簡單的例子。

class MoviesBloc {
  final _repository = Repository();
  final _moviesFetcher = PublishSubject<ItemModel>();

  Observable<ItemModel> get allMovies => _moviesFetcher.stream;

  fetchAllMovies() async {
    ItemModel itemModel = await _repository.fetchAllMovies();
    _moviesFetcher.sink.add(itemModel);
  }

  dispose() {
    _moviesFetcher.close();
  }
}
複製程式碼

不要在 BLoC 中使用 StatelessWidget

每當你想要建立一個傳遞資料到 BLoC 或者從 BLoC 中獲取資料的頁面的時候,請使用 StatefulWidget 。使用 StatefulWidget 相比於使用 StatelessWidget 的最大優點在於 StatefulWidget 中的生命週期方法。在文章的後面,我們會討論在使用 BLoC 模式時需要覆蓋的兩個最重要的方法。StatelessWidget 很適合製作頁面的小的靜態部分,例如顯示影象或者是硬編碼的文字。如果你想要看看怎麼用 StatelessWidget 來實現 BLoC 模式,請看上面推薦的文章的 第一部分,而在第二部分中,我講述了自己為什麼要從 StatelessWidget 遷移到 StatefulWidget

重寫 didChangeDependencies() 來初始化 BLoC

如果你需要在初始化的時候需要一個 context 來初始化 BLoC 物件,那麼這個方法就是在 StatefulWidget 中需要重寫的最重要的方法。你可以將其視為初始化方法(最好僅用於 BLoC 的初始化)。你或許會說,我們有 initState() 方法,那麼為什麼我們要使用 didChangeDependencies() 方法。文件裡面清楚地提到,從 didChangeDependencies() 呼叫 BuildContext.inheritFromWidgetOfExactType 是安全的。下面是使用這個方法的一個簡單的例子:

@override
  void didChangeDependencies() {
    bloc = MovieDetailBlocProvider.of(context);
    bloc.fetchTrailersById(movieId);
    super.didChangeDependencies();
  }
複製程式碼

重寫 dispose() 方法來銷燬 BLoC

就和有一個初始化方法一樣,我們還有一個方法,來處理掉我們在 BLoC 中建立的連線。dispose() 方法是呼叫與該頁面相連的對應的 BLoC 的 dispose() 方法的最佳位置。每當你離開頁面的時候,需要呼叫這個方法(實際上就是StatefulWidget被處理掉的時候)。以下是該方法的一個小例子:

@override
  void dispose() {
    bloc.dispose();
    super.dispose();
  }
複製程式碼

只有需要處理複雜邏輯的時候,才使用 RxDart

如果你之前使用過 BLoC 模式的話,那麼你一定聽說過 [RxDart](https://github.com/ReactiveX/rxdart) 庫。這個庫是 Google Dart 的響應式函數語言程式設計庫,它只是一個包裝器,用來包裝 Dart 提供的 Stream API。我建議你僅在需要處理,類似於連結多個網路請求這樣的複雜邏輯時,才使用這個庫。對於一些簡單的實現,使用 Dart 語言提供的 Stream API 就足夠了,因為這個 API 已經非常成熟了。下面我新增了一個 BLoC,它使用了 Stream API 而不是 RxDart 庫,這樣會讓操作變得非常簡單,我們不需要額外的庫來實現同樣的事情:

import 'dart:async';

class Bloc {

  //Our pizza house
  final order = StreamController<String>();

  //Our order office
  Stream<String> get orderOffice => order.stream.transform(validateOrder);

  //Pizza house menu and quantity
  static final _pizzaList = {
    "Sushi": 2,
    "Neapolitan": 3,
    "California-style": 4,
    "Marinara": 2
  };

  //Different pizza images
  static final _pizzaImages = {
    "Sushi": "http://pngimg.com/uploads/pizza/pizza_PNG44077.png",
    "Neapolitan": "http://pngimg.com/uploads/pizza/pizza_PNG44078.png",
    "California-style": "http://pngimg.com/uploads/pizza/pizza_PNG44081.png",
    "Marinara": "http://pngimg.com/uploads/pizza/pizza_PNG44084.png"
  };


  //Validate if pizza can be baked or not. This is John
  final validateOrder =
      StreamTransformer<String, String>.fromHandlers(handleData: (order, sink) {
    if (_pizzaList[order] != null) {
      //pizza is available
      if (_pizzaList[order] != 0) {
        //pizza can be delivered
        sink.add(_pizzaImages[order]);
        final quantity = _pizzaList[order];
        _pizzaList[order] = quantity-1;
      } else {
        //out of stock
        sink.addError("Out of stock");
      }
    } else {
      //pizza is not in the menu
      sink.addError("Pizza not found");
    }
  });

  //This is Mia
  void orderItem(String pizza) {
    order.sink.add(pizza);
  }
}
複製程式碼

使用 PublishSubject 代替 BehaviorSubject

對於那些在 Flutter 專案中使用 RxDart 庫的人來說,這一點會更加地明確。BehaviorSubject 是一個特殊的 StreamController,它會捕獲到已經新增到 controller 的最新項,並且將其作為新的 listener 的第一個事件觸發。即使你在 BehaviorSubject 上呼叫 close() 或者 drain(),它仍然會保留最後一項,並且在這個 listener 被訂閱的時候觸發。如果開發人員不瞭解這個功能,這有可能會變成一場噩夢。而 PublishSubject 不會儲存最後一項,更加適合於大多數情況。在這個專案中,可以檢視 BehaviorSubject 的功能。執行應用程式,並且跳轉到 'Add Goal' 頁面,在表單中輸入詳細資訊,並且跳轉回來。現在,再次訪問 'Add Goal' 頁面,你就會發現表單裡已經預先填寫了你之前輸入的資料。如果你和我一樣懶,那麼可以看我下面附上的視訊:

Goals App Demo

正確地使用 BLoC Providers

在我說這一點之前,請看下面的程式碼片(第 9 行和第 10 行)。

import 'package:flutter/material.dart';
import 'ui/login.dart';
import 'blocs/goals_bloc_provider.dart';
import 'blocs/login_bloc_provider.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LoginBlocProvider(
      child: GoalsBlocProvider(
        child: MaterialApp(
          theme: ThemeData(
            accentColor: Colors.black,
            primaryColor: Colors.amber,
          ),
          home: Scaffold(
            appBar: AppBar(
              title: Text(
                "Goals",
                style: TextStyle(color: Colors.black),
              ),
              backgroundColor: Colors.amber,
              elevation: 0.0,
            ),
            body: LoginScreen(),
          ),
        ),
      ),
    );
  }
}

複製程式碼

你可以清楚地看到,多個 BLoC Provider 是巢狀的。這時候,那麼你一定會擔心,如果繼續在同一個鏈中新增更多的 BLoC,會導致一場噩夢,你可能會得出 BLoC 模式無法擴充套件的結論。但是,讓我告訴你,當你需要在 Widget 樹中訪問多個 BLoC 的時候,可能會有一種特殊的情況(BLoC 只儲存應用程式所需要的 UI 配置),因此,對於這種情況,上述的巢狀是完全沒問題的。但是我建議你在大多數的情況下,還是要避免這種巢狀的,並且只在實際需要的地方提供 BLoC。因此,比如當你需要導航到新的頁面的時候,可以像這樣使用 BLoC Provider:

openDetailPage(ItemModel data, int index) {
    final page = MovieDetailBlocProvider(
      child: MovieDetail(
        title: data.results[index].title,
        posterUrl: data.results[index].backdrop_path,
        description: data.results[index].overview,
        releaseDate: data.results[index].release_date,
        voteAverage: data.results[index].vote_average.toString(),
        movieId: data.results[index].id,
      ),
    );
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) {
        return page;
      }),
    );
  }
複製程式碼

這樣,MovieDetailBlocProvider 就不會為整個元件樹,而是會為 MovieDetail 頁面提供 BLoC。你可以看到,我將 MovieDetailScreen 儲存在一個新的 final variable 中,來避免每次在 MovieDetailScreen 中開啟或者關閉鍵盤的時候,都會重新建立 MovieDetailScreen 的問題。

還沒有結束

雖然這裡是本文的結尾了,但並不是這個主題的結尾。我也會在這個有關優化 BLoC 模式的文集中不斷新增新的想法,從而繼續豐富它的內容。我希望這些想法可以幫助你更好地實現 BLoC 模式。Keep learning and keep coding :)。如果你喜歡這篇文章,可以通過點贊來表達你的愛。

有任何疑問,請在 LinkedIn 與我聯絡,或者在 Twitter 上關注我。我會盡我所能解決你的問題。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章