Flutter開發踩坑記錄(乾貨總結)

NO IMAGE

Flutter開發踩坑記錄(乾貨總結)

Flutter 太好學了!BUG 真的太少了! issues 只有 5000 多!也就那麼億點!簡單得我都枯了!畢竟每次遇到問題,👴🏻 都是直接去找群裡的法佬、低調、Alex 等幾位大佬(🐶管理,此處小聲嗶嗶)來解決,只要有大佬在,問題也就不大。雖然法佬經常說要學會看源碼,但道理大家其實都懂,看源碼也就圖一樂,真正有 BUG 還是得找法佬。

不多嗶嗶,單寫一篇文章,先記錄它一手。本文記錄 👴🏻 在 Flutter 開發中遇到的一些 BUG(as design),避免遺忘,如果正在看文章的你也遇到了,那咱們可以握個手。

目錄

容器寬高相關問題

Container 設置寬高不生效

一般是由於父級容器的 constraints 屬性引起的,在 Flutter 中,子組件的大小會被父組件的 constraints 屬性限制,例如

ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100.0, // 最小寬度為 100 像素
minHeight: 50.0 // 最小高度為 50 像素
),
child: Container(
height: 5.0,// 高度為 5 邏輯像素
child: redBox 
),
)

上面的代碼中,Container 組件設置高度為 5 像素,是無法生效的,因為父級容器已經設置了最小高度為 50 像素,所以 Container 組件的最終高度將會是 50 像素。

當然,這肯定不是我們想要的效果,我們就想讓 Container 組件的最終高度是 5 像素怎麼辦?其實很簡單,可以使用 UnconstraindBox 解除父級容器的 constraints 屬性對子組件大小的限制。例如:

ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100.0, // 最小寬度為 100 像素
minHeight: 50.0 // 最小高度為 50 像素
),
child: UnconstraintsBox(
child: Container(
height: 5.0, 
child: redBox 
),
),
)

UnconstrainedBox 允許其子組件按照其自身的大小繪製,我們很少直接使用此組件,除非對於 Material 自帶的一些組件,如 Appbar 的 icon 被官方限制了固定的大小,利用該組件可以解除限制,而一般情況下,我們在組件外面套一層佈局類組件就可以解決需求,例如以下組件:

Row()
Column()
Align()
Center()
Flex()
Wrap()
Flow()
Stack()

SignleChildScrollView 不滿一屏高度時無法撐滿全屏

其實和上面這個問題是相似的,可以使用佈局類組件解決,或者用如下方式:

Container(
alignment: Alignment.topLeft,
child: SingleChildScrollView(),
),

如果你看過 Container 的源碼你會發現其實設置 alignment 屬性,和用 Align 組件是一回事,源碼也是使用 Align 組件,這就是個語法糖,僅此而已。

說到語法糖,其實 Center 組件也是 Align 組件的語法糖,當你不給 Align 傳遞任何參數時,使用 Center() 和使用 Align() 是一模一樣的效果,我的習慣是不管什麼情況,都是隻用Align 組件。

如何自定義 AppBar

上文提到過,Flutter 官方對 AppBar 的限制非常嚴格,連基本的高度都被寫死了,這怎麼能滿足我們項目錦鯉所提出的花式需求呢?所以我在項目中除了使用了自帶的 SliverAppBar,其他相關的 AppBar 組件基本沒用。

自定義 AppBar 有兩種方式:

第一種方式,使用 ColumnExpanded 組件,提供我項目中的一個簡單示例:

class VideoEditPage extends StatelessWidget {
get appBar => DecoratedBox(
decoration: BoxDecoration(
color: currentTheme.primaryColor,
boxShadow: tabBoxShadow,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: navigatorState.pop,
child: Container(
width: Screens.appBarHeight,
height: Screens.appBarHeight,
margin: EdgeInsets.only(right: setWidth(7)),
child: Icon(
IcoMoon.arrowLeft,
size: setWidth(42),
color: currentTheme.primaryColorDark,
),
),
),
Text('編輯視頻', style: titleStyle),
],
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: Routes.pushUploadCoverEditPage,
child: Container(
height: Screens.appBarHeight,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: setWidth(19)),
child: Text('下一步', style: titleStyle),
),
),
],
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: <Widget>[
appBar,
Expanded(child: listView)
],
),
),
);
}
}

第二種方式,使用 StackPositioned 組件,示例:

class MyApp extends StatelessWidget {
get body => Stack(
children: <Widget>[
appBar,
Positioned(
left: 0,
top: Screens.appBarHeight,
right: 0,
bottom: bottomAppBarHeight,
child: listViewBox,
),
bottomAppBar,
],
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SizedBox.expand(child: body),
),
);
}
}

Container 設置 borderRadius 不生效

設置 borderRadius 有兩種做法,第一種使用 Container 等組件自帶的 borderRadius 屬性,第二種是,直接用 ClipRRect 等 clip 組件對容器進行裁剪,第二種比第一種更加暴力、消耗性能,但更有效。

例如給 TabView 的容器設置 borderRadius,你會發現無法生效,而使用 ClipRRect 則可以解決,我的理解是 ClipRRect 會直接裁剪成圓角形狀,而 BorderRadius 的圓角外的弧形範圍是透明的,類似 css 中的 display:noneopaticy:0 的區別,實際具體是什麼原因,我也沒有去細究,複製粘貼、能跑就行。

元素顯示層級問題

可以認為 Flutter 中 widget 佈局的層級關係是遞進的,例如 child 的層級比父 Widget 層級更高, ColumnRow 等組件的 children 中同級 widget,誰在後面誰的層級就更高,和 Stackchildren 的層級關係相同。

顯示隱藏的幾種做法

