花里胡俏地用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。
相关文章
相关标签/搜索