從 0 開始手把手帶你搭建一套規範的 Flutter-mvp 專案工程環境

mldong發表於2021-05-10

前言

隨著Flutter2.0的釋出,Flutter對桌面和Web的支援也正式宣佈進入stable渠道。相信過不了多久,Flutter必會成為另一主流。前一陣子,基於個人的需要以及想真正的體驗一下Flutter開發,本人就使用Flutter開發了一款記賬類APP。對於這次開發的體驗總結一下:就是爽!開發體驗非常棒!還沒嘗試過的同學可以從本文開始學習,從0開始搭建一套規範的Flutter專案工程環境。

本文篇幅較長,會從以下幾個方面展開:

  • 環境安裝
  • 架構搭建
  • Flutter MVP規範
  • 常用外掛
  • 程式碼規範
  • 提交規範(待定)
  • 單元測試
  • 打包釋出

本專案完整的程式碼託管在 Gitee 倉庫,歡迎點亮小星星。

技術棧

  • 程式語言:Dart + Flutter
  • 路由工具:fluro: ^2.0.3
  • 網路請求庫:dio: ^3.0.10
  • 介面服務封裝工具:retrofit: 1.3.4+1
  • toast外掛:fluttertoast: ^7.1.5
  • 狀態管理:provider: ^4.3.3
  • 事件匯流排:^2.0.0

環境安裝

配置與工具要求

  • 作業系統: Windows 7 或更高版本 (64-bit)
  • 磁碟空間: 2G.
  • 工具 : Flutter 依賴下面這些命令列工具.

獲取Flutter SDK

去flutter官網下載其最新可用的安裝包,點選下載

這裡使用版本

解壓如下:

image-20210421112227644

image-20210421112257965

配置環境變數

  • 我的電腦->右鍵屬性->高階系統設定->環境設定

    • 系統變數找到Path追加flutter/bin

      image-20210421112547766

      image-20210421112737141

    • 使用者環境變數新增PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL

      PUB_HOSTED_URL=https://pub.flutter-io.cn
      FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
      複製程式碼

      image-20210421113201288

執行flutter doctor

使用管理員身份執行git bash或PowerShell

image-20210421113358718

flutter doctor
複製程式碼

效果如下:

image-20210421113615638

獲取Android SDK

image-20210421141240356

這裡使用版本

image-20210421141431738

安裝相關工具

image-20210421154334350

image-20210421154358739

再次配置環境變數

  • 我的電腦->右鍵屬性->高階系統設定->環境設定

    • 使用者環境變數新增ANDROID_HOME

      image-20210421154913329

    • 系統變數找到Path追加platform-tools和tools

      主要是platform-tools/adb.exe

      image-20210421155108836

安裝夜神模擬器

  1. 替換夜神模擬器adb.exe

將上面的platform-tools/adb.exe覆蓋夜神模擬器的Nox/bin/nox_adb.exe

覆蓋前先備份

image-20210421160525723

  1. 開啟夜神模擬器

    image-20210421161556573

  2. 使用flutter devices檢視裝置情況

    flutter devices
    複製程式碼

    修改環境變數後,要重新開啟git bash。

    image-20210421161318674

    這裡會看到有三個裝置,VOG AL10就是夜神模擬器,另外兩個為瀏覽器。

    到此,環境安裝完成。

安裝VSCode

架構搭建

使用flutter create命令初始化專案雛形

使用flutter create命令建立一個project

# 預設為Kotlin語言,如果使用java語言,則需要-a引數
flutter create -a java fluttermvp
cd fluttermvp
複製程式碼

預設工程目錄如下圖:

image-20210421152201316

執行應用程式

  • 檢查Android裝置是否在執行。如果沒有顯示

    flutter devices
    複製程式碼
  • 執行flutter run命令來執行應用程式

    flutter run
    複製程式碼

    因剛才安裝的Android SDK build-tools工具版本不對,會報如下錯

    image-20210421162308353

    開啟SDK Manager.exe安裝對應版本即可

    image-20210421162401874

    Android SDK Build-tools安裝完後,還會報錯,因為還有一個問題未解決。

    image-20210421165453458

    目前最高版本只有29,所以要只能選下載29的,然後再修改fluttermvp/android/app/gradle.bulid檔案

    compileSdkVersion 30 ==> compileSdkVersion 29
    targetSdkVersion 30 ==> targetSdkVersion 29
    複製程式碼

    安裝android SDK Platform

    image-20210421163329930

    image-20210421163347122

  • 如果 一切正常,在應用程式建成功後,您應該在您的裝置或模擬器上看到應用程式:

    image-20210421170009511

使用VSCode開啟工程

image-20210421170258717

暫時安裝3個常用外掛

image-20210421170658755

體驗一波熱過載

Flutter 可以通過 熱過載(hot reload) 實現快速的開發週期,熱過載就是無需重啟應用程式就能實時載入修改後的程式碼,並且不會丟失狀態(譯者語:如果是一個web開發者,那麼可以認為這和webpack的熱過載是一樣的)。簡單的對程式碼進行更改,然後告訴IDE或命令列工具你需要重新載入(點選reload按鈕),你就會在你的裝置或模擬器上看到更改。

  1. 開啟檔案lib/main.dart
  2. 將字串 'You have pushed the button this many times:' 更改為 'You have clicked the button this many times:'
  3. 不要按“停止”按鈕; 讓您的應用繼續執行.
  4. 要檢視您的更改,請呼叫 Save (cmd-s / ctrl-s), 或者點選 熱過載按鈕 (帶有閃電圖示的按鈕).