第一種,利用 IndexedStack 組件控制層級,上面也提到過,子組件誰在後面誰的層級就高,Flutter 中雖然沒有 z-index 這一說法,但其實原理和 css 的 z-index 是類似的,index 越大,層級越高,當然這裡的 IndexedStackindex 屬性是用來控制當前顯示的某一個 children,只能顯示一個。該方法常用於 APP 首頁切換底部導航。

第二種,利用 IgnorePointerOpacity 組件組合隱藏 widget,可以使用 AnimationOpacity 組件達到以前 JQuery 中常用的 fadeIn 效果。

第三種,利用 PositionedTransform.translate 移動到屏幕外,需要顯示時再移動回來,這種做法非常適合動畫切換,例如視頻進度條等效果。

第四種,利用 Offstage 組件,前三種都是利用視覺效果將元素隱藏起來,其實在佈局上並未發生改變,而此組件就是類似於 css 中的 display:none,直接讓元素在佈局中隱藏,不會在佈局上繼續佔用空間。

最後一種,在 build 方法中提前判斷,不符合條件直接不渲染,或者返回空 box,這就類似於 HTML 中刪除 dom 元素,我人沒了,還顯示個🔨,這是最恐怖的。

GestureDetector 設置 onTap 不生效

Listener 默認的 behaviorHitTestBehavior.deferToChild

如果 Listener 的子組件是一個 Container,這個 Container 不設置 decoration 的情況下,即透明背景色、無邊框,則點擊 Container 時,無法觸發 down、up 等事件。

同理,GestureDetector 是對 Listener 的封裝,無法觸發 onTap 等事件也是必然的,那麼解決辦法也很簡單,有以下兩種解決辦法:

1. 給 Container 設置 decoration
2. 將 behavior 屬性設置為 opaque 或 translucent

調用 setState 或 markNeedsBuild 後報錯

第一種報錯

setState() or markNeedsBuild() called during build

遇到此提示,一般解決思路都是利用 addPostFrameCallback 來解決,例如:

WidgetsBinding.instance.addPostFrameCallback((_){
_model.setOpacity(opacity);
});

第二種報錯

setState() called after dispose()

一般定時器在 app 返回桌面後仍在調用 setState 或 頁面 pop 銷燬後異步任務才完成,此時調用了 setState 必然會出現該提示,那麼解決辦法也很簡單,判斷生命週期再執行重構邏輯。

if (!mounted) return;
setState(() {
// do somthing
});

動態更改 TabBar 的長度後 setState 報錯

其實這個問題肯定是由於使用了 SingleTickerProviderStateMixin 造成的,解決方案有兩種。

第一種是使用 DefaultTabController 來解決,這個方案比較適合大佬造輪子,因為需要自己寫 TabBar 的切換效果,非常之麻煩。

第二種方案就是我目前正在使用的,非常簡單,只需要將 SingleTickerProviderStateMixin 替換為 TickerProviderStateMixin 即可,相關代碼如下:

class EntryPage extends StatefulWidget {
@override
createState() => _EntryPageState();
}
class _EntryPageState extends State<EntryPage> with TickerProviderStateMixin {
TabController tabController;
final tabs = <DanceSort>[
DanceSort.fromJson({"id": -1, "name": "推薦"}),
DanceSort.fromJson({"id": 0, "name": "關注"}),
];
Future<void> getTabBar() async {
final danceSorts = await EntryApi.getDanceSorts();
if (danceSorts == null) return;
tabs.addAll(danceSorts);
tabController.dispose();
tabController = TabController(length: tabs.length, vsync: this);
setState(() {});
}
@override
initState() {
getTabBar();
tabController = TabController(length: tabs.length, vsync: this);
super.initState();
}
@override
dispose() {
tabController.dispose();
super.dispose();
}
get tabBar => TabBar(
controller: tabController,
tabs: tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
);
get tabBarView => TabBarView(
controller: tabController,
children: tabs.map<RecommendList>((v) => RecommendList(v.id)).toList(),
);
@override
Widget build(BuildContext context) {
return FloatingScrollView(
tabBar: tabBar,
tabBarView: tabBarView,
);
}
}

initState 時,先初始化本地默認的 tab,通過 Api 請求到服務端的 tab 數據後,再將原 tabController 銷燬,生成一個新的 tabController,由於使用的是 TickerProviderStateMixin,所以並不會因為 Single 而報錯。

為了便於理解,這個例子使用的 setState 來重新構建佈局,其實完全可以使用 Provider 進行優化,我的項目也是全部使用 Provider 來進行管理的,利用 Selector 將構建範圍縮小至最小,能很大地改善重構佈局時的性能問題,例如上面 tabBar 部分可以換成:

get tabBar => Selector<HomeModel, TabController>(
selector: (context, model) => model.tabController,
builder: (context, controller, _) => TabBar(
controller: controller,
tabs: model.tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
),
);

鍵盤相關問題

鍵盤彈出後將佈局頂起來了,而不是遮住佈局

解決辦法:在 scafold 裡設置 resizeToAvoidBottomInset: false,鍵盤會遮住佈局,而不是頂起佈局。

就想讓鍵盤頂起佈局,佈局卻溢出了怎麼辦?

溢出肯定是因為沒有鍵盤時,整體高度沒有一屏高,鍵盤出現了,卻超出了一屏的高度。解決辦法很簡單,首先將佈局使用 SingleChildScrolleView 之類的滾動組件包裹住,將佈局改變為可滾動的,這樣鍵盤彈出後佈局就不會溢出了。

接著可以使用 WidgetsBindingObserver 類來監聽鍵盤彈起事件,每次彈起鍵盤後會自動觸發 didChangeMetrics 鉤子,在該鉤子裡執行邏輯即可,例如將 SingleChildScrolleView 的當前位置調整至最底部,相關代碼如下:

