Android即时通讯设计——腾讯IM接入和WebSocket接入
一、前言
之前项目的群聊是用数据库直接操作的,体验很差,消息很难即时反馈,所以最后考虑到了使用腾讯的IM完成群聊的接入,不过中途还是有点小坎坷的,接入完成之后发现体验版一个群聊只有20人,当时看到体验版支持100个用户也就忍了,现在一个群聊只能20用户,忍不了了,所以暂时找到了WebSocket作为临时的解决方案(等有钱了再换),同时支持50个用户在线聊天,也算还行,勉强够用,下面就介绍两种实现方案的接入,正文即将开始~~
二、腾讯IM接入
腾讯云IM的官网,这里的接入将其中群聊相关的api抽取出来,更多请看文档(如果有时间的话,完全可以实现一个类似QQ的简单聊天平台)
https://cloud.tencent.com/document/product/269/42440
1.准备工作
-
需求分析
需要实现一个类似QQ中群聊的功能,只需要开发简单的接收消息,发送消息,获取历史记录这三个简单的功能即可
-
创建应用
这部分就不演示了,很简单,体验版可以支持100个用户和一个群聊20个用户,提供免费的云存储7天,同时可以创建多个IM实例,如果是学习使用的话体验版足够了,商业化考虑专业版和旗舰版,创建好大概是下图的样子
-
依赖集成
使用gradle集成,也可以使用sdk集成,这里采用新版的sdk进行集成
api 'com.tencent.imsdk:imsdk-plus:6.1.2155'
2.初始化工作
初始化IM
-
创建实例
参数中有一个回调,这里的object相当于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", "连接腾讯云服务器失败")
}
}) -
生成登录凭据
这部分官方提供客户端快速生成的代码和服务端代码,具体可以到官网找找,一开始测试的时候可以考虑客户端代码后面正式的项目最好部署到服务端进行处理,这部分就提个醒,服务端有两个文件,当时没看清楚,找了好久的函数,最后发现是某个java文件忘记看了,还是同一级目录下,应该是其他api也复用了Base64URL这个类
同时官方还提供生成和校验凭据的工具
用户登录
这部分只需要传入参数即可
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 即用户的id -
sig 即用户的登录凭据 -
V2TIMCallback 回调的一个类
3.群聊相关
创建群聊
创建群聊的时候需要注意几个方面的问题
群聊类别(groupType)
需要审批还是不需要,最大的容纳用户数,未支不支持未入群查看群聊消息,详见下图其中社群其实挺符合我的需求的,但有个问题,社群需要付费才能开通(还挺贵),所以最后选择了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主要解决的痛点就是服务端不能主动推送消息,代替之前轮询的实现方案
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()
四、列表设计的一些细节
这里简单叙述一下列表设计的一些细节,这部分设计还是挺繁琐的
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的布局进行设计的,这里底色部分没有做调整
可以优化的更好的部分就是时间,可以对列表时间进行判断,然后实现类似昨天,前天等等的相对时间,这里使用的是constraintlayout和linearlayout的嵌套使用,这里当时遇到一个问题即文字需要自适应列表,如果没有另外嵌套一个布局就会导致wrap_content的填充方式可能会超出界面,出现半个字的情况,猜测wrap_content最大的宽度是根布局的宽度导致的,所以最后嵌套了一个布局解决了,下面是设计的框架图
五、项目使用的接口和地址
web项目比较复杂,是在之前的基础上开发的,独立抽离出来有点困难,所以这里就不放web端的代码,这里提供客户端的代码,只需要替换自己的sdkId和服务端相关的url即可运行,同时这里涉及一些与服务端有关的交互,这里简单介绍一下服务端需要开发的接口
-
获取历史数据的接口
这里两个参数,一个确定拉取消息数目,一个确定拉取起始时间点
//获取聊天记录
@GET("chat/refreshes/{time}/{number}")
fun getChat(@Path("time")time:String, @Path("number")count:Int): Call<MessageResponse> -
获取腾讯IM的user签名
//生成应用凭据
@GET("imSig/{userId}/{expire}")
fun getSig(@Path("userId")userId:String,@Path("expire")expire:Long):Call<String>还有两个推送使用的接口,在前面已经叙述过了
-
https://github.com/xyh-fu/ImTest.git
-
这里的应用目前仅开放了两个ID,如果有朋友就可以面对面测试一下
https://res.dreamstudio.online/apk/imtest.apk
六、总结
这次IM即时通讯的设计收获满满,get到一个新的知识点也算还行(主要是贫穷限制的),后期可以考虑全部换成腾讯的IM,毕竟自己实现的只是小规模测试和商业产品还是有很大的区别。服务端涉及的稍微多一点点,客户端是比较简单,比较麻烦的就是消息处理机制,考虑到设计的接口各异,还有服务端的数据库等等,难以统一,故不一一展开叙述。