你會立即在執行的應用程式中看到更新的字串

將上前面執行的命令列關閉。使用VSCode啟動除錯

image-20210421171621618

image-20210421171940553

image-20210421172033459

Flutter配置檔案

後續使用到再依次說明

name: fluttermvp
description: A new Flutter project.
publish_to: 'none' 
version: 1.0.0+1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
dev_dependencies:
  flutter_test:
    sdk: flutter
flutter:
  uses-material-design: true
複製程式碼

規範目錄結構

├── android/                 # 安卓工程
├── ios/                     # ios工程
├── lib/                     # flutter&dart程式碼
	├── api/                 # 介面層
    ├── base/                # 基類
    ├── event/               # eventbus相關
    ├── http/                # http請求工具
    ├── iconfont/            # 阿里雲向量圖示
    ├── model/               # 實體層
	├── modules/             # 功能模組
   	├── router/              # 路由
    └── tool/				 # 工具庫
├── test/					 # 測試
├── web/                     # web工程
└── pubspec.yaml			 # flutter配置檔案
複製程式碼

為了後續導包統一,這裡建議修改一下pubspec.yaml的name為app。修改後需要重啟一下vscode,這樣導包功能才生效。

name: app                       # 這裡由之前的fluttermvp->app
description: A new Flutter project.
publish_to: 'none' 
version: 1.0.0+1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
dev_dependencies:
  flutter_test:
    sdk: flutter
flutter:
  uses-material-design: true
複製程式碼

整合路由工具fluro

fluro: ^2.0.3

image-20210421175321399

  1. 獲取外掛

image-20210421180028102

  1. 新建兩個頁面moudules/example/route_a.dartmoudules/example/RouterBPage.dart

image-20210510085428532

  1. stateful與stateless這裡暫時不說區別,選stateful,輸入名稱

image-20210510085514493

  1. 快速修復,導包

image-20210510085539615

image-20210422093539323

image-20210510085614793

  1. 最後程式碼修改成如下:
import 'package:flutter/material.dart';

class RouterAPage extends StatefulWidget {
  @override
  _RouterAPageState createState() => _RouterAPageState();
}

class _RouterAPageState extends State<RouterAPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("RouterA"),
      ),
      body: new Center(
        child: new Text("RouterA"),
      ),
    );
  }
}


複製程式碼
  1. route_b.dart重複上述操作。

  2. 新建路由處理檔案router/route_handles.dart

    import 'package:app/modules/example/route_b.dart';
    import 'package:fluro/fluro.dart';
    import 'package:flutter/material.dart';
    import 'package:app/main.dart';
    import 'package:app/modules/common/error.dart';
    import 'package:app/modules/example/route_a.dart';
    
    // 根頁面
    var rootHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return MyApp();
    });
    // 空頁面
    var emptyHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return ErrorPage();
    });
    // RouterPageA頁面
    var routerAHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return RouterAPage();
    });
    // RouterPageB頁面
    var routerBHandler = new Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      return RouterBPage();
    });
    
    
    複製程式碼

    上述的空頁面請參考RouterPageA或RouterPageB的方式自行建立。

    這裡要注意的是這裡的導包

    import 'package:app/main.dart';

    app對應的就是pubspec.yaml的name,在沒有修改之前就是

    import 'package:fluttermvp/main.dart';

  3. 新建路由配置檔案router/routes.dart

    import 'package:app/router/router_handlers.dart';
    import 'package:fluro/fluro.dart';
    
    class Routes {
      static void configureRoutes(FluroRouter router) {
        //空頁面
        router.notFoundHandler = emptyHandler;
        // 根頁面
        router.define("/", handler: rootHandler);
        // RouterPageA
        router.define("/routerA", handler: routerAHandler);
        // RouterPageB
        router.define("/routerB", handler: routerBHandler);
      }
    }
    
    
    複製程式碼
  4. 新建路由工具類tool/NavTool.dart

    import 'package:fluro/fluro.dart';
    import 'package:flutter/material.dart';
    
    class NavTool {
      static FluroRouter router;
    
      /// 設定路由物件
      static void setRouter(FluroRouter router) {
        router = router;
      }
    
      /// 跳轉到首頁
      static void goRoot(BuildContext context) {
        router.navigateTo(context, "/", replace: true, clearStack: true);
      }
    
      /// 跳轉到指定地址
      static void push(BuildContext context, String path,
          {bool replace = false, bool clearStack = false}) {
        FocusScope.of(context).unfocus();
        router.navigateTo(context, path,
            replace: replace,
            clearStack: clearStack,
            transition: TransitionType.native);
      }
    
      /// 跳轉到指定地址,有回撥
      static void pushResult(
          BuildContext context, String path, Function(Object) function,
          {bool replace = false, bool clearStack = false}) {
        FocusScope.of(context).unfocus();
        router
            .navigateTo(context, path,
                replace: replace,
                clearStack: clearStack,
                transition: TransitionType.native)
            .then((value) {
          if (value == null) {
            return;
          }
          function(value);
        }).catchError((onError) {
          print("$onError");
        });
      }
    
      /// 跳轉到指定地址-傳參
      static void pushArgumentResult(BuildContext context, String path,
          Object argument, Function(Object) function,
          {bool replace = false, bool clearStack = false}) {
        router
            .navigateTo(context, path,
                routeSettings: RouteSettings(arguments: argument),
                replace: replace,
                clearStack: clearStack)
            .then((value) {
          if (value == null) {
            return;
          }
          function(value);
        }).catchError((onError) {
          print("$onError");
        });
      }
    
      /// 跳轉到指定地址-傳參
      static void pushArgument(BuildContext context, String path, Object argument,
          {bool replace = false, bool clearStack = false}) {
        router.navigateTo(context, path,
            routeSettings: RouteSettings(arguments: argument),
            replace: replace,
            clearStack: clearStack);
      }
    
      /// 回退
      static void goBack(BuildContext context) {
        FocusScope.of(context).unfocus();
        Navigator.pop(context);
      }
    
      static void goBackWithParams(BuildContext context, result) {
        FocusScope.of(context).unfocus();
        Navigator.pop(context, result);
      }
    
      /// 替換當前地址
      static String changeToNavigatorPath(String registerPath,
          {Map<String, Object> params}) {
        if (params == null || params.isEmpty) {
          return registerPath;
        }
        StringBuffer bufferStr = StringBuffer();
        params.forEach((key, value) {
          bufferStr
            ..write(key)
            ..write("=")
            ..write(Uri.encodeComponent(value))
            ..write("&");
        });
        String paramStr = bufferStr.toString();
        paramStr = paramStr.substring(0, paramStr.length - 1);
        print("傳遞的引數  $paramStr");
        return "$registerPath?$paramStr";
      }
    }
    複製程式碼
  5. 入口頁新增路由配置

    
    void main() {
      /// 配置路由開始
      FluroRouter router = FluroRouter();
      Routes.configureRoutes(router);
      NavTool.router = router;
    
      /// 入口
      runApp(MyApp());
    }
    複製程式碼
  6. 佈局程式碼片段

    new RaisedButton(
        child: new Text("RouterA"),
        onPressed: () {
            NavTool.push(context, "/routerA");
        }),
    new RaisedButton(
        child: new Text("RouterB"),
        onPressed: () {
            NavTool.push(context, "/routerB");
        })
    複製程式碼
  7. 效果截圖

    image-20210422102127010

    image-20210422102143909

    image-20210422102203330

    至此,路由算是整合完畢,路由的進一步學習這裡就先不展開。