import 'package:flutter/material.dart';
class Demo extends StatefulWidget {
@override
createState() => _DemoState();
}
class _DemoState extends State<Demo> with WidgetsBindingObserver {
final _scrollController = ScrollController();
final _phoneController = TextEditingController();
FocusNode _phoneFocusNode = FocusNode();
FocusScopeNode _focusScopeNode;
get _phoneTextFiled => TextField(
controller: _phoneController,
focusNode: _phoneFocusNode,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: InputDecoration(
hintText: '請輸入手機號',
border: InputBorder.none,
counterText: '',
),
);
void handlePostFrame() {
if (!_phoneFocusNode.hasFocus) {
print('requestFocus');
_focusScopeNode.requestFocus(_phoneFocusNode);
}
print('jumpTo');
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
@override
void initState() {
WidgetsBinding.instance.addObserver(this);
super.initState();
}
@override
void didChangeMetrics() {
WidgetsBinding.instance.addPostFrameCallback(handlePostFrame);
super.didChangeMetrics();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}

鍵盤彈起和收回會引起頁面重新build

我的項目中有一個接近 1 萬行代碼的視頻詳情頁,全部使用 Provider 進行狀態管理,如果鍵盤彈起、收回引起重新 build,就可能出現一些奇怪的 BUG,比如當前的滾動組件在屏幕中的位置發生變化。

我的解決方案是利用 showBottomSheet 方法,頁面中展示的 TextField 上蓋一層透明遮罩,使用戶無法點擊,而點擊遮罩時,則觸發 showBottomSheet, push 進一個新的路由,彈起鍵盤,卻不會引起重新 build,收起鍵盤時,則會 pop 回頁面,其實視覺上一直都保持在同一頁面中,和普通的彈起鍵盤沒區別,並且性能也非常棒,相關代碼如下:

  get textField => TextField(
autofocus: true,
cursorColor: currentTheme.hoverColor,
cursorWidth: 1.0,
textInputAction: TextInputAction.done,
style: TextStyle(
color: currentTheme.primaryColorLight,
fontSize: setSp(32),
),
decoration: InputDecoration(
hintText: '發一句友善的評論來見證當下吧',
hintStyle: TextStyle(fontSize: setSp(28)),
contentPadding: EdgeInsets.symmetric(horizontal: setWidth(31)),
filled: true,
fillColor: currentTheme.primaryColorDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(setWidth(30)),
borderSide: BorderSide.none
),
),
onSubmitted: (value) {},
);
Widget buildTextFieldPage(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomLeft,
children: <Widget>[
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: Container(color: Colors.black.withOpacity(.5)),
),
),
buildInput(),
],
),
);
}
buildInput({hasTextField = true}) {
Widget child;
child = hasTextField
? Container(
decoration: BoxDecoration(
color: currentTheme.backgroundColor,
borderRadius: BorderRadius.circular(setWidth(31)),
),
child: textField,
)
: GestureDetector(
onTap: () {
showBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: buildTextFieldPage,
);
},
child: Container(
decoration: BoxDecoration(
color: currentTheme.backgroundColor,
borderRadius: BorderRadius.circular(setWidth(31)),
),
),
);
return Container(
height: setWidth(103),
padding: EdgeInsets.symmetric(
vertical: setWidth(20),
horizontal: setWidth(25),
),
decoration: BoxDecoration(
border: Border(top: commentDivider),
color: currentTheme.primaryColor,
),
child: Row(
children: <Widget>[
Expanded(child: child),
Container(
width: setWidth(66),
padding: EdgeInsets.only(left: setWidth(25)),
alignment: Alignment.center,
child: Icon(
IcoMoon.send,
color: currentTheme.hoverColor.withOpacity(.5),
size: setWidth(42),
),
),
],
),
);
}

相關效果如下:

Flutter開發踩坑記錄(乾貨總結)

TextField 如何根據內容自適應高度

TextField 在最大行數為一行時,可以直接通過父級 Container 限制 TextField 的高度,不需要設置額外屬性,但是在多行高度時,這種做法就不太靈驗。

可以看到上圖中評論框會根據輸出的內容自動調整高度,並且限制了最大行數為 4,而這種需求又是特別常見的,我是如何做到的呢?

首先,需要設置 TextFieldmaxLine 屬性為 null,這樣 TextField 就會根據內容自動調整高度

接著,設置 decoration: InputDecoration(isDense:true),這樣 TextField 將允許我們限制 TexField 的最大高度

最後,利用父級容器限制 TextField 的最大高度,例如

Container(
constraints: BoxConstraints(maxHeight: 200),
child: TextField(),
)

通過以上設置後, TextField 的高度就是根據內容自動適應,並且會被限制最大高度,但是還需要說明一點,如何保證最大高度剛好是 4 行的高度?

由於 TextField 沒有屬性是用來限制每行高度的,所以這就需要我們自己計算 contentPadding 了,例如:

decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 20),
}

TextField 設置 border 不生效

TextField 的 border 有如下 3 種,需要針對性地設置,只設置一個是無法生效的:

decoration: InputDecoration(border enabledBorder focusBorder)

ps:設置 maxLength 屬性後,decoration 裡需要設置 counterText: '',否則默認會附帶一個統計字數的樣式。

路由跳轉相關問題

push、pop 常見需求

例如瀏覽記錄中有如下 4 個頁面,當前頁面為 d

a->b->c->d

在當前頁面使用 Navigator.popUtil(context, ModalRoute.withName('a')),可以直接返回至 a 頁面,並銷燬 bc 頁面。

