1. 程式人生 > >[譯]Flutter響應式程式設計:Streams和BLoC

[譯]Flutter響應式程式設計:Streams和BLoC

想看原文請出門右轉原文傳送門 版本所有,轉載請註明出處。本文主要介紹Streams,Bloc和Reactive Programming(響應式程式設計)的概念。 理論和實踐範例。

難度:中級

介紹

我花了很長時間才找到介紹Reactive Programming,BLoC和Streams概念的方法。由於這可以對構建應用程式的方式做出重大改變,我想要一個實際示例來說明:

  • 很可能不使用它們,但有時可能更難以編碼和效能更低,
  • 使用它們的好處同時也是
  • 使用它們的影響,正面的和(或)負面的。

用我做的偽應用程式作為一個例子,簡而言之,它允許使用者從線上目錄中檢視電影列表,按型別和釋出日期過濾它們,標記/取消標記為收藏夾。 當然,一切都是互動的,使用者可以在不同的頁面中或在同一個頁面內發生各種動作,並且可以實時觀察到結果。下面的動畫展示了該程式:image.png

當您進入此頁面以獲取有關Reactive Programming,BLoC和Streams的資訊時,我將首先介紹它們。 此後,我將向您展示如何在實踐中實施和使用它們。

什麼是Stream?

介紹

為了便於想象Stream的概念,我們可以簡單把Stream想象為一個有兩個埠的管道,只有其中的一個允許插入一些東西。 當您將某物插入管道時,它會在管道內流動並從另一端流出。In Flutter,

  • the pipe is called a Stream
  • to control the Stream, we usually<upper style="box-sizing: border-box;">(*)</upper> use a 
    StreamController
  • to insert something into the Stream, the StreamController exposes the “entrance”, called a StreamSink, accessible via the sink property
  • the way out of the Stream, is exposed by the StreamController via the streamproperty

在Flutter中,

  • 為了在Stream中插入一些東西,StreamController公開了一個名為StreamSink
    的“入口”,可以通過sink屬性訪問
  • Stream 流出方式是由StreamController通過stream屬性暴露的。

(*):我故意使用術語“通常”,因為很可能不使用任何StreamController。 但是,正如您將在本文中看到的那樣,我將只使用StreamControllers

Stream可以傳達什麼?

所有型別以及任何型別。 從值,事件,物件,集合,對映,錯誤或甚至另一個流,任何型別的資料都可以由Stream傳遞 。

 我怎麼知道Stream傳達的東西?

當您需要通知`Stream`傳達某些內容時,您只需要監聽`StreamController`的`stream`屬性。

定義監聽時,你會得到StreamSubscription物件。 通過StreamSubscription物件,你將會接受到通知由於Stream發生變化而帶來的的通知。

只要至少有一個活動偵聽器,Stream就會開始生成事件,以便每次都通知活動的StreamSubscription物件:

  • 一些資料來自流,
  • 當一些錯誤傳送到流時,
  • 當流關閉時。

StreamSubscription也允許以下操作:

  • 停止監聽
  • 暫時
  • 恢復

Stream只是一個簡單的管道嗎?

不,Stream還允許在流出之前處理流入其中的資料。為了控制Stream內部資料的處理,我們使用StreamTransformer,它只是:

  • 一個“捕獲”Stream內部流動資料的函式
  • 對資料做一些處理
  • 這種轉變的結果也是一個Stream

到此你應該很容易意識到你可以按順序使用多個[StreamTransformer]()。

StreamTransformer可用於進行任何型別的處理,例如:

  • 過濾:根據任何型別的條件過濾資料,
  • 重新組合:重新組合資料,
  • 修改:對資料應用任何型別的修改,
  • 將資料注入其他流,
  • 緩衝,
  • 處理:根據資料進行任何型別的操作/操作,

...

Stream的型別

Stream有兩種型別。

單訂閱Stream

這種型別的Stream只允許在該Stream的整個生命週期內使用單個監聽器。

