从零开始让你的 APP 增加即时通信功能

4,142 阅读10分钟

前言

可能很多人谈到即时通信就望而却步,包括我之前也是一样,长链接、自动重连、保活、消息存储等等,感觉每个都是个大项目,一般我都是转头就去找第三方平台。

这种想法可能在前几年,没有 OkHttp,没有各种封装的数据库的时候,确实是比较麻烦的,几年前也写过一篇使用 Netty 实现的聊天 demo Android 长连接初体验(基于netty)

而现在有了这些优秀的开源框架,站在巨人的肩膀上,我们也可以实现一个完整的即时通信应用。

希望这篇文章,能让大家觉得即时通信也没什么难的,不再依靠第三方平台。

功能拆分

  • 长链接 我们知道,WebSocket 近些年在客户端的应用非常广泛,而且现在 OkHttp 也可以方便快捷的使用 WebSocket,因此我们也使用 WebSocket 作为通信的桥梁

  • 自动重连 移动设备是无法保证网络质量的,因此我们需要支持断线自动重连

  • 保活 时至今日,已经没有真正意义的保活了,不得不说,这也是国内 Android 环境的进化,那么微信等是怎么做到保活的呢,是因为微信的影响力太大,各大手机厂商都开了后门

保活的目的是为了让应用进入后台后,仍然可以畅通无阻的收到消息,现如今各大厂商都已经提供了系统级推送,当应用进入后台之后,我们利用厂商推送进行消息提醒,无需再做保活

  • 消息存储 服务端的消息是实时发送的,为了方便用户查看历史消息,我们需要将消息存储在本地数据库,而且一般聊天都支持账号切换,因此还需要考虑多数据库存储

  • 离线消息 当应用被系统 kill,或者设备断网,期间的消息将无法收到,因此还需要获取离线消息的功能,保证用户收到的消息是完整的

  • 消息展示 上面的功能是我们实现聊天的基础,而聊天最终是用户交互的,这里主要介绍会话列表页和聊天页

长链接

OkHttp 3.5 开始支持 WebSocket,你只需要一个 ws 链接,即可快速与服务器链接

object WebSocketManager {
    private const val WS_URL = "ws://x.x.x"
    private val httpClient by lazy {
        OkHttpClient().newBuilder()
            .readTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(10, TimeUnit.SECONDS)
            .connectTimeout(10, TimeUnit.SECONDS)
            .pingInterval(40, TimeUnit.SECONDS)
            .retryOnConnectionFailure(true)
            .build()
    }
    private var webSocket: WebSocket? = null

    private fun connect() {
        val request = Request.Builder()
            .url(WS_URL)
            .build()
        httpClient.newWebSocket(request, wsListener)
    }

    private val wsListener = object : WebSocketListener() {
        override fun onOpen(webSocket: WebSocket, response: Response) {
            super.onOpen(webSocket, response)
            // 连接建立
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            super.onMessage(webSocket, text)
            // 收到服务端发送来的 String 类型消息
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosing(webSocket, code, reason)
            // 收到服务端发来的 CLOSE 帧消息,准备关闭连接
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosed(webSocket, code, reason)
            // 连接关闭
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            super.onFailure(webSocket, t, response)
            // 出错了
        }
    }
}

是不是非常简单?

不过到这里我们仅仅是实现了与服务端的连接,接下来添加自动重连

自动重连

我们知道,移动设备可能经常遇到网络差或者移动 / WiFi 网络切换,这时长链接将会断开,我们需要在合适的时机重新连接服务器

用户登录

这个由业务决定,一般是监听登录状态,登录成功即连接,退出登录即断连

网络从断开切换到连接状态

这个很好理解,主要发生在设备从无网到有网,从移动网络切换到 WiFi 网络,这里注册网络状态监听即可

object NetworkStateManager : CoroutineScope by MainScope() {
    private const val TAG = "NetworkStateManager"
    private val _networkState = MutableLiveData(false)
    val networkState: LiveData<Boolean> = _networkState

    @JvmStatic
    fun init(context: Context) {
        _networkState.postValue(NetworkUtils.isNetworkConnected(context))
        val filter = IntentFilter()
        filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
        context.registerReceiver(NetworkStateReceiver(), filter)
    }

    class NetworkStateReceiver : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (context == null || intent == null) {
                return
            }
            val isConnected =
                intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false).not()
            Log.d(TAG, "network state changed, is connected: $isConnected")
            launch {
                _networkState.postValue(isConnected)
            }
        }
    }
}