在當前頁面使用 Navigator.pushNamedAndRemoveUntil(context, 'e', (route) => false),可以進入 e 頁面之前,銷燬所有歷史記錄,即 e 頁面變成第一頁,e 頁面裡無法繼續 pop 返回上一頁。

如何在 initState 階段就能使用 context

Flutter 有一些需要使用 context 的方法,例如 Theme.of(context)Navigator.of(context) 等等,一般情況下,我們需要在 build 方法中才能拿到 context,如果我們在定義類的屬性時就直接使用 Theme.of(context) 顯然是沒辦法做到的,編輯器都會直接提示語法錯誤。

解決辦法也很簡單,首先創建一個文件 constants.dart 用於保存項目的全局變量,在 Contants 類中聲明一個 navigatorKey

class Constants {
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
}

接著在 MaterailApp 下掛載 navigatorKey

MaterialApp(navigatorKey: Constants.navigatorKey)

最後在 constants.dart 中聲明如下 3 個 getter

NavigatorState get navigatorState => Constants.navigatorKey.currentState;
BuildContext get currentContext => navigatorState.context;
ThemeData get currentTheme => Theme.of(currentContext);

由於所有組件都是掛載在 MaterialApp 下的,所以我們在任何一個組件中引入了 constants.dart,就可以使用這 3 個 getter,用法如下:

class LoginPage extends StatelessWidget {
final box = GestureDetector(
onTap: navigatorState.pop,
child: Text('返回上一頁'),
);
final userModel = Provider.of<UserModel>(currentContext, listen: false);
final primaryColor = currentTheme.primaryColor;
}

上面的代碼等價於:

class LoginPage extends StatelessWidget {
Widget box;
UserModel userModel;
Color primaryColor;
build(context) {
box = GestureDetector(
onTap: Navigator.of(context).pop,
child: Text('返回上一頁'),
);
userModel = Provider.of<UserModel>(context, listen: false);
primaryColor = Theme.of(context).primaryColor;
}
}

網絡請求相關

如何封裝 Dio

網絡請求大家基本都是使用 Dio,我在項目上手後遇到的第一個問題就是如何對 Dio 進行封裝,下面提供一下我的做法(僅供參考):

(1)lib 目錄下創建一個 api 目錄,用於存放網絡請求相關的文件。

(2)api 目錄下創建一個 api_path.dart 用於存放項目接口的地址配置信息,內容大致如下:

const baseUrl = 'http://app.lcgod.com/';
const version = '1.0';
class ApiPath {
static const messages = 'messages';
static const loginWithSMS = 'login/sms';
static const loginWithPassword = 'login/password';
static const loginWithWeChat = 'login/wechat';
static const loginWithQQ = 'login/qq';
static const loginWithWeibo = 'login/weibo';
static const videos = 'videos';
static const users = 'users';
static followUser(int id) => '$users/$id/follow_users';
static videoCollect(int id) => '$videos/$id/collect_users';
static videoLike(int id) => '$videos/$id/like_users';
static videoShare(int id) => '$videos/$id/share_users';
static videoComments(int id) => '$videos/$id/comments';
}

(3)api 目錄下創建一個 api.dart 作為 Dio 的基類,內容大致如下:

import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:http/io_client.dart';
import 'package:http_client_helper/http_client_helper.dart';
import 'api_path.dart';
export 'login_api.dart';
export 'user_api.dart';
class Api {
static Dio _dio;
static void init() {
HttpClient http = HttpClient();
http.badCertificateCallback = (cert, host, port) => true;
IOClient client = IOClient(http);
HttpClientHelper().set(client);
_dio = Dio()
..options.baseUrl = baseUrl
..options.headers['accept'] = 'application/vnd.vhiphop.v$version+json'
..interceptors.add(InterceptorsWrapper(onRequest: _handleRequest));
}
static RequestOptions _handleRequest(RequestOptions options) {
String fullPath = options.baseUrl + options.path;
if (options.method == 'GET' && options.queryParameters.length > 0) {
List params = [];
options.queryParameters.forEach((k, v) => params.add('$k=$v'));
fullPath += '?' + params.join('&').toString();
print(fullPath);
}
final time = DateTime.now().millisecondsSinceEpoch ~/ 1000;
options.headers.addAll({
'x-date': '$time',
'x-token': '123456',
'x-user-id': Constants.user.id == null ? '' : '${Constants.user.id}',
'x-user-token': Constants.user.token ?? '',
});
return options;
}
static final _fetchTypes = <String, Function>{
'post': _dio.post,
'put': _dio.put,
'patch': _dio.patch,
'delete': _dio.delete,
'head': _dio.head,
};
static Future<dynamic> head(url, {Map<String, dynamic> data}) async {
return await _fetch('head', url, data);
}
static Future<dynamic> get(url, {Map<String, dynamic> data}) async {
return await _fetch('get', url, data);
}
static Future<dynamic> post(url, {Map<String, dynamic> data}) async {
return await _fetch('post', url, data);
}
static Future<dynamic> put(url, {Map<String, dynamic> data}) async {
return await _fetch('put', url, data);
}
static Future<dynamic> patch(url, {Map<String, dynamic> data}) async {
return await _fetch('patch', url, data);
}
static Future<dynamic> delete(url, {Map<String, dynamic> data}) async {
return await _fetch('delete', url, data);
}
static Future<dynamic> _fetch(method, url, data) async {
try {
final Response response = method == 'get'
? await _dio.get(url, queryParameters: data)
: await _fetchTypes[method](url, data: data);
return response.data;
} catch (e) {
final error = (e is DioError
&& e.response != null
&& e.response.statusCode == 403)
? e.response.data
: {"message": "服務器網絡繁忙,請稍後再試", "status_code": 1001};
showTip(error['message']);
throw error;
}
}
}

