前言
隨著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 依賴下面這些命令列工具.
- Git for Windows (Git命令列工具)
獲取Flutter SDK
去flutter官網下載其最新可用的安裝包,點選下載 ;
這裡使用版本
解壓如下:
配置環境變數
-
我的電腦->右鍵屬性->高階系統設定->環境設定
-
系統變數找到Path追加flutter/bin
-
使用者環境變數新增PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL
PUB_HOSTED_URL=https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn 複製程式碼
-
執行flutter doctor
使用管理員身份執行git bash或PowerShell
flutter doctor
複製程式碼
效果如下:
獲取Android SDK
這裡使用版本
安裝相關工具
再次配置環境變數
-
我的電腦->右鍵屬性->高階系統設定->環境設定
-
使用者環境變數新增ANDROID_HOME
-
系統變數找到Path追加platform-tools和tools
主要是platform-tools/adb.exe
-
安裝夜神模擬器
- 替換夜神模擬器adb.exe
將上面的platform-tools/adb.exe
覆蓋夜神模擬器的Nox/bin/nox_adb.exe
覆蓋前先備份
-
開啟夜神模擬器
-
使用
flutter devices
檢視裝置情況flutter devices 複製程式碼
修改環境變數後,要重新開啟git bash。
這裡會看到有三個裝置,VOG AL10就是夜神模擬器,另外兩個為瀏覽器。
到此,環境安裝完成。
安裝VSCode
略
架構搭建
使用flutter create命令初始化專案雛形
使用flutter create
命令建立一個project
# 預設為Kotlin語言,如果使用java語言,則需要-a引數
flutter create -a java fluttermvp
cd fluttermvp
複製程式碼
預設工程目錄如下圖:
執行應用程式
-
檢查Android裝置是否在執行。如果沒有顯示
flutter devices 複製程式碼
-
執行
flutter run
命令來執行應用程式flutter run 複製程式碼
因剛才安裝的
Android SDK build-tools
工具版本不對,會報如下錯開啟SDK Manager.exe安裝對應版本即可
Android SDK Build-tools安裝完後,還會報錯,因為還有一個問題未解決。
目前最高版本只有29,所以要只能選下載29的,然後再修改
fluttermvp/android/app/gradle.bulid
檔案compileSdkVersion 30 ==> compileSdkVersion 29 targetSdkVersion 30 ==> targetSdkVersion 29 複製程式碼
安裝android SDK Platform
-
如果 一切正常,在應用程式建成功後,您應該在您的裝置或模擬器上看到應用程式:
使用VSCode開啟工程
暫時安裝3個常用外掛
體驗一波熱過載
Flutter 可以通過 熱過載(hot reload) 實現快速的開發週期,熱過載就是無需重啟應用程式就能實時載入修改後的程式碼,並且不會丟失狀態(譯者語:如果是一個web開發者,那麼可以認為這和webpack的熱過載是一樣的)。簡單的對程式碼進行更改,然後告訴IDE或命令列工具你需要重新載入(點選reload按鈕),你就會在你的裝置或模擬器上看到更改。
- 開啟檔案
lib/main.dart
- 將字串
'You have pushed the button this many times:'
更改為'You have clicked the button this many times:'
- 不要按“停止”按鈕; 讓您的應用繼續執行.
- 要檢視您的更改,請呼叫 Save (
cmd-s
/ctrl-s
), 或者點選 熱過載按鈕 (帶有閃電圖示的按鈕).
你會立即在執行的應用程式中看到更新的字串
將上前面執行的命令列關閉。使用VSCode啟動除錯
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
- 獲取外掛
- 新建兩個頁面
moudules/example/route_a.dart
與moudules/example/RouterBPage.dart
- stateful與stateless這裡暫時不說區別,選stateful,輸入名稱
- 快速修復,導包
- 最後程式碼修改成如下:
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"),
),
);
}
}
複製程式碼
-
route_b.dart重複上述操作。
-
新建路由處理檔案
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';
-
新建路由配置檔案
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); } } 複製程式碼
-
新建路由工具類
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"; } } 複製程式碼
-
入口頁新增路由配置
void main() { /// 配置路由開始 FluroRouter router = FluroRouter(); Routes.configureRoutes(router); NavTool.router = router; /// 入口 runApp(MyApp()); } 複製程式碼
-
佈局程式碼片段
new RaisedButton( child: new Text("RouterA"), onPressed: () { NavTool.push(context, "/routerA"); }), new RaisedButton( child: new Text("RouterB"), onPressed: () { NavTool.push(context, "/routerB"); }) 複製程式碼
-
效果截圖
至此,路由算是整合完畢,路由的進一步學習這裡就先不展開。
整合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
外掛小試:
{
"userId": 1,
"userName": "張三",
"avatar": ""
}
複製程式碼
複製上述json字串->選中要建立dart檔案的目錄右鍵->Covert Json from Clipboard Here
輸入類名回車
選擇yes回車
選擇yes回車
最終生成如下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中沒有反射,所以需要一個個欄位去轉換,該工作可以由上述外掛幫轉換。
完整的介面請求樣例
-
找到介面請求返回的樣例資料
這裡以我個人記賬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 } ] } ] } 複製程式碼
-
使用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;
}
}
複製程式碼
-
新建介面類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;
-
vscode開啟新終端執行如下命令
flutter pub run build_runner build 複製程式碼
-
檢視生成檔案
在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; } } 複製程式碼
-
新建一個單元測試類
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()); } 複製程式碼
-
開啟檔案Ctrl+F5執行,或者滑鼠點選Run
控制檯輸出:
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
複製程式碼
整合阿里向量圖示庫
引入svg庫
flutter_svg: ^0.22.0
安裝flutter-iconfont-cli外掛
flutter-iconfont-cli為Nodejs外掛,做為工具類,可以基於阿里雲的js檔案生成對應的dart圖示依賴類。
npm install flutter-iconfont-cli -g
複製程式碼
阿里向量圖示流程樣例
-
登入
略
-
建立專案
-
新增圖示到專案
略
-
點選生成程式碼
-
使用外掛初始化
npx iconfont-init 複製程式碼
-
開啟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 複製程式碼
如果向量圖示有變動,可以再次複復上述流程。
圖示使用樣例
/// 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),
),
)
],
)),
);
}));
}
}
複製程式碼
整合載入中元件
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 複製程式碼
-
生成新模組
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
實際值與預期值不一致
實際值與預期值一致
分組測試
使用 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);
});
});
}
複製程式碼
網路介面測試
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);
});
}
複製程式碼
Widget測試
- 新建一個頁面
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!");
})),
),
);
}
}
複製程式碼
- 單元測試流程
通過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);
});
}
複製程式碼
- 執行Run
- 結果
注意:待測試的 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
-
修改目錄
android/app/src/main/java/com/example/
==>android/app/src/main/java/com/mldong/
-
修改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 { } 複製程式碼
-
修改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"> 複製程式碼
-
修改build.gradle檔案
android/app/build.gradle
大概32行左右,節點
android->defaultConfig->applicationId
applicationId "com.mldong.fluttermvp" 複製程式碼
修改圖示
可使用圖示工場生成圖示
將生成的檔案複製到android/app/src/res/mipmap-*
目錄即可。
檔名為:ic_launcher.png
生成簽名檔案
-
生成簽名檔案
keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 36500 -alias fluttermvp 複製程式碼
-
將簽名檔案複製到android根目錄上
fluttermvp/android/key.jks
-
檢視簽名檔案資訊(按需)
keytool -list -v -keystore android/key.jks -storepass 123456 複製程式碼
-
新建key.properties配置檔案
storeFile=../key.jks storePassword=123456 keyAlias=fluttermvp keyPassword=123456 複製程式碼
-
修改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
複製程式碼
最後
本文從技術選型到架構搭建,從單元測試到打包釋出,一步步帶領大家如何從一個最簡單的Flutter專案骨架到規範的Flutter MVP工程化環境,基本上涵蓋了Flutter專案開發的整個流程,特別適合剛接觸Flutter工程化的同學學習。
因篇幅較長,所涉及技術點較多,難免會出現錯誤,希望大家多多指正,謝謝大家!