即使在第一個訂閱被取消後,也無法在此類流上收聽兩次。

廣播Stream

這是第二種型別Stream,這種Stream允許任意個數的監聽器。

可以隨時向廣播流新增監聽器。 新的監聽器將在它開始收聽Stream時收到事件。

基本例子

任何型別的資料

第一個示例顯示了“單訂閱”Stream,它只是列印輸入的資料。 你可能會看到無關緊要的資料型別。

StreamTransformer

第二個示例顯示“廣播”Stream,它傳達整數值並僅列印偶數。 為此,我們應用StreamTransformer來過濾(第14行)值,只讓偶數經過。

RxDart

如今,如果我不提及RxDart,那麼Streams的介紹將不再完整。

RxDart是ReactiveX API的Dart實現,它擴充套件了原始的Dart Streams API以符合ReactiveX標準。

由於它最初並未由Google定義,因此它使用不同的詞彙表。 下表給出了DartRxDart之間的相關性:| Dart | RxDart | | :-------- | --------:| | Stream | Observable | | StreamController | Subject |RxDart 正如我剛剛所說的,繼承了原生的[Dart Streams API]() 並且提供了3種主要的StreamController變種:

PublishSubject

PublishSubject是一個普通的廣播StreamController,但有一種情況是例外的:當stream返回一個Observable而不是一個[Stream]()時。image.png如你所見,PublishSubject僅向監聽器傳送在訂閱之後新增到Stream的事件。

BehaviorSubject

BehaviorSubject也是一個廣播StreamController,它返回一個[Observable]()而不是一個[Stream]()。image.pngPublishSubject的主要區別在於BehaviorSubject還將最後傳送的事件傳送給剛剛訂閱的監聽器。

ReplaySubject

ReplaySubject也是一個廣播StreamController,它返回一個[Observable]()而不是一個[Stream]()。image.png預設情況下,ReplaySubjectStream已經發出的所有事件作為第一個事件傳送到任何新的監聽器。

關於Resources的重要說明

始終釋放不再需要的Resources是一種非常好的做法。適用於:
  • StreamSubscription - 當您不再需要收聽Stream時,取消訂閱;
  • StreamController - 當你不再需要StreamController時,關閉它;
  • 這同樣適用於RxDart Subjects,當你不再需要BehaviourSubjectPublishSubject ...時,請將其關閉。

如何基於由Stream提供的資料構建Widget?

Flutter提供了一個非常方便的StatefulWidget,稱為StreamBuilder

StreamBuilder監聽Stream,每當某些資料輸出Stream時,它會自動重建,呼叫其builder回撥。

下面的程式碼演示瞭如何使用StreamBuilder:

StreamBuilder<T>(
    key: ...optional, the unique ID of this Widget...
    stream: ...the stream to listen to...
    initialData: ...any initial data, in case the stream would initially be empty...
    builder: (BuildContext context, AsyncSnapshot<T> snapshot){
        if (snapshot.hasData){
            return ...the Widget to be built based on snapshot.data
        }
        return ...the Widget to be built if no data is available
    },
)

以下示例模仿預設的“ counter”應用程式,但我們將使用Stream而不再使用任何setState。

注:counter是flutter的預設生成的demo。

解釋和說明:

  • 第24-30行:我們正在監聽stream,每當stream輸出一個新的值,我們將用該值更新Text;
  • 第35行:當我們點選FloatingActionButton時,我們遞增計數器並通過接收器將其傳送到Stream; 在流中注入值的事實導致偵聽它的StreamBuilder重建並“重新整理”計數器;
  • 我們不再需要State的概念,所有內容都通過Stream接收;
  • 這是一個很大的改進,因為呼叫setState()方法會強制整個Widget(和任何子視窗小部件)重建。 在這裡,只重建StreamBuilder(當然還有子視窗小部件);
  • 我們仍然在為頁面使用StatefulWidget的唯一原因,僅僅是因為我們需要通過dispose方法釋放StreamController,第15行;

什麼是響應式程式設計?