(4)api 目錄下創建具體請求邏輯的文件,例如 login_api.dart,用於登錄邏輯的接口邏輯放在該文件中:

import 'package:vhiphop/constants/constants.dart';
import 'api.dart';
import 'api_path.dart';
class LoginApi {
static Future<dynamic> loginWithPassword({phone, password}) async {
print('開始手機密碼登錄');
var isError = false;
final user = await Api.post(ApiPath.loginWithPassword, data: {
'phone': phone,
'password': password,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
static Future<dynamic> loginWithWeChat(code) async {
print('開始微信授權登錄');
var isError = false;
final user = await Api.post(ApiPath.loginWithWeChat, data: {
'code': code,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
static Future<dynamic> checkCodeAndGetToken({
String phone,
String code
}) async {
print('開始校驗驗證碼並獲取token');
var isError = false;
final user = await Api.get(ApiPath.messages, data: {
'phone': phone,
'code': code,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
}

(5)調用示例:

Future<void> _handleEnterButtonTap() async {
if (_isLogging) return;
if (_code < 1 || _code > 999999) return showTip('驗證碼錯誤');
_isLogging = true;
showLoading('正在登錄');
final user = await LoginApi.loginWithSMS(
phone: _phone,
code: _code,
nation: _nation,
);
_isLogging = false;
if (user == true) return;
dismissAllToast();
Constants.user = user;
Constants.saveUser();
Routes.pop();
}

補充:

  • 所有接口請求類都會由 api.dart 導出,再由 constants.dart 導出,那麼項目中所有文件只需要引入一個 constants.dart 就可以拿到各個請求類。
  • 所有網絡請求遇到錯誤都會統一進行處理,例如我的代碼中是統一使用 OkToast 彈出提示框,提示具體信息。

Dio 請求的 content-type 問題

使用 Dio 進行 HTTP 請求時,請求頭 content-type 的默認值是

application/json; charset=utf-8

如果返回頭的 content-type

application/json

Dio 將自動解析返回 json 數據為 Dart 相應的數據類型,而不需要手動地調用 jsonDecode 方法,所以客戶端、服務端的統一使用 application/json 作為 content-type,他好我也好。

Android 打包後無法進行網絡請求

在我第一次使用 Flutter 打包項目時遇到了這個問題,最後發現是沒有網絡請求的權限,類似的,儲存讀取本地文件時可能也會有類似問題,這種問題設置權限就可以解決了。

android/app/src/profile/AndroidManifest.xml

以及 android/app/src/main/AndroidManifest.xml 兩個文件的 manifest 標籤內添加如下子標籤即可:

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

如果需要動態申請權限,可以使用 permission_handler 插件。

Mac 環境 build 時的錯誤

提示如下:

Automatically assigning platform iOS with version 9.0 on target Runner because no platform was specified. Please specify a platform for this target in your Podfile.

解決辦法是:刪除 pod 文件中 platform前的 #

因為沒有做過原生開發,所以對於這種 build 問題真的是一臉茫然,最開始遇到過幾次類似錯誤,我通過網上搜索答案、群裡問大佬來解決,非常之麻煩。所以後來我在 Mac 環境 build 產生錯誤時,都是直接重建項目,把邏輯代碼複製進新項目裡,再重新 build 就不會發生各種亂七八糟看不懂的錯誤了,效率也快。

TabBar 切換導致 PageView、ListView 等滾動組件高度位置發生改變

解決辦法:給滾動組件加上 key 屬性,用於保存位置信息,例如: key: PageStorageKey(1)

其實一般的 ListView 還無法滿足我們日常開發中各種花式的需求,推薦使用法佬的 NestedScrollView

法佬已經給我們解決了很多奇怪的 BUG,還要什麼自行車?

如何監聽 App 返回桌面事件

我需要當 app 返回桌面時暫停視頻的播放,從桌面返回 app 後再繼續播放,解決方案如下:

class _DemoState extends State<Demo> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
print('app lifecycle state: $state');
if (state == AppLifecycleState.inactive) {
_playerModel.pausePlayer();
} else if (state == AppLifecycleState.resumed) {
if (_homeModel.isFindPage) _playerModel.startPlayer();
}
super.didChangeAppLifecycleState(state);
}
}

WidgetsBindingObserver 這個類我經常使用,例如監聽鍵盤彈起事件也會用到這個類。

對於類中的屬性和方法的定義規範的一些建議

  • 不引用其他屬性的成員,定義為屬性

  • 引用其他屬性,且不接收參數的成員,定義為getter

  • 引用其他屬性,且接受參數的成員,定義為function

全屏相關設置

強制豎屏:

void initState() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown
]);
super.initState();
}

強制橫屏:

initState() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
super.initState();
}

Transform 3D 轉換

推薦使用 Transform 組件來完成動畫效果,例如 Transform.translateTransform.scale 可以完成位置、縮放的變化, Transform.rotate 可以完成旋轉角度的變化。

Transform.rotateRotateBox 都可以完成旋轉功能,他們之間有什麼區別?

使用 RotateBox 渲染 widget 是在 layout 階段,渲染完畢後就會佔用實際位置,而 Transform 組件則是在 layout 之後的繪製階段, Transform 只是一個視覺效果,實際所佔空間大小是 transform 變化之前所佔用的空間大小,所以重新渲染 Transform.rotate 組件比重新渲染 RotateBox 開銷更小。

Flutter 的 Transform 組件的這個特性和 CSS 的 transform 屬性非常相似,都可以用來提升動畫性能。

不過做視頻全屏功能時,可以用 IndexedStack + RotateBox 替代 push 一個橫屏的路由的做法,RotateBox 它會使容器填充全屏,而 IndexedStack 可以控制是否顯示全屏,這裡如果使用 Transform 則無法填充全屏,因為容器的寬高在 layout 時就已經確定了,所以只能使用 RotateBox

視頻鏡像翻轉

我在項目中不僅使用 RotatedBox 完成視頻全屏功能,還利用了 Transform 來完成鏡像翻轉功能,寫法如下:

Selector<VideoModel, bool>(
selector: (context, model) => model.isMirror,
builder: (context, isMirror, child) => Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateY(isMirror ? math.pi : 0),
child: child,
),
child: FijkView(
player: model.player,
color: Colors.black,
panelBuilder: (player, context, size, pos) => emptyBox,
),
)

原理很簡單,FijkView 是 fijkplayer 提供的視頻容器,我將視頻容器以中心位置為圓心,沿 Y 軸做一個 180 度的旋轉,即可滿足需求。

setEntry 用於設置透視,否則將無法看到 Y 軸及 X 軸的立體轉換效果

rotateY 則與 css 中的 rotateY 是相同含義,即沿 Y 軸旋轉。在 css 中可以設置 transform: rotateY(180deg) 來達到相同的效果。

狀態欄相關設置

隱藏狀態欄:

import 'package:flutter/services.dart';
void toggleFullscreen() {
_isFullscreen = !_isFullscreen;
_isFullscreen
? SystemChrome.setEnabledSystemUIOverlays([])
: SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}

改變狀態欄顏色,則需要使用插件:flutter_statusbarcolor,下面是用法示例:

// 改變狀態欄背景顏色,默認改變為透明
Future<void> changeStatusColor({Color color: Colors.transparent}) async {
try {
await FlutterStatusbarcolor.setStatusBarColor(
color,
animate: true,
);
FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
FlutterStatusbarcolor.setNavigationBarWhiteForeground(true);
} on PlatformException catch (e) {
debugPrint(e.toString());
}
}

下面介紹一個用法,我的 home 頁使用 indexStack 組件包含了 4 個 tab 頁,每次更改 tab 會改變 currentHomeTab 的值,但不會觸發重新 build,而由於路由 pushpop 又會觸發重新 build,所以如果需要當進入 home 頁的 發現 tab 頁 時改變為黑色狀態欄,則可以用下面這種做法:

// 在發現頁的 build 方法裡進行判斷
@override
Widget build(BuildContext context) {
if (ModalRoute.of(context).isCurrent && currentHomeTab == '發現') {
changeStatusColor(color: Colors.black);
}
}

fijkplayer 秒開、進度跳轉等優化

fijkplayer 默認情況下,進度跳轉、播放可能會有性能問題,針對這些問題,可以進行以下優化:

_player.setDataSource(_video.src);
await _player.applyOptions(
FijkOption()
..setFormatOption('flush_packets', 1)
..setFormatOption('analyzemaxduration', 100)
..setFormatOption('analyzeduration', 1)
..setCodecOption('skip_loop_filter', 48)
..setPlayerOption('start-on-prepared', 1)
..setPlayerOption('packet-buffering', 0)
..setPlayerOption('framedrop', 1)
..setPlayerOption('enable-accurate-seek', 1)
..setPlayerOption('find_stream_info', 0)
..setPlayerOption('render-wait-start', 1)
);
await _player.prepareAsync();

參考鏈接:

IjkPlayer 起播速度優化

IjkPlayer 播放器秒開優化以及常用 Option 設置

LayoutBuilder 相關的實踐

如何實現微信朋友圈、嗶哩嗶哩評論的多行文本收起、展開功能

我寫了下面這個工具類,簡單、好用得我都枯了,原理是利用先 LayoutBuilder 判斷是否超出指定的行數,如果超出則返回 Column,如果未超出則返回原 widget

import 'package:flutter/material.dart';
class ExpandableText extends StatefulWidget {
final String text;
final int maxLines;
final TextStyle style;
final bool expand;
final TextStyle markerStyle;
final String atName;
const ExpandableText(this.text, {
Key key,
this.maxLines,
this.style,
this.markerStyle,
this.expand = false,
this.atName = '',
}) : super(key: key);
@override
createState() => _ExpandableTextState();
}
class _ExpandableTextState extends State<ExpandableText> {
bool expand;
TextStyle style;
int maxLines;
@override
void initState() {
expand = widget.expand;
style = widget.style;
maxLines = widget.maxLines;
super.initState();
}
Widget buildOrdinaryText() {
final text = widget.text;
return LayoutBuilder(builder: (_, size) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: maxLines,
textDirection: TextDirection.ltr,
);
tp.layout(maxWidth: size.maxWidth);
if (!tp.didExceedMaxLines) return Text(text, style: style);
return Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(text, maxLines: expand ? null : widget.maxLines, style: style),
GestureDetector(
onTap: () {
expand = !expand;
(context as Element).markNeedsBuild();
},
child: Text(
expand ? '收起' : '展開',
style: widget.markerStyle,
),
),
],
),
);
});
}
Widget buildAtText() {
return LayoutBuilder(builder: (_, size) {
final tp = TextPainter(
text: TextSpan(text: '回覆 @${widget.text}:', style: style),
maxLines: maxLines,
textDirection: TextDirection.ltr,
);
tp.layout(maxWidth: size.maxWidth);
if (!tp.didExceedMaxLines) return Text.rich(
TextSpan(
children: [
TextSpan(text: '回覆 '),
TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
TextSpan(text: ':${widget.text}'),
],
),
style: style,
);
return Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text.rich(
TextSpan(
children: [
TextSpan(text: '回覆 '),
TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
TextSpan(text: ':${widget.text}'),
],
),
maxLines: expand ? null : widget.maxLines,
style: style,
),
GestureDetector(
onTap: () {
expand = !expand;
(context as Element).markNeedsBuild();
},
child: Text(
expand ? '收起' : '展開',
style: widget.markerStyle,
),
),
],
),
);
});
}
@override
build(context) => widget.atName == '' ? buildOrdinaryText() : buildAtText();
}

