原文: 使用 BLOC 模式構建你的 Flutter 項目
嗨夥計!我帶着另一篇關於 Flutter 的全新文章回來了。這一次,將討論和示範“如何構建 Flutter 項目”。這樣你就可以輕鬆地維護、擴展和測試你的 Flutter 項目。在深入實際主題之前,我想分享一個小故事,關於為什麼應該專注於為項目構建一個可靠的架構。
更新:本文的 第 2 篇 和第 3 篇 已發佈,對當前設計進行了一些更改,以解決一些問題並展示一些驚人的實現。 鏈接在這裏。
第 3 篇
Compile time Dependency Injection in Flutter
第 4 篇
Integration and Unit testing in Flutter
為什麼需要結構化你的項目?
“2015年,曾幾何時,我當時是一名競技程序員(Hackerearth 個人簡介),同時也在學習 Android 應用程序開發。作為一名競技程序員,我只關心程序的輸出和效率,從來沒有考慮過結構化我的程序和項目。這種趨勢和編碼風格也反映在我的 Android 項目中。我正在以一個競技程序員的思維模式編寫 Android 應用程序。一開始,一切都很好。因為只有我自己在項目上工作,沒有老闆給我提需求,不需要添加新功能或更改現有功能。但是,當我開始在一家初創公司工作併為他們構建 Android 應用程序時,我總是花很多時間去修改程序中的現有功能。不僅如此,我甚者在構建應用程序的過程中引入了 Bugs。所有這些問題的根本原因是:‘我從來不遵循任何的架構模式,或者從未結構化我的項目’。隨着時間的流逝,我開始瞭解軟件世界,併成功的把我自己從一個競技程序員轉變成了一個軟件工程師。如今,當啓動一個新項目時,我的主要關注點是為項目構建一個堅實的結構,或者架構。這幫助我成為一名優秀的、遊刃有餘的軟件工程師。😄”
結束我無聊的故事😅,讓我們迴歸本文的正題:“使用 BLOC 模式構建你的 Flutter 項目”。
我們的目標
構建一個非常簡單的 app,有一個頁面,頁面內包含一個網格列表。列表項是從服務端獲取的。列表的內容是The Movies DB站點中的熱門電影。
Note: 在繼續之前,你應該已經瞭解 Widgets,how to make a network call in Flutter,並具備 Dart 相關知識的中級水平。本文有點長,並附帶了大量其他資源的鏈接,方便你進一步閲讀相關的主題。
讓我們開始表演吧. 😍
在直接進入代碼之前,先展示一下 BLOC 架構的視覺體驗。我們將遵循這個架構構建 app。
上圖展示了數據如何從 UI 流向 Data Layer,以及如何從 Data Layer 流向 UI。BLOC 不會持有 UI 中 Widgets 的引用。UI 僅會監聽來自 BLOC 的變化。讓我們做一個小問答來理解這個圖:
1. 什麼是 BLOC 模式?
它是 Google 開發人員推薦的 Flutter 狀態管理系統。它從項目的中心位置訪問數據,有助於管理狀態。
2. 我可以將此架構與其他任何架構相關聯嗎?
當然可以。 MVP 和 MVVM 就是一些很好的例子。唯一會改變的是:BLOC 將被 MVVM 中的 ViewModel 所替代。
3. BLOC 的底層是什麼?或者在一個地方管理狀態的核心是什麼?
底層是 STREAMS 或 REACTIVE 方式。一般來説,數據將以流的形式從 BLOC 流向 UI 或從 UI 流向 BLOC。如果你從未聽説過流,請閲讀 Stack Overflow 的回答。
希望小問答部分能消除你的疑慮。如果需要進一步解釋或有其他問題,可以在下面評論或直接通過 LinkedIn 與我聯繫。
開始使用 BLOC 模式構建項目
1.首先新建一個項目,清除 main.dart 文件中的所有代碼。在終端中輸入以下命令:
flutter create myProjectName
2.在 main.dart 文件中寫下以下代碼:
import 'package:flutter/material.dart';
import 'src/app.dart'
void main() {
void main() {
runApp(App);
}
}
3.在 lib 包下創建一個 src 包,在 src 包中創建一個文件並將其命名為 app.dart,將以下代碼複製粘貼到 app.dart 文件中。
import 'package:flutter/material.dart';
import 'ui/movie_list.dart';
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
// TODO: implement build
return MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: MovieList(),
),
);
}
}
4.在 src 包下創建一個新包,並將其命名為 resources。
現在創建幾個新包,即 blocs、models、resources 和 ui,如下圖所示,然後我們設置項目的骨架:
blocs 包將存放 BLOC 實現的相關文件。models 包將存放 POJO 類,或從服務器獲取的 JSON 模型類。資源包將包含存儲庫類和網絡調用實現類。resources 包將存放數據存儲庫類和負責網絡調用的實現類。 ui 包將存放用户可見的 UI 頁面。
5.最後一件事,我們需要添加一個第三方庫 RxDart 。打開 pubspec.yaml,添加 rxdart: ^0.18.0,如下所示:
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
rxdart: ^0.18.0
http: ^0.12.0+1
sync 你的項目,或在終端中鍵入以下命令。需要在 project 根目錄中執行此命令。
flutter packages get
6.已經完成了 project 的骨架搭建,現在開始處理項目的底層邏輯,即網絡層。首先,瞭解一下即將使用的服務端 API 。點擊 link,進入電影網站數據庫 API 頁面。完成註冊,並從設置頁面獲取你的 API key。我們將從下面的 url 獲取數據:
http://api.themoviedb.org/3/movie/popular?api_key="your\_api\_key"
將你的 API key 放到上面的 url 中並點擊這個 url (刪除雙引號),你可以看到類似下面的 JSON 返回數據:
{
"page": 1,
"total_results": 19772,
"total_pages": 989,
"results": [
{
"vote_count": 6503,
"id": 299536,
"video": false,
"vote_average": 8.3,
"title": "Avengers: Infinity War",
"popularity": 350.154,
"poster_path": "\/7WsyChQLEftFiDOVTGkv3hFpyyt.jpg",
"original_language": "en",
"original_title": "Avengers: Infinity War",
"genre_ids": [
12,
878,
14,
28
],
"backdrop_path": "\/bOGkgRGdhrBYJSLpXaxhXVstddV.jpg",
"adult": false,
"overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his goal is to collect all six Infinity Stones, artifacts of unimaginable power, and use them to inflict his twisted will on all of reality. Everything the Avengers have fought for has led up to this moment - the fate of Earth and existence itself has never been more uncertain.",
"release_date": "2018-04-25"
},
7.為網絡返回的這種數據類型創建數據模型或 POJO 類。在 models 包下創建一個新的文件,命名為 item\_model.dart ,並複製下面的代碼到文件中:
class ItemModel {
int _page;
int _total_results;
int _total_pages;
List<_Result> _results = [];
ItemModel.fromJson(Map<String, dynamic> parsedJson) {
print(parsedJson['results'].length);
_page = parsedJson['page'];
_total_results = parsedJson['total_results'];
_total_pages = parsedJson['total_pages'];
List<_Result> temp = [];
for (int i = 0; i < parsedJson['results'].length; i++) {
_Result result = _Result(parsedJson['results'][i]);
temp.add(result);
}
_results = temp;
}
List<_Result> get results => _results;
int get total_pages => _total_pages;
int get total_results => _total_results;
int get page => _page;
}
class _Result {
int _vote_count;
int _id;
bool _video;
var _vote_average;
String _title;
double _popularity;
String _poster_path;
String _original_language;
String _original_title;
List<int> _genre_ids = [];
String _backdrop_path;
bool _adult;
String _overview;
String _release_date;
_Result(result) {
_vote_count = result['vote_count'];
_id = result['id'];
_video = result['video'];
_vote_average = result['vote_average'];
_title = result['title'];
_popularity = result['popularity'];
_poster_path = result['poster_path'];
_original_language = result['original_language'];
_original_title = result['original_title'];
for (int i = 0; i < result['genre_ids'].length; i++) {
_genre_ids.add(result['genre_ids'][i]);
}
_backdrop_path = result['backdrop_path'];
_adult = result['adult'];
_overview = result['overview'];
_release_date = result['release_date'];
}
String get release_date => _release_date;
String get overview => _overview;
bool get adult => _adult;
String get backdrop_path => _backdrop_path;
List<int> get genre_ids => _genre_ids;
String get original_title => _original_title;
String get original_language => _original_language;
String get poster_path => _poster_path;
double get popularity => _popularity;
String get title => _title;
double get vote_average => _vote_average;
bool get video => _video;
int get id => _id;
int get vote_count => _vote_count;
}
希望你可以將此文件和服務端返回的 JSON 進行影射。如果不是這樣,你需要知道的是我們最關心的是 Results 類中的 poster_path 屬性,我們將在主頁面中顯示所有熱門電影的海報(posters)。fromJson() 方法用來獲取解碼後的 JSON ,並將 JSON 數據映射到正確的變量中。
8.現在處理網絡請求。在 resources 包下新建一個文件,命名為 movie\_api\_provider.dart ,複製下面的代碼到文件中,稍後會進行解釋:
import 'dart:async';
import 'package:http/http.dart' show Client;
import 'dart:convert';
import '../models/item_model.dart';
class MovieApiProvider {
Client client = Client();
final _apiKey = 'your_api_key';
Future<ItemModel> fetchMovieList() async {
print("entered");
final response = await client
.get("http://api.themoviedb.org/3/movie/popular?api_key=$_apiKey");
print(response.body.toString());
if (response.statusCode == 200) {
// If the call to the server was successful, parse the JSON
return ItemModel.fromJson(json.decode(response.body));
} else {
// If that call was not successful, throw an error.
throw Exception('Failed to load post');
}
}
}
Note:將 moive\_api\_provider.dart 文件中的 _apiKey 的值替換為你的 API key,否則將不能請求到數據。
fetchMovieList() 方法用來向服務端 API 發起網絡請求。如果網絡請求成功,將返回一個 Feature ItemModel 對象;否則,將拋出一個異常。
9.在 resource 包下創建一個新的文件,命名為 repository.dart。複製下面的代碼到文件中:
import 'dart:async';
import 'movie_api_provider.dart';
import '../models/item_model.dart';
class Repository {
final moviesApiProvider = MovieApiProvider();
Future<ItemModel> fetchAllMovies() => moviesApiProvider.fetchMovieList();
}
文件中導入了 movie\_api\_provider.dart,並調用了 fetchMovieList() 方法。Repository 類是數據流向 BLOC 的中心點。
10.下面的部分稍微有點複雜,實現 bloc 邏輯。在 blocs 包下新建一個文件,命名為 movies_bloc.dart 。複製下面的代碼到文件中,後面會詳細解釋代碼:
import '../resources/repository.dart';
import 'package:rxdart/rxdart.dart';
import '../models/item_model.dart';
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();
}
}
final bloc = MoviesBloc();
導入 RxDart package import 'package:rxdart/rxdart.dart';,這會將 RxDart 相關的所有方法和類導入到文件中。在 MoviesBloc 類中創建一個 Repository 對象,用來訪問 fetchAllMovies() 方法。創建一個 PublishSubject 對象,它的職責是:以流的形式將添加到其中的 ItemModel 對象(從服務端獲取的數據模型類)傳遞給 UI。為了將 ItemModel 對象作為流傳遞,需要創建另一個方法 allMovies() ,返回類型是 Observable (如果你不瞭解 Observables,請觀看此視頻)。文件的最後創建了一個 bloc 對象,這樣方便 UI 以單例的方式訪問 MoviesBloc 類。
如果你不知道什麼是響應式編程,請看這個簡單的説明。簡單來説,只要服務端有新的數據返回,我們就必須更新 UI。為了簡化更新任務,讓 UI 監聽來自 MoviesBloc 的數據變化,並相應的更新所展示的內容。這種對數據的監聽,可以通過使用 RxDart 完成。
11.這是最後部分了,在 UI 包下創建一個文件,命名為 movie\_list.dart 。複製下面的代碼到文件中:
import 'package:flutter/material.dart';
import '../models/item_model.dart';
import '../blocs/movies_bloc.dart';
class MovieList extends StatelessWidget {
@override
Widget build(BuildContext context) {
bloc.fetchAllMovies();
return Scaffold(
appBar: AppBar(
title: Text('Popular Movies'),
),
body: StreamBuilder(
stream: bloc.allMovies,
builder: (context, AsyncSnapshot<ItemModel> snapshot) {
if (snapshot.hasData) {
return buildList(snapshot);
} else if (snapshot.hasError) {
return Text(snapshot.error.toString());
}
return Center(child: CircularProgressIndicator());
},
),
);
}
Widget buildList(AsyncSnapshot<ItemModel> snapshot) {
return GridView.builder(
itemCount: snapshot.data.results.length,
gridDelegate:
new SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (BuildContext context, int index) {
return Image.network(
'https://image.tmdb.org/t/p/w185${snapshot.data
.results[index].poster_path}',
fit: BoxFit.cover,
);
});
}
}
有意思的是,這個類沒有使用 StatefulWidget,而是使用了一個 StreamBuilder ,它可以像 StatefulWidget 一樣實現更新 UI。
這裏需要指出的一點是,我在 build 方法中進行了網絡請求調用。這是不應該的,因為 build(context) 方法會被調用多次。但由於文章變得越來越長,也越來越複雜,為了保持簡單,這裏仍然在 build(context) 方法中調用網絡請求。後續我會更新這篇文章,以一種更好的方式進行網絡調用。
正如我所説的,MoviesBloc 類將新數據作為流傳遞。為了處理流,有一個很好的內置類,即 StreamBuilder,它將監聽傳入的流並相應地更新 UI。StreamBuilder 需要一個 stream 參數,這裏傳遞的是 MovieBloc 的 allMovies() 方法,因為 allMovies() 返回一個流。當有數據流過來,StreamBuilder 將使用最新的數據重新渲染 widget,這些數據中包含 ItemModel 對象。當然,你可以使用任何的 Widget 展示數據對象中的任何數據(這是你的額創造力就展現出來了)。這裏使用一個 GridView 顯示 ItemModel 對象列表中的所有海報。最終產品的輸出如下:
到了文章的末尾,夥計們,你們能堅持到最後真是太好了,希望你們喜歡這篇文章。如果你有任何疑問或問題,請通過 LinkedIn 或 Twitter 聯繫我。請欣賞這篇文章,不要吝惜你的掌聲和評論。
如果你需要完整的源碼,請訪問這個工程項目的 githut repository
看看我的其他文章
Effective BLoC pattern
When Firebase meets BLoC Pattern