響應式程式設計是使用非同步資料流進行程式設計。換句話說,從事件(例如,點選),變數的變化,訊息,......到構建請求,可能改變或發生的所有事物的所有內容將被傳送,由資料流觸發。

很明顯,所有這些意味著,通過響應應式程式設計,應用程式將會:

  • 變得非同步,
  • 圍繞Streams和listeners的概念進行架構,
  • 當某些事情發生在某個地方(事件,變數的變化......)時,會向Stream傳送通知,
  • 如果“某人”收聽該Stream,它將被通知並將採取適當的行動,無論其在應用程式中的位置如何。
元件之間不再存在緊密耦合。

簡而言之,當WidgetStream傳送內容時,該Widget不再需要知道:

  • 接下來會發生什麼,
  • 誰可能使用這些資訊(沒有一個,一個或幾個Widget...)
  • 可能使用此資訊的地方(無處,同一頁面,另一個頁面,或者幾個頁面...),
  • 當這些資訊可能被使用時(幾乎是直接,幾秒鐘之後,永遠不會......)。
...... Widget只關心自己的業務,就是這樣!

乍一看,讀到這個,這似乎可能導致應用程式的“無法控制”,但正如我們將看到的,情況恰恰相反。 它給你:

  • 構建僅負責特定活動的部分應用程式的機會,
  • 輕鬆模擬一些元件的行為,以允許更完整的測試覆蓋,
  • 輕鬆重用元件(當前應用程式或其他應用程式中的其他位置),
  • 重新設計應用程式,並能夠在不進行太多重構的情況下將元件從一個地方移動到另一個地方,
  • ...

我們將很快看到使用響應式程式設計的好處......但在此之前我還需要介紹一下最後一個話題:BLoC模式。

BLoC模式

BLoC模式由來自Google的Paolo Soares和Cong Hui設計,並在2018年DartConf期間(2018年1月23日至24日)首次展示。 在YouTube上觀看此視訊。

BLoC代表業務邏輯元件(Business Logic Component)。

簡而言之,業務邏輯(Business Logic )需要:

  • 轉移到一個或幾個BLoC,
  • 儘可能從表現層中刪除。 換句話說,UI元件應該只關心UI事物而不關心業務,
  • 依賴Streams獨家使用輸入(Sink)和輸出(流),
  • 保持平臺獨立,
  • 保持環境獨立。

事實上,BLoC模式最初被設想為允許獨立於平臺重用相同的程式碼:Web應用程式,移動應用程式,後端。

它到底意味著什麼?

BLoC模式利用了我們剛才討論過的概念:Streams。image.png

  • Widgets通過Sinks向BLoC傳送事件,
  • BLoC通過Stream通知Widgets,
  • 由BLoC實現的業務邏輯不是他們關注的問題。

從上面來看,我們可以直接看到使用BLoC的一個巨大的好處。

感謝業務邏輯與UI的分離:

  • 我們可以隨時更改業務邏輯,對應用程式的影響最小,
  • 我們可能會更改UI而不會對業務邏輯產生任何影響,
  • 現在,測試業務邏輯變得更加容易。

如何將此BLoC模式應用於Counter應用?

將BLoC模式應用於Counter 應用可能看起來有點矯枉過正,但請允許我先向你展示......

我已經聽到你說“哇......為什麼這一切? 這一切都是必要的嗎?“

首先,是責任分離

如果你檢查CounterPage(第21-45行),你會發現其中絕對沒有任何業務邏輯。

此頁面現在僅負責:

  • 顯示計數器,現在只在必要時重新整理(即使頁面不必知道)
  • 提供按鈕,當按鈕按下時,將會在counter面板上請求一個動作

此外,整個業務邏輯集中在一個單獨的類“IncrementBloc”中。

現在如果你需要更改業務邏輯,您只需更新方法_handleLogic(第77-80行)。 也許新的業務邏輯會要求做非常複雜的事情...... CounterPage永遠不會知道它,這非常好!

