Android instant messaging design (Tencent IM access and WebSocket access)

I. Introduction

The group chat of the previous project was directly operated with the database, the experience was very poor, and it was difficult to provide instant feedback of messages, so in the end, I considered using Tencent's IM to complete the group chat access, but it was a little bumpy in the middle, and I found out after the access was completed. There are only 20 people in a group chat in the trial version. At that time, I saw that the trial version supports 100 users. Now I can only have 20 users in a group chat . It is not enough to support online chat with 50 users at the same time. It is barely enough. The following will introduce the access of the two implementation schemes. The text will start soon~~

2. Tencent IM access

The official website of Tencent Cloud IM, the access here extracts the APIs related to group chat, please refer to the document for more information (if you have time, a simple chat platform similar to QQ can be realized)

https://cloud.tencent.com/document/product/269/42440
复制代码

1. Preparations

  • demand analysis

    You need to implement a function similar to group chat in QQ, you only need to develop three simple functions of receiving messages, sending messages, and obtaining history records.

  • Create an app

    This part will not be demonstrated. It is very simple. After creating it, it will look like the picture below.

1.jpg

The trial version can support 100 users and 20 users in a group chat, provide free cloud storage for 7 days, and can create multiple IM instances at the same time. If you are learning to use, the trial version is enough, and commercialization considers the professional version and the ultimate version

  • Dependency integration

    Using gradle integration, you can also use sdk integration, here the new version of sdk is used for integration

    api 'com.tencent.imsdk:imsdk-plus:6.1.2155'
    复制代码

2. Initialization work

Initialize IM

  • Create instance

    There is a callback in the parameter, the object here is equivalent to the anonymous class in java

    val config = V2TIMSDKConfig()
    V2TIMManager.getInstance()
        .initSDK(this, sdkId, config, object : V2TIMSDKListener() {
            override fun onConnecting() {
                // 正在连接到腾讯云服务器
                Log.e("im", "正在连接到腾讯云服务器")
            }
    
            override fun onConnectSuccess() {
                // 已经成功连接到腾讯云服务器
                Log.e("im", "已经成功连接到腾讯云服务器")
            }
    
            override fun onConnectFailed(code: Int, error: String) {
                // 连接腾讯云服务器失败
                Log.e("im", "连接腾讯云服务器失败")
            }
        })
    复制代码
  • Generate login credentials

    This part officially provides the code and server code generated by the client quickly. You can find it on the official website. At the beginning of the test, you can consider that the official project behind the client code is best deployed to the server for processing. This part is mentioned here. Wake up, there are two files on the server side. I didn’t see it clearly at the time. After looking for the function for a long time, I finally found that it was a java file that I forgot to read, or it was in the same level directory. It should be that other APIs also reuse the Base64URL class.

2.jpg

At the same time, the official also provides tools to generate and verify credentials

3.jpg

User login

This part only needs to pass in the parameters

V2TIMManager.getInstance().login(currentUser,sig, object : V2TIMCallback {
    override fun onSuccess() {
        Log.e("im", "${currentUser}登录成功")
    }
    override fun onError(code: Int, desc: String?) {
        Log.e("im", "${currentUser}登录失败,错误码为:${code},具体错误:${desc}")
    }
})
复制代码
  • currentUser is the id of the user
  • sig is the user's login credentials
  • A class for V2TIMCallback callbacks

3. Group chat related

Create a group chat

创建群聊的时候需要注意几个方面的问题

  • 群聊类别(groupType)

    需要审批还是不需要,最大的容纳用户数,未支不支持未入群查看群聊消息,详见下图

4.jpg

其中社群其实挺符合我的需求的,但有个问题,社群需要付费才能开通(还挺贵),所以最后选择了Meeting类型的群组

  • 群聊资料设置

    群聊id(groupID)是没有字母数字和特殊符号(当然不能中文)都是可以的,群聊名字(groupName),群聊介绍(introduction)等等,还有就是设置初始的成员,可以将主管理员加入(这里稍微有点疑惑的就是创建群聊,居然没有默认添加创建人)

  • 创建群聊的监听回调

    这里传入的参数就是上述的groupInfo和memberInfoList,主要用于初始化群聊,然后有一个回调的参数监听创建结果