調用方法如下:

Container(
padding: EdgeInsets.only(top: setWidth(6), bottom: setWidth(11)),
alignment: Alignment.centerLeft,
child: ExpandableText(
reply.content,
maxLines: 4,
style: commentTextStyle,
markerStyle: commentMarkerStyle,
atName: reply.isDirect > 0 ? '' : reply.pNickname,
),
),

相關效果如下:

Flutter開發踩坑記錄(乾貨總結)

監聽父級 widget 的實際寬高信息

LayoutBuilder 的作用非常大,可以用它來監聽某個widget的寬高信息,我在項目中遇到了 一個需求,需要根據某個 widget 的高度來彈出 BottomSheet,而這個 widget 的高度是可以滑動改變的,那麼 LayoutBuilder 就派上用場了,做法如下:

需要監聽的 widgetBody() 組件,給 Body() 組件套上一個 Stack

get body => Stack(
children: <Widget>[
Body(),
BodyLayout(model),
],
);

然後用 BodyLayout 組件來監聽:

import 'package:flutter/material.dart';
import 'package:vhiphop/provider/video/video_model.dart';
class BodyLayout extends StatelessWidget {
final VideoModel model;
BodyLayout(this.model);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, BoxConstraints constraints) {
model.bottomSheetDy = constraints.maxHeight;
return emptyBox;
});
}
}