提供 LiveData 监听网络状态

应用从后台切换到前台

部分厂商在设备开启节能模式后可能会限制应用后台联网,即应用进入后台就无法连接网络,但是设备并没有断网,因此网络状态监听失效,这种场景我们可以在应用切回前台后尝试重连

object AppForeground : Application.ActivityLifecycleCallbacks {
    private var foregroundActivityCount = 0
    private val appForegroundInternal = MutableLiveData(false)

    val appForeground: LiveData<Boolean> = appForegroundInternal

    fun init(application: Application) {
        application.registerActivityLifecycleCallbacks(this)
    }

    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
    }

    override fun onActivityStarted(activity: Activity) {
        foregroundActivityCount++
        if (appForegroundInternal.value == false) {
            appForegroundInternal.value = true
        }
    }

    override fun onActivityResumed(activity: Activity) {
    }

    override fun onActivityPaused(activity: Activity) {
    }

    override fun onActivityStopped(activity: Activity) {
        foregroundActivityCount--
        if (foregroundActivityCount == 0 && appForegroundInternal.value == true) {
            appForegroundInternal.value = false
        }
    }

    override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
    }

    override fun onActivityDestroyed(activity: Activity) {
    }
}

定时重连

有这样一种场景,用户连接了无效的 WiFi 网络,即网络为连接状态,但是却无法连接互联网,或者服务器秃然宕机,导致无法连接成功,因此我们需要定时重连机制

为了避免重复的无效连接,我们可以使用斐波那契数列作为重连的时间,但是也不能无限变大,需要一个最大重连时间

同时为了避免服务器宕机后,每个设备使用相同的重连间隔,导致服务器恢复后所有设备同时连接,连接数瞬间达到峰值,很可能导致服务器再次宕机,我们需要使用一个随机重连时间

private const val MAX_INTERVAL = DateUtils.HOUR_IN_MILLIS
private var lastInterval = 0L
private var currInterval = 1000L

private fun getReconnectInterval(): Long {
    if (currInterval >= MAX_INTERVAL) {
        return MAX_INTERVAL
    }
    val interval = lastInterval + currInterval
    lastInterval = currInterval
    currInterval = interval
    return interval
}

private fun resetReconnectInterval() {
    lastInterval = 0
    // 使用随机数,避免服务器宕机后所有人同时连接,再次宕机
    currInterval = Random.nextLong(1000, 2000)
}

整理下长链接和自动重连部分的完整代码

object WebSocketManager {
    private const val WS_URL = "ws://x.x.x"
    private lateinit var threadHandler: Handler
    private val httpClient by lazy {
        OkHttpClient().newBuilder()
            .readTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(10, TimeUnit.SECONDS)
            .connectTimeout(10, TimeUnit.SECONDS)
            .pingInterval(40, TimeUnit.SECONDS)
            .retryOnConnectionFailure(true)
            .build()
    }
    private var webSocket: WebSocket? = null

    private const val MAX_INTERVAL = DateUtils.HOUR_IN_MILLIS
    private var lastInterval = 0L
    private var currInterval = 1000L

    private val _connectState = MutableLiveData(ConnectState.DISCONNECT)
    val connectState: LiveData<ConnectState> = _connectState

    enum class ConnectState {
        CONNECTING,
        CONNECTED,
        DISCONNECT
    }

    fun init(context: Context) {
        val handlerThread = HandlerThread(TAG)
        handlerThread.start()
        threadHandler = Handler(handlerThread.looper)

        NetworkStateManager.networkState.observeForever { isConnected ->
            if (isConnected && connectState.value == ConnectState.DISCONNECT) {
                resetReconnectInterval()
                // 判断网络状态有延时,延迟重连
                connect(1000)
            }
        }

        // APP 回到前台,尝试重连
        AppForeground.appForeground.observeForever { foreground ->
            Log.d(TAG, "app foreground state changed, is foreground: $foreground")
            if (foreground && connectState.value == ConnectState.DISCONNECT) {
                connect(1000)
            }
        }
    }

    private fun connect(delay: Long = 0) {
        removeCallbacks(connectRunnable)
        runInThread(connectRunnable, delay)
    }

    private fun autoReconnect() {
        val interval = getReconnectInterval()
        removeCallbacks(connectRunnable)
        runInThread(connectRunnable, interval)
    }