整合Flutter常用工具類

flustars: ^2.0.1

flustars依賴於Dart常用工具類庫common_utils,以及對其他第三方庫封裝,致力於為大家分享簡單易用工具類。如果你有好的工具類歡迎PR. 目前包含SharedPreferences Util, Screen Util, Directory Util, Widget Util, Image Util。

整合網路請求庫dio+retrofit+json

因為dart不支援反射,確切說是Flutter 禁用了dart:mirror無法使用反射,所以在json to bean上處理並不是很友好,不過我們可以藉助一些工具,通過命令在編譯期觸發,能儘可能的還原原生開發處理的舒適度。

dependencies環境依賴包:

dio: ^3.0.10

retrofit: 1.3.4+1

json_annotation: ^3.0.1

dev_dependencies環境依賴包:

retrofit_generator: 1.4.1+3

build_runner: ^1.7.3

json_serializable: ^3.1.1

安裝Json To Dart外掛

該外掛可以將json轉成Dart 的bean

image-20210508103514032

外掛小試:

{
    "userId": 1,
    "userName": "張三",
    "avatar": ""
}
複製程式碼

複製上述json字串->選中要建立dart檔案的目錄右鍵->Covert Json from Clipboard Here

image-20210508103636907

輸入類名回車

image-20210508141932065

選擇yes回車

image-20210508142012380

選擇yes回車

image-20210508141640768

最終生成如下user_vo.dart


class UserVo {
  int userId;
  String userName;
  String avatar;

  UserVo({this.userId, this.userName, this.avatar});

  UserVo.fromJson(Map<String, dynamic> json) {
    if(json["userId"] is int)
      this.userId = json["userId"];
    if(json["userName"] is String)
      this.userName = json["userName"];
    if(json["avatar"] is String)
      this.avatar = json["avatar"];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["userId"] = this.userId;
    data["userName"] = this.userName;
    data["avatar"] = this.avatar;
    return data;
  }
}
複製程式碼

在dart中,json to map是預設支援的,這裡就不再說明,由json to bean共有兩步,第一步是json to map,第二步是map to bean。

從UserVo類的結構可以看出,要想將map to bean 或 bean to map,需要定義四個部分內容:

  • 屬性欄位
  • 構造方法
  • map to bean方法
  • bean to map 方法

因為dart中沒有反射,所以需要一個個欄位去轉換,該工作可以由上述外掛幫轉換。

