基於Flutter的仿微信聊天應用

廣蘭路地鐵發表於2020-02-13

前言

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

功能簡介

  1. 聊天列表 本應用支援使用者直接點對點聊天,使用webSocket實現訊息提醒與同步 好友列表頁:
image
在聊天列表展示所有好友,點選進入聊天詳情,未讀訊息通過好友頭像右上角小紅點表示。 聊天頁:
image
  1. 搜尋頁 使用者可以通過搜尋新增好友:
image
  1. 個人中心頁
    該頁面可以進行個人資訊的修改,包括調整暱稱,頭像,修改密碼等等,同時可以退出登入。
image

工具鏈梳理

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

  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檔案的開頭,進行如下操作:

為了避免文章充斥著大段具體業務程式碼影響閱讀體驗,本文的程式碼部分只會列舉核心內容,部分常見邏輯和樣式內容會省略,完整程式碼詳見專案倉庫

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
  @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';
    }
}
複製程式碼
  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繫結點選事件,切換路由進入修改頁。

  • 個人資訊修改頁(暱稱) 效果圖如下:
    基於Flutter的仿微信聊天應用
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服務端的相關邏輯實現。
本專案程式碼倉庫
如有任何疑問,歡迎留言交流~

相關文章