    private val connectRunnable = Runnable {
        if (connectState.value != ConnectState.DISCONNECT) {
            Log.w(TAG, "connect cancel cause state error")
            return@Runnable
        }
        if (!NetworkUtils.isNetworkConnected(context)) {
            Log.w(TAG, "connect cancel cause network disconnect")
            return@Runnable
        }
        removeBindTimeoutRunnable()
        realConnect()
    }

    private fun realConnect() {
        _connectState.postValue(ConnectState.CONNECTING)
        val request = Request.Builder()
            .url(WS_URL)
            .build()
        httpClient.newWebSocket(request, wsListener)
    }

    private val wsListener = object : WebSocketListener() {
        override fun onOpen(webSocket: WebSocket, response: Response) {
            super.onOpen(webSocket, response)
            // 连接建立
            runInThread {
                this@WebSocketManager.webSocket = webSocket
            }
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            super.onMessage(webSocket, text)
            // 收到服务端发送来的 String 类型消息
            runInThread {
                handleMessage(text)
            }
        }

        override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosing(webSocket, code, reason)
            // 收到服务端发来的 CLOSE 帧消息,准备关闭连接
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            super.onClosed(webSocket, code, reason)
            // 连接关闭
            onFailure(webSocket, IllegalStateException("web socket closed unexpected"), null)
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            super.onFailure(webSocket, t, response)
            // 出错了
            runInThread {
                this@WebSocketManager.webSocket = null
                _connectState.postValue(ConnectState.DISCONNECT)
                autoReconnect()
            }
        }
    }

    private fun release() {
        webSocket?.cancel()
    }

    private fun getReconnectInterval(): Long {
        if (currInterval >= MAX_INTERVAL) {
            return MAX_INTERVAL
        }
        val interval = lastInterval + currInterval
        lastInterval = currInterval
        currInterval = interval
        return interval
    }

    private fun resetReconnectInterval() {
        lastInterval = 0
        // 使用随机数,避免服务器宕机后所有人同时连接,再次宕机
        currInterval = Random.nextLong(1000, 2000)
    }

    private fun runInThread(r: Runnable) {
        runInThread(r, 0)
    }

    private fun runInThread(r: Runnable, delay: Long) {
        threadHandler.postDelayed(r, delay)
    }

    private fun removeCallbacks(r: Runnable) {
        threadHandler.removeCallbacks(r)
    }
}

消息存储

我们首先梳理表结构

聊天整理分为会话消息两部分,我们可以以此来划分数据库表,分为2张表,一张存储会话,一张存储消息

有些人可能有疑问,私聊和单聊是否需要区分?

在我看来,私聊和单聊只是两种聊天形式,存储的数据没有区别,因此无需区分

  • 会话表 由于一个会话只会有一个聊天对象,私聊是对方,群聊是群组,因此可以使用聊天对象 ID 作为主键

  • 消息表 消息表中,消息和聊天对象是多对多的存在,我们可以使用消息 ID 或自增 ID 作为主键

对于账号切换,我们需要支持不同数据库切换,可使用用户 ID 作为数据库名称,用户登录成功后切换到对应的数据上

GoogleJetPack 中提供了 Room 数据库,帮助我们方便的操作 SqlLite 数据库,不过考虑到快速切换数据库的便捷性,最终选择了郭神的 LitePal

你们要的多数据库功能终于来了

会话表

data class ConversionBean(
    // 聊天对象 ID
    @Column(unique = true, index = true, nullable = false)
    private val chat_id: String? = null,
    // 会话类型,私聊 or 群聊
    @Column(nullable = false, defaultValue = ChatType.PERSON)
    private val chat_type: String? = null,
    // 会话名称
    @Column(nullable = true)
    var name: String? = null,
    // 会话头像
    @Column(nullable = true)
    var avatar: String? = null,
    // 最近一条消息
    @Column(nullable = true)
    var last_message: String? = null,
    // 未读数
    @Column(nullable = false, defaultValue = "0")
    var unread_count: Int? = null,
    // 最近一条消息的时间
    @Column(nullable = false, defaultValue = "0")
    var update_time: Long? = null,
) : LitePalSupport()

消息表