val group = V2TIMGroupInfo()
group.groupName = "test"
group.groupType = "Meeting"
group.introduction = "more to show"
group.groupID = "test"
val memberInfoList: MutableList<V2TIMCreateGroupMemberInfo> = ArrayList()
val memberA = V2TIMCreateGroupMemberInfo()
memberA.setUserID("master")
memberInfoList.add(memberA)
V2TIMManager.getGroupManager().createGroup(
    group, memberInfoList, object : V2TIMValueCallback<String?> {
        override fun onError(code: Int, desc: String) {
            // 创建失败
            Log.e("im","创建失败${code},详情:${desc}")
        }

        override fun onSuccess(groupID: String?) {
            // 创建成功
            Log.e("im","创建成功,群号为${groupID}")
        }
    })
复制代码

加入群聊

这部分只需要一个回调监听即可,这里没有login的用户的原因是,默认使用当前登录的id加群,所以一个很重要的前提是登录

V2TIMManager.getInstance().joinGroup("群聊ID","验证消息",object :V2TIMCallback{
    override fun onSuccess() {
        Log.e("im","加群成功")
    }
    override fun onError(p0: Int, p1: String?) {
        Log.e("im","加群失败")
    }
})
复制代码

4.消息收发相关

发送消息

这里发送消息是采用高级接口,发送的消息类型比较丰富,并且支持自定义消息类型,所以这里采用了高级消息收发接口

首先创建消息,这里是创建自定义消息,其他消息同理

val myMessage = "一段自定义的json数据"

//由于这里自定义消息接收的参数为byteArray类型的,所以进行一个转换
val messageCus= V2TIMManager.getMessageManager().createCustomMessage(myMessage.toByteArray())
复制代码

发送消息,这里需要设置一些参数

messageCus即转换过后的byte类型的数据,toUserId即接收方,这里为群聊的话,用空字符串置空即可,groupId即群聊的ID,如果是单聊的话,这里同样置空字符串即可,weight即你的消息被接收到的权重(不保证全部都能收到,这里设置权重确定优先级),onlineUserOnly即是否只有在线的用户可以收到,这个的话设置false即可,offlinePushInfo这个只有旗舰版才有推送消息的功能,所以这里设置null即可,然后就是一个发送消息的回调

V2TIMManager.getMessageManager().sendMessage(messageCus,toUserId,groupId,weight,onlineUserOnly, offlinePushInfo,object:V2TIMSendCallback<V2TIMMessage>{
    override fun onSuccess(message: V2TIMMessage?) {
       	Log.e("im","发送成功,内容为:${message?.customElem}")
        //这里同时需要自己进行解析消息,需要转换成String类型的数据
        val data = String(message?.customElem?.data)
       	...
    }

    override fun onError(p0: Int, p1: String?) {
        Log.e("im","错误码为:${p0},具体错误:${p1}")
    }

    override fun onProgress(p0: Int) {
        Log.e("im","处理进度:${p0}")
    }
})
复制代码

获取历史消息

  • groupId即群聊ID
  • pullNumber即拉取消息数量
  • lastMessage即上一次的消息,用于获取更多消息的定位
  • V2TIMValueCallback即消息回调

这里关于lastMessage进行解释说明,这个参数可以设置成全局变量,然后一开始设置为null,然后获取到的消息列表的最后一条设置成lastMessage即可

V2TIMManager.getMessageManager().getGroupHistoryMessageList(
    groupId,pullNumber,lastMessage,object:V2TIMValueCallback<List<V2TIMMessage>>{
    override fun onSuccess(p0: List<V2TIMMessage>?) {
       if (p0 != null) {
           if (p0.isEmpty()){
               Log.e("im","没有更多消息了")
               "没有更多消息了".showToast()
           }else {
               //记录最后一条消息
               lastMessage = p0[p0.size - 1]
               for (msgIndex in p0.indices) {
                   //解析各种消息
                   when(p0[msgIndex].elemType){
                       V2TIMMessage.V2TIM_ELEM_TYPE_CUSTOM ->{
                           ...
                       }
                       V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {}
                          ...
                       }
                       else -> {
                         ...
                       }
                   }  							
               }
           }
       }
    }
    override fun onError(p0: Int, p1: String?) {
        ....
    }
})
复制代码