完整的介面請求樣例

  1. 找到介面請求返回的樣例資料

    這裡以我個人記賬app的系統分類介面做為舉例。

    {
      "code": 0,
      "msg": "查詢分類成功",
      "data": [
        {
          "id": 94,
          "name": "職業收入",
          "sort": 10,
          "icon": "m_zhiyeshouru",
          "selected": false,
          "children": [
            {
              "id": 95,
              "name": "薪資",
              "sort": 10.65,
              "icon": "m_xinzi",
              "selected": false
            },
            {
              "id": 97,
              "name": "獎金",
              "sort": 10.67,
              "icon": "m_jiangjin",
              "selected": false
            }
          ]
        }
      ]
    }
    複製程式碼
  2. 使用Json to Dart外掛轉成實體類

sys_cate_resp.dart


class SysCateResp {
  int code;
  String msg;
  List<Data> data;

  SysCateResp({this.code, this.msg, this.data});

  SysCateResp.fromJson(Map<String, dynamic> json) {
    if(json["code"] is int)
      this.code = json["code"];
    if(json["msg"] is String)
      this.msg = json["msg"];
    if(json["data"] is List)
      this.data = json["data"]==null?[]:(json["data"] as List).map((e)=>Data.fromJson(e)).toList();
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["code"] = this.code;
    data["msg"] = this.msg;
    if(this.data != null)
      data["data"] = this.data.map((e)=>e.toJson()).toList();
    return data;
  }
}

class Data {
  int id;
  String name;
  int sort;
  String icon;
  bool selected;
  List<Children> children;

  Data({this.id, this.name, this.sort, this.icon, this.selected, this.children});

  Data.fromJson(Map<String, dynamic> json) {
    if(json["id"] is int)
      this.id = json["id"];
    if(json["name"] is String)
      this.name = json["name"];
    if(json["sort"] is int)
      this.sort = json["sort"];
    if(json["icon"] is String)
      this.icon = json["icon"];
    if(json["selected"] is bool)
      this.selected = json["selected"];
    if(json["children"] is List)
      this.children = json["children"]==null?[]:(json["children"] as List).map((e)=>Children.fromJson(e)).toList();
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["id"] = this.id;
    data["name"] = this.name;
    data["sort"] = this.sort;
    data["icon"] = this.icon;
    data["selected"] = this.selected;
    if(this.children != null)
      data["children"] = this.children.map((e)=>e.toJson()).toList();
    return data;
  }
}

class Children {
  int id;
  String name;
  double sort;
  String icon;
  bool selected;

  Children({this.id, this.name, this.sort, this.icon, this.selected});

  Children.fromJson(Map<String, dynamic> json) {
    if(json["id"] is int)
      this.id = json["id"];
    if(json["name"] is String)
      this.name = json["name"];
    if(json["sort"] is double)
      this.sort = json["sort"];
    if(json["icon"] is String)
      this.icon = json["icon"];
    if(json["selected"] is bool)
      this.selected = json["selected"];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data["id"] = this.id;
    data["name"] = this.name;
    data["sort"] = this.sort;
    data["icon"] = this.icon;
    data["selected"] = this.selected;
    return data;
  }
}
複製程式碼
  1. 新建介面類cate_service.dart

    import 'package:app/model/sys_cate_resp.dart';
    import 'package:dio/dio.dart';
    import 'package:retrofit/retrofit.dart';
    part 'CateService.g.dart';
    
    @RestApi()
    abstract class CateService {
      factory CateService(Dio dio, {String baseUrl}) = _CateService;
      @POST("/bill/category/listCategory")
      Future<SysCateResp> listSysCate(@Field() String tallyType);
    }
    複製程式碼

    注意兩個地方,開始沒有生成相關檔案,會報錯。

    • part 'cate_service.g.dart';
    • factory CateService(Dio dio) = _CateService;
  2. vscode開啟新終端執行如下命令

    flutter pub run build_runner build
    複製程式碼

    image-20210508145854184

  3. 檢視生成檔案

    在cate_service.dart同級目錄下會生成cate_service.g.dart檔案,其實也就是part指定的檔案。

    // GENERATED CODE - DO NOT MODIFY BY HAND
    
    part of 'cate_service.dart';
    
    // **************************************************************************
    // RetrofitGenerator
    // **************************************************************************
    
    class _CateService implements CateService {
      _CateService(this._dio, {this.baseUrl}) {
        ArgumentError.checkNotNull(_dio, '_dio');
      }
    
      final Dio _dio;
    
      String baseUrl;
    
      @override
      Future<SysCateResp> listSysCate(tallyType) async {
        ArgumentError.checkNotNull(tallyType, 'tallyType');
        const _extra = <String, dynamic>{};
        final queryParameters = <String, dynamic>{};
        final _data = {'tallyType': tallyType};
        _data.removeWhere((k, v) => v == null);
        final _result = await _dio.request<Map<String, dynamic>>(
            '/bill/category/listCategory',
            queryParameters: queryParameters,
            options: RequestOptions(
                method: 'POST',
                headers: <String, dynamic>{},
                extra: _extra,
                baseUrl: baseUrl),
            data: _data);
        final value = SysCateResp.fromJson(_result.data);
        return value;
      }
    }
    複製程式碼
  4. 新建一個單元測試類

    test/main_test.dart

    import 'package:app/api/cate_service.dart';
    import 'package:app/model/sys_cate_resp.dart';
    import 'package:dio/dio.dart';
    
    Future<void> main() async {
      CateService cateService =
          new CateService(new Dio(), baseUrl: "http://bill-app.mldong.com");
      SysCateResp cateResp = await cateService.listSysCate("10");
      print(cateResp.toJson());
    }
    複製程式碼
  5. 開啟檔案Ctrl+F5執行,或者滑鼠點選Run

    image-20210510090234933

    控制檯輸出:

    image-20210510090322088

