21、聊天与消息:文字聊天集成、表情与图片发送、消息历史记录

视频会议里,光有画面和声音其实不够。你想想看,开会时突然网络卡顿,对方说的话听不清怎么办?或者你想在会议中分享一个链接、一段代码,口头说一遍大家记不住。这时候,文字聊天就成了救命稻草。

我在做第一个WebRTC项目时,就踩过这个坑。当时觉得“视频通话才是核心”,聊天功能随便搞了个简单的EditText加发送按钮。结果上线后用户反馈最多的居然是“聊天消息发不出去”、“表情显示成方框”。嗯,从那以后我再也不敢小看聊天模块了。

这一章,我们就来搞定Android平台上的文字聊天集成。包括基础的消息收发、表情支持、图片发送,以及消息历史记录的本地持久化。

21.1 聊天消息的数据结构设计

先想清楚消息长什么样。我习惯用一个统一的Message类来承载所有类型:

public class ChatMessage {
    public static final int TYPE_TEXT = 0;
    public static final int TYPE_EMOJI = 1;
    public static final int TYPE_IMAGE = 2;
    public static final int TYPE_FILE = 3;

    private String messageId;      // 唯一ID
    private String senderId;       // 发送者ID
    private String senderName;     // 发送者昵称
    private int type;              // 消息类型
    private String content;        // 文本内容或图片URL
    private long timestamp;        // 时间戳(毫秒)
    private boolean isSent;        // 是否已发送
    private boolean isRead;        // 是否已读
}

为什么要有messageId?因为消息去重。WebRTC的数据通道是UDP的,虽然我们通常用可靠模式,但偶尔还是会收到重复消息。用UUID生成唯一ID,接收端根据ID去重,这是基本操作。

核心思路: 消息类型用int枚举,不要用String。int在序列化时更小,传输更快。表情和图片本质上都是“非文本”,但为了UI展示方便,我建议分开处理。

21.2 通过WebRTC DataChannel发送文字消息

WebRTC除了传输音视频,还提供了DataChannel。这东西本质上是建立在P2P连接上的双向数据管道。用它来传聊天消息,再合适不过。

初始化DataChannel的代码大概是这样:

// 创建DataChannel配置
DataChannel.Init dcInit = new DataChannel.Init();
dcInit.ordered = true;          // 保证消息顺序
dcInit.negotiated = false;      // 自动协商
dcInit.id = 1;                  // 通道ID

// 在PeerConnection上创建DataChannel
DataChannel dataChannel = peerConnection.createDataChannel("chat", dcInit);

// 设置监听器
dataChannel.registerObserver(new DataChannel.Observer() {
    @Override
    public void onMessage(DataChannel.Buffer buffer) {
        // 读取消息
        ByteBuffer data = buffer.data;
        byte[] bytes = new byte[data.remaining()];
        data.get(bytes);
        String message = new String(bytes, StandardCharsets.UTF_8);
        // 解析并展示
        handleIncomingMessage(message);
    }

    @Override
    public void onStateChange() {
        // 通道状态变化
    }
});

发送消息时,把ChatMessage对象序列化成JSON字符串,然后通过DataChannel发送:

public void sendMessage(ChatMessage msg) {
    if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) {
        // 通道未就绪,缓存消息
        pendingMessages.add(msg);
        return;
    }
    String json = new Gson().toJson(msg);
    ByteBuffer byteBuffer = ByteBuffer.wrap(json.getBytes(StandardCharsets.UTF_8));
    DataChannel.Buffer buffer = new DataChannel.Buffer(byteBuffer, false);
    dataChannel.send(buffer);
}
避坑指南: 我曾经遇到过DataChannel在连接建立初期还没OPEN就发送消息,结果消息丢失。解决方案是维护一个pendingMessages队列,等通道状态变为OPEN后再批量发送。

21.3 表情与图片发送

表情发送其实有两种思路:

  • 方案一: 把表情当作特殊文本,用类似[微笑]这样的标记,客户端本地映射成图片。优点是省流量,缺点是表情库更新麻烦。
  • 方案二: 直接发送Unicode Emoji字符。Android原生支持,不需要额外处理。我推荐这个方案。