新消息的监听

这个主要用于新消息的接收和监听,同时需要自己对于各种消息的解析和相关处理

V2TIMManager.getMessageManager().addAdvancedMsgListener(object:V2TIMAdvancedMsgListener(){
    override fun onRecvNewMessage(msg: V2TIMMessage?) {
        Log.e("im","新消息${msg?.customElem}")

        //这里针对多种消息类型有不同的处理方法
        when(msg?.elemType){
            V2TIMMessage.V2TIM_ELEM_TYPE_CUSTOM ->{
                val message = msg.customElem?.data
                ...
            }
            V2TIMMessage.V2TIM_ELEM_TYPE_TEXT ->{
                val message = msg.textElem.text
                ...
            }
            else -> {
                "暂时不支持此消息的接收".showToast()
                Log.e("im","${msg?.elemType}")
            }
        }
    }
})
复制代码

至此接入部分就已经完成了,这里只是简单的介绍接入,还有更多的细节可以查看项目源码

三、WebSocket接入

这个需求和上面的是一样的,同时提供和上面腾讯IM类似功能的api,这部分涉及网络相关的api(不是非常专业),主要描述一些思路上的,具体代码不是很困难

1.WebSocket介绍

webSocket可以实现长连接,可以作为消息接收的即时处理的一个工具,采用ws协议或者wss协议(SSL)进行通信,腾讯IM的版本也推出了webSocket实现方案,webSocket主要解决的痛点就是服务端不能主动推送消息,代替之前轮询的实现方案

5.jpg

2.服务端相关