Dio全域性配置

上述的new Dio()使用的是預設配置,但是大多數情況下我們都是需要做一些全域性請求攔截器的,比如列印請求日誌、請求中追加token等。

新建一個類http/dio_manager.dart

對Dio物件進行如下處理:

  • 單例Dio
  • 請求頭追加版本號
  • 設定請求根地址
  • 請求超時時間
  • 響應超時時間
  • 請求日誌列印
/*
 * 網路請求管理類
 */
import 'package:app/config/config.dart';
import 'package:dio/dio.dart';

class DioManager {
  //寫一個單例
  //在 Dart 裡,帶下劃線開頭的變數是私有變數
  static DioManager _instance;

  Dio dio = new Dio();
  DioManager() {
    // Set default configs
    dio.options.headers = {
      "version": GlobalConfig.API_VERSION,
    };
    dio.options.baseUrl = GlobalConfig.BASE_URL;
    dio.options.connectTimeout = 5000;
    dio.options.receiveTimeout = 3000;
  }
  static DioManager getInstance() {
    if (_instance == null) {
      _instance = DioManager();
    }
    // 除錯模式下開啟請求日誌列印
    if (GlobalConfig.isDebug) {
      _instance.dio.interceptors.add(LogInterceptor(
          request: false, // 不列印請求
          requestBody: true, // 列印請求體
          responseHeader: false, // 不列印響應頭
          responseBody: true)); // 列印響應體
    }
    return _instance;
  }
}
複製程式碼

呼叫樣例

import 'package:app/api/cate_service.dart';
import 'package:app/http/dio_manager.dart';
import 'package:app/model/sys_cate_resp.dart';

Future<void> main() async {
  CateService cateService = new CateService(DioManager.getInstance().dio);
  SysCateResp cateResp = await cateService.listSysCate("10");
  print(cateResp.toJson());
}
複製程式碼

忽略*.g.dart檔案

因為*.g.dart檔案是由工具生成的,所以不建議將其加入到版本控制,需要在.gitignore檔案追加一行

*.g.dart
複製程式碼

整合阿里向量圖示庫

官網:www.iconfont.cn/

引入svg庫

flutter_svg: ^0.22.0

安裝flutter-iconfont-cli外掛

flutter-iconfont-cli為Nodejs外掛,做為工具類,可以基於阿里雲的js檔案生成對應的dart圖示依賴類。

npm install flutter-iconfont-cli -g
複製程式碼

image-20210508153522752

阿里向量圖示流程樣例

  • 登入

  • 建立專案

    image-20210508160610523

  • 新增圖示到專案

  • 點選生成程式碼

    image-20210508154336424

    image-20210508154417124

  • 使用外掛初始化

    npx iconfont-init
    複製程式碼

    image-20210508154555871

  • 開啟iconfont.json,將上述的js地址替換如下:

    {
        "symbol_url": "請參考README.md,複製官網提供的JS連結",
        "save_dir": "./lib/iconfont",
        "trim_icon_prefix": "icon",
        "default_icon_size": 18,
        "null_safety": true
    }
    複製程式碼

    ==>

    {
        "symbol_url": "//at.alicdn.com/t/font_2534875_d4lkc1mlsxk.js",
        "save_dir": "./lib/iconfont",
        "trim_icon_prefix": "",
        "default_icon_size": 18,
        "null_safety": false
    }
    複製程式碼
    • symbol_url js連結

      請直接複製iconfont官網提供的專案連結。請務必看清是.js字尾而不是.css字尾。如果你現在還沒有建立iconfont的倉庫,那麼可以填入這個連結去測試:http://at.alicdn.com/t/font_1373348_ghk94ooopqr.js

    • save_dir

      根據iconfont圖示生成的元件存放的位置。每次生成元件之前,該資料夾都會被清空。

    • trim_icon_prefix

      如果你的圖示有通用的字首,而你在使用的時候又不想重複去寫,那麼可以通過這種配置這個選項把字首統一去掉。

    • default_icon_size

      我們將為每個生成的圖示元件加入預設的字型大小,當然,你也可以通過傳入props的方式改變這個size值

    • null_safety

      dart 2.12.0 開始支援的空安全特性,開啟該引數後,生成的語法會有所變化,所以需要變更sdk以保證語法能被識別。

      environment:
      - sdk: ">=2.7.0 <3.0.0"
      + sdk: ">=2.12.0 <3.0.0"
      複製程式碼

      目前版本不支援null_safety,所以要修改為false。

  • 使用命令生成

    npx iconfont-flutter
    複製程式碼

image-20210508155817427

如果向量圖示有變動,可以再次複復上述流程。

圖示使用樣例

/// IconFont(IconNames.xxx);
/// IconFont(IconNames.xxx, color: '#f00');
/// IconFont(IconNames.xxx, colors: ['#f00', 'blue']);
/// IconFont(IconNames.xxx, size: 30, color: '#000');

import 'package:app/iconfont/icon_font.dart';
import 'package:flutter/material.dart';

class IconPage extends StatefulWidget {
  @override
  _IconPageState createState() => _IconPageState();
}