data class MessageBean(
    // 消息 ID
    @Column(index = true, nullable = false, defaultValue = "0")
    var msg_id: Long? = null,
    // 聊天对象 ID
    @Column(index = true, nullable = false)
    private val chat_id: String? = null,
    // 会话类型,私聊 or 群聊
    @Column(nullable = false, defaultValue = ChatType.PERSON)
    private val chat_type: String? = null,
    // 消息类型
    @Column(nullable = false, defaultValue = MsgType.TEXT)
    private val msg_type: String? = null,
    // 消息发送者 ID
    @Column(nullable = false)
    val from_uuid: String? = null,
    // 消息发送者昵称
    @Column(nullable = true)
    val from_nickname: String? = null,
    // 消息发送者头像
    @Column(nullable = true)
    val from_avatar: String? = null,
    // 消息内容
    @Column(nullable = true)
    val content: String? = null,
    // 是否已读
    @Column(index = true, nullable = false, defaultValue = "0")
    var is_read: Int? = null,
    // 消息发送状态
    @Column(defaultValue = MsgStatus.SUCCESS)
    var status: String? = null,
    // 消息发送时间
    @Column(index = true, nullable = false, defaultValue = "0")
    val time: Long? = null,
) : LitePalSupport()

数据库操作

object IMDatabase {
    fun init(context: Context) {
        LitePal.initialize(context)
        loginState.observeForever { isLogin ->
            if (isLogin) {
                onLogin()
            } else {
                onLogout()
            }
        }
    }

    /**
     * 登录成功,打开数据库
     */
    fun onLogin() {
        if (uuid.isNotEmpty()) {
            val litePalDB = LitePalDB.fromDefault("im#${uuid}")
            LitePal.use(litePalDB!!)
        }
    }

    /**
     * 注销登录,关闭数据库
     */
    fun onLogout() {
        LitePal.useDefault()
    }

    /**
     * 查询会话列表
     */
    fun queryConversionList(): List<ConversionBean> {
        return LitePal.order("update_time desc").find(ConversionBean::class.java)
    }

    /**
     * 获取会话对象
     */
    fun getConversion(chatId: String): ConversionBean? {
        return LitePal.where("chat_id = ?", chatId).findFirst(ConversionBean::class.java)
    }

    /**
     * 保存会话,用于本地新建会话
     */
    fun saveConversion(
        chatId: String,
        chatType: String,
        name: String? = null,
        avatar: String? = null,
        lastMsg: String? = null,
        unreadCount: Int? = null,
        updateTime: Long? = null
    ): ConversionBean? {
        val conversion = ConversionBean(
            chat_id = chatId,
            chat_type = chatType,
            name = name,
            avatar = avatar,
            last_message = lastMsg,
            unread_count = unreadCount,
            update_time = updateTime ?: System.currentTimeMillis()
        )
        conversion.save()
        return conversion
    }

    /**
     * 更新会话信息
     */
    fun updateConversion(
        chatId: String,
        name: String? = null,
        avatar: String? = null,
        lastMsg: String? = null,
        unreadCount: Int? = null,
        unreadCountAdd: Int? = null,
        updateTime: Long? = null
    ) {
        val conversion = getConversion(chatId) ?: return
        if (name != null) {
            conversion.name = name
        }
        
        // ...

        conversion.save()
    }

    /**
     * 删除会话
     */
    fun deleteConversion(chatId: String) {
        LitePal.deleteAll(ConversionBean::class.java, "chat_id = ?", chatId)
    }

    /**
     * 会话设为已读
     */
    fun setRead(chatId: String) {
        LitePal.where("chat_id = ?", chatId)
            .findFirst(ConversionBean::class.java)?.apply {
                unread_count = 0
                save()
            }
        MessageBean(is_read = 1).updateAllAsync("chat_id = ? AND is_read = ?", chatId, "0")
    }

    /**
     * 查询消息
     */
    fun queryMessageList(chatId: String, offset: Int, limit: Int): MutableList<MessageBean> {
        return LitePal.where("chat_id = ?", chatId)
            .order("time desc")
            .offset(offset)
            .limit(limit)
            .find(MessageBean::class.java)
    }

    /**
     * 保存消息
     */
    fun saveMessage(message: MessageBean) {
        message.save()
    }

    /**
     * 批量保存消息
     */
    fun saveMessageList(msgList: List<MessageBean>) {
        LitePal.saveAll(msgList)
    }

    /**
     * 更新消息发送状态
     */
    fun updateMessageStatus(id: Long, status: String, msg_id: Long? = null) {
        val bean = MessageBean(status = status, msg_id = msg_id)
        bean.update(id)
    }

    /**
     * 删除会话消息
     */
    fun deleteMessages(chatId: String) {
        LitePal.deleteAll(MessageBean::class.java, "chat_id = ?", chatId)
    }
}

