花裏胡俏地用Dart+Flutter實現簡單聊天功能

介紹

做爲一個Android開發,基本沒怎麼接觸後臺開發的東西,對這方面也有點興趣,一直都想寫套接口實現下簡單的後端服務玩一玩。 Flutter也學習了快一年了,加上以前看了下閒魚的一篇文章Flutter & Dart三端一體化開發,興趣就來了,有興趣就有學習熱情。因而將Dart的HttpServer學習了一下,實現了一個簡單的聊天室應用。html

作這個應用還有其餘的目的:linux

  • 學習WebSocket,順便複習下計算機網絡的一些知識。
  • 開發過程當中須要兩個客戶端進行聊天的聊天,使用兩個android studio模擬器的話,電腦簡直卡飛天了。因此就使用Flutter開發的Desktop客戶端來進行調試。反正基本上就是一套代碼,而後本身作下desktop端和app端的屏幕適配就好了。
  • 學習Dart的HttpServer和第三方服務端框架aqueduct。看了下網上的幾個dart服務器框架,就這個比較好,上手容易,功能和文檔也比較完善。
  • 繼續練手Flutter,這段時間沒作項目,感受有點生疏了
  • 體驗一波全棧開發的過程

演示

Github源碼,包括了客戶端和服務端的代碼。android

clone項目後,能夠在本地運行個人客戶端代碼。基本上是一套代碼,目前只適配了app和desktop平臺。(web遇到點問題,因此還沒弄好)git

基本功能

因爲時間關係,也只是作了下一些最基本的功能,後面有空再繼續完善。github

客戶端

  • 用戶登陸註冊
  • 查看全部會話
  • 用戶發送消息和接收消息

服務端

  • 提供登陸註冊接口
  • 查詢全部會話記錄
  • 查詢歷史聊天記錄
  • 提供socket鏈接實現並和客戶端進行交互

這是生成的接口文檔地址:web

項目實現

  • 開發工具,Flutter客戶端使用的是Android Studio開發,服務端是使用IntelliJ IDEA。

服務端

  • 服務端實現,這裏不是用最基本的HttpServer來實現,而是用了一個第三方庫的服務端框架aqueduct,一個構建支持RESTful APIs/ORM對象數據庫映射/OAuth2.0的http server 框架。咱們能夠利用這個框架,快速實現接口的開發,使用Router來進行路由處理,使用Controller來處理每一個請求,使用Postgres數據庫框架來進行數據庫操做,使用集成OAuth2.0受權框架來提供受權服務。(具體關於aqueduct框架的使用,後面會再翻譯下文檔,寫篇更加具體的使用文章。這裏簡單介紹下。)

總覽

  • ApplicationChannel(應用通道),每一個aqueduct應用程序會根據isolate數目去啓動相應數量的ApplicationChannel(一個isolate會建立一個ApplicationChannel)。sql

  • 不一樣的HTTP請求,會根據Router配置的路徑,由不一樣的Controller進行處理。每一個裏面都有相應的邏輯去處理HTTP請求。數據庫

  • 能夠連接多個controller處理,造成子通道。好比實現一個獲取好友列表的接口,很明顯,前提是咱們須要在請求接口的時候帶上用戶信息(好比token)。這樣的話,就能夠考慮一個Authorizer controller,用來驗證請求的受權憑據是否正確。再加一個FriendController來獲取好友列表數據做爲response。json

定義路由

好比下面的代碼,定義了註冊接口和登陸接口的路由。ubuntu

router
        .route("/register")
        .link(() => RegisterController(authServer, context));

    router.route("/login").link(() => LoginController(context));

複製代碼

實現Controller

針對不一樣的接口,定義Controller進行相應的處理。下面的登陸接口的相關代碼。

  1. 首先查詢數據庫是否存在這個用戶庫。用戶不存在,接口返回失敗提示。
  2. 用戶存在,經過auth/token獲取token。token獲取失敗,接口返回失敗。
  3. token獲取成功,接口將token和用戶信息返回給客戶端
class LoginController extends ResourceController {
  final ManagedContext context;

  LoginController(this.context);