其次,可測試性

現在,測試業務邏輯變得更加容易。

無需再通過UI測試業務邏輯。 只需要測試IncrementBloc

第三,自由組織布局

由於使用了Streams,你現在可以獨立於業務邏輯組織布局。

可以從應用程式中的任何位置啟動任何操作:只需呼叫.incrementCounter sink即可。

您可以在任何頁面的任何位置顯示counter,只需聽取.outCounter stream。

第四,減少“build”的數量

不使用setState()而是使用StreamBuilder大大減少了“build”的數量。

從效能角度來看,這是一個巨大的進步。

只有一個限制...BLoC的可訪問性

為了使所有這些工作,BLoC需要可以被訪問到。

有幾種方法可以訪問它:

  • 通過全域性單例

    這種方式可以實現,但不是真的推薦。 此外,由於Dart中沒有類解構函式,因此你永遠無法正確釋放資源。

  • 作為區域性變數

    你可以例項化BLoC的區域性例項。 在某些情況下,此解決方案完全符合某些需求。 在這種情況下,你應該始終考慮在StatefulWidget中初始化,以便您 可以利用dispose()方法來釋放相關資源。

  • 由父級提供

    使其可訪問的最常見方式是通過父級Widget訪問,通過StatefulWidget實現。

以下程式碼顯示了通用BlocProvider的示例。

關於這種通用BlocProvider的一些解釋

首先,如何將其作為provider使用?

如果你檢視示例程式碼“streams_4.dart”,你將看到以下程式碼行(第12-15行)

 home: BlocProvider<IncrementBloc>(
          bloc: IncrementBloc(),
          child: CounterPage(),
        ),

通過這些程式碼,我們只需例項化一個新的BlocProvider,它將處理一個IncrementBloc,並將CounterPage作為子項呈現。

從那一刻開始,從BlocProvider開始的子樹的任何Widget都將能夠通過以程式碼訪問IncrementBloc:

IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

可以使用多個BLoC嗎?

當然,這是非常可取的。建議如下:

  • (如果有任何業務邏輯)每個頁面的頂部有一個BLoC,
  • 為什麼不是ApplicationBloc來處理應用程式狀態?
  • 每個“足夠複雜的元件”都有相應的BLoC。

以下示例程式碼在整個應用程式的頂部顯示ApplicationBloc,然後在CounterPage頂部顯示IncrementBloc。

該示例還顯示瞭如何檢索兩個bloc。

為什麼不使用InheritedWidget?

在與BLoC相關的大多數文章中,你會看到通過InheritedWidget實現Provider。

當然,沒有什麼能阻止這種型別的實現。 然而,

  • 一個InheritedWidget沒有提供任何dispose方法,請記住,在不再需要資源時總是釋放資源是一種很好的做法。
  • 當然,沒有什麼能阻止你將InheritedWidget包裝在另一個StatefulWidget中,但是,使用InheritedWidget增加了什麼呢?
  • 最後,如果不受控制,使用InheritedWidget經常會導致副作用(請參閱下面的InheritedWidget上的Reminder)。

這三點解釋了我為什麼選擇通過StatefulWidget實現BlocProvider,這樣做可以讓我在Widget dispose時釋放相關資源。

Flutter無法例項化泛型型別不幸的是,Flutter無法例項化泛型型別,我們必須將BLoC的例項傳遞給BlocProvider。 為了在每個BLoC中強制執行dispose()方法,所有BLoC都必須實現BlocBase介面。

InheritedWidget的一些提醒

在使用InheritedWidget並通過context.inheritFromWidgetOfExactType(...)獲取指定型別最近的Widget時,每當InheritedWidget的父級或者子佈局發生變化時,這個方法會自動將當前“context”(= BuildContext)註冊到要重建的widget當中。

連結到BuildContext的Widget(Stateful或Stateless)的型別無關緊要。

關於BLoC的個人建議

與BLoC相關的第三條規則是:“依賴於Streams對輸入(Sink)和輸出(stream)的獨佔使用”。