离线消息

在长链接建立成功后通过 API 接口获取离线消息即可

可以向服务器提供最新一条消息的 ID,获取所有离线消息

由于这里和具体业务有关,因此仅提供实现思路

消息展示

为了便于逻辑层和 UI 层交互,我们将和 UI 相关的逻辑抽象出接口,提供给 UI

根据功能划分,我们可以提供 会话服务消息接收服务消息发送服务

会话服务

interface ConversionService : IMService {
    // 全部未读数,一般是在入口处展示
    val totalUnreadCount: LiveData<Int>
    // 会话列表
    val conversionList: LiveData<List<ConversionBean>>

    /**
     * 获取会话
     */
    fun getConversion(chatId: String): ConversionBean?

    /**
     * 发起新会话
     */
    fun newConversion(
        chatId: String,
        chatType: String,
        name: String?,
        avatar: String?
    ): ConversionBean?

    /**
     * 进入会话,不再提示新消息
     */
    fun onEnterConversion(chatId: String)

    /**
     * 离开会话,继续提示新消息
     */
    fun onExitConversion(chatId: String)

    /**
     * 更新会话信息
     */
    suspend fun updateConversionInfo(
        chatId: String,
        name: String?,
        avatar: String?
    )

    /**
     * 删除会话
     */
    suspend fun deleteConversion(chatId: String)

    /**
     * 清空会话消息
     */
    suspend fun deleteMessages(chatId: String)
}

消息接收服务

typealias MessageObserver = (msgList: List<MessageBean>) -> Unit

interface MessageReceiveService : IMService {
    /**
     * 添加新消息监听
     */
    fun addMessageObserve(observer: MessageObserver)

    /**
     * 移出新消息监听
     */
    fun removeMessageObserve(observer: MessageObserver)

    /**
     * 查询消息
     */
    suspend fun queryMessageList(chatId: String, offset: Int, limit: Int): List<MessageBean>
}

消息发送服务

typealias SendMessageCallback = (result: Result<MessageBean>) -> Unit

typealias MessageStatusObserver = (msg: MessageBean) -> Unit

interface MessageSendService : IMService {
    /**
     * 添加消息状态监听
     */
    fun addMessageStatusObserve(observer: MessageStatusObserver)

    /**
     * 移出消息状态监听
     */
    fun removeMessageStatusObserve(observer: MessageStatusObserver)

    /**
     * 发送文本消息
     */
    fun sendTextMessage(uuid: String, chatType: String, text: String, callback: SendMessageCallback)

    /**
     * 发送图片消息
     */
    fun sendImageMessage(uuid: String, chatType: String, file: File, callback: SendMessageCallback)

    /**
     * 重试发送
     */
    fun resendMessage(msg: MessageBean, callback: SendMessageCallback?)
}

目前仅实现了发送文本和图片消息,还可以扩展更多的消息类型

服务的实现类也比较简单,主要是消息发送、接收逻辑处理,和数据库接口的调用

有了这些服务接口,对于 UI 来说就比较简单了,无需感知具体实现

会话列表

监听 ConversionService#conversionList 的数据更新,更新 UI 即可

IM.getService<ConversionService>().conversionList.observe(this) {
    adapter.refresh(it)
    if (it.isEmpty()) {
        showEmpty()
    } else {
        showSuccess()
    }
}

聊天页面

从数据库获取历史消息

lifecycleScope.launch {
    val list = IM.getService<MessageReceiveService>()
        .queryMessageList(conversion.getChatId(), messageList.size, QUERY_MSG_COUNT)
    messageList.clear()
    messageList.addAll(list)
    adapter.notifyDataSetChanged()
    scrollToBottom()
}

添加新消息监听,收到新消息后添加到消息列表

IM.getService<MessageReceiveService>().addMessageObserve(messageObserver)

private val messageObserver: MessageObserver = { list ->
    list.forEach { msg ->
        if (msg.getChatId() == conversion.getChatId()) {
            onNewMessage(msg)
        }
    }
}

private fun onNewMessage(msg: MessageBean) {
    messageList.add(msg)
    adapter.notifyDataSetChanged()
    scrollToBottom()
}

监听输入框和发送按钮,调用 API 接口发送消息