  @Operation.post()
  Future<Response> login(@Bind.body() User user) async {
    String msg = "登陸異常";
    //查詢數據庫是否存在這個用戶
    var query = Query<User>(context)
      ..where((u) => u.username).equalTo(user.username);
    User result = await query.fetchOne();

    if (result == null) {
      msg = "用戶不存在";
    } else {
      //經過auth/token獲取token。登陸成功的話,返回token
      var clientId = "com.donggua.chat";
      var clientSecret = "dongguasecret";
      var body =
          "username=${user.username}&password=${user.password}&grant_type=password";
      var clientCredentials =
          Base64Encoder().convert("$clientId:$clientSecret".codeUnits);

      res.Response response =
          await http.post("http://127.0.0.1:8888/auth/token",
              headers: {
                "Content-Type": "application/x-www-form-urlencoded",
                "Authorization": "Basic $clientCredentials"
              },
              body: body);

      if (response.statusCode == 200) {
        var map = json.decode(response.body);

        return Response.ok(
          BaseResult(
            code: 1,
            msg: "登陸成功",
            data: {
              'userId': result.id,
              'access_token': map['access_token'],
              'userName': result.username
            },
          ),
        );
      }
    }

    return Response.ok(
      BaseResult(
        code: 1,
        msg: msg,
      ),
    );
  }
}
複製代碼

建立WebSocket

  • 利用WebSocketTransformer.upgrade,將HTTP請求升級爲一個WebSocket鏈接。
  • 使用socket.listen()方法,監聽客戶端發送過來的消息
  • 本地使用一個類型爲Map<int, WebSocket>的connections變量,來保存當前isolate中的全部的socket鏈接
  • 利用messageHub將消息發送到其餘isolate中
//跟服務器創建鏈接
    router
        .route("/connect")
        .link(() => Authorizer.bearer(authServer))
        .linkFunction((request) async {
      //鏈接的用戶id
      int userId = request.authorization.ownerID;
      var socket = await WebSocketTransformer.upgrade(request.raw);

      print("userId:$userId的用戶跟服務器創建鏈接");
      socket.listen((event) {
        print("server listen:${event}");
        handleEvent(event, fromUserId: userId);

        messageHub.add(
          {
            "event": "websocket_broadcast",
            "message": event,
            'fromUserId': userId,
          },
        );
      }, onDone: () {
        //socket鏈接斷了的話,移除鏈接
        connections.remove(userId);
      });
      //保存鏈接
      connections[userId] = socket;

      print("當前鏈接用戶有${connections.length}個");
      connections.keys.forEach((userId) {
        print("userId:$userId");
      });
      return null;
    });
複製代碼

配置數據庫

項目目錄下有一個config.yaml文件,用來實現一些信息的配置,好比數據庫方面的配置。

database:
  host: localhost
  port: 5432
  username: donggua
  password: password
  databaseName: database_chat
複製代碼

在項目中初始化數據庫。在prepare()方法中,進行數據庫的鏈接,並獲取到數據庫的上下文ManagedContext對象。將ManagedContext保存到一個context的成員變量中,而後能夠傳給須要數據庫操做的controller的構造函數,這樣的話,咱們就能夠在controller裏面進行一些數據庫方面的操做。

