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

前言

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

这种想法可能在前几年,没有 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 即时通信的主要功能拆分,和简单的实现方式,由于涉及项目代码,因此不便贴出源码。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容