前言

  作为当下风头正劲的跨端框架,flutter成为原生开发者和前端开发者争相试水的领域,笔者将通过一个仿微信聊天的应用,展现flutter的开发流程和相关工具链,旨在熟悉flutter的开发生态,同时也对自己的学习过程进行一个总结。笔者是web前端开发,相关涉及原生的地方难免有错漏之处,欢迎批评指正。项目代码库链接放在文末。

功能简介

  1. 聊天列表
    本应用支持用户直接点对点聊天,使用webSocket实现消息提醒与同步
    好友列表页:

    在聊天列表展示所有好友,点击进入聊天详情,未读消息通过好友头像右上角小红点表示。
    聊天页:

  2. 搜索页
    用户可以通过搜索添加好友:

  3. 个人中心页
    该页面可以进行个人信息的修改,包括调整昵称,头像,修改密码等等,同时可以退出登录。

工具链梳理

这里列举了本例中使用的几个关键第三方库,具体的使用细节在功能实现部分会有详解。

  1. 消息同步与收发
    项目中使用webSocket同server进行通信,我的服务器是用node写的,webSocket使用socket.io来实现(详见文末链接),socket.io官方最近也开发了基于dart的配套客户端库socket_io_client,其与服务端配合使用。由此可来实现消息收发和server端事件通知。
  2. 状态管理
  • 持久化状态管理
    持久化状态指的是用户名、登录态、头像等等持久化的状态,用户退出app之后,不用重新登录应用,因为登录态已经保存在本地,这里使用的是一个轻量化的包shared_preferences,将持久化的状态通过写文件的方式保存在本地,每次应用启动的时候读取该文件,恢复用户状态。
  • 非持久化状态
    这里使用社区广泛使用的库provider来进行非持久化的状态管理,非持久化缓存指的是控制app展示的相关状态,例如用户列表、消息阅读态以及依赖接口的各种状态等等。笔者之前也有一篇博文对provider进行了介绍Flutter Provider使用指南
  1. 网络请求
    这里使用dio进行网络请求,进行了简单的封装
  2. 其他
  • 手机桌面消息通知小红点通过flutter_app_badger包来实现,效果如下:

  • 修改用户头像时,获取本地相册或调用照相机,使用image_picker库来实现,图片的裁剪通过image_cropper库来实现

  • 网络图片缓存,使用cached_network_image来完成,避免使用图片时反复调用http服务

功能实现

  1. 应用初始化
    在打开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 文件与库的引用导出

  1. 状态管理
    接下来我们回到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@overrideWidget 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;@overridevoid 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';}
}
  1. 路由管理
    接下来我们继续梳理main.dart文件:
class ContextContainer extends StatefulWidget {//    后文中类似代码将省略@override_ContextContainerState createState() => _ContextContainerState();
}class _ContextContainerState extends State<ContextContainer> with CommonInterface {//  上下文容器,主要用来注册登记和传递根上下文@overrideWidget 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组件是否存在的全局keyfinal GlobalKey<ChatState> myK = GlobalKey<ChatState>();//  注册路由的组件,删好友每次pop的时候都会到这里,上下文都会刷新@overrideWidget 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则表示应用的登录页

  • 登录页:

    其代码在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;@overridevoid initState() {//  初始化用户名_unameController.text = Global.profile.user;if (_unameController.text != null) {_nameAutoFocus = false;}super.initState();}@overrideWidget 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;@overrideWidget 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'];//  注册事件的映射mapMap 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;@overrideWidget 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);@overrideWidget build(BuildContext context) {UserModle myInfo = Provider.of<UserModle>(context);String sayTo = myInfo.sayTo;cUsermodal(context).toastContext = context;//  更新桌面iconupdateBadger(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 {//    省略业务代码...}@overrideWidget 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;@overrideWidget 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{@overrideWidget 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, });...@overrideWidget 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;@overrideWidget 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;@overrideWidget 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服务端的相关逻辑实现。
本项目代码仓库
如有任何疑问,欢迎留言交流~

基于Flutter的仿微信聊天应用相关推荐

  1. 基于svelte3+sass仿微信网页版聊天|svelte.js 桌面聊天实例SvelteWebChat

    svelte-webchat:基于svelte3+svelteKit仿微信mac网页版聊天实战案例. 采用了最新前端svelte.js框架,基于svelte3+svelteKit+sass+svelt ...

  2. 一款基于flutter的仿微博客户端(仿微博首页,视频页,聊天页面等)

    基于flutter的仿微博客户端 在学习了flutter之后,写了一个仿微博最新的10.4.0版本, 还原微博80%的界面 总共涉及到了几十个界面和接口,用到了flutter中的大部分组件 该项目分为 ...

  3. 高仿微信聊天界面长按弹框样式

    效果图 背景 在公司做的项目里面,刚好有需要用到微信聊天界面长按弹框样式这种UI的. 网上找了一下,没找到. Android现成的 ListPopupWindow又不能满足需求. 因此在非上班时间撸一 ...

  4. native聊天界面 react_ReactNative 仿微信聊天 App 实例分享|RN 仿朋友圈

    今天给大家分享的是 RN 聊天室项目,基于 react-native+react-navigation+react-redux+react-native-image-picker+rnPop 等技术实 ...

  5. react仿微信聊天室|react即时聊天IM系统|react群聊

    react+redux仿微信聊天IM实战|react仿微信界面|react多人群聊天室 最近一直捣鼓react开发,就运用react开发了个仿微信聊天室reactChatRoom项目,基于react+ ...

  6. Flutter高仿微信-第52篇-群聊-清空聊天记录

     Flutter高仿微信系列共59篇,从Flutter客户端.Kotlin客户端.Web服务器.数据库表结构.Xmpp即时通讯服务器.视频通话服务器.腾讯云服务器全面讲解. 详情请查看 效果图: 实现 ...

  7. php写的微信聊天界面,Android_Android高仿微信聊天界面代码分享,微信聊天现在非常火,是因其 - phpStudy...

    Android高仿微信聊天界面代码分享 微信聊天现在非常火,是因其界面漂亮吗,哈哈,也许吧.微信每条消息都带有一个气泡,非常迷人,看起来感觉实现起来非常难,其实并不难.下面小编给大家分享实现代码. 先 ...

  8. Flutter高仿微信-第47篇-群聊-语音

     Flutter高仿微信系列共59篇,从Flutter客户端.Kotlin客户端.Web服务器.数据库表结构.Xmpp即时通讯服务器.视频通话服务器.腾讯云服务器全面讲解. 详情请查看 效果图: 详情 ...

  9. Flutter高仿微信-表结构

    平时经常使用到的命令行也放出来 命令行登录本地sql数据库:mysql -uroot -proot1234 显示数据库列表:show databases; 进入数据库:use demo3; 显示表:s ...

最新文章

  1. 前端验证码后端返回一个图片_Web后端开发(6)——简易图片验证码的制作
  2. 安装了libevent和memcached之后却发现在执行的时候出现了 error while loading shared libraries问题...
  3. lucene源码分析(1)基本要素
  4. 手机开启开发模式 hbuilder无法搜索到_MIUI 12这个惊艳功能,其他手机也能一键开启...
  5. 移动开发者选项手机如何打开真机调试模式
  6. netty SimpleChannelInboundHandler类继承使用
  7. C/C++混淆点-转义字符
  8. Python面试题之下面代码会输出什么
  9. css3之背景属性之background-size
  10. java 字符串 查找 多个_初学者求教,如何在字符串中查找多个子字符串的位置...
  11. java安装path_JDK安装时设置PATH和CLASSPATH环境变量有何作用?
  12. 定时器Quartz和插件pageHelper使用
  13. 手机计算机键盘技巧,【盲打计算器】看似简单,你不一定会的小技巧
  14. Typora下载及使用
  15. 区块链简介与PMD投资方式
  16. What is CRA
  17. 外贸人需要准备的浏览器插件有哪些?
  18. 类型转换及类型转换函数
  19. iOS 事件分类及事件分发机制
  20. 有道少儿词典正式上线,CEO周枫发朋友圈:“是时候让小学生词典进入互联网时代了”...

热门文章

  1. POWER DESIGNER导出数据字典
  2. 如何删除PDF水印?PDF删除水印怎么操作
  3. Ceph 存储集群2-配置:心跳选项、OSD选项、存储池、归置组和 CRUSH 选项
  4. uni-app z-index无效的解决办法(遮罩层)
  5. Unity 3D PC平台发布|| Unity 3D Web 平台发布||Unity 3D Android平台发布
  6. Go语言debug调试
  7. Web前端——移动端页面开发
  8. PMP项目管理备考资料都有哪些?
  9. 已经无力吐槽 vcpkg
  10. 机器学习中precision和accuracy区别