class _IconPageState extends State<IconPage> {
  List<IconNames> iconList = new List();
  @override
  void initState() {
    super.initState();
    IconNames.values.forEach((element) {
      iconList.add(element);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("圖示"),
        ),
        body: new GridView.builder(
            scrollDirection: Axis.vertical,
            gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
              maxCrossAxisExtent: 80, //子控制元件最大寬度為100
              childAspectRatio: 1.0, //寬高比為1:1
              crossAxisSpacing: 5,
              mainAxisSpacing: 10,
            ),
            padding: EdgeInsets.all(10),
            itemCount: iconList.length,
            itemBuilder: (BuildContext context, int position) {
              IconNames icon = this.iconList[position];
              return new GestureDetector(
                child: new Container(
                    alignment: Alignment.center,
                    decoration: new BoxDecoration(color: Colors.white),
                    child: new Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        new Container(
                          decoration: new BoxDecoration(
                              color: new Color(0xfff0f0f0),
                              borderRadius:
                                  BorderRadius.all(new Radius.circular(24))),
                          width: 48,
                          height: 48,
                          //child: IconTool.getIcon("${tag.icon}"),
                          child: new Center(
                            child: IconFont(icon),
                          ),
                        )
                      ],
                    )),
              );
            }));
  }
}
複製程式碼

image-20210509131448481

整合載入中元件

component/common_components.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class LoadingDialog extends Dialog {
  /// 顯示載入中
  /// @param 當前上下文
  static void show(BuildContext context, {bool mateStyle}) {
    Navigator.of(context).push(DialogRouter(LoadingDialog()));
  }

  /// 隱藏載入中
  /// @param 當前上下文
  static void hide(BuildContext context) {
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
        child: Material(
          //建立透明層
          type: MaterialType.transparency, //透明型別
          child: Center(
            //保證控制元件居中效果
            child: CupertinoActivityIndicator(
              radius: 18,
            ),
          ),
        ),
        onWillPop: () async {
          return Future.value(false);
        });
  }
}

class DialogRouter extends PageRouteBuilder {
  final Widget page;

  DialogRouter(this.page)
      : super(
          opaque: false,
          barrierColor: Color(0x00000001),
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) =>
              child,
        );
}

複製程式碼

整合吐司

fluttertoast: ^8.0.6

Fluttertoast.showToast("登入成功!");
複製程式碼

整合狀態管理

provider: ^4.3.3

這裡使用provider文件的例子講解:

lib/modules/example/provider_test.dart

定義要共享的物件

class Counter with ChangeNotifier, DiagnosticableTreeMixin {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  /// Makes `Counter` readable inside the devtools by listing all of its properties
  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(IntProperty('count', count));
  }
}
複製程式碼

定義提供者

為了方便測試,將提供者定義在最上層。

void main() {
  runApp(
    // 可以定義多個提供者
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => Counter()),
      ],
      child: const MyApp(),
    ),
  );
}
複製程式碼

定義消費者

主要使用context.watch來監聽資料變動情況