我的個人經歷稍微關係到這個說法......讓我解釋一下。

起初,BLoC模式被設想為跨平臺共享相同的程式碼(AngularDart,...),並且從這個角度來看,該語句非常有意義。

但是,如果您只打算開發一個Flutter應用程式,那麼根據我的謙遜經驗,這有點矯枉過正。

如果我們堅持這種說法,那麼就沒有getter或settr,只有sinkstream。缺點是“所有這些都是非同步的”。

我們來看兩個樣本來說明缺點:

  • 你需要從BLoC中檢索一些資料,以便使用這些資料作為應該立即顯示這些引數的頁面的輸入(例如,想一個引數頁面),如果我們不得不依賴Streams,這會使構建非同步頁面(很複雜)。通過Streams使其工作的示例程式碼可能如下所示......醜陋不是它。
  • 在BLoC級別,您還需要轉換某些資料的“假”注入,以觸發提供您希望通過流接收的資料。使這項工作的示例程式碼可以是:

我不知道您的意見,但就個人而言,如果我沒有任何與程式碼移植/共享相關的限制,我發現這太笨重了,我寧願在需要時使用常規的getter / setter並使用Streams / Sinks來保持分離責任並在需要的地方廣播資訊,這很棒。

現在是時候在實踐中看到這一切......

正如本文開頭所提到的,我構建了一個偽應用程式來展示如何使用所有這些概念。 完整的原始碼可以在Github上找到。

請放縱,因為這段程式碼遠非完美,可能會做的更好和(或)有更好的架構,但唯一的目標只是告訴你這一切是如何工作的。

由於原始碼太多很多,我只會解釋主要的幾條。

電影目錄的來源

我使用免費的TMDB API來獲取所有電影的列表,以及海報,評級和描述。

為了能夠執行此示例應用程式,您需要註冊並獲取API金鑰(完全免費),然後將您的API金鑰放在檔案“/api/tmdb_api.dart”第15行。

應用程式的體系結構

該應用程式使用到了:

  • 3個主要的BLoC:

    1. ApplicationBloc(在所有內容之上),負責提供所有電影型別的列表;
   2.*FavoriteBloc*(就在下面),負責處理“收藏夾”的概念;
   3.*MovieCatalogBloc*(在2個主要頁面之上),負責根據過濾器提供電影列表;
  • 6個頁面:1.HomePage:登陸頁面,允許導航到3個子頁面; 2.ListPage:將電影列為GridView的頁面,允許過濾,收藏夾選擇,訪問收藏夾以及在後續頁面中顯示電影詳細資訊; 3.ListOnePage:類似於ListPage,但電影列表顯示為水平列表,下面是詳細資訊;

    1. FavoritesPage:列出收藏夾的頁面,允許取消選擇任何收藏夾;
    5.* Filters*:允許定義過濾器的EndDrawer:流派和最小/最大發布日期。從ListPage或ListOnePage呼叫此頁面;
    6.* Details*詳細資訊:頁面僅由ListPage呼叫以顯示電影的詳細資訊,但也允許選擇/取消選擇電影作為收藏;
  • 1個子BLoC: 1.FavoriteMovieBloc,連結到MovieCardWidget或MovieDetailsWidget,以處理作為收藏的電影的選擇/取消選擇
  • 5個主要Widget:1.FavoriteButton:負責顯示收藏夾的數量,實時,並在按下時重定向到FavoritesPage;2.FavoriteWidget:負責顯示一個喜歡的電影的細節並允許其取消選擇;3.FiltersSummary:負責顯示當前定義的過濾器;4.MovieCardWidget:負責將一部電影顯示為卡片,電影海報,評級和名稱,以及一個圖示,表示該特定電影的選擇是最喜歡的;5.MovieDetailsWidget:負責顯示與特定電影相關的詳細資訊,並允許其選擇/取消選擇作為收藏。

不同BLoCs / Streams的編排

