前言
作為當下風頭正勁的跨端框架,flutter成為原生開發者和前端開發者爭相試水的領域,筆者將通過一個仿微信聊天的應用,展現flutter的開發流程和相關工具鏈,旨在熟悉flutter的開發生態,同時也對自己的學習過程進行一個總結。筆者是web前端開發,相關涉及原生的地方難免有錯漏之處,歡迎批評指正。專案程式碼庫連結放在文末。
功能簡介
- 聊天列表 本應用支援使用者直接點對點聊天,使用webSocket實現訊息提醒與同步 好友列表頁:
- 搜尋頁 使用者可以通過搜尋新增好友:
- 個人中心頁
該頁面可以進行個人資訊的修改,包括調整暱稱,頭像,修改密碼等等,同時可以退出登入。
工具鏈梳理
這裡列舉了本例中使用的幾個關鍵第三方庫,具體的使用細節在功能實現部分會有詳解。
- 訊息同步與收發
專案中使用webSocket同server進行通訊,我的伺服器是用node
寫的,webSocket使用socket.io
來實現(詳見文末連結),socket.io
官方最近也開發了基於dart的配套客戶端庫socket_io_client
,其與服務端配合使用。由此可來實現訊息收發和server端事件通知。 - 狀態管理
- 持久化狀態管理
持久化狀態指的是使用者名稱、登入態、頭像等等持久化的狀態,使用者退出app之後,不用重新登入應用,因為登入態已經儲存在本地,這裡使用的是一個輕量化的包shared_preferences
,將持久化的狀態通過寫檔案的方式儲存在本地,每次應用啟動的時候讀取該檔案,恢復使用者狀態。 - 非持久化狀態
這裡使用社群廣泛使用的庫
provider
來進行非持久化的狀態管理,非持久化快取指的是控制app展示的相關狀態,例如使用者列表、訊息閱讀態以及依賴介面的各種狀態等等。筆者之前也有一篇博文對provider
進行了介紹Flutter Provider使用指南
- 網路請求
這裡使用dio
進行網路請求,進行了簡單的封裝 - 其他
- 手機桌面訊息通知小紅點通過
flutter_app_badger
包來實現,效果如下:
- 修改使用者頭像時,獲取本地相簿或呼叫照相機,使用
image_picker
庫來實現,圖片的裁剪通過image_cropper
庫來實現 - 網路圖片快取,使用
cached_network_image
來完成,避免使用圖片時反覆呼叫http服務
功能實現
- 應用初始化 在開啟app時,首先要進行初始化,請求相關介面,恢復持久化狀態等。在main.dart檔案的開頭,進行如下操作:
為了避免文章充斥著大段具體業務程式碼影響閱讀體驗,本文的程式碼部分只會列舉核心內容,部分常見邏輯和樣式內容會省略,完整程式碼詳見專案倉庫
import 'global.dart';
...
// 在執行runApp,之間,執行global中的初始化操作
void main() => Global.init().then((e) => runApp(MyApp(info: e)));
複製程式碼
接下來我們檢視global.dart
檔案
library global;
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
...
// 篇幅關係,省略部分包引用
// 為了避免單檔案過大,這裡使用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的構造方法,通過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方法,將例項轉化為json方便儲存
Map<String, dynamic> toJson() => {
'user': user,
'isLogin': isLogin,
'friendRequest': friendRequest,
'avatar': avatar,
'friendsList': friendsList,
'nickName': nickName
};
}
// 定義全域性類,實現初始化操作
class Global {
static SharedPreferences _prefs;
static Profile profile = Profile();
static Future init() async {
// 這裡使用了shared_preferences這個庫輔助持久化狀態儲存
_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例項,這個是訊息收發和server與客戶端通訊的關鍵
IO.Socket socket = IO.io(socketIODomain, <String, dynamic>{
'transports': ['websocket'],
'path': '/mySocket'
});
// 將socket例項和訊息列表作為結果返回
return {
'messageArray': message != null ? message.data : [],
'socketIO': socket
};
}
// 定義靜態方法,在需要的時候更新本地儲存的資料
static saveProfile() => _prefs.setString('profile', jsonEncode(profile.toJson()));
}
...
複製程式碼
global.dart檔案中定義了Profile類,這個類定義了使用者的持久化資訊,如頭像、使用者名稱、登入態等等,Profilet類還提供了將其json化和根據json資料還原Profile例項的方法。Global類中定義了整個應用的初始化方法,首先借助shared_preferences
庫,讀取儲存的json化的Profile資料,並將其還原,從而恢復使用者狀態。Global中還定義了saveProfile方法,供外部應用呼叫,以便更新本地儲存的內容。在恢復本地狀態後,init方法還請求了必須的介面,建立全域性的socket例項,將這兩者作為引數傳遞給main.dart中的runApp方法。global.dart內容過多,這裡使用了part
關鍵字進行內容拆分,UserModel等類的定義都拆分出去了,詳見筆者的另一篇博文dart flutter 檔案與庫的引用匯出
- 狀態管理 接下來我們回到main.dart中,觀察MyApp類的實現:
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類做的做主要的工作就是建立整個應用的狀態例項,包括使用者資訊,webSocket例項以及聊天資訊等。通過provider
庫中的MultiProvider,根據狀態的型別,以類似鍵值對的形式將狀態例項暴露給子元件,方便子元件讀取和使用。其原理有些類似於前端框架react中的Context,能夠跨元件傳遞引數。這裡我們繼續檢視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;
}
複製程式碼
為了在改變資料的時候能夠同步更新UI,這裡UserModel繼承了ProfileChangeNotifier類,該類定義了notifyListeners方法,UserModel內部設定了各個屬性的set和get方法,將讀寫操作代理到Global.profile上,同時劫持set方法,使得在更新模型的值的時候會自動觸發notifyListeners函式,該函式負責更新UI和同步狀態的修改到持久化的狀態管理中。在具體的業務程式碼中,如果要改變model的狀態值,可以參考如下程式碼:
if (key == 'avatar') {
Provider.of<UserModle>(context).avatar = '圖片url';
}
複製程式碼
這裡通過provider包,根據提供的元件context,在元件樹中上溯尋找最近的UserModle,並修改它的值。這裡大家可能會抱怨,只是為了單純讀寫一個值,前面居然要加如此長的一串內容,使用起來太不方便,為了解決這個問題,我們可以進行簡單的封裝,在global.dart檔案中我們有如下的定義:
// 給其他widget做的抽象類,用來獲取資料
abstract class CommonInterface {
String cUser(BuildContext context) {
return Provider.of<UserModle>(context).user;
}
UserModle cUsermodal(BuildContext context) {
return Provider.of<UserModle>(context);
}
...
}
複製程式碼
通過一個抽象類,將引數的字首部分都封裝起來,具體使用如下:
class testComponent extends State<FriendList> with CommonInterface {
...
if (key == 'avatar') {
cUsermodal(context).avatar = '圖片url';
}
}
複製程式碼
- 路由管理
接下來我們繼續梳理main.dart檔案:
class ContextContainer extends StatefulWidget {
// 後文中類似程式碼將省略
@override
_ContextContainerState createState() => _ContextContainerState();
}
class _ContextContainerState extends State<ContextContainer> with CommonInterface {
// 上下文容器,主要用來註冊登記和傳遞根上下文
@override
Widget build(BuildContext context) {
// 向伺服器傳送訊息,表示該使用者已登入
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進行了一次元件包裹,是為了保證向伺服器登記使用者上線的邏輯僅觸發一次,在ListenContainer的MaterialApp中,定義了應用中會出現的所有路由頁,/
代表根路由,在根路由下,根據使用者的登入態來選擇渲染的元件:MyHomePage是應用的主頁面,裡面包含好友列表頁,搜尋頁和個人中心頁以及底部的切頁tab,LogIn則表示應用的登入頁
- 登入頁:
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);
}
}
}
複製程式碼
對這個路由頁進行簡單的拆解後,我們發現該頁面的主幹就三個元件,兩個TextFormField分別用作使用者名稱和密碼的表單域,一個RaisedButton用做登入按鈕。這裡是最典型的TextFormField widget應用,通過元件的controller來獲取填寫的值,TextFormField的validator會自動對填寫的內容進行校驗,但要注意的是,只要在這個頁面,validator的校驗每時每刻都會執行,感覺很不智慧。登入驗證通過後,會拉取使用者的聊天記錄。
- 專案主頁
繼續回到我們的main.dart檔案,主頁的頁面繪製內容如下:
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元件本身會因為路由重建,導致上下文丟失,全域性監聽事件報錯找不到元件樹
BuildContext rootContext = widget.originCon;
UserModle newUserModel = cUsermodal(rootContext);
Message mesArray = Provider.of<Message>(rootContext);
// 監聽聊天資訊
if(!cMysocket(rootContext).hasListeners('chat message')) {
cMysocket(rootContext).on('chat message', (msg) {
...
SingleMesCollection mesC = mesArray.getUserMesCollection(owner);
// 在訊息列表中插入新的訊息
...
// 根據所處環境更新未讀訊息數
...
updateBadger(rootContext);
});
}
// 系統通知
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的引數我們可以發現,這裡從上級元件傳遞了兩個BuildContext例項。每個元件都有自己的context,context就是元件的上下文,由此作為切入點我們可以遍歷元件的子元素,也可以向上追溯父元件,每當元件重繪的時候,context都會被銷燬然後重建。_MyHomePageState的build方法首先呼叫registerNotification來註冊對伺服器端發起的事件的響應,比如好友發來訊息時,訊息列表自動更新;有人發起好友申請時觸發提醒等。其中通過provider
庫來同步應用狀態,provider
的原理也是通過context來追溯元件的狀態。registerNotification內部使用的context必須使用父級元件的context,即originCon。因為MyHomePage會因為狀態的重新整理而重建,但事件註冊只會呼叫一次,如果使用MyHomePage自己的context,在註冊後元件重繪,呼叫相關事件的時候將會報無法找到context的錯誤。registerNotification內部註冊了提醒彈出toast的邏輯,此處的toast的實現用到了上溯找到的MaterialApp的上下文,此處不能使用originCon,因為它是MyHomePage父元件的上下文,無法溯找到MaterialApp,直接使用會報錯。
底部tab的我們通過BottomNavigationBarItem來實現,每個item繫結點選事件,點選時切換展示的元件,聊天列表、搜尋和個人中心都通過單個的元件來實現,由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)
],
),
);
}
// 點選跳轉好友詳情頁
void toFriendInfo() {
Navigator.pushNamed(context, 'friendInfo');
}
void slideToEnd() {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent + 40);
}
}
複製程式碼
這裡的結構相對簡單,由TalkList和ChatInputForm分別構成聊天頁和輸入框,外圍用Scaffold包裹,實現使用者名稱展示和右上角點選icon,接下來我們來看看TalkList元件:
class _TalkLitState extends State<TalkList> with CommonInterface {
bool isLoading = false;
// 計算請求的長度
int get acculateReqLength {
// 省略業務程式碼
...
}
// 拉取更多訊息
_getMoreMessage() async {
// 省略業務程式碼
...
}
@override
Widget build(BuildContext context) {
SingleMesCollection mesCol = cTalkingCol(context);
return Expanded(
child: Container(
color: Color(0xfff5f5f5),
// 通過NotificationListener實現下拉操作拉取更多訊息
child: NotificationListener<OverscrollNotification>(
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
// 滾動的菊花
if (index == 0) {
// 根據資料狀態控制顯示標誌 沒有更多或正在載入
...
}
return MessageContent(mesList: mesCol.message, rank:index);
},
itemCount: mesCol.message.length + 1,
controller: widget.scrollController,
),
// 註冊通知函式
onNotification: (OverscrollNotification notification) {
if (widget.scrollController.position.pixels <= 10) {
_getMoreMessage();
}
return true;
},
)
)
);
}
}
複製程式碼
這裡的關鍵是通過NotificationListener實現使用者在下拉操作時拉取更多聊天資訊,即分次載入。通過widget.scrollController.position.pixels來讀取當前滾動列表的偏移值,當其小於10時即判定為滑動到頂部,此時執行_getMoreMessage拉取更多訊息。這裡詳細解釋下聊天功能的實現:訊息的傳遞非常頻繁,使用普通的http請求來實現是不現實的,這裡通過dart端的socket.io來實現訊息交換(類似於web端的webSocket,服務端就是用node上的socket.io server實現的),當你傳送訊息時,首先會更新本地的訊息列表,同時通過socket的例項向伺服器傳送訊息,伺服器收到訊息後將接收到的訊息轉發給目標使用者。目標使用者在初始化app時,就會監聽socket的相關事件,收到伺服器的訊息通知後,更新本地的訊息列表。具體的過程比較繁瑣,有很多實現細節,這裡暫時略去,完整實現在原始碼中。
接下來我們檢視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,
// 傳送摁鈕
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;
}
// 想伺服器傳送訊息,更新未讀訊息,並更新本地訊息列表
...
// 保證在元件build的第一幀時才去觸發取消清空內容
WidgetsBinding.instance.addPostFrameCallback((_) {
_messController.clear();
});
// 鍵盤自動收起
//FocusScope.of(context).requestFocus(FocusNode());
widget.scrollController.jumpTo(widget.scrollController.position.maxScrollExtent + 50);
setState(() {
canSend = false;
});
}
}
複製程式碼
這裡用Form包裹TextFormField元件,通過註冊onChanged方法來對輸入內容進行校驗,防止其為空,點選傳送按鈕後通過socket例項傳送訊息,列表滾動到最底部,並且清空當前輸入框。
- 個人中心頁
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(
// 通用元件,展現使用者資訊
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
繫結點選事件,切換路由進入修改頁。
- 個人資訊修改頁(暱稱)
效果圖如下:
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檢驗輸入是否為空,是否同原來內容一致等等。修改密碼的邏輯此處類似,不再贅述。
- 個人資訊修改頁(頭像)
具體效果圖如下:
程式碼實現如下:
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; // 臨時資料夾
// 建立檔案
final File imageFile = File(path.join(tempPath, 'dart.png')); // 儲存在應用資料夾內
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的相關配置
...
);
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))
來實現,該呼叫將返回一個file檔案,而後通過ImageCropper.cropImage
來進入裁剪操作,裁剪完成後將成品圖片通過getBase64
轉換成base64字串,通過post請求傳送給伺服器,從而完成頭像的修改。
後記
該專案只是涉及app端的相關邏輯,要正常執行還需要配合後端服務,具體邏輯可以參考筆者自己的node伺服器,包含了常規http請求和websocket服務端的相關邏輯實現。
本專案程式碼倉庫
如有任何疑問,歡迎留言交流~