class Count extends StatelessWidget {
  const Count({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(

        /// Calls `context.watch` to make [Count] rebuild when [Counter] changes.
        '${context.watch<Counter>().count}',
        key: const Key('counterState'),
        style: Theme.of(context).textTheme.headline4);
  }
}
複製程式碼

MyApp相關

class MyApp extends StatelessWidget {
  const MyApp({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Example'),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          children: const <Widget>[
            Text('You have pushed the button this many times:'),

            Count(),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: const Key('increment_floatingActionButton'),
        onPressed: () => context.read<Counter>().increment(),
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
複製程式碼

這裡達到的效果是:點選MyHomePage頁面元件上的浮動按鈕,Count頁面元件的值會變化。演示,略。

主題色管理

待定。

整合事件匯流排

event_bus: ^2.0.0

Flutter MVP規範

建立mvp層基類

base/mvp.dart

import 'package:app/component/common_components.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';

/// v層基類
abstract class BaseView {
  BuildContext getContext();

  //顯示載入loading
  void showLoading();

  //隱藏loading
  void hideLoading();

  //顯示吐司
  void showToast(String msg);
  /***
   * 設定按鈕索引-用於控制禁用啟用的
   */
  void setCurrentBtnName(String btnName);
  String getCurrentBtnName();
  // 設定載入狀態
  void setLoading(bool loading);
  bool getLoading();
}

/// p層抽象類
abstract class IPresenter {
  void deactivatePresenter();
  void disposePresenter();
  void initPresenter();
}

/// p層基類
class BasePresenter<V extends BaseView> extends IPresenter {
  V view;
  CancelToken _cancelToken;

  @override
  void deactivatePresenter() {}

  @override
  void disposePresenter() {
    //請求取消
    if (_cancelToken != null) {
      if (_cancelToken.isCancelled) {
        _cancelToken.cancel();
      }
    }
  }

  @override
  void initPresenter() {}
}

/// State 基類
abstract class BaseState<T extends StatefulWidget, V extends BasePresenter>
    extends State<T> implements BaseView {
  V presenter;
  String currentBtnName = "";
  bool loading = false;
  V createPresenter();
  BaseState() {
    presenter = createPresenter();
    presenter.view = this;
  }
  @override
  BuildContext getContext() {
    return context;
  }

  bool _isShowDialog = false;
  @override
  void hideLoading() {
    if (mounted && _isShowDialog) {
      _isShowDialog = false;
      LoadingDialog.hide(context);
    }
  }

  @override
  void showLoading() {
    /// 避免重複彈出
    if (mounted && !_isShowDialog) {
      _isShowDialog = true;
      Future.delayed(Duration.zero, () {
        LoadingDialog.show(context);
      });
    }
  }

  @override
  void showToast(String msg) {
    Fluttertoast.showToast(msg: msg);
  }

  @override
  void dispose() {
    super.dispose();
    presenter?.disposePresenter();
  }

  @override
  void deactivate() {
    super.deactivate();
    presenter?.deactivatePresenter();
  }

  @override
  void initState() {
    super.initState();
    presenter?.initPresenter();
  }

  @override
  void setCurrentBtnName(String btnName) {
    setState(() {
      this.currentBtnName = btnName;
    });
  }

  @override
  String getCurrentBtnName() {
    return this.currentBtnName;
  }

  @override
  void setLoading(bool loading) {
    setState(() {
      this.loading = loading;
    });
  }

  @override
  bool getLoading() {
    return this.loading;
  }
}

複製程式碼

mvp結構說明

本框架中mvp結構共有三個檔案

  • reg_contact.dart

    用於定義v與p的介面-抽象類

  • reg_presenter_impl.dart

    p介面的具體實現類-編寫業務邏輯

  • reg.dart

    ui層

mvp骨架程式碼生成工具

generate/index.js

  • 安裝依賴

    第一次使用前需要安裝依賴,在當前工程下執行如下命令:

    npm install
    複製程式碼
  • 檢視幫助

    node generate/index.js -h
    複製程式碼

    image-20210509221233282

  • 生成新模組

    node ./generate/index.js -f reg -co 1
    複製程式碼
  • 生成新模組-覆蓋式

    node ./generate/index.js -f reg -co 1
    複製程式碼

上述操作最終生成的模組存放在lib/modules/reg

常用外掛

主要是VsCode外掛

  • Dart

    Dart程式碼擴充套件了VS程式碼,並支援Dart程式語言,並提供了有效編輯、重構、執行和重新載入Flall移動應用程式和AngularDart web應用程式的工具。

  • Flutter

    這個VS程式碼擴充套件增加了對有效編輯、重構、執行和重新載入Flitter移動應用程式的支援,以及對Dart程式語言的支援。

  • Flutter Widget Snippts

    Dart 與 Flutter 語法片段提示

  • Json To Dart

    將json 轉成 Dart 實體類工具

程式碼規範

檔案命名

所有檔名採用下劃線命名方式。

router_handlers.dart
icon.dart
tools.dart
複製程式碼

類名

參考java的命名規則,大駝峰。

class UserService {
}
class UserVo {
}
複製程式碼

方法名屬性名

參考java的命名規則,小駝峰。

String userName="";
int age = 0;
void loginByUserName(String userName,String password){
    
}
複製程式碼

私有方法名與屬性名

參考java的命名規則,小駝峰,但以下劃線開頭

String _userName = "";
int _age = 0;
複製程式碼

提交規範

單元測試

開始生成的腳手架預設已經整合了單元測試的依賴

dev_dependencies:
  flutter_test:
    sdk: flutter
複製程式碼

簡單使用

lib/test/main_test.dart

import 'dart:math';

import 'package:flutter_test/flutter_test.dart';
void main() {
  test("簡單判斷", () {
    expect(new Random().nextInt(3), 1);
  });
}
複製程式碼

點選Run

image-20210510093846636

實際值與預期值不一致

image-20210510093213800

實際值與預期值一致

image-20210510093151892

分組測試

使用 group 合併多個測試,用來測試多個有關聯的測試。

import 'dart:math';

import 'package:flutter_test/flutter_test.dart';

void main() {
  group("組測試", () {
    test("測試1", () {
      expect(new Random().nextInt(3), 1);
    });
    test("測試2", () {
      expect(new Random().nextInt(3), 1);
    });
    test("測試3", () {
      expect(new Random().nextInt(3), 1);
    });
  });
}
複製程式碼

image-20210510093522198

網路介面測試

import 'package:app/api/cate_service.dart';
import 'package:app/http/http.dart';
import 'package:app/model/sys_cate_resp.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test("介面請求測試:", () async {
    CateService cateService = new CateService(DioManager.getInstance().dio);
    SysCateResp cateResp = await cateService.listSysCate("20");
    // 驗證 cateResp.code 的是是否為 0
    expect(cateResp.code, 0);
  });
}
複製程式碼

image-20210510093819066

Widget測試

  1. 新建一個頁面lib/modules/example/unit_test.dart
import 'package:flutter/material.dart';

class UnitPage extends StatefulWidget {
  @override
  _UnitPageState createState() => _UnitPageState();
}

class _UnitPageState extends State<UnitPage> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Unit Test",
      home: Scaffold(
        appBar: AppBar(
          title: Text("Unit Test"),
        ),
        body: new Center(
            child: new RaisedButton(
                key: new Key("btnClickMe"),
                child: new Text("點我"),
                onPressed: () {
                  print("Hello World!");
                })),
      ),
    );
  }
}
複製程式碼
  1. 單元測試流程

通過Key獲取RaisedButton物件->執行該物件的點選事件