Body() 組件高度發生變化時,會觸發 LayoutBuilderbuilder 回調函數,在此函數中將高度信息傳遞給 model ,那麼每次彈出 BottomSheet 之前,我就可以從 model 中拿到高度,以設置 BottomSheet 的高度。

底部彈出動畫的兩種實現方式

這種動畫在 App 中是很常見的效果,例如 App 分享功能,點擊分享按鈕後,會從頁面底部彈出分享組件。

第一種,利用 showModalBottomSheet,相關實現代碼如下:

  void showShareBottomSheet() {
showModalBottomSheet(
elevation: 0,
backgroundColor: currentTheme.highlightColor,
context: context,
builder: (context) => Container(
width: Screens.width,
decoration: BoxDecoration(color: currentTheme.primaryColor),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
alignment: Alignment.bottomLeft,
height: setWidth(59),
padding: EdgeInsets.only(left: setWidth(42)),
child: Text(
'分享',
style: TextStyle(
fontSize: setSp(32),
color: currentTheme.highlightColor,
),
),
),
Container(
height: setWidth(206),
padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
alignment: Alignment.topLeft,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: setWidth(.7),
color: currentTheme.dividerColor,
),
),
),
child: Row(
children: <Widget>[
shareIconOfQQ,
shareIconOfQQZone,
shareIconOfWeChat,
shareIconOfWeChatMoments,
shareIconOfMicroBlog,
],
),
),
Container(
height: setWidth(206),
padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
alignment: Alignment.topLeft,
child: Row(
children: <Widget>[
shareIconOfLink,
],
),
),
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
width: Screens.width,
height: setWidth(125),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
width: setWidth(10),
color: currentTheme.backgroundColor,
),
),
),
child: Text(
'取消',
style: TextStyle(
fontSize: setSp(36),
color: currentTheme.highlightColor,
),
),
),
),
],
),
),
);
}

使用 translate 實現

我在項目中使用 showModalBottomSheet 時發現動畫有點卡頓,可能是測試手機不行,只花了 1000 大洋,但咱是個倔強窮人,非要找一種性能更好的方式,那就是 translate 了。

這種方法比 showModalBottomSheet 動畫性能更高,在我 1000 大洋的測試機 debug 模式下都非常地絲滑流暢,只是代碼實現更復雜一點,並且需要依賴 Provider 來更新,我比較喜歡這種方式。

整個頁面都使用 Stack 構建,而 bottomSheet 與遮罩 box 則使用 Positioned 定位至頁面底部:

get body => Stack(
children: <Widget>[
page,
Positioned(
left: 0,
bottom: 0,
right: 0,
child: bottomSheetBox,
),
Positioned(
left: 0,
top: 0,
right: 0,
bottom: shareBottomSheetHeight,
child: bottomSheetBoxMask,
),
],
);

接著使用我定義的一個工具類,名字叫 AnimatedTranslateBox,我發現 Animated 家族有各種動畫組件,比如 AnimatedPaddingAnimatedPositioned 等等,唯獨沒有 Translate,不知道官方是什麼意思,可能他們覺得 Positioned 來調整位置就夠用了叭,可是 translate 動畫性能更高,它不香嗎?沒關係,咱自己造了一個,代碼如下:

import 'package:flutter/material.dart';
class AnimatedTranslateBox extends StatefulWidget {
AnimatedTranslateBox({
Key key,
this.dx,
this.dy,
this.child,
this.curve = Curves.linear,
this.duration = const Duration(milliseconds: 200),
this.reverseDuration,
});
final double dx;
final double dy;
final Widget child;
final Duration duration;
final Curve curve;
final Duration reverseDuration;
@override
createState() => _AnimatedTranslateBoxState();
}
class _AnimatedTranslateBoxState extends State<AnimatedTranslateBox>
with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
Tween<double> tween;
void _updateCurve() {
animation = widget.curve == null
? controller
: CurvedAnimation(parent: controller, curve: widget.curve);
}
@override
void initState() {
super.initState();
controller = AnimationController(
duration: widget.duration,
reverseDuration: widget.reverseDuration,
vsync: this,
);
tween = Tween<double>(begin: widget.dx ?? widget.dy);
_updateCurve();
}
@override
void didUpdateWidget(AnimatedTranslateBox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.curve != oldWidget.curve) _updateCurve();
controller
..duration = widget.duration
..reverseDuration = widget.reverseDuration;
if ((widget.dx ?? widget.dy) != (tween.end ?? tween.begin)) {
tween
..begin = tween.evaluate(animation)
..end = widget.dx ?? widget.dy;
controller
..value = 0.0
..forward();
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
build(context) => AnimatedBuilder(
animation: animation,
builder: (context, child) => widget.dx == null
? Transform.translate(
offset: Offset(0, tween.animate(animation).value),
child: child,
)
: Transform.translate(
offset: Offset(tween.animate(animation).value, 0),
child: child,
),
child: widget.child,
);
}

