Introduction
Le chat de groupe du projet précédent était directement exploité avec la base de données, l'expérience était très médiocre et il était difficile de fournir un retour instantané des messages, donc à la fin, j'ai envisagé d'utiliser la messagerie instantanée de Tencent pour compléter l'accès au chat de groupe, mais il était un peu cahoteux au milieu, et je l'ai découvert une fois l'accès terminé. Il n'y a que 20 personnes dans une discussion de groupe dans la version d'essai. À ce moment-là, j'ai vu que la version d'essai prend en charge 100 utilisateurs. Maintenant, je ne peux que avoir 20 utilisateurs dans un chat de groupe . Il ne suffit pas de prendre en charge le chat en ligne de 50 utilisateurs en même temps, c'est à peine suffisant, ce qui suit introduira l'accès des deux schémas de mise en œuvre, le texte commencera bientôt ~~
2. Accès à la messagerie instantanée Tencent
Le site officiel de Tencent Cloud IM, l'accès ici extrait les API liées au chat de groupe, veuillez vous référer au document pour plus d'informations (si vous avez le temps, une simple plateforme de chat similaire à QQ peut être réalisée)
https://cloud.tencent.com/document/product/269/42440
复制代码
1. Préparatifs
-
analyse de la demande
Vous devez implémenter une fonction similaire à la discussion de groupe dans QQ, il vous suffit de développer trois fonctions simples pour recevoir des messages, envoyer des messages et obtenir des enregistrements d'historique.
-
Créer une application
Cette partie ne sera pas démontrée. Elle est très simple. Après sa création, elle ressemblera à l'image ci-dessous.
La version d'essai peut prendre en charge 100 utilisateurs et 20 utilisateurs dans une discussion de groupe, fournir un stockage cloud gratuit pendant 7 jours et peut créer plusieurs instances de messagerie instantanée en même temps. Si vous apprenez à utiliser, la version d'essai est suffisante et la commercialisation prend en compte la version professionnelle et la version ultime
-
Intégration des dépendances
En utilisant l'intégration progressive, vous pouvez également utiliser l'intégration sdk, ici la nouvelle version de sdk est utilisée pour l'intégration
api 'com.tencent.imsdk:imsdk-plus:6.1.2155' 复制代码
2. Travail d'initialisation
Initialiser la messagerie instantanée
-
Créer une instance
Il y a un rappel dans le paramètre, l'objet ici est équivalent à la classe anonyme en 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", "连接腾讯云服务器失败") } }) 复制代码
-
Générer des identifiants de connexion
Cette partie fournit officiellement le code généré par le client et le code côté serveur rapidement. Vous pouvez le trouver sur le site officiel. Lors des tests, vous pouvez considérer que le projet officiel derrière le code côté client est mieux déployé sur le serveur pour traitement. Cette partie est mentionnée ici. Réveillez-vous, il y a deux fichiers côté serveur. Je ne l'ai pas vu clairement à l'époque. Après avoir longtemps cherché la fonction, j'ai finalement trouvé qu'il s'agissait d'un fichier java que j'ai oublié de lire, ou c'était dans le même répertoire de niveau. Il faudrait que d'autres API réutilisent aussi la classe Base64URL.
Dans le même temps, le fonctionnaire fournit également des outils pour générer et vérifier les informations d'identification
Utilisateur en ligne
Cette partie n'a qu'à passer dans les paramètres
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 est l'identifiant de l'utilisateur
- sig est les identifiants de connexion de l'utilisateur
- Une classe pour les rappels V2TIMCallback
3. Discussion de groupe liée
Créer une discussion de groupe
创建群聊的时候需要注意几个方面的问题
-
群聊类别(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() 复制代码
这里还有太多很多细节不能一一展示,但就总体而言是模仿上述腾讯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的布局进行设计的,这里底色部分没有做调整
La meilleure partie qui peut être optimisée est le temps. Vous pouvez juger de l'heure de la liste, puis réaliser l'heure relative comme hier, avant-hier, etc. L' utilisation imbriquée de la mise en page page linéaireetcontrainte wrap_content peut dépasser l'interface, et il y aura un demi-mot. On suppose que la largeur maximale de wrap_content est causée par la largeur de la mise en page racine, donc finalement une mise en page est imbriqué Résolu, ce qui suit est le schéma de cadre de la conception
5. Interfaces et adresses utilisées par le projet
Le projet web est relativement complexe et a été développé sur la base du précédent. Il est un peu difficile de le séparer indépendamment, donc le code côté web n'est pas mis ici. Le code côté client est fourni ici. Il vous suffit de remplacer votre propre sdkId et l'url liée au côté serveur. En même temps, il y a quelques interactions liées au côté serveur. Voici une brève introduction aux interfaces que le côté serveur doit développer.
-
Interface pour obtenir des données historiques
Il y a deux paramètres ici, l'un détermine le nombre de messages d'extraction et l'autre détermine le point de départ de l'extraction.
//获取聊天记录 @GET("chat/refreshes/{time}/{number}") fun getChat(@Path("time")time:String, @Path("number")count:Int): Call<MessageResponse> 复制代码
-
Obtenir la signature d'utilisateur de Tencent IM
//生成应用凭据 @GET("imSig/{userId}/{expire}") fun getSig(@Path("userId")userId:String,@Path("expire")expire:Long):Call<String> 复制代码
Il existe également deux interfaces utilisées par push, qui ont été décrites précédemment
-
adresse du projet
https://github.com/xyh-fu/ImTest.git 复制代码
-
Adresse de l'application de démonstration
L'application ici n'est actuellement ouverte qu'à deux identifiants, si vous avez des amis, vous pouvez la tester en face à face
https://res.dreamstudio.online/apk/imtest.apk 复制代码
6. Résumé
Cette fois, la conception de la messagerie instantanée IM est pleine de récoltes, et il n'est pas mauvais d'acquérir un nouveau point de connaissance (principalement limité par la pauvreté). Dans une étape ultérieure, vous pouvez envisager de le remplacer par la messagerie instantanée de Tencent. Après tout, ce qui vous vous rendez compte n'est que des tests et des affaires à petite échelle Les produits sont encore très différents. Le côté serveur implique un peu plus, le côté client est relativement simple, et le plus gênant est le mécanisme de traitement des messages. Compte tenu des différentes interfaces conçues, ainsi que de la base de données côté serveur, etc., il est difficile d'unifier, donc je ne les décrirai pas un par un.