Flutter2 for Web,寫了個部落格站點,已上線

oldbirds發表於2021-03-18

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 工程。後面就是往專案中添磚加瓦,補充血液了。

專案結構規劃

那麼接下來,我們一起搭建專案的基本骨架:

-w402

  • 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 有三個:

  1. 在 debug 環境的時候,是 a.com
  2. 在 preview 環境的時候,是 b.com
  3. 在 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

當然文章可能有理解不當的地方,歡迎大牛們指出。下一章節我們將會講狀態管理的內容,敬請期待!

相關文章