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

入魔的冬瓜發表於2019-12-23

介紹

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

做這個應用還有其他的目的:

  • 學習WebSocket,順便複習下計算機網路的一些知識。
  • 開發過程中需要兩個客戶端進行聊天的聊天,使用兩個android studio模擬器的話,電腦簡直卡飛天了。所以就使用Flutter開發的Desktop客戶端來進行除錯。反正基本上就是一套程式碼,然後自己做下desktop端和app端的螢幕適配就行了。
  • 學習Dart的HttpServer和第三方服務端框架aqueduct。看了下網上的幾個dart伺服器框架,就這個比較好,上手容易,功能和文件也比較完善。
  • 繼續練手Flutter,這段時間沒做專案,感覺有點生疏了
  • 體驗一波全棧開發的過程

演示

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

Github原始碼,包括了客戶端和服務端的程式碼。

clone專案後,可以在本地執行我的客戶端程式碼。基本上是一套程式碼,目前只適配了app和desktop平臺。(web遇到點問題,所以還沒弄好)

基本功能

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

客戶端

  • 使用者登入註冊
  • 檢視所有會話
  • 使用者傳送訊息和接收訊息

服務端

  • 提供登入註冊介面
  • 查詢所有會話記錄
  • 查詢歷史聊天記錄
  • 提供socket連線實現並和客戶端進行互動

這是生成的介面文件地址:

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

專案實現

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

服務端

  • 服務端實現,這裡不是用最基本的HttpServer來實現,而是用了一個第三方庫的服務端框架aqueduct,一個構建支援RESTful APIs/ORM物件資料庫對映/OAuth2.0的http server 框架。我們可以利用這個框架,快速實現介面的開發,使用Router來進行路由處理,使用Controller來處理每個請求,使用Postgres資料庫框架來進行資料庫操作,使用整合OAuth2.0授權框架來提供授權服務。(具體關於aqueduct框架的使用,後面會再翻譯下文件,寫篇更加具體的使用文章。這裡簡單介紹下。)

總覽

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

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

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

  • 可以連結多個controller處理,形成子通道。比如實現一個獲取好友列表的介面,很明顯,前提是我們需要在請求介面的時候帶上使用者資訊(比如token)。這樣的話,就可以考慮一個Authorizer controller,用來驗證請求的授權憑據是否正確。再加一個FriendController來獲取好友列表資料作為response。

定義路由

比如下面的程式碼,定義了註冊介面和登入介面的路由。

    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用來搞檔案傳輸。具體使用百度一下。

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

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

  • Dart官網下載Dart SDK,然後利用FileZilla上傳到伺服器上,解壓,安裝,搞定。
  • 執行命令啟用aqueduct
pub global activate aqueduct
複製程式碼
  1. 安裝Postgresql,建立使用者,建立資料庫

這塊具體也可以百度一下,這裡就不細說了。建立配置的資訊,要和我們的服務端專案中的配置資訊保持一致就行。

  1. 在專案目錄下,執行下面的命令,開啟服務。成功的話,就可以使用Postman去測試介面呼叫了。/
aqueduct serve
複製程式碼

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

  1. 使用Screen管理遠端會話,讓程式在後臺執行

一般情況下,當我們關閉遠端視窗的話,專案就跟著退出執行了。所以可以使用Screen來讓我們在關閉ssh連線的情況下,讓程式繼續在後臺執行。screen命令可以實現當前視窗與任務分離,我們即使離線了,伺服器仍在後臺執行任務。當我們重新登入伺服器,可以讀取視窗執行緒,重新連線任務視窗。

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

客戶端

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

總覽

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

只是做簡單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之支援不同的螢幕尺寸和方向

花裡胡俏地用Dart+Flutter實現簡單聊天功能
花裡胡俏地用Dart+Flutter實現簡單聊天功能
這裡的場景是,在App裡面就顯示一個聊天列表頁面,這個頁面是充滿整個螢幕的,點選item才會進入一個新的聊天詳情頁面。但是在桌面端或者平板,這種大尺寸的螢幕上,可以在左側顯示聊天列表,右側顯示聊天詳情,合理地使用螢幕空間。

整體的思路是類似Android的Fragment。我們需要做的就是定義兩個Widget,一個用於顯示主列表,一個用於顯示詳細檢視。實際上,這些就是類似的fragments。

我們只需要檢查裝置是否具有足夠的寬度來處理列表檢視和詳細檢視。如果是,我們在同一螢幕上顯示兩個widget。如果裝置沒有足夠的寬度來包含兩個介面,那我們只需要在螢幕中展示主列表,點選列表項後導航到獨立的螢幕來顯示詳細檢視。

總結

  1. 做這個專案主要還是為了體驗下用Dart進行全棧開發的感覺,總體效率確實提高很多。
  2. 沒有在真正的專案中進行實戰。先把基礎的知識學習積累起來,期待在後面能夠應用到真正的專案中。
  3. 現在的Demo比較簡單,有空再把這個專案進行完善
  4. 近段時間還是在看原生的東西,有些技術還是類似的,對原生了解得比較深入,可以更好地使用和理解Flutter。

相關文章