private fun sendTextMsg() {
    val text = viewBinding.etInput.text.toString()
    viewBinding.btnSend.isEnabled = false
    IM.getService<MessageSendService>()
        .sendTextMessage(conversion.getChatId(), conversion.getChatType(), text) { result ->
            viewBinding.btnSend.isEnabled = true
            if (result.isSuccess) {
                onNewMessage(result.getOrNull()!!)
                viewBinding.etInput.text = null
            } else {
                "发送失败,请稍后再试".toast()
            }
        }
}

对于不同类型的消息,可以使用 RecyclerViewviewType 区分展示,其实消息的很多属性都是通用的,比如头像、昵称、时间等,因此我们可以封装一个消息基类,不同类型的消息继承该基类,只需要关心消息内容的渲染即可

消息 Item 基类

abstract class MessageBaseViewHolder(
    private val binding: ItemChatMessageBaseBinding,
    private val listener: OnMessageEventListener? = null
) : RecyclerView.ViewHolder(binding.root) {
    protected val content: View
    protected lateinit var msg: MessageBean

    init {
        content = LayoutInflater.from(binding.root.context)
            .inflate(getContentResId(), binding.content, false)
        binding.content.addView(content)
        val onClickListener = ClickListener()
        binding.ivPortraitRight.setOnClickListener(onClickListener)
        binding.ivPortraitLeft.setOnClickListener(onClickListener)
        binding.ivMessageStatus.setOnClickListener(onClickListener)
    }

    @LayoutRes
    protected abstract fun getContentResId(): Int

    fun onBind(msg: MessageBean) {
        this.msg = msg
        setGravity()
        setPortrait()
        refreshContent()
        setNickname()
        setTime()
        setStatus()
    }

    protected fun isReceivedMessage(): Boolean {
        return msg.isFromMe().not()
    }

    protected abstract fun refreshContent()

    protected open fun isCenterMessage(): Boolean {
        return false
    }

    protected open fun isShowNick(): Boolean {
        return msg.getChatType() == ChatType.GROUP && isReceivedMessage() && isCenterMessage().not()
    }

    protected open fun isShowTime(): Boolean {
        return msg.isShowTime == true
    }

    private fun setGravity() {
        val gravity = if (isCenterMessage()) {
            Gravity.CENTER_HORIZONTAL
        } else if (isReceivedMessage()) {
            Gravity.LEFT
        } else {
            Gravity.RIGHT
        }

        binding.contentWithStatus.gravity = gravity
    }

    private fun setPortrait() {
        binding.ivPortraitRight.visibleOrGone(false)
        binding.ivPortraitLeft.visibleOrGone(false)
        var show: ImageView? = null
        if (isReceivedMessage() && !isCenterMessage()) {
            binding.ivPortraitLeft.visibleOrGone(true)
            show = binding.ivPortraitLeft
        } else if (!isReceivedMessage() && !isCenterMessage()) {
            binding.ivPortraitRight.visibleOrGone(true)
            show = binding.ivPortraitRight
        }

        show?.loadAvatar(getPortraitUrl())
    }

    private fun setNickname() {
        binding.tvNickname.text = if (isShowNick()) this.msg.getFromNickname() else null
    }

    private fun setTime() {
        binding.tvTime.visibleOrGone(isShowTime())
        binding.tvTime.text = this.msg.time.dateFriendly()
    }

    private fun setStatus() {
        binding.ivMessageStatus.visibleOrGone(msg.isFromMe() && msg.status == MsgStatus.FAIL)
    }

    private fun getPortraitUrl(): String? {
        if (isReceivedMessage()) {
            return msg.from_avatar
        } else {
            return UserCenter.userInfoState.value?.avatar
        }
    }
}

文本消息 Item

open class MessageTextViewHolder(
    binding: ItemChatMessageBaseBinding,
    listener: OnMessageEventListener? = null
) : MessageBaseViewHolder(binding, listener) {
    protected val tvMessageContent: TextView by lazy {
        content.findViewById(R.id.tvMessageContent)
    }

    override fun getContentResId(): Int {
        return R.layout.item_chat_message_text
    }

    override fun refreshContent() {
        tvMessageContent.isSelected = !isReceivedMessage()
        val content = msg.content
        tvMessageContent.text = content
    }
}

至此,一个简单的即时通信功能已基本完成。

总结

本文以个人亲身经历带大家梳理了 APP 即时通信的主要功能拆分,和简单的实现方式,由于涉及项目代码,因此不便贴出源码。

如果对大家有帮助,请点赞支持,如果大家在开发过程中遇到问题也可以在文章下评论留言,我会尽可能帮大家解答。