图片发送就复杂一些。WebRTC DataChannel虽然可以传二进制,但单次传输大小有限制(通常16KB-64KB)。一张高清图动不动几MB,直接传会失败。

我的做法是分两步:

  1. 先把图片上传到自己的文件服务器或云存储(比如七牛、OSS)
  2. 然后通过DataChannel发送图片的URL

接收端拿到URL后,用Glide或Coil加载显示。

// 发送图片消息
public void sendImageMessage(Uri imageUri) {
    // 1. 压缩图片
    Bitmap bitmap = compressImage(imageUri, 720, 1280);
    // 2. 上传到服务器
    String url = uploadToServer(bitmap);
    // 3. 构造消息
    ChatMessage msg = new ChatMessage();
    msg.type = ChatMessage.TYPE_IMAGE;
    msg.content = url;
    // 4. 发送
    sendMessage(msg);
}
注意: 图片压缩一定要做。我曾经有个项目没做压缩,用户拍了张4000万像素的照片直接发,结果上传花了30秒,体验极差。建议限制图片最大尺寸,比如宽度不超过1080px。

21.4 消息历史记录的本地存储

聊天记录不能只存在内存里。App一关就没了,用户肯定骂娘。我一般用Room数据库来存。

先定义Entity:

@Entity(tableName = "chat_messages")
public class MessageEntity {
    @PrimaryKey
    public String messageId;
    public String senderId;
    public String senderName;
    public int type;
    public String content;
    public long timestamp;
    public boolean isSent;
    public boolean isRead;
    public String sessionId;  // 会话ID,区分不同会议
}

然后写DAO:

@Dao
public interface MessageDao {
    @Query("SELECT * FROM chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC")
    LiveData<List<MessageEntity>> getMessages(String sessionId);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertMessage(MessageEntity message);

    @Query("DELETE FROM chat_messages WHERE sessionId = :sessionId")
    void clearSession(String sessionId);
}

收到消息时,先存数据库,再更新UI。这样即使App被系统杀掉,重启后聊天记录还在。

性能优化: 不要每收到一条消息就刷新整个RecyclerView。用DiffUtil或者Paging 3来做增量更新。我习惯用AsyncListDiffer,它只更新变化的部分,不会闪烁。

21.5 知识体系结构图

下面这张图展示了本章聊天模块的核心逻辑:

聊天消息模块架构 发送端 消息类型 • 文字消息 (TYPE_TEXT) • 表情消息 (TYPE_EMOJI) • 图片消息 (TYPE_IMAGE) • 文件消息 (TYPE_FILE) WebRTC DataChannel 接收端 接收处理 • JSON反序列化 • 消息去重 (messageId) • 图片URL加载 • UI更新 (DiffUtil) Room 本地数据库 消息历史记录

从图中可以看到,消息从发送端出发,经过DataChannel传输,到达接收端。接收端做反序列化、去重、UI更新,同时把消息存到Room数据库。下次打开App时,直接从数据库加载历史记录。

21.6 实际项目中的避坑经验

最后分享几个我实际踩过的坑:

  • 消息顺序问题: DataChannel的ordered模式虽然保证顺序,但如果发送端连续发送多条消息,接收端处理时如果用了异步线程,顺序可能乱。我建议接收端用一个单线程的Handler来处理消息入队。
  • 大消息拆分: 超过64KB的消息,DataChannel可能会失败。我一般限制单条消息不超过16KB。图片URL通常很短,没问题。但如果要传长文本,建议分片发送。
  • 数据库写入频率: 如果会议中消息刷屏,每一条都立刻写入数据库,会导致IO压力。我习惯用批量插入,每500ms或积累10条消息写一次。
  • 表情兼容性: 不同Android版本对Emoji的支持不一样。Android 7以下有些表情显示为方框。我建议用EmojiCompat库来做兼容处理。
个人习惯: 我一般在Application初始化时预创建好DataChannel,而不是每次进入会议才创建。这样可以减少连接建立后的等待时间。当然,前提是用户已经加入了某个会议房间。

好了,聊天消息这块的核心内容就这些。说白了就是:设计好数据结构,用DataChannel传JSON,图片走URL,本地用Room存。把这些搞定了,一个能用的聊天功能就有了。


公众号:蓝海资料掘金营,微信deep3321