Flutter 迎來了它的的第二個大版本 Flutter2
,其中最大變更之一就是對 Web 的生產質量有了新的支援,已經從 Beta 測試順利轉正。
常言道“是騾是馬,拉出來溜溜”,寫個專案驗證下是非常有必要的。
因在寫本文時,已完成專案編寫,可優先體驗專案成果:webdemo.loveli.site
專案原始碼:swiftdo/web-demo
本專案將參照我的微信小程式 OldBirds
的功能,實現文章列表、文章詳情、分類文章列表等頁面,資料是通過 api 動態獲取的。
OldBirds 小程式裡除了更新自己的部落格外,也會推薦一些優質文章供大家閱讀,歡迎體驗
那麼下面將從零開始講解這個專案的實現過程。因為從 0 到 1 也不是件容易的事情,所以會分 N 篇文章講解。大體有以下內容:
- 專案搭建
- 網路請求的封裝
- 專案環境的封裝
- 實現首頁,請求跨域問題
- 狀態管理封裝
- 頁面適配
- 路由2.0的封裝
- url 策略
- 專案打包、部署上線
搭建環境,建立初始專案
因本人習慣每個 Flutter 專案對應各自的 Flutter 版本,所以採用 fvm 進行 Flutter 的版本管理。如果您不熟悉如何使用 fvm,不防閱讀下我之前寫的文章:
建立專案的大致命令如下:
$ mkdir web-demo # 建立目錄
$ cd web-demo # 進入目錄
$ fvm install stable # 安裝flutter stable channel 的版本
$ fvm use stable --force # web-demo 使用 stable 版本
$ fvm flutter create . # 生成以 web-demo 為專案名的工程
$ fvm flutter run -d Chrome # 執行到 Chrome 上
複製程式碼
當專案成功執行,自動開啟瀏覽器顯示頁面的時候,說明我們成功的建立了 web-demo 工程。後面就是往專案中添磚加瓦,補充血液了。
專案結構規劃
那麼接下來,我們一起搭建專案的基本骨架:
- assets:images、files、fonts 等資原始檔
- components:存放的是公共元件,重業務型
- config:專案的環境配置,比如 debug,product,preview 各環境的配置
- core:輕業務型工具類,或者公共元件,可以方便移植到其他專案
- models:模型類,json 資料解析
- pages:頁面
- router:路由
- services:一些第三方庫的封裝、網路請求等
- style:公共的樣式,顏色,字型,尺寸等
以上的目錄規劃,是根據自己的經驗總結劃分的,你也可以按自己專案結構的來。但元件、頁面、路由、資源、環境、服務基本上是達成了行業共識,很多專案都這麼劃分。
完成基本劃分後,接下來,我們從哪裡下手?
通常在開發的時候,我們會先有UI設計稿和需求文件,然後我們開始編寫靜態UI,待後端同學介面完成,繼續對接介面,然後測試,改bug,發版。
本專案比較特殊,已有 API 介面和資料,所以我們可以優先封裝網路請求。
網路封裝
Flutter 網路請求,通常會使用 dio
外掛。
那麼首先在 servers 目錄下建立檔案api.dart
,定義一個介面 Api
:
abstract class Api {
/// 獲取文章列表
/// [categoryId] 是文章分類id
Future<Map> fetchArticleList({int pageNo, int pageSize = 20, String categoryId});
/// 獲取文章詳情
Future<Map> fetchArticleDetail({String articleId});
}
複製程式碼
然後我們在定義一個實現類:
class ApiImpl implements Api {
Dio _dio;
ApiImpl() {
_dio = Dio(
BaseOptions(baseUrl: 'baseurl.com', connectTimeout: 20000, receiveTimeout: 20000),
);
}
/// 介面請求
Future<Map> fetchArticleList({int pageNo, int pageSize = 20, String categoryId}) async {
final response = await _dio.get('list', queryParameters: {
'pageNo': pageNo,
'pageSize': pageSize,
"category_id": categoryId,
});
Map data = response.data;
return data;
}
Future<Map> fetchArticleDetail({String articleId}) async {
final response = await _dio.get('detail', queryParameters: {
'article_id': articleId,
});
Map data = response.data;
return ValueUtil.toMap(data['data']);
}
}
複製程式碼
以上就是我們完成 dio 的二次封裝。抽出 Api 基類,ApiImpl 進行實現,這樣封裝的好處是在呼叫 Api 的地方不需要 dio 的細節,然後如果你哪天不用 dio,用其他的請求庫,那麼你只需要改 ApiImpl
的實現即可。
那麼上面的程式碼有沒有比較突出的問題呢?我們一起來分析下
問題分析
baseurl 問題
程式碼中 ApiImpl 的 baseurl.com
是有問題的。因為在開發環境,我們可能用的是 localhost
,線上上環境才是 baseurl.com
。
那麼很快有人會說可以通過 kDebugMode
區分正式環境或者是開發環境。
BaseOptions(baseUrl: kDebugMode ? 'localhost': 'baseurl.com', connectTimeout: 20000, receiveTimeout: 20000),
複製程式碼
如果只有兩個環境,確實可以這麼幹。但是如果有一天,新增了個預釋出環境,那麼這個時候 kDebugMode 就不受用了,無法通過 bool 型別去區分 3 種情況。
還有一種情況是,各環境除了 baseUrl 不一樣,其他的一些配置如 connectTimeout
也可能需要不同的值,那麼就會有很多kDebugMode?:
的判斷。
該如何解決?
對於 baseurl 的分析我們引出了2個問題:
- baseurl 的值跟環境有關
- 如果有多個值都跟環境有關,需要進行很多判斷
假設我們現在的 baseurl 有三個:
- 在 debug 環境的時候,是 a.com
- 在 preview 環境的時候,是 b.com
- 在 product 環境的時候,是 c.com
我們一開始的關注點在 baseurl,這次我們換個思考物件:環境。如果環境確定了,那麼 baseurl 也就定了。我們可以沿著這個方向思考。
那麼我們如何確認環境?
通常,有很多人是這麼做的:
- env == 1, debug 環境
- env == 2, preivew 環境
- env == 3, product 環境
然後通過設定 env
的值來確定環境(也有些人會使用列舉)。
if (env == 1) {
baseurl = "a.com";
} else if (env == 2) {
baseurl = "b.com"
} else if (env == 3) {
baseurl = "c.com"
}
複製程式碼
確實這樣實現了我們的目的,但是跟環境有關的地方,就會充斥著各種 if else
判斷,不是很優雅。傲嬌的我們不喜歡。
既然變數不喜歡,那我們就整一個類吧,不就是要一個 baseurl,我們給你:
abstract class Config {
String get baseurl; /// 這就是我們想要的
}
複製程式碼
因為整個應用只有一個環境,我們可以把它作為一個全域性變數:
Config config = Config();
複製程式碼
但是 Config 是抽象類,所以我們不能直接賦值。我們需要 Config 的實現類,因為有三個環境,所以就實現三個 Config 子類:
class ConfigDebug extends Config {
@override
String get baseurl => "a.com";
}
class ConfigPreview extends Config {
@override
String get baseurl => "b.com";
}
class ConfigProduct extends Config {
@override
String get baseurl => "c.com";
}
複製程式碼
如果現在是 debug 環境,那麼:
Config config = ConfigDebug();
複製程式碼
然後在需要使用 baseurl
的地方,直接呼叫 config.baseurl
,這個時候我們不再需要任何條件判斷。如果我們還需要個客戶環境,我們直接建立個 Config 的實現類即可。
還有剛上面說到的 connectTimeout
也跟環境有關係,那麼可以在 Config 新增 connectTimeout
:
abstract class Config {
String get baseurl; /// 這就是我們想要的
int get connectTimeout = 2000;
}
class ConfigProduct extends Config {
@override
String get baseurl => "c.com";
@override
int get connectTimeout = 6000;
}
複製程式碼
上面程式碼實現 debug 和 preview 環境的時候 connectTimeout 為 2000,在 product 環境的時候為 6000。
這樣封裝下來,是不是比全域性 env 變數控制優雅多了?
呼叫問題
對於我們封裝好的 ApiImpl
該如何使用?相信你也看過或者寫過類似程式碼:
// home.dart
getList() async{
var res = await ApiImpl().fetchArticleList(pageNo: pageNo);
//....
}
複製程式碼
這樣呼叫,確實可以完成介面的請求,專案完美跑起來。但是有想過更優雅的解決方案麼?難道 Api
這個東西抽出成介面就沒啥作用麼?很多人會回答,Api
抽象類有啥作用,我就沒有這個類,沒啥卵用,直接 class ApiImpl {}
。
真的沒有價值麼,一起來看看下面程式碼:
// home.dart
class Home {
Home({this.api})
final Api api;
getList() async{
var res = await api.fetchArticleList(pageNo: pageNo);
//....
}
}
Home(api: ApiImpl())
複製程式碼
Home 只依賴了 Api,不需要跟 ApiImpl 產生關聯。如果A在開發的時候,需要完成一個功能,但是這個功能又依賴了 B 寫的程式碼,但 B 又還沒時間實現。這個時候,我們需要將我們需要的功能抽象成介面,然後依賴這個抽象基類,這樣,即使不提供實現,程式碼也可以正常編譯。當然我們也可以寫個臨時的實現,讓程式碼能夠執行起來。待別人有時間,或者別人的模組已寫好,對接相應的介面實現即可。
class ApiMockImpl implements Api {}
// Home(api: ApiMockImpl())
Home(api: ApiImpl())
複製程式碼
這樣寫程式碼就不怕被別人耽誤,同時程式碼的靈活度也提升了。
對於上面的程式碼,如果只有 Home 這一個類,改起來還是挺容易的,但是像網路請求這種,可能就會散落在 N 處,那麼我們就需要將 N 處 ApiMockImpl
替換為 ApiImpl
,是不是很蛋疼。要是能只改一個地方就好了,接下來我們就這個問題給出了實現方案。
依賴注入
Config config = ConfigDebug();
複製程式碼
全域性變數對於我們來說,是程式資料 “同步” 的最方便最快捷的方式。
- 記憶體地址固定,讀寫效率比較高。
- 全域性可見,任何一個函式或執行緒都可以讀寫全域性變數
非常簡單靈活,然後太過自由,修改的風險性就越高。全域性變數破壞了函式的封裝效能,由於多個函式都可能使用全域性變數,函式執行時全域性變數的值可能隨時發生變化,那麼同樣的輸入就不一定有同樣的輸出。對於程式的查錯和除錯都非常不利,可靠性大打折扣。
如果不是萬不得已,最好不要使用全域性變數
複製程式碼
所以怎麼辦?可以採用單例。但是我們 Config 不適合作為一個單例。所以我們需要一個單例物件,然後 Config 作為其一個屬性。
class SomeSharedInstance {
// 單例公開訪問點
factory SomeSharedInstance() =>_sharedInstance()
// 靜態私有成員,沒有初始化
static SomeSharedInstance _instance;
// 私有建構函式
SomeSharedInstance._() {
// 具體初始化程式碼
}
// 靜態、同步、私有訪問點
static SomeSharedInstance _sharedInstance() {
if (_instance == null) {
_instance = SomeSharedInstance._();
}
return _instance;
}
Config config;
}
複製程式碼
然後在使用config 的時候,我們需要做類似操作:
SomeSharedInstance()
..config = ConfigDebug();
// SomeSharedInstance().config.baseurl;
複製程式碼
我個人覺得一個普通應用就一個單例基本夠用。
寫到這裡,強烈推薦一個外掛 get_it,非常適合我們現在這個場景。將建立的程式碼解耦。
服務定位模式(Service Locator Pattern)是一種軟體開發中的設計模式,通過應用強大的抽象層,可對涉及嘗試獲取一個服務的過程進行封裝。該模式使用一個稱為"Service Locator"的中心登錄檔來處理請求並返回處理特定任務所需的必要資訊. 來自: Service Locator 模式
在 lib 目錄下建立 locator.dart
:
GetIt locator = GetIt.instance;
setupLocator() {
// 配置專案環境
if (kDebugMode) {
locator.registerSingleton<Config>(ConfigDebug());
} else {
locator.registerSingleton<Config>(ConfigProduct());
}
/// 這裡就實現了改一處實現全域性替換
locator.registerLazySingleton<Api>(() => ApiImpl());
}
複製程式碼
這樣就實現了服務的註冊。然後在 main.dart
中呼叫 setupLocator()
:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await setupLocator();
runApp(MyApp());
}
複製程式碼
在需要使用服務的時候,如需要獲取 Config 的配置,直接呼叫 locator<Config>()
即可:
class ApiImpl implements Api {
Dio _dio;
ApiImpl() {
_dio = Dio(
BaseOptions(baseUrl: locator<Config>().baseUrl, connectTimeout: 20000, receiveTimeout: 20000),
);
}
}
複製程式碼
還有也順帶解決了 ApiImpl
的呼叫可能多處修改的問題。
getList() async{
var res = await locator<Api>().fetchArticleList(pageNo: pageNo);
//....
}
複製程式碼
更多 get_it 的使用,可以參考其文件。
章節總結
本文我們帶大家實現了:
- 網路請求的封裝
- 專案環境的封裝
在封裝過程中,我們不斷的讓程式碼變得優雅些、靈活些。設計是個不斷迭代的過程,不斷的優化,思考就能離目標越來越近。
總之,切記:以抽象為基準比以細節為基準搭建起來的架構要穩定得多,因此在拿到需求後,要面相介面程式設計,先頂層設計再細節地設計程式碼結構。
最後本專案的原始碼已上傳到 github 中:swiftdo/web-demo
如果想加入微信交流群的話,請關注微信公眾號:OldBirds
當然文章可能有理解不當的地方,歡迎大牛們指出。下一章節我們將會講狀態管理的內容,敬請期待!