調用很簡單,使用 Selector 依賴 model 中的布爾值,用於控制顯示隱藏:

get bottomSheetBox => Selector<VideoModel, bool>(
selector: (context, model) => model.showBottomSheet,
builder: (context, show, child) => AnimatedOpacity(
opacity: show ? 1 : 0,
curve: show ? Curves.easeOut : Curves.easeIn,
duration: bottomSheetDuration,
child: AnimatedTranslateBox(
dy: show ? 0 : bottomSheetHeight,
curve: show ? Curves.easeOut : Curves.easeIn,
duration: bottomSheetDuration,
child: child,
),
),
child: Container(
height: bottomSheetHeight,
child: bottomSheet,
),
);

每當 dxdy 的值發生改變,AnimatedTranslateBox 的 child 就會根據 dxdy 的值進行 y 軸 或 x 軸的移動動畫。

相關的效果如下:

Flutter開發踩坑記錄(乾貨總結)

Provider 調用問題

我發現如果在 MaterialApp 下全局掛載了 Provider ,則在 Home 頁初始化完成前,是無法使 Provider 的,例如:

class MyApp extends StatelessWidget {
final _userModel = UserModel();
final _homeModel = HomeModel();
Widget build(BuildContext context) {
return OKToast(
dismissOtherOnShow: true,
child: MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _userModel),
ChangeNotifierProvider.value(value: _homeModel),
],
child: Selector<ThemeModel, ThemeData>(
selector: (context, model) => model.theme,
builder: (context, theme, child) => MaterialApp(
navigatorKey: Constants.navigatorKey,
debugShowCheckedModeBanner: false,
theme: theme,
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
},
),
),
),
);
}
}

上面的代碼聲明瞭 MultiProvider,如果在首頁做如下調用:

@override
initState() {
_model = Provider.of<HomeModel>(context);
_userModel = Provider.of<UserModel>(context);
super.initState();
}

則會報錯:

I/flutter ( 8380): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter ( 8380): The following assertion was thrown building Builder:
I/flutter ( 8380): dependOnInheritedWidgetOfExactType<_DefaultInheritedProviderScope<HomeModel>>() or
I/flutter ( 8380): dependOnInheritedElement() was called before _HomePageState.initState() completed.
I/flutter ( 8380): When an inherited widget changes, for example if the value of Theme.of() changes, its dependent
I/flutter ( 8380): widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor
I/flutter ( 8380): or an initState() method, then the rebuilt dependent widget will not reflect the changes in the
I/flutter ( 8380): inherited widget.
I/flutter ( 8380): Typically references to inherited widgets should occur in widget build() methods. Alternatively,
I/flutter ( 8380): initialization based on inherited widgets can be placed in the didChangeDependencies method, which
I/flutter ( 8380): is called after initState and whenever the dependencies change thereafter.

提示 initState 必須調用完成,才能使用 Provider.of 來獲取祖先節點的 model,非要使用怎麼辦?辦法也很簡單, of 方法有一個屬性值 listen,默認值為 true,將此值設置為 false 則不會建立與 Provider 的依賴關係,其實我在 Provider 的手冊中也發現,建議在 initState 方法中調用 of 時,將 listen 設置為 false

@override
initState() {
_userModel = Provider.of<UserModel>(context, listen: false);
_model = Provider.of<HomeModel>(context, listen: false);
super.initState();
}

如何實現網易雲音樂、QQ音樂播放頁面的背景圖片模糊效果

分析一下,其實這種效果特別簡單,首先放大背景圖片,其次對圖片進行高斯模糊,直接上代碼:

import 'package:flutter/material.dart';
import 'dart:ui';
main() => runApp(MyApp());
class MyApp extends StatelessWidget {
final image = Image.asset(
'assets/images/test.jpg',
fit: BoxFit.cover,
width: 200,
height: 200,
);
get blurImage => ClipRRect(
child: Stack(
children: <Widget>[
Transform.scale(
scale: 1.5,
child: image,
),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
width: 200,
height: 200,
alignment: Alignment.center,
color: Colors.black.withOpacity(.3),
child: Text(
'1 個內容',
style: TextStyle(
fontSize: 24,
color: Colors.white,
),
),
),
),
],
),
);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo app',
theme: ThemeData(primarySwatch: Colors.blue),
home: Scaffold(
appBar: AppBar(title: Text('blur image demo')),
body: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
margin: EdgeInsets.only(bottom: 30),
child: image,
),
blurImage,
],
),
],
)
),
);
}
}

這個效果其實沒什麼難度,主要的知識點在於 BackdropFilter 組件默認的模糊效果是全屏的,必須使用 ClipRRect 進行裁剪,而且 Transform 的幾個命名構造函數,如 Transform.translate 帶來的效果是在繪製階段發生的,會超出 widget 實際佔用的空間,也需要使用 ClipRRect 進行裁剪,最後的效果圖如下:

Flutter開發踩坑記錄(乾貨總結)