前言
??作為當下風頭正勁的跨端框架,flutter成為原生開發(fā)者和前端開發(fā)者爭相試水的領(lǐng)域,筆者將通過一個仿微信聊天的應用,展現(xiàn)flutter的開發(fā)流程和相關(guān)工具鏈,旨在熟悉flutter的開發(fā)生態(tài),同時也對自己的學習過程進行一個總結(jié)。筆者是web前端開發(fā),相關(guān)涉及原生的地方難免有錯漏之處,歡迎批評指正。項目代碼庫鏈接放在文末。
功能簡介
-
聊天列表
本應用支持用戶直接點對點聊天,使用webSocket實現(xiàn)消息提醒與同步
好友列表頁:
好友列表頁
在聊天列表展示所有好友,點擊進入聊天詳情,未讀消息通過好友頭像右上角小紅點表示。
聊天頁:
聊天頁 -
搜索頁
用戶可以通過搜索添加好友:
搜索頁 -
個人中心頁
該頁面可以進行個人信息的修改,包括調(diào)整昵稱,頭像,修改密碼等等,同時可以退出登錄。
個人中心頁
工具鏈梳理
這里列舉了本例中使用的幾個關(guān)鍵第三方庫,具體的使用細節(jié)在功能實現(xiàn)部分會有詳解。
- 消息同步與收發(fā)
項目中使用webSocket同server進行通信,我的服務器是用node寫的,webSocket使用socket.io來實現(xiàn)(詳見文末鏈接),socket.io官方最近也開發(fā)了基于dart的配套客戶端庫socket_io_client,其與服務端配合使用。由此可來實現(xiàn)消息收發(fā)和server端事件通知。 - 狀態(tài)管理
- 持久化狀態(tài)管理
持久化狀態(tài)指的是用戶名、登錄態(tài)、頭像等等持久化的狀態(tài),用戶退出app之后,不用重新登錄應用,因為登錄態(tài)已經(jīng)保存在本地,這里使用的是一個輕量化的包shared_preferences,將持久化的狀態(tài)通過寫文件的方式保存在本地,每次應用啟動的時候讀取該文件,恢復用戶狀態(tài)。 - 非持久化狀態(tài)
這里使用社區(qū)廣泛使用的庫provider來進行非持久化的狀態(tài)管理,非持久化緩存指的是控制app展示的相關(guān)狀態(tài),例如用戶列表、消息閱讀態(tài)以及依賴接口的各種狀態(tài)等等。筆者之前也有一篇博文對provider進行了介紹Flutter Provider使用指南
- 網(wǎng)絡(luò)請求
這里使用dio進行網(wǎng)絡(luò)請求,進行了簡單的封裝 - 其他
-
手機桌面消息通知小紅點通過
flutter_app_badger包來實現(xiàn),效果如下:
小紅點 修改用戶頭像時,獲取本地相冊或調(diào)用照相機,使用
image_picker庫來實現(xiàn),圖片的裁剪通過image_cropper庫來實現(xiàn)網(wǎng)絡(luò)圖片緩存,使用
cached_network_image來完成,避免使用圖片時反復調(diào)用http服務
功能實現(xiàn)
- 應用初始化
在打開app時,首先要進行初始化,請求相關(guān)接口,恢復持久化狀態(tài)等。在main.dart文件的開頭,進行如下操作:
為了避免文章充斥著大段具體業(yè)務代碼影響閱讀體驗,本文的代碼部分只會列舉核心內(nèi)容,部分常見邏輯和樣式內(nèi)容會省略,完整代碼詳見項目倉庫
import 'global.dart';
...
// 在運行runApp,之間,運行g(shù)lobal中的初始化操作
void main() => Global.init().then((e) => runApp(MyApp(info: e)));
接下來我們查看global.dart文件
library global;
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
...
// 篇幅關(guān)系,省略部分包引用
// 為了避免單文件過大,這里使用part將文件拆分
part './model/User.dart';
part './model/FriendInfo.dart';
part './model/Message.dart';
// 定義Profile,其為持久化存儲的類
class Profile {
String user = '';
bool isLogin = false;
// 好友申請列表
List friendRequest = [];
// 頭像
String avatar = '';
// 昵稱
String nickName = '';
// 好友列表
List friendsList = [];
Profile();
// 定義fromJson的構(gòu)造方法,通過json還原Profile實例
Profile.fromJson(Map json) {
user = json['user'];
isLogin = json['isLogin'];
friendRequest = json['friendRequest'];
avatar = json['avatar'];
friendsList = json['friendsList'];
nickName = json['nickName'];
}
// 定義toJson方法,將實例轉(zhuǎn)化為json方便存儲
Map<String, dynamic> toJson() => {
'user': user,
'isLogin': isLogin,
'friendRequest': friendRequest,
'avatar': avatar,
'friendsList': friendsList,
'nickName': nickName
};
}
// 定義全局類,實現(xiàn)初始化操作
class Global {
static SharedPreferences _prefs;
static Profile profile = Profile();
static Future init() async {
// 這里使用了shared_preferences這個庫輔助持久化狀態(tài)存儲
_prefs = await SharedPreferences.getInstance();
String _profile = _prefs.getString('profile');
Response message;
if (_profile != null) {
try {
// 如果存在用戶,則拉取聊天記錄
Map decodeContent = jsonDecode(_profile != null ? _profile : '');
profile = Profile.fromJson(decodeContent);
message = await Network.get('getAllMessage', { 'userName' : decodeContent['user'] });
} catch (e) {
print(e);
}
}
String socketIODomain = 'http://testDomain';
// 生成全局通用的socket實例,這個是消息收發(fā)和server與客戶端通信的關(guān)鍵
IO.Socket socket = IO.io(socketIODomain, <String, dynamic>{
'transports': ['websocket'],
'path': '/mySocket'
});
// 將socket實例和消息列表作為結(jié)果返回
return {
'messageArray': message != null ? message.data : [],
'socketIO': socket
};
}
// 定義靜態(tài)方法,在需要的時候更新本地存儲的數(shù)據(jù)
static saveProfile() => _prefs.setString('profile', jsonEncode(profile.toJson()));
}
...
global.dart文件中定義了Profile類,這個類定義了用戶的持久化信息,如頭像、用戶名、登錄態(tài)等等,Profilet類還提供了將其json化和根據(jù)json數(shù)據(jù)還原Profile實例的方法。Global類中定義了整個應用的初始化方法,首先借助shared_preferences庫,讀取存儲的json化的Profile數(shù)據(jù),并將其還原,從而恢復用戶狀態(tài)。Global中還定義了saveProfile方法,供外部應用調(diào)用,以便更新本地存儲的內(nèi)容。在恢復本地狀態(tài)后,init方法還請求了必須的接口,創(chuàng)建全局的socket實例,將這兩者作為參數(shù)傳遞給main.dart中的runApp方法。global.dart內(nèi)容過多,這里使用了part關(guān)鍵字進行內(nèi)容拆分,UserModel等類的定義都拆分出去了,詳見筆者的另一篇博文dart flutter 文件與庫的引用導出
- 狀態(tài)管理
接下來我們回到main.dart中,觀察MyApp類的實現(xiàn):
class MyApp extends StatelessWidget with CommonInterface {
MyApp({Key key, this.info}) : super(key: key);
final info;
// This widget is the root of your application.
// 根容器,用來初始化provider
@override
Widget build(BuildContext context) {
UserModle newUserModel = new UserModle();
Message messList = Message.fromJson(info['messageArray']);
IO.Socket mysocket = info['socketIO'];
return MultiProvider(
providers: [
// 用戶信息
ListenableProvider<UserModle>.value(value: newUserModel),
// websocket 實例
Provider<MySocketIO>.value(value: new MySocketIO(mysocket)),
// 聊天信息
ListenableProvider<Message>.value(value: messList)
],
child: ContextContainer(),
);
}
}
MyApp類做的做主要的工作就是創(chuàng)建整個應用的狀態(tài)實例,包括用戶信息,webSocket實例以及聊天信息等。通過provider庫中的MultiProvider,根據(jù)狀態(tài)的類型,以類似鍵值對的形式將狀態(tài)實例暴露給子組件,方便子組件讀取和使用。其原理有些類似于前端框架react中的Context,能夠跨組件傳遞參數(shù)。這里我們繼續(xù)查看UserModle的定義:
part of global;
class ProfileChangeNotifier extends ChangeNotifier {
Profile get _profile => Global.profile;
@override
void notifyListeners() {
Global.saveProfile(); //保存Profile變更
super.notifyListeners();
}
}
class UserModle extends ProfileChangeNotifier {
String get user => _profile.user;
set user(String user) {
_profile.user = user;
notifyListeners();
}
bool get isLogin => _profile.isLogin;
set isLogin(bool value) {
_profile.isLogin = value;
notifyListeners();
}
...省略類似代碼
BuildContext toastContext;
}
為了在改變數(shù)據(jù)的時候能夠同步更新UI,這里UserModel繼承了ProfileChangeNotifier類,該類定義了notifyListeners方法,UserModel內(nèi)部設(shè)置了各個屬性的set和get方法,將讀寫操作代理到Global.profile上,同時劫持set方法,使得在更新模型的值的時候會自動觸發(fā)notifyListeners函數(shù),該函數(shù)負責更新UI和同步狀態(tài)的修改到持久化的狀態(tài)管理中。在具體的業(yè)務代碼中,如果要改變model的狀態(tài)值,可以參考如下代碼:
if (key == 'avatar') {
Provider.of<UserModle>(context).avatar = '圖片url';
}
這里通過provider包,根據(jù)提供的組件context,在組件樹中上溯尋找最近的UserModle,并修改它的值。這里大家可能會抱怨,只是為了單純讀寫一個值,前面居然要加如此長的一串內(nèi)容,使用起來太不方便,為了解決這個問題,我們可以進行簡單的封裝,在global.dart文件中我們有如下的定義:
// 給其他widget做的抽象類,用來獲取數(shù)據(jù)
abstract class CommonInterface {
String cUser(BuildContext context) {
return Provider.of<UserModle>(context).user;
}
UserModle cUsermodal(BuildContext context) {
return Provider.of<UserModle>(context);
}
...
}
通過一個抽象類,將參數(shù)的前綴部分都封裝起來,具體使用如下:
class testComponent extends State<FriendList> with CommonInterface {
...
if (key == 'avatar') {
cUsermodal(context).avatar = '圖片url';
}
}
- 路由管理
接下來我們繼續(xù)梳理main.dart文件:
class ContextContainer extends StatefulWidget {
// 后文中類似代碼將省略
@override
_ContextContainerState createState() => _ContextContainerState();
}
class _ContextContainerState extends State<ContextContainer> with CommonInterface {
// 上下文容器,主要用來注冊登記和傳遞根上下文
@override
Widget build(BuildContext context) {
// 向服務器發(fā)送消息,表示該用戶已登錄
cMysocket(context).emit('register', cUser(context));
return ListenContainer(rootContext: context);
}
}
class ListenContainer extends StatefulWidget {
ListenContainer({Key key, this.rootContext})
: super(key: key);
final BuildContext rootContext;
@override
_ListenContainerState createState() => _ListenContainerState();
}
class _ListenContainerState extends State<ListenContainer> with CommonInterface {
// 用來記錄chat組件是否存在的全局key
final GlobalKey<ChatState> myK = GlobalKey<ChatState>();
// 注冊路由的組件,刪好友每次pop的時候都會到這里,上下文都會刷新
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
// 配置初始路由
initialRoute: '/',
routes: {
// 主路由
'/': (context) => Provider.of<UserModle>(context).isLogin ? MyHomePage(myK: myK, originCon: widget.rootContext, toastContext: context) : LogIn(),
// 聊天頁
'chat': (context) => Chat(key: myK),
// 修改個人信息頁
'modify': (context) => Modify(),
// 好友信息頁
'friendInfo': (context) => FriendInfoRoute()
}
);
}
}
這里使用ContextContainer進行了一次組件包裹,是為了保證向服務器登記用戶上線的邏輯僅觸發(fā)一次,在ListenContainer的MaterialApp中,定義了應用中會出現(xiàn)的所有路由頁,/代表根路由,在根路由下,根據(jù)用戶的登錄態(tài)來選擇渲染的組件:MyHomePage是應用的主頁面,里面包含好友列表頁,搜索頁和個人中心頁以及底部的切頁tab,LogIn則表示應用的登錄頁
- 登錄頁:
登錄頁
其代碼在login.dart文件中:
class LogIn extends StatefulWidget {
...
}
class _LogInState extends State<LogIn> {
// 文字輸入控制器
TextEditingController _unameController = new TextEditingController();
TextEditingController _pwdController = new TextEditingController();
// 密碼是否可見
bool pwdShow = false;
GlobalKey _formKey = new GlobalKey<FormState>();
bool _nameAutoFocus = true;
@override
void initState() {
// 初始化用戶名
_unameController.text = Global.profile.user;
if (_unameController.text != null) {
_nameAutoFocus = false;
}
super.initState();
}
@override
Widget build(BuildContext context){
return Scaffold(
appBar: ...
body: SingleChildScrollView(
child: Padding(
child: Form(
key: _formKey,
autovalidate: true,
child: Column(
children: <Widget>[
TextFormField(
// 是否自動聚焦
autofocus: _nameAutoFocus,
// 定義TextFormField控制器
controller: _unameController,
// 校驗器
validator: (v) {
return v.trim().isNotEmpty ? null : 'required userName';
},
),
TextFormField(
controller: _pwdController,
autofocus: !_nameAutoFocus,
decoration: InputDecoration(
...
// 控制密碼是否展示的按鈕
suffixIcon: IconButton(
icon: Icon(pwdShow ? Icons.visibility_off : Icons.visibility),
onPressed: () {
setState(() {
pwdShow = !pwdShow;
});
},
)
),
obscureText: !pwdShow,
validator: (v) {
return v.trim().isNotEmpty ? null : 'required passWord';
},
),
Padding(
child: ConstrainedBox(
...
// 登錄按鈕
child: RaisedButton(
...
onPressed: _onLogin,
child: Text('Login'),
),
),
)
],
),
),
)
)
);
}
void _onLogin () async {
String userName = _unameController.text;
UserModle globalStore = Provider.of<UserModle>(context);
Message globalMessage = Provider.of<Message>(context);
globalStore.user = userName;
Map<String, String> name = { 'userName' : userName };
// 登錄驗證
if (await userVerify(_unameController.text, _pwdController.text)) {
Response info = await Network.get('userInfo', name);
globalStore.apiUpdate(info.data);
globalStore.isLogin = true;
// 重新登錄的時候也要拉取聊天記錄
Response message = await Network.get('getAllMessage', name);
globalMessage.assignFromJson(message.data);
} else {
showToast('賬號密碼錯誤', context);
}
}
}
對這個路由頁進行簡單的拆解后,我們發(fā)現(xiàn)該頁面的主干就三個組件,兩個TextFormField分別用作用戶名和密碼的表單域,一個RaisedButton用做登錄按鈕。這里是最典型的TextFormField widget應用,通過組件的controller來獲取填寫的值,TextFormField的validator會自動對填寫的內(nèi)容進行校驗,但要注意的是,只要在這個頁面,validator的校驗每時每刻都會運行,感覺很不智能。登錄驗證通過后,會拉取用戶的聊天記錄。
- 項目主頁
繼續(xù)回到我們的main.dart文件,主頁的頁面繪制內(nèi)容如下:
class MyHomePage extends StatefulWidget {
...
}
class _MyHomePageState extends State<MyHomePage> with CommonInterface{
int _selectedIndex = 1;
@override
Widget build(BuildContext context) {
registerNotification();
return Scaffold(
appBar: ...
body: MiddleContent(index: _selectedIndex),
bottomNavigationBar: BottomNavigationBar(
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.chat), title: Text('Friends')),
BottomNavigationBarItem(
icon: Stack(
overflow: Overflow.visible,
children: <Widget>[
Icon(Icons.find_in_page),
cUsermodal(context).friendRequest.length > 0 ? Positioned(
child: Container(
...
),
) : null,
].where((item) => item != null).toList()
),
title: Text('Contacts')),
BottomNavigationBarItem(icon: Icon(Icons.my_location), title: Text('Me')),
],
currentIndex: _selectedIndex,
fixedColor: Colors.green,
onTap: _onItemTapped,
),
);
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
// 注冊來自服務器端的事件響應
void registerNotification() {
// 這里的上下文必須要用根上下文,因為listencontainer組件本身會因為路由重建,導致上下文丟失,全局監(jiān)聽事件報錯找不到組件樹
BuildContext rootContext = widget.originCon;
UserModle newUserModel = cUsermodal(rootContext);
Message mesArray = Provider.of<Message>(rootContext);
// 監(jiān)聽聊天信息
if(!cMysocket(rootContext).hasListeners('chat message')) {
cMysocket(rootContext).on('chat message', (msg) {
...
SingleMesCollection mesC = mesArray.getUserMesCollection(owner);
// 在消息列表中插入新的消息
...
// 根據(jù)所處環(huán)境更新未讀消息數(shù)
...
updateBadger(rootContext);
});
}
// 系統(tǒng)通知
if(!cMysocket(rootContext).hasListeners('system notification')) {
cMysocket(rootContext).on('system notification', (msg) {
String type = msg['type'];
Map message = msg['message'] == 'msg' ? {} : msg['message'];
// 注冊事件的映射map
Map notificationMap = {
'NOT_YOUR_FRIEND': () { showToast('對方開啟好友驗證,本消息無法送達', cUsermodal(rootContext).toastContext); },
...
};
notificationMap[type]();
});
}
}
}
class MiddleContent extends StatelessWidget {
MiddleContent({Key key, this.index}) : super(key: key);
final int index;
@override
Widget build(BuildContext context) {
final contentMap = {
0: FriendList(),
1: FindFriend(),
2: MyAccount()
};
return contentMap[index];
}
}
查看MyHomePage的參數(shù)我們可以發(fā)現(xiàn),這里從上級組件傳遞了兩個BuildContext實例。每個組件都有自己的context,context就是組件的上下文,由此作為切入點我們可以遍歷組件的子元素,也可以向上追溯父組件,每當組件重繪的時候,context都會被銷毀然后重建。_MyHomePageState的build方法首先調(diào)用registerNotification來注冊對服務器端發(fā)起的事件的響應,比如好友發(fā)來消息時,消息列表自動更新;有人發(fā)起好友申請時觸發(fā)提醒等。其中通過provider庫來同步應用狀態(tài),provider的原理也是通過context來追溯組件的狀態(tài)。registerNotification內(nèi)部使用的context必須使用父級組件的context,即originCon。因為MyHomePage會因為狀態(tài)的刷新而重建,但事件注冊只會調(diào)用一次,如果使用MyHomePage自己的context,在注冊后組件重繪,調(diào)用相關(guān)事件的時候?qū)鬅o法找到context的錯誤。registerNotification內(nèi)部注冊了提醒彈出toast的邏輯,此處的toast的實現(xiàn)用到了上溯找到的MaterialApp的上下文,此處不能使用originCon,因為它是MyHomePage父組件的上下文,無法溯找到MaterialApp,直接使用會報錯。
底部tab的我們通過BottomNavigationBarItem來實現(xiàn),每個item綁定點擊事件,點擊時切換展示的組件,聊天列表、搜索和個人中心都通過單個的組件來實現(xiàn),由MiddleContent來包裹,并不改變路由。
- 聊天頁
在聊天列表頁點擊任意對話,即進入聊天頁:
class ChatState extends State<Chat> with CommonInterface {
ScrollController _scrollController = ScrollController(initialScrollOffset: 18000);
@override
Widget build(BuildContext context) {
UserModle myInfo = Provider.of<UserModle>(context);
String sayTo = myInfo.sayTo;
cUsermodal(context).toastContext = context;
// 更新桌面icon
updateBadger(context);
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(cFriendInfo(context, sayTo).nickName),
actions: <Widget>[
IconButton(
icon: Icon(Icons.attach_file, color: Colors.white),
onPressed: toFriendInfo,
)
],
),
body: Column(children: <Widget>[
TalkList(scrollController: _scrollController),
ChatInputForm(scrollController: _scrollController)
],
),
);
}
// 點擊跳轉(zhuǎn)好友詳情頁
void toFriendInfo() {
Navigator.pushNamed(context, 'friendInfo');
}
void slideToEnd() {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent + 40);
}
}
這里的結(jié)構(gòu)相對簡單,由TalkList和ChatInputForm分別構(gòu)成聊天頁和輸入框,外圍用Scaffold包裹,實現(xiàn)用戶名展示和右上角點擊icon,接下來我們來看看TalkList組件:
class _TalkLitState extends State<TalkList> with CommonInterface {
bool isLoading = false;
// 計算請求的長度
int get acculateReqLength {
// 省略業(yè)務代碼
...
}
// 拉取更多消息
_getMoreMessage() async {
// 省略業(yè)務代碼
...
}
@override
Widget build(BuildContext context) {
SingleMesCollection mesCol = cTalkingCol(context);
return Expanded(
child: Container(
color: Color(0xfff5f5f5),
// 通過NotificationListener實現(xiàn)下拉操作拉取更多消息
child: NotificationListener<OverscrollNotification>(
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
// 滾動的菊花
if (index == 0) {
// 根據(jù)數(shù)據(jù)狀態(tài)控制顯示標志 沒有更多或正在加載
...
}
return MessageContent(mesList: mesCol.message, rank:index);
},
itemCount: mesCol.message.length + 1,
controller: widget.scrollController,
),
// 注冊通知函數(shù)
onNotification: (OverscrollNotification notification) {
if (widget.scrollController.position.pixels <= 10) {
_getMoreMessage();
}
return true;
},
)
)
);
}
}
這里的關(guān)鍵是通過NotificationListener實現(xiàn)用戶在下拉操作時拉取更多聊天信息,即分次加載。通過widget.scrollController.position.pixels來讀取當前滾動列表的偏移值,當其小于10時即判定為滑動到頂部,此時執(zhí)行_getMoreMessage拉取更多消息。這里詳細解釋下聊天功能的實現(xiàn):消息的傳遞非常頻繁,使用普通的http請求來實現(xiàn)是不現(xiàn)實的,這里通過dart端的socket.io來實現(xiàn)消息交換(類似于web端的webSocket,服務端就是用node上的socket.io server實現(xiàn)的),當你發(fā)送消息時,首先會更新本地的消息列表,同時通過socket的實例向服務器發(fā)送消息,服務器收到消息后將接收到的消息轉(zhuǎn)發(fā)給目標用戶。目標用戶在初始化app時,就會監(jiān)聽socket的相關(guān)事件,收到服務器的消息通知后,更新本地的消息列表。具體的過程比較繁瑣,有很多實現(xiàn)細節(jié),這里暫時略去,完整實現(xiàn)在源碼中。
接下來我們查看ChatInputForm組件
class _ChatInputFormState extends State<ChatInputForm> with CommonInterface {
TextEditingController _messController = new TextEditingController();
GlobalKey _formKey = new GlobalKey<FormState>();
bool canSend = false;
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Container(
color: Color(0xfff5f5f5),
child: TextFormField(
...
controller: _messController,
onChanged: validateInput,
// 發(fā)送摁鈕
decoration: InputDecoration(
...
suffixIcon: IconButton(
icon: Icon(Icons.message, color: canSend ? Colors.blue : Colors.grey),
onPressed: sendMess,
)
),
)
)
);
}
void validateInput(String test) {
setState(() {
canSend = test.length > 0;
});
}
void sendMess() {
if (!canSend) {
return;
}
// 想服務器發(fā)送消息,更新未讀消息,并更新本地消息列表
...
// 保證在組件build的第一幀時才去觸發(fā)取消清空內(nèi)容
WidgetsBinding.instance.addPostFrameCallback((_) {
_messController.clear();
});
// 鍵盤自動收起
//FocusScope.of(context).requestFocus(FocusNode());
widget.scrollController.jumpTo(widget.scrollController.position.maxScrollExtent + 50);
setState(() {
canSend = false;
});
}
}
這里用Form包裹TextFormField組件,通過注冊onChanged方法來對輸入內(nèi)容進行校驗,防止其為空,點擊發(fā)送按鈕后通過socket實例發(fā)送消息,列表滾動到最底部,并且清空當前輸入框。
- 個人中心頁
class _MyAccountState extends State<MyAccount> with CommonInterface{
@override
Widget build(BuildContext context) {
String me = cUser(context);
return SingleChildScrollView(
child: Container(
...
child: Column(
...
children: <Widget>[
Container(
// 通用組件,展現(xiàn)用戶信息
child: PersonInfoBar(infoMap: cUsermodal(context)),
...
),
// 展示昵稱,頭像,密碼三個配置項
Container(
margin: EdgeInsets.only(top: 15),
child: Column(
children: <Widget>[
ModifyItem(text: 'Nickname', keyName: 'nickName', owner: me),
ModifyItem(text: 'Avatar', keyName: 'avatar', owner: me),
ModifyItem(text: 'Password', keyName: 'passWord', owner: me, useBottomBorder: true)
],
),
),
// 退出摁鈕
Container(
child: GestureDetector(
child: Container(
...
child: Text('Log Out', style: TextStyle(color: Colors.red)),
),
onTap: quit,
)
)
],
)
)
);
}
void quit() {
Provider.of<UserModle>(context).isLogin = false;
}
}
var borderStyle = BorderSide(color: Color(0xffd4d4d4), width: 1.0);
class ModifyItem extends StatelessWidget {
ModifyItem({this.text, this.keyName, this.owner, this.useBottomBorder = false, });
...
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Container(
...
child: Text(text),
),
onTap: () => modify(context, text, keyName, owner),
);
}
}
void modify(BuildContext context, String text, String keyName, String owner) {
Navigator.pushNamed(context, 'modify', arguments: {'text': text, 'keyName': keyName, 'owner': owner });
}
頭部是一個通用的展示組件,用來展示用戶名和頭像,之后通過三個ModifyItem來展示昵稱,頭像和密碼修改項,其上通過GestureDetector綁定點擊事件,切換路由進入修改頁。
- 個人信息修改頁(昵稱)
效果圖如下:
image
class NickName extends StatefulWidget {
NickName({Key key, @required this.handler, @required this.modifyFunc, @required this.target})
: super(key: key);
...
@override
_NickNameState createState() => _NickNameState();
}
class _NickNameState extends State<NickName> with CommonInterface{
TextEditingController _nickNameController = new TextEditingController();
GlobalKey _formKey = new GlobalKey<FormState>();
bool _nameAutoFocus = true;
@override
Widget build(BuildContext context) {
String oldNickname = widget.target == cUser(context) ? cUsermodal(context).nickName : cFriendInfo(context, widget.target).nickName;
return Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
autovalidate: true,
child: Column(
children: <Widget>[
TextFormField(
...
validator: (v) {
var result = v.trim().isNotEmpty ? (_nickNameController.text != oldNickname ? null : 'please enter another nickname') : 'required nickname';
widget.handler(result == null);
widget.modifyFunc('nickName', _nickNameController.text);
return result;
},
),
],
),
),
);
}
}
這里的邏輯相對比較簡單,一個簡單的TextFormField,使用validator檢驗輸入是否為空,是否同原來內(nèi)容一致等等。修改密碼的邏輯此處類似,不再贅述。
- 個人信息修改頁(頭像)
具體效果圖如下:
選擇頁
選擇好圖片后,進入裁剪邏輯:
裁剪頁
代碼實現(xiàn)如下:
import 'package:image_picker/image_picker.dart';
import 'package:image_cropper/image_cropper.dart';
import '../../tools/base64.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
class Avatar extends StatefulWidget {
Avatar({Key key, @required this.handler, @required this.modifyFunc})
: super(key: key);
final ValueChanged<bool> handler;
final modifyFunc;
@override
_AvatarState createState() => _AvatarState();
}
class _AvatarState extends State<Avatar> {
var _imgPath;
var baseImg;
bool showCircle = false;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
SingleChildScrollView(child: imageView(context),) ,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
RaisedButton(
onPressed: () => pickImg('takePhote'),
child: Text('拍照')
),
RaisedButton(
onPressed: () => pickImg('gallery'),
child: Text('選擇相冊')
),
],
)
],
);
}
Widget imageView(BuildContext context) {
if (_imgPath == null && !showCircle) {
return Center(
child: Text('請選擇圖片或拍照'),
);
} else if (_imgPath != null) {
return Center(
child:
// 漸進的圖片加載
FadeInImage(
placeholder: AssetImage("images/loading.gif"),
image: FileImage(_imgPath),
height: 375,
width: 375,
)
);
} else {
return Center(
child: Image.asset("images/loading.gif",
width: 375.0,
height: 375,
)
);
}
}
Future<String> getBase64() async {
// 生成圖片實體
final img.Image image = img.decodeImage(File(_imgPath.path).readAsBytesSync());
// 緩存文件夾
Directory tempDir = await getTemporaryDirectory();
String tempPath = tempDir.path; // 臨時文件夾
// 創(chuàng)建文件
final File imageFile = File(path.join(tempPath, 'dart.png')); // 保存在應用文件夾內(nèi)
await imageFile.writeAsBytes(img.encodePng(image));
return 'data:image/png;base64,' + await Util.imageFile2Base64(imageFile);
}
void pickImg(String action) async{
setState(() {
_imgPath = null;
showCircle = true;
});
File image = await (action == 'gallery' ? ImagePicker.pickImage(source: ImageSource.gallery) : ImagePicker.pickImage(source: ImageSource.camera));
File croppedFile = await ImageCropper.cropImage(
// cropper的相關(guān)配置
...
);
setState(() {
showCircle = false;
_imgPath = croppedFile;
});
widget.handler(true);
widget.modifyFunc('avatar', await getBase64());
}
}
該頁面下首先繪制兩個按鈕,并給其綁定不同的事件,分別控制選擇本地相冊或者拍攝新的圖片(使用image_picker),具體通過ImagePicker.pickImage(source: ImageSource.gallery)與ImagePicker.pickImage(source: ImageSource.camera))來實現(xiàn),該調(diào)用將返回一個file文件,而后通過ImageCropper.cropImage來進入裁剪操作,裁剪完成后將成品圖片通過getBase64轉(zhuǎn)換成base64字符串,通過post請求發(fā)送給服務器,從而完成頭像的修改。
后記
該項目只是涉及app端的相關(guān)邏輯,要正常運行還需要配合后端服務,具體邏輯可以參考筆者自己的node服務器,包含了常規(guī)http請求和websocket服務端的相關(guān)邏輯實現(xiàn)。
本項目代碼倉庫
如有任何疑問,歡迎留言交流~