做爲當下風頭正勁的跨端框架,flutter成爲原生開發者和前端開發者爭相試水的領域,筆者將經過一個仿微信聊天的應用,展示flutter的開發流程和相關工具鏈,旨在熟悉flutter的開發生態,同時也對本身的學習過程進行一個總結。筆者是web前端開發,相關涉及原生的地方不免有錯漏之處,歡迎批評指正。項目代碼庫連接放在文末。前端
這裏列舉了本例中使用的幾個關鍵第三方庫,具體的使用細節在功能實現部分會有詳解。node
node
寫的,webSocket使用socket.io
來實現(詳見文末連接),socket.io
官方最近也開發了基於dart的配套客戶端庫socket_io_client
,其與服務端配合使用。由此可來實現消息收發和server端事件通知。shared_preferences
,將持久化的狀態經過寫文件的方式保存在本地,每次應用啓動的時候讀取該文件,恢復用戶狀態。provider
來進行非持久化的狀態管理,非持久化緩存指的是控制app展現的相關狀態,例如用戶列表、消息閱讀態以及依賴接口的各類狀態等等。筆者以前也有一篇博文對provider
進行了介紹Flutter Provider使用指南dio
進行網絡請求,進行了簡單的封裝flutter_app_badger
包來實現,效果以下:image_picker
庫來實現,圖片的裁剪經過image_cropper
庫來實現cached_network_image
來完成,避免使用圖片時反覆調用http服務爲了不文章充斥着大段具體業務代碼影響閱讀體驗,本文的代碼部分只會列舉核心內容,部分常見邏輯和樣式內容會省略,完整代碼詳見項目倉庫react
import 'global.dart';
...
// 在運行runApp,之間,運行global中的初始化操做
void main() => Global.init().then((e) => runApp(MyApp(info: e)));
複製代碼
接下來咱們查看global.dart
文件git
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 文件與庫的引用導出github
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的定義:web
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的狀態值,能夠參考以下代碼:json
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);
}
...
}
複製代碼
經過一個抽象類,將參數的前綴部分都封裝起來,具體使用以下:api
class testComponent extends State<FriendList> with CommonInterface {
...
if (key == 'avatar') {
cUsermodal(context).avatar = '圖片url';
}
}
複製代碼
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的校驗每時每刻都會運行,感受很不智能。登陸驗證經過後,會拉取用戶的聊天記錄。
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服務端的相關邏輯實現。
本項目代碼倉庫 若有任何疑問,歡迎留言交流~