服务端采用springboot进行开发,同时也是使用kotlin进行编程

  • webSoket 依赖集成

    下面是gradle的依赖集成

    implementation "org.springframework.boot:spring-boot-starter-websocket"
    复制代码
  • WebSocketConfig配置相关

    @Configuration
    class WebSocketConfig {
        @Bean
        fun serverEndpointExporter(): ServerEndpointExporter {
            return ServerEndpointExporter()
        }
    }
    复制代码
  • WebSocketServer相关

    这部分代码是关键代码,里面重写了webSocket的四个方法,然后配置静态的变量和方法用于全局通信,下面给出一个框架

    @ServerEndpoint("/imserver/{userId}")
    @Component
    class WebSocketServer {
        @OnOpen
        fun onOpen(session: Session?, @PathParam("userId") userId: String) {
            ...
        }
    
        @OnClose
        fun onClose() {
            ...
        }
    
        @OnMessage
        fun onMessage(message: String, session: Session?) {
          ...
        }
        
        @OnError
        fun onError(session: Session?, error: Throwable) {
           ...
        }
    
        //主要解决@Component和@Resource冲突导致未能自动初始化的问题
        @Resource
        fun setMapper(chatMapper: chatMapper){
            WebSocketServer.chatMapper = chatMapper
        }
        
        //这是发送消息用到的函数
        @Throws(IOException::class)
        fun sendMessage(message: String?) {
            session!!.basicRemote.sendText(message)
        }
    
        //静态变量和方法
        companion object {
    		...
        }
    }
    复制代码

    companion object

    这里一个比较关键的变量就是webSocketMap存储用户的webSocket对象,后面将利用这个实现消息全员推送和部分推送

    companion object {
        //统计在线人数
        private var onlineCount: Int = 0
        
        //用于存放每个用户对应的webSocket对象
        val webSocketMap = ConcurrentHashMap<String, WebSocketServer>()
    
        //操作数据库的mapper对象的延迟初始化
        lateinit var chatMapper:chatMapper
        
        //服务端主动推送消息的对外开放的方法
        @Throws(IOException::class)
        fun sendInfo(message: String, @PathParam("userId") userId: String) {
            if (userId.isNotBlank() && webSocketMap.containsKey(userId)) {
                webSocketMap[userId]?.sendMessage(message)
            } else {
                println("用户$userId,不在线!")
            }
        }
    
        //在线统计
        @Synchronized
        fun addOnlineCount() {
            onlineCount++
        }
    
        //离线统计
        @Synchronized
        fun subOnlineCount() {
            onlineCount--
        }
    }
    复制代码

    @OnOpen

    这个方法在websocket打开时执行,主要执行一些初始化和统计工作

    @OnOpen
    fun onOpen(session: Session?, @PathParam("userId") userId: String) {
        this.session = session
        this.userId = userId
        if (webSocketMap.containsKey(userId)) {
            //包含此id说明此时其他地方开启了一个webSocket通道,直接kick下线重新连接
            webSocketMap.remove(userId)
            webSocketMap[userId] = this
        } else {
            webSocketMap[userId] = this
            addOnlineCount()
        }
        println("用户连接:$userId,当前在线人数为:$onlineCount")
    }
    复制代码

    @OnClose

    这个方法在webSocket通道结束时调用,执行下线逻辑和相关的统计工作

    @OnClose
    fun onClose() {
        if (webSocketMap.containsKey(userId)) {
            webSocketMap.remove(userId)
            subOnlineCount()
        }
        println("用户退出:$userId,当前在线人数为:$onlineCount")
    }
    复制代码

    @OnMessage

    这个方法用于处理消息分发,这里一般需要对消息进行一些处理,具体处理参考自定义消息的处理,这里是设计成群聊的方案,所以采用

    @OnMessage
    fun onMessage(message: String, session: Session?) {
        if (message.isNotBlank()) {
            //解析发送的报文
            val newMessage = ...
            
            //这里需要进行插入一条数据,做持久化处理,即未在线的用户也同样可以看到这条消息
            chatMapper.insert(newMessage)
            
            //遍历所有的消息
            webSocketMap.forEach { 
                it.value.sendMessage(sendMessage.toMyJson())
            }
        }
    }
    复制代码

    @OnError

    发生错误调用的方法

    @OnError
    fun onError(session: Session?, error: Throwable) {
        println("用户错误:$userId 原因: ${error.message}")
        error.printStackTrace()
    }
    复制代码

    sendMessage

    此方法用于消息分发给各个客户端时调用的

    fun sendMessage(message: String?) {
        session!!.basicRemote.sendText(message)
    }
    复制代码
  • WebSocketController

    这部分主要是实现服务端直接推送消息设计的,类似系统消息的设定

    @PostMapping("/sendAll/{message}")
    fun sendAll(@PathVariable message: String):String{
        //消息的处理
        val newMessage = ... 
        
        //需不要存储系统消息就看具体需求了
        WebSocketServer.webSocketMap.forEach { 
            WebSocketServer.sendInfo(sendMessage.toMyJson(), it.key)
        }
        
        return "ok"
    }
    
    @PostMapping("/sendC2C/{userId}/{message}")
    fun sendC2C(@PathVariable userId:String,@PathVariable message:String):String{
        //消息的处理
        val newMessage = ... 
        
        WebSocketServer.sendInfo(newMessage, userId)
        return  "ok"
    }
    复制代码

    至此服务端的讲解就结束了,下面就看看我们安卓客户端的实现了

3.客户端相关

  • 依赖集成

    集成java语言的webSocket(四舍五入就是Kotlin版本的)

    implementation 'org.java-websocket:Java-WebSocket:1.5.2'
    复制代码
  • 实现部分

    这部分的重写的方法和服务端差不多,但少了服务相关的处理,代码少了很多,这里需要提醒的一点就是,重写的这些方法都是子线程中运行的,不允许直接写入UI相关的操作,所以这里需要使用handle进行处理或者使用runOnUIThread

    val userSocket = object :WebSocketClient(URI("wss://服务端地址:端口号/imserver/${userId}")){
        override fun onOpen(handshakedata: ServerHandshake?) {
            //打开进行初始化的操作
        }
    
        override fun onMessage(message: String?) {
           ...
            //这里做recyclerView的更新
        }
    
        override fun onClose(code: Int, reason: String?, remote: Boolean) {
           //这里执行一个通知操作即可
            ...
        }
    
        override fun onError(ex: Exception?) {
           ...
        }
    
    }
    userSocket.connect()
    
    //断开连接的话使用自带的reconnect重新连接即可
    //需要注意的一点就是不能在重写方法里面执行这个操作
    userSocket.reconnect()
    复制代码

    这里还有太多很多细节不能一一展示,但就总体而言是模仿上述腾讯IM实现的,具体的可以看项目地址

