基於Flutter的仿微信聊天應用

前言

  做爲當下風頭正勁的跨端框架,flutter成爲原生開發者和前端開發者爭相試水的領域,筆者將經過一個仿微信聊天的應用,展示flutter的開發流程和相關工具鏈,旨在熟悉flutter的開發生態,同時也對本身的學習過程進行一個總結。筆者是web前端開發,相關涉及原生的地方不免有錯漏之處,歡迎批評指正。項目代碼庫連接放在文末。前端

功能簡介

  1. 聊天列表 本應用支持用戶直接點對點聊天,使用webSocket實現消息提醒與同步 好友列表頁:
image
在聊天列表展現全部好友,點擊進入聊天詳情,未讀消息經過好友頭像右上角小紅點表示。 聊天頁:
image
  1. 搜索頁 用戶能夠經過搜索添加好友:
image
  1. 我的中心頁
    該頁面能夠進行我的信息的修改,包括調整暱稱,頭像,修改密碼等等,同時能夠退出登陸。
image

工具鏈梳理

這裏列舉了本例中使用的幾個關鍵第三方庫,具體的使用細節在功能實現部分會有詳解。node

  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
  • 修改用戶頭像時,獲取本地相冊或調用照相機,使用image_picker庫來實現,圖片的裁剪經過image_cropper庫來實現
  • 網絡圖片緩存,使用cached_network_image來完成,避免使用圖片時反覆調用http服務

功能實現

  1. 應用初始化 在打開app時,首先要進行初始化,請求相關接口,恢復持久化狀態等。在main.dart文件的開頭,進行以下操做:

爲了不文章充斥着大段具體業務代碼影響閱讀體驗,本文的代碼部分只會列舉核心內容,部分常見邏輯和樣式內容會省略,完整代碼詳見項目倉庫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

  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
  @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';
    }
}
複製代碼
  1. 路由管理
    接下來咱們繼續梳理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則表示應用的登陸頁緩存

  • 登陸頁:
image
其代碼在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);
    }
  }
}
複製代碼

對這個路由頁進行簡單的拆解後,咱們發現該頁面的主幹就三個組件,兩個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檢驗輸入是否爲空,是否同原來內容一致等等。修改密碼的邏輯此處相似,再也不贅述。

  • 我的信息修改頁(頭像)
    具體效果圖以下:
image
選擇好圖片後,進入裁剪邏輯:
image

代碼實現以下:

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服務端的相關邏輯實現。
本項目代碼倉庫 若有任何疑問,歡迎留言交流~

相關文章
相關標籤/搜索