@override
  Future prepare() async {
    final config = CustomConfig(options.configurationFilePath);
    final dateModel = ManagedDataModel.fromCurrentMirrorSystem();
    final persistentStore = PostgreSQLPersistentStore.fromConnectionInfo(
        config.database.username,
        config.database.password,
        config.database.host,
        config.database.port,
        config.database.databaseName);
    context = ManagedContext(dateModel, persistentStore);
複製代碼

運行服務器

  1. 在服務器上面安裝Dart sdk,這裏的服務器建議是ubuntu,能夠直接安裝官網的Dart SDK。若是是centOS的話,須要本身下載dart sdk源碼並進行編譯構建,好麻煩,並且可能還會遇到其餘問題。(因此我最後重裝系統,搞成ubuntu系統了)

  2. 將本地的服務器代碼,放置到服務器上面。用到兩個工具,SecureCRT和FileZilla,SecureCRT用來搞遠程登陸,FileZilla用來搞文件傳輸。具體使用百度一下。

  3. 在服務器上面安裝dart sdk和aqueduct框架

  • Dart官網下載Dart SDK,而後利用FileZilla上傳到服務器上,解壓,安裝,搞定。
  • 運行命令激活aqueduct
pub global activate aqueduct
複製代碼
  1. 安裝Postgresql,建立用戶,建立數據庫

這塊具體也能夠百度一下,這裏就不細說了。建立配置的信息,要和咱們的服務端項目中的配置信息保持一致就行。

  1. 在項目目錄下,運行下面的命令,開啓服務。成功的話,就可使用Postman去測試接口調用了。/
aqueduct serve
複製代碼

  1. 使用Screen管理遠程會話,讓程序在後臺運行

通常狀況下,當咱們關閉遠程窗口的話,項目就跟着退出運行了。因此可使用Screen來讓咱們在關閉ssh鏈接的狀況下,讓程序繼續在後臺運行。screen命令能夠實現當前窗口與任務分離,咱們即便離線了,服務器仍在後臺運行任務。當咱們從新登陸服務器,能夠讀取窗口線程,從新鏈接任務窗口。

推薦一篇文章,瞭解下什麼是Screen。linux 技巧:使用 screen 管理你的遠程會話

客戶端

客戶端實現,Flutter。客戶端這邊的實現比較簡單,爲了快點體驗出三端一體化的快感,用了一些第三方庫加快節奏。UI的話就是一個登陸註冊頁面,再加上一個聊天列表和聊天窗口頁面。

總覽

只是作簡單Demo,因此總體的代碼架構比較簡單,後期再優化下。

  • config目錄:保存App配置的一些信息。好比當前平臺是不是大屏幕、配置根據當前環境去拿去host(本地環境拿本地host,線上環境拿生產host)
  • model目錄:定義接口返回的實體類。
  • page目錄:定義多個頁面,登陸註冊頁面、聊天列表頁面、聊天詳情頁面。
  • util目錄:定義工具類,主要是簡單封裝了一個建立socket鏈接並添加事件監聽的Manager類。
  • widget目錄:如今只有一個,就是顯示聊天消息item的widget
  • main.dart和main_local.dart:這兩個的代碼是同樣的,區別就是接口的host不同。main_local.dart在開發階段測試接口用的是本地的localhost,main.dart用的是生產環境的host。

登陸註冊頁面

UI的代碼就不展現了,無非就是兩個文本框加個登陸按鈕。 看一下以前在前面的項目中,LoginController定義好的登陸接口返回的結構:

//登陸成功
{
  "code": 1,
  "msg": 「登陸成功」,
  "data":{
    "userId":"12345",
    「access_token」:"abcdefg",
    "userName":"donggua"
  }
}
//登陸失敗
{
  "code":1,
  "msg":"登陸異常:具體緣由"
}

複製代碼

點擊按鈕,使用Dio調用以前定義好的後端接口。

void login() async {
    Dio dio = Dio(BaseOptions(baseUrl: GetIt.instance<AppConfig>().apiHost));

    Response<Map<String, dynamic>> response =
        await dio.post<Map<String, dynamic>>(
      "/login",
      data: {
        'username': username_controller.text.toString(),
        'password': password_controller.text.toString(),
      },
    );

    print("登陸結果:$response");
    if (response != null &&
        response.data != null &&
        response.data['code'] == 1 &&
        response.data['data']['access_token'] != null) {
      //登陸成功
      String token = response.data['data']['access_token'];
      int fromUserId = response.data['data']['userId'];
      String userName = response.data['data']['userName'];

      Navigator.of(context).push(MaterialPageRoute(builder: (context) {
        return ChatListPage(
          token: token,
          fromUserId: fromUserId,
          userName: userName,
        );
      }));
    }
  }
複製代碼

聊天列表頁面

登陸成功,進入到聊天列表頁面。

1.請求聊天列表接口/chat_list,獲取聊天列表並展現。(後臺定義接口類ChatListController,注意客戶端接口請求是要帶上token的,由於服務端會作token驗證。若token無效,則返回401錯誤碼)。

getChatList() async {
    Dio dio = Dio(BaseOptions(
        baseUrl: GetIt.instance<AppConfig>().apiHost,
        headers: {'Authorization': 'Bearer ${widget.token}'}));

    Response<Map<String, dynamic>> response =
        await dio.get<Map<String, dynamic>>(
      "/chat_list",
    );

    if (response != null &&
        response.data != null &&
        response.data['code'] == 1) {
      List list = response.data['data'];
      list?.forEach((json) {
        userList.add(User.fromJson(json));
      });

      setState(() {});
    }
  }
複製代碼

2.使用SocketManager建立WebSocket鏈接,使客戶端和服務器之間能夠進行通訊。

void initState(){
      socketManager.connectWithServer(widget.token).then((bool) {
      if (bool) {
        showToast("鏈接服務器成功");
      } else {
        showToast("鏈接服務器失敗");
      }
    });
  }
複製代碼

聊天詳情頁面

1.打開聊天詳情頁面,獲取歷史聊天記錄(App這邊暫時沒作數據保存,因此數據全是在後端的數據庫中)。這裏就不展現代碼了,跟前面的同樣,請求接口,獲取數據後進行展現。有一點就是要根據是不是當前用戶,消息item的展現會有所區別。

2.在文本框中輸入內容,使用socket進行發送消息。

sendMessage() async {
    //發送消息
    Map<String, dynamic> data = {
      'toUserId': widget.toUser.id,
      'msg_content': inputController.text.toString(),
      'msg_type': 1,
    };

    GetIt.instance<SocketManager>().sendMessage(data);

    //清空editText
    inputController.clear();

    debugPrint("向服務器發送消息:$data");
  }
複製代碼

3.監聽服務器的消息。當接收到服務端的消息後,往ListView的數據源中添加一條消息。

void initState(){
     listener = (Map<String, dynamic> json) {
      if (mounted) {
        setState(() {
          print("messageList增長一條消息");
          Message newMessage = Message.fromJson(json);
          //消息是本身發的,或者是別人要發給本身的,才進行展現
          if (newMessage.fromUserId == widget.fromUserId ||
              newMessage.toUserId == widget.fromUserId) {
            messageList.add(newMessage);
          }
        });
        Future.delayed(Duration(milliseconds: 50), () {
          scrollController.jumpTo(scrollController.position.maxScrollExtent);
        });
      }
    };
    //添加監聽
    GetIt.instance<SocketManager>().addListener(listener);
}
複製代碼

根據屏幕進行適配

這裏介紹下以前寫過的一篇文章Flutter之支持不一樣的屏幕尺寸和方向

這裏的場景是,在App裏面就顯示一個聊天列表頁面,這個頁面是充滿整個屏幕的,點擊item纔會進入一個新的聊天詳情頁面。可是在桌面端或者平板,這種大尺寸的屏幕上,能夠在左側顯示聊天列表,右側顯示聊天詳情,合理地使用屏幕空間。

總體的思路是相似Android的Fragment。咱們須要作的就是定義兩個Widget,一個用於顯示主列表,一個用於顯示詳細視圖。實際上,這些就是相似的fragments。

咱們只須要檢查設備是否具備足夠的寬度來處理列表視圖和詳細視圖。若是是,咱們在同一屏幕上顯示兩個widget。若是設備沒有足夠的寬度來包含兩個界面,那咱們只須要在屏幕中展現主列表,點擊列表項後導航到獨立的屏幕來顯示詳細視圖。

總結

  1. 作這個項目主要仍是爲了體驗下用Dart進行全棧開發的感受,整體效率確實提升不少。
  2. 沒有在真正的項目中進行實戰。先把基礎的知識學習積累起來,期待在後面可以應用到真正的項目中。
  3. 如今的Demo比較簡單,有空再把這個項目進行完善
  4. 近段時間仍是在看原生的東西,有些技術仍是相似的,對原生了解得比較深刻,能夠更好地使用和理解Flutter。
相關文章
相關標籤/搜索