import 'package:app/modules/example/unit_test.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('這是一個 Widget 測試', (WidgetTester tester) async {
    await tester.pumpWidget(new UnitPage());
    // 獲取RaisedButton物件
    final btnClickMe = find.byKey(new Key("btnClickMe"));
    // 驗證物件是否存在
    expect(btnClickMe, findsWidgets);
    // 執行一下按鈕的點選事件
    tester.tap(btnClickMe);
  });
}
複製程式碼
  1. 執行Run

image-20210510101704718

  1. 結果

image-20210510101720145

注意:待測試的 widget 需要用 MaterialApp() 包裹;

當然,也可以通過StatefulBuilder構造的方式,測試非MaterialApp()包裹的元件。

例1:

import 'package:app/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('這是一個 Widget 測試', (WidgetTester tester) async {
    await tester.pumpWidget(new StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
      return new MaterialApp(
        home: new MyHomePage(title: 'Flutter Demo Home Page'),
      );
    }));
    // 獲取FloatingActionButton物件
    final btn = find.byType(FloatingActionButton);
    // 驗證物件是否存在
    expect(btn, findsWidgets);
    // 執行一下按鈕的點選事件
    tester.tap(btn);
  });
}

複製程式碼

例2:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('這是一個 Widget 測試', (WidgetTester tester) async {
    await tester.pumpWidget(new StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
      return new MaterialApp(
        home: new Text("T"),
      );
    }));
    // 獲取Text物件
    final t = find.byType(Text);
    // 驗證物件是否存在
    expect(t, findsWidgets);
  });
}
複製程式碼

其他複雜的互動,這裡就不一一演示了,更深入的請轉

打包釋出

因條件有限,這裡僅介紹安卓版的打包。

修改應用包名

假定com.example修改成com.mldong

  1. 修改目錄

    android/app/src/main/java/com/example/==>android/app/src/main/java/com/mldong/

  2. 修改MainActivity.java檔案

    android/app/src/main/java/com/example/MainActivity.java

    com.example==>com.mldong

    package com.mldong.fluttermvp;
    
    import io.flutter.embedding.android.FlutterActivity;
    
    public class MainActivity extends FlutterActivity {
    }
    複製程式碼
  3. 修改AndroidManifest.xml檔案

    生產配置:

    android/app/src/main/java/AndroidManifest.xml

    開發配置:

    android/app/src/debug/AndroidManifest.xml

    第2行

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        package="com.mldong.fluttermvp">
    複製程式碼
  4. 修改build.gradle檔案

    android/app/build.gradle

    大概32行左右,節點

    android->defaultConfig->applicationId

    applicationId "com.mldong.fluttermvp"
    複製程式碼

修改圖示

可使用圖示工場生成圖示

icon.wuruihong.com/

image-20210510111809742

將生成的檔案複製到android/app/src/res/mipmap-*目錄即可。

檔名為:ic_launcher.png

生成簽名檔案

  1. 生成簽名檔案

    keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 36500 -alias fluttermvp
    複製程式碼

    image-20210510113208960

  2. 將簽名檔案複製到android根目錄上

    fluttermvp/android/key.jks

  3. 檢視簽名檔案資訊(按需)

    keytool -list -v -keystore android/key.jks -storepass 123456
    複製程式碼

    image-20210510142241512

  4. 新建key.properties配置檔案

    storeFile=../key.jks
    storePassword=123456
    keyAlias=fluttermvp
    keyPassword=123456
    複製程式碼
  5. 修改build.gradle檔案

    android/app/build.gradle

    以android節點同級新增如下程式碼

    def keystoreProperties = new Properties()
    def keystorePropertiesFile = rootProject.file('key.properties')
    if (keystorePropertiesFile.exists()) {
        keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
    }
    複製程式碼

    android節點下新增signingConfigs節點

    android {
    	signingConfigs {
            release {
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
    }
    複製程式碼

    android->buildTypes->release 修改如下:

    signingConfig signingConfigs.debug修改成signingConfig signingConfigs.release

    android {
        buildTypes {
            release {
                signingConfig signingConfigs.release
            }
        }
    }
    複製程式碼

注意:為了安全,key.properties檔案不要加入到版本庫。

為了相容key.properties不存在的情況,可以修改為:

android {
   
    if(keystoreProperties['keyAlias'] &&
        keystoreProperties['keyPassword'] &&
        keystoreProperties['storeFile'] &&
        keystoreProperties['storePassword']){
        signingConfigs {
            release {
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
        buildTypes {
            release {
                signingConfig signingConfigs.release
            }
        }
    } else {
        buildTypes {
            release {
            // TODO: Add your own signing config for the release build.
            // Signing with the debug keys for now, so `flutter run --release` works.
                signingConfig signingConfigs.debug
            }
        }
    }
    
}
複製程式碼

生成APK檔案

flutter build apk
複製程式碼

image-20210510141654511

最後

本文從技術選型到架構搭建,從單元測試到打包釋出,一步步帶領大家如何從一個最簡單的Flutter專案骨架到規範的Flutter MVP工程化環境,基本上涵蓋了Flutter專案開發的整個流程,特別適合剛接觸Flutter工程化的同學學習。

因篇幅較長,所涉及技術點較多,難免會出現錯誤,希望大家多多指正,謝謝大家!

相關文章