四、列表设计的一些细节

这里简单叙述一下列表设计的一些细节,这部分设计还是挺繁琐的

1.handle的使用

列表的更新时间和时机是取决于具体网络获取情况的,故需要一个全局的handle用于处理其中的消息,同时列表滑动行为不一样,这里需要注意的一个小问题,就是message最好是用一个发一个,不然可能出现内存泄漏的风险

  • 下拉刷新,此时刷新完毕列表肯定就是在第一个item的位置不然就有点奇怪
  • 首次获取历史消息,此时的场景应该是列表最后一个item
  • 获取新消息,也是最后一个item
private val up = 1
private val down = 2
private val fail = 0
private val handler = object : Handler(Looper.getMainLooper()) {
    override fun handleMessage(msg: android.os.Message) {
        when (msg.what) {
            up -> {
                viewBinding.chatRecyclerview.scrollToPosition(0)
                viewBinding.swipeRefresh.isRefreshing = false
            }
            down ->{
                viewBinding.chatRecyclerview.scrollToPosition(viewModel.chatList.size-1)
            }
            fail -> {
                "刷新失败请检查网络".showToast()
                viewBinding.swipeRefresh.isRefreshing = false
            }
        }
    }
}
复制代码

2.消息的获取和RecycleView的刷新

消息部分设计成从新到老的设计,上述腾讯IM也是这个顺序,所以这部分添加列表时需要加在最前面

viewModel.chatList.add(0,msg)
adapter.notifyItemInserted(0)
复制代码

同时需要注意的就是刷新位置,这部分是插入故使用adapter中响应的notifyItemInserted方法进行提醒列表刷新,虽然直接使用最通用的notifyDataSetChanged也是可以达到相同的目的,但体验效果就不那么好了,如果是大量的数据,可能会产生比较大的延迟

3.关于消息item的设计细节

这个item具体是模仿QQ的布局进行设计的,这里底色部分没有做调整

6.jpg

The better part that can be optimized is time. You can judge the time of the list, and then realize the relative time like yesterday, the day before yesterday, etc. The nested use of constraintlayout and linearlayout is used here. For an adaptive list, if there is no other nested layout, the filling method of wrap_content may exceed the interface, and there will be half a word. It is guessed that the maximum width of wrap_content is caused by the width of the root layout, so finally a layout is nested Solved, the following is the frame diagram of the design

7.jpg

5. Interfaces and addresses used by the project

The web project is relatively complex and was developed on the basis of the previous one. It is a bit difficult to separate it out independently, so the code on the web side is not included here. The code on the client side is provided here. You only need to replace your own sdkId and the url related to the server side. At the same time, there are some interactions related to the server side. Here is a brief introduction to the interfaces that the server side needs to develop.

  • Interface for obtaining historical data

    There are two parameters here, one determines the number of pull messages, and the other determines the pull start time point.

    //获取聊天记录
    @GET("chat/refreshes/{time}/{number}")
    fun getChat(@Path("time")time:String, @Path("number")count:Int): Call<MessageResponse>
    复制代码
  • Obtain the user signature of Tencent IM

    //生成应用凭据
    @GET("imSig/{userId}/{expire}")
    fun getSig(@Path("userId")userId:String,@Path("expire")expire:Long):Call<String>
    复制代码

    There are also two interfaces used by push, which have been described earlier

  • project address

    https://github.com/xyh-fu/ImTest.git
    复制代码
  • Demo application address

    The application here is currently only open to two IDs, if you have friends, you can test it face-to-face

    https://res.dreamstudio.online/apk/imtest.apk
    复制代码

6. Summary

This time, the design of IM instant messaging is full of harvest, and it is not bad to get a new knowledge point (mainly limited by poverty). In the later stage, you can consider replacing it with Tencent's IM. After all, what you realize is only small-scale testing and business The products are still very different. The server side involves a little more, the client side is relatively simple, and the more troublesome is the message processing mechanism. Considering the different interfaces designed, as well as the database on the server side, etc., it is difficult to unify, so I will not describe them one by one.

Guess you like

Origin juejin.im/post/7083017803388682270