Flutter3 for Web,寫了個博客網站,已上線

Flutter3 for Web,寫了個部落格網站,已上線

Flutter 迎來了它的的第二個大版本 Flutter2,其中最大變更之一就是對 Web 的生產品質有了新的支援,已經從 Beta 測試順利轉正。

現已遷移到 Flutter3(2022.06.17),支援了macOS

常言道“是騾是馬,拉出來溜溜”,寫個項目驗證下是非常有必要的。

“ 因在寫本文時,已完成專案編寫,可優先體驗專案成果:https://webdemo.oldbird.run

本專案將參照我的微信小程式 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有三個:

  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

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

計算機二級web題目(1)–web基礎

區塊鏈研究實驗室 | Web3 .js基於乙太坊的Javascript API