下圖顯示瞭如何使用主要3個BLoC:

  • 在BLoC的左側,哪些元件呼叫Sink
  • 在右側,哪些元件監聽流

例如,當MovieDetailsWidget呼叫inAddFavorite Sink時,會觸發2個stream:

  • outTotalFavorites流強制重建FavoriteButton,和
  • outFavorites流

    • 強制重建MovieDetailsWidget(“最喜歡的”圖示)
    • 強制重建_buildMovieCard(“最喜歡的”圖示)
    • 用於構建每個MovieDetailsWidget

image.pngimage.pngimage.png

觀察

大多數Widget和Page都是StatelessWidgets,這意味著:

  • 強制重建的setState()幾乎從未使用過。 例外情況是:

    • 在ListOnePage中,當用戶點選MovieCard時,重新整理MovieDetailsWidget。 這也可能是由一個stream驅動的......
    • 在FiltersPage中允許使用者在接受篩選條件之前通過Sink更改過篩選條件。
  • 應用程式不使用任何InheritedWidget
  • 該應用程式幾乎是100%BLoCs / Streams驅動,這意味著大多數小部件彼此獨立,並且它們在應用程式中的位置

一個實際的例子是FavoriteButton,它顯示徽章中所選收藏夾的數量。 該應用程式共有3個FavoriteButton例項,每個例項顯示在3個不同的頁面中。

顯示電影列表(顯示無限列表的技巧說明)

要顯示符合過濾條件的電影列表,我們使用GridView.builder(ListPage)或ListView.builder(ListOnePage)作為無限滾動列表。

電影是通過TMDB API獲取的,每次拉取20個。

提醒一下,GridView.builderListView.builder都將itemCount作為輸入,如果提供了item數量,則表示要根據itemCount的數量來顯示列表。itemBuilderindex從0到itemCount - 1不等。

正如您將在程式碼中看到的那樣,我隨意為GridView.builder添加了30多個。 理由是,在這個例子中,我們正在操縱假定的無限數量的專案(這不是完全正確但是又有誰關心這個例子)。 這將強制GridView.builder請求顯示“最多30個”專案。

此外,GridView.builderListView.builder只在認為必須在視口中呈現某個專案(索引)時才呼叫itemBuilder。

MovieCatalogBloc.outMoviesList返回一個List <MovieCard>,它被迭代以構建每個Movie Card。 第一次,這個List <MovieCard>是空的,但是由於itemCount:... + 30,我們欺騙系統,它將要求通過_buildMovieCard(...)呈現30個不存在的專案。

正如您將在程式碼中看到的,此例程對Sink進行了一次奇怪的呼叫:

// Notify the MovieCatalogBloc that we are rendering the MovieCard[index]
//通知MovieCatalogBloc我們正在渲染MovieCard[index]
    movieBloc.inMovieIndex.add(index);

這個呼叫告訴MovieCatalogBloc我們要渲染MovieCard [index]。

然後_buildMovieCard(...)繼續驗證與MovieCard [index]相關的資料是否存在。 如果是,則渲染後者,否則顯示CircularProgressIndicator。

StreamCatalogBloc.inMovieIndex.add(index)的呼叫由StreamSubscription監聽,StreamSubscription將索引轉換為某個pageIndex數字(一頁最多可計20部電影)。 如果尚未從TMDB API獲取相應頁面,則會呼叫API。 獲取頁面後,所有已獲取電影的新列表將傳送到_moviesController。 當GridView.builder監聽該Stream(= movieBloc.outMoviesList)時,後者請求重建相應的MovieCard。 由於我們現在擁有資料,我們可以渲染它了。

名單和其他連結

介紹PublishSubject,BehaviorSubject和ReplaySubject的圖片由ReactiveX釋出。

其他一些有趣的文章值得一讀:

結論

很長的文章,但還有更多的話要說,因為對我而言,這是展開Flutter應用程式的方法。 它提供了很大的靈活性。

很快就會繼續關注新文章。 快樂寫程式碼。

版本所有,轉載請註明出處