哎,Flutter真香啊
早在一年前想學習下flutter,但當時對於它佈局中地獄式的巢狀有點望而生畏,心想為什麼巢狀這麼複雜,就沒有xml佈局方式嗎,用jsx方式也行啊,為什麼要用dart
而不用javascript
,走開,勞資不學了。
然而,隨著今年google io大會flutter新版本釋出,大勢宣揚。我又開始從頭學習flutter了:
- 瀏覽dart.dev/
- 瀏覽book.flutterchina.club/ 本想看下視訊實戰的,後面發現效率太低(有點囉嗦),放棄了。最終還是決定通過閱讀flutter專案原始碼學習,事實上還是這種效率最高。
剛好公司有新app開發,這次決定用flutter開發了,邊開發邊學習,既完成了工作又完成了學習(ps:現在公司ios和前端也在學了?)。
用完flutter的感受是,一旦接受這種巢狀佈局後,發現佈局也沒那麼難,hot reload
牛皮,async
真好用,dart
語言真方便,嗯,香啊。
下面就此次app開發記錄相關要點(菜鳥階段,歡迎指正)
第三方庫
- dio: 網路
- sqflite: 資料庫
- pull_to_refresh: 下拉重新整理,上拉載入
- json_serializable: json序列化,自動生成model工廠方法
- shared_preferences: 本地儲存
- fluttertoast: 吐司訊息
圖片資源
為適配各個解析度的圖片資源,通常需要1,2,3倍的圖。在flutter專案根目錄下建立assets/images目錄,在pubspec.yaml
檔案中加入圖片配置
flutter:
# ...
assets:
- assets/images/
複製程式碼
然後通過sketch
切出1/2/3倍圖片,這裡可通過編輯預設
,在詞首加入2.0x/
和3.0x/
,這樣匯出的格式便符合flutter圖片資源所需了。
這裡再建一個image_helper.dart
的工具類,用於產生Image
class ImageHelper {
static String png(String name) {
return "assets/images/$name.png";
}
static Widget icon(String name, {double width, double height, BoxFit boxFit}) {
return Image.asset(
png(name),
width: width,
height: height,
fit: boxFit,
);
}
}
複製程式碼
主介面Tab導航
在app主介面,tab底部導航是最常用的。通常基於Scaffold
的bottomNavigationBar
配和PageView
使用。通過PageController
控制PageView
介面切換,同時使用BottomNavigationBar
的currentIndex
控制tab選中狀態。
為了能使監聽返回鍵,使用WillPopScope
實現點兩次返回鍵退出app。
List pages = <Widget>[HomePage(), MinePage()];
class _TabNavigatorState extends State<TabNavigator> {
DateTime _lastPressed;
int _tabIndex = 0;
var _controller = PageController(initialPage: 0);
BottomNavigationBarItem buildTab(
String name, String normalIcon, String selectedIcon) {
return BottomNavigationBarItem(
icon: ImageHelper.icon(normalIcon, width: 20),
activeIcon: ImageHelper.icon(selectedIcon, width: 20),
title: Text(name));
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: _tabIndex,
backgroundColor: Colors.white,
onTap: (index) {
setState(() {
_controller.jumpToPage(index);
_tabIndex = index;
});
},
selectedItemColor: Color(0xff333333),
unselectedItemColor: Color(0xff999999),
selectedFontSize: 11,
unselectedFontSize: 11,
type: BottomNavigationBarType.fixed,
items: [
buildTab("Home", "ic_home", "ic_home_s"),
buildTab("Mine", "ic_mine", "ic_mine_s")
]),
body: WillPopScope(
child: PageView.builder(
itemBuilder: (ctx, index) => pages[index],
controller: _controller,
physics: NeverScrollableScrollPhysics(),//禁止PageView左右滑動
),
onWillPop: () async {
if (_lastPressed == null ||
DateTime.now().difference(_lastPressed) >
Duration(seconds: 1)) {
_lastPressed = DateTime.now();
Fluttertoast.showToast(msg: "Press again to exit");
return false;
} else {
return true;
}
}),
);
}
}
複製程式碼
網路層封裝
網路框架使用的是dio,不管是哪種平臺,網路請求最終要轉成實體model用於ui展示。這裡先將dio做一個封裝,便於使用。
通用攔截器
網路請求中通常需要新增自定義攔截器來預處理網路請求,往往需要將登入資訊(如user_id等)放在公共引數中,例如;
import 'package:dio/dio.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
class CommonInterceptor extends Interceptor {
@override
Future onRequest(RequestOptions options) async {
options.queryParameters = options.queryParameters ?? {};
options.queryParameters["app_id"] = "1001";
var pref = await SharedPreferences.getInstance();
options.queryParameters["user_id"] = pref.get(Constants.keyLoginUserId);
options.queryParameters["device_id"] = pref.get(Constants.keyDeviceId);
return super.onRequest(options);
}
}
複製程式碼
Dio封裝
然後使用dio
封裝get
和post
請求,預處理響應response
的code
。假設我們的響應格式是這樣的:
{
code:0,
msg:"獲取資料成功",
result:[] //或者{}
}
複製程式碼
import 'package:dio/dio.dart';
import 'common_interceptor.dart';
/*
* 網路管理
*/
class HttpManager {
static HttpManager _instance;
static HttpManager getInstance() {
if (_instance == null) {
_instance = HttpManager();
}
return _instance;
}
Dio dio = Dio();
HttpManager() {
dio.options.baseUrl = "https://api.xxx.com/";
dio.options.connectTimeout = 10000;
dio.options.receiveTimeout = 5000;
dio.interceptors.add(CommonInterceptor());
dio.interceptors.add(LogInterceptor(responseBody: true));
}
static Future<Map<String, dynamic>> get(String path, Map<String, dynamic> map) async {
var response = await getInstance().dio.get(path, queryParameters: map);
return processResponse(response);
}
/*
表單形式
*/
static Future<Map<String, dynamic>> post(String path, Map<String, dynamic> map) async {
var response = await getInstance().dio.post(path,
data: map,
options: Options(
contentType: "application/x-www-form-urlencoded",
headers: {"Content-Type": "application/x-www-form-urlencoded"}));
return processResponse(response);
}
static Future<Map<String, dynamic>> processResponse(Response response) async {
if (response.statusCode == 200) {
var data = response.data;
int code = data["code"];
String msg = data["msg"];
if (code == 0) {//請求響應成功
return data;
}
throw Exception(msg);
}
throw Exception("server error");
}
}
複製程式碼
map轉model
使用dio
可以將最終的請求響應response
轉成Map<String, dynamic>
物件,我們還需要將map
轉成相應的model
。假如我們有一個獲取文章列表的介面響應如下:
{
code:0,
msg:"獲取資料成功",
result:[
{
article_id:1,
article_title:"標題",
article_link:"https://xxx.xxx"
}
]
}
複製程式碼
就需要一個Article
的model。由於Flutter
下是禁用反射的,我們只能手動初始化每個成員變數。
不過我們可以通過json_serializable將手動初始化的工作交給它。
首先在pubspec.yaml
引入它:
dependencies:
json_annotation: ^2.0.0
dev_dependencies:
json_serializable: ^2.0.0
複製程式碼
我們建立一個article.dart
的model類:
import 'package:json_annotation/json_annotation.dart';
part 'article.g.dart';
//FieldRename.snake 表示json欄位下劃線分割型別如:article_id
@JsonSerializable(fieldRename: FieldRename.snake, checked: true)
class Article {
final int articleId;
final String articleTitle;
final String articleLikn;
}
複製程式碼
注意這裡引用到了一個article.g.dart
沒有產生的檔案,我們通過pub run build_runner build
命令就會生成這個檔案
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'article.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Article _$ArticleFromJson(Map<String, dynamic> json) {
return $checkedNew('Article', json, () {
final val = Article();
$checkedConvert(json, 'article_id', (v) => val.articleId = v as int);
$checkedConvert(
json, 'article_title', (v) => val.articleTitle = v as String);
$checkedConvert(json, 'article_link', (v) => val.articleLink = v as String);
return val;
}, fieldKeyMap: const {
'articleId': 'article_id',
'articleTitle': 'article_title',
'articleLink': 'article_link'
});
}
Map<String, dynamic> _$ArticleToJson(Article instance) => <String, dynamic>{
'article_id': instance.articleId,
'article_title': instance.articleTitle,
'article_link': instance.articleLink
};
複製程式碼
然後在article.dart
裡新增工廠方法
class Article{
...
factory Article.fromJson(Map<String, dynamic> json) => _$ArticleFromJson(json);
}
複製程式碼
具體請求封裝
建立好model
類後,就可以建一個具體的api請求類ApiRepository
,通過async
庫,可以將網路請求最終封裝成一個Future
物件,實際呼叫時,我們可以將非同步回撥形式的請求轉成同步的形式,這有點和kotlin
的協程類似:
import 'dart:async';
import '../http/http_manager.dart';
import '../model/article.dart';
class ApiRepository {
static Future<List<Article>> articleList() async {
var data = await HttpManager.get("articleList", {"page": 1});
return data["result"].map((Map<String, dynamic> json) {
return Article.fromJson(json);
});
}
}
複製程式碼
實際呼叫
封裝好網路請求後,就可以在具體的元件中使用了。假設有一個_ArticlePageState
:
import 'package:flutter/material.dart';
import '../model/article.dart';
import '../repository/api_repository.dart';
class ArticlePage extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _ArticlePageState();
}
}
class _ArticlePageState extends State<ArticlePage> {
List<Article> _list = [];
@override
void initState() {
super.initState();
_loadData();
}
void _loadData() async {//如果需要展示進度條,就必須try/catch捕獲請求異常。
showLoading();
try {
var list = await ApiRepository.articleList();
setState(() {
_list = list;
});
} catch (e) {}
hideLoading();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListView.builder(
itemCount: _list.length,
itemBuilder: (ctx, index) {
return Text(_list[index].articleTitle);
})),
);
}
}
複製程式碼
資料庫
資料庫操作通過sqflite,簡單封裝處理事例了文章Article
的插入操作。
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:async';
import '../model/article.dart';
class DBManager {
static const int _VSERION = 1;
static const String _DB_NAME = "database.db";
static Database _db;
static const String TABLE_NAME = "t_article";
static const String createTableSql = '''
create table $TABLE_NAME(
article_id int,
article_title text,
article_link text,
user_id int,
primary key(article_id,user_id)
);
''';
static init() async {
String dbPath = await getDatabasesPath();
String path = join(dbPath, _DB_NAME);
_db = await openDatabase(path, version: _VSERION, onCreate: _onCreate);
}
static _onCreate(Database db, int newVersion) async {
await db.execute(createTableSql);
}
static Future<int> insertArticle(Article item, int userId) async {
var map = item.toMap();
map["user_id"] = userId;
return _db.insert("$TABLE_NAME", map);
}
}
複製程式碼
Android層相容通訊處理
為了相容底層,需要通過MethodChannel
進行Flutter
和Native(Android/iOS)
通訊
flutter呼叫Android層方法
這裡舉例flutter端開啟系統相簿意圖,並取得最終的相簿路徑回撥給flutter端。
我們在Android中的MainActivity
中onCreate
方法處理通訊邏輯
eventChannel = MethodChannel(flutterView, "event")
eventChannel?.setMethodCallHandler { methodCall, result ->
when (methodCall.method) {\
"openPicture" -> PictureUtil.openPicture(this) {
result.success(it)
}
}
}
複製程式碼
因為是通過result.success
將結果回撥給Flutter
端,所以封裝了開啟相簿的工具類。
object PictureUtil {
fun openPicture(activity: Activity, callback: (String?) -> Unit) {
val f = getFragment(activity)
f.callback = callback
val intentToPickPic = Intent(Intent.ACTION_PICK, null)
intentToPickPic.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
f.startActivityForResult(intentToPickPic, 200)
}
private fun getFragment(activity: Activity): PictureFragment {
var fragment = activity.fragmentManager.findFragmentByTag("picture")
if (fragment is PictureFragment) {
} else {
fragment = PictureFragment()
activity.fragmentManager.apply {
beginTransaction().add(fragment, "picture").commitAllowingStateLoss()
executePendingTransactions()
}
}
return fragment
}
}
複製程式碼
然後在PictureFragment
中加入callback
,並且處理onActivityResult
邏輯
class PictureFragment : Fragment() {
var callback: ((String?) -> Unit)? = null
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 200) {
if (data != null) {
callback?.invoke(FileUtil.getFilePathByUri(activity, data!!.data))
}
}
}
}
複製程式碼
這裡FileUtil.getFilePathByUri
是通過data
獲取相簿路徑邏輯就不貼程式碼了,網上很多可以搜尋一下。
然後在flutter端使用
void _openPicture() async {
var result = await MethodChannel("event").invokeMethod("openPicture");
images.add(result as String);
setState(() {});
}
複製程式碼
Android端呼叫Flutter程式碼
將剛剛MainActivity
中的eventChannel
宣告成類變數,就可以在其他地方使用它了。比如推送通知,如果需要呼叫Flutter
端的埋點介面方法。
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)
eventChannel = MethodChannel(flutterView, "event")
eventChannel?.setMethodCallHandler { methodCall, result ->
...
}
}
checkNotify(intent)
initPush()
}
companion object {
var eventChannel: MethodChannel? = null
}
}
複製程式碼
在Firebase訊息通知中呼叫Flutter
方法
class FirebaseMsgService : FirebaseMessagingService() {
override fun onMessageReceived(msg: RemoteMessage?) {
super.onMessageReceived(msg)
"onMessageReceived:$msg".logE()
if (msg != null){
showNotify(msg)
MainActivity.eventChannel?.invokeMethod("saveEvent", 1)
}
}
}
複製程式碼
然後在Flutter
層我們新增回撥
class NativeEvent {
static const platform = const MethodChannel("event");
static void init() {
platform.setMethodCallHandler(platformCallHandler);
}
static Future<dynamic> platformCallHandler(MethodCall call) async {
switch (call.method) {
case "saveEvent":
print("saveEvent.....");
await ApiRepository.saveEvent(call.arguments);
return "";
break;
}
}
}
複製程式碼