Unity聊天系统设计实现

本文基于 Unity 客户端工程中的 Assets/Scripts/Modules/ChatSystem/ 模块,梳理 ChatSystem 从功能设计、技术架构、协议数据流、UI 渲染到关键代码细节的完整实现思路。

ChatSystem 不是一个简单的"发消息列表",它同时承担了公共频道聊天、联盟频道聊天、私聊会话、顶部私聊联系人列表、聊天简略条、表情消息、历史分页、消息推送、缓存复用、输入限制和发送冷却等职责。它的设计核心是:Server 只负责协议收发与数据落库,Model 维护会话状态与缓存,Logic 处理 UI 交互和刷新,具体 Item 组件负责渲染。

一、功能设计

1. 支持的聊天场景

当前聊天系统主要包含三类频道:

频道 枚举 说明
公共聊天 ChatSystemChannelType.Public 所有玩家可见的公共频道
联盟聊天 ChatSystemChannelType.Union 联盟或组织内部频道
私聊 ChatSystemChannelType.Private 玩家与玩家之间的一对一会话

频道定义在 ChatSystemChannelType.cs 中,频道页签不是写死在 UI 里的,而是通过配置表 ChatChannel 生成。ChatSystemChannelTabMap 会读取配置中的页签名称、图标、解锁条件、发送间隔、字数限制、时间分隔阈值等信息。

这种做法的好处是,后续如果新增一个频道,不一定要大改 UI 逻辑,只要协议、配置和枚举能对齐,页签层面就能复用现有流程。

2. 主聊天界面能力

主界面 ChatSystemView 具备以下能力:

  • 按频道切换聊天页签。
  • 私聊频道显示顶部横向好友列表 list_friend
  • 消息列表支持上滑加载更早历史。
  • 支持文本、表情、照片、剧情、小游戏等消息类型。
  • 根据发送者判断左侧或右侧气泡。
  • 输入框支持字数限制、敏感词过滤、发送冷却。
  • 表情面板支持小表情和大表情两种展示形态。
  • 点击头像可进入玩家详情。
  • 私聊联系人支持选中、删除、未读状态。

3. 简略聊天条

除了完整聊天界面,模块里还有一个 ChatSystemPop 简略条。它由 ChatSystemPopLogic 驱动,数据存在 ChatSystemPopModel 中。

简略条只缓存最近几条消息,默认容量是 MessageCapacity = 4。收到推送时,Server 会同时把消息写入简略条模型和主聊天模型:

  • HandlePopMsgPush 写入 ChatSystemPopModel
  • HandleViewMsgPush 写入 ChatSystemModel

这样即使主聊天界面没有打开,外层也可以展示最近聊天动态。

二、模块架构

ChatSystem 遵循项目里的 MVC 模块模式。模块入口在 ChatSystemModule.cs

csharp 复制代码
public override void Execute()
{
    InjectModel<ChatSystemModel>();
    InjectModel<ChatSystemPopModel>();
    InjectManager<ChatSystemManager>();
    InjectServer<ChatSystemServer>();
    OnEnable(true);
}

整个模块可以分成几层:

层级 代表类 职责
Module ChatSystemModule 注入 Model、Manager、Server
Events ChatSystemEvents 定义 UI、请求、响应事件
Model ChatSystemModelChatSystemPopModel 保存聊天缓存、会话状态、好友列表
Manager ChatSystemManager 打开 UI、分发 UI 刷新
Server ChatSystemServer 发送协议、处理回包和推送
Logic ChatSystemViewLogic 主界面交互、页签切换、列表刷新
UI ChatSystemViewUI UI 节点绑定
Component / Cell ChatContentItemChatSystemSideItemCompChatTextItemComp 具体消息渲染
Util ChatSystemProtoMapperChatSystemTimeDividerUtil 协议转换、时间分隔、滚动刷新辅助

事件划分

ChatSystemEvents.cs 中把事件分为三类:

csharp 复制代码
// UI
UI_OPEN_VIEW
UI_MESSAGE_LIST_CHANGED
UI_OPEN_REPORT
UI_VIEW_STATE_CHANGED

// SERVER_REQUEST
SERVER_REQUEST_QUERY
SERVER_REQUEST_QUERY_FRIEND
SERVER_REQUEST_QUERY_OLDER
SERVER_REQUEST_SEND
SERVER_REQUEST_CLEAR_PRIVATE

// SERVER_RESPONSE
SERVER_RESPONSE_PUSH
SERVER_RESPONSE_SEND
SERVER_RESPONSE_SEND_FAILED

这种划分让不同层之间不会直接耦合。比如 UI 点击私聊好友时,不直接调用 Server 方法,而是发出 SERVER_REQUEST_QUERY_FRIEND 事件,由 ChatSystemServer 处理真正的协议请求。

三、协议与数据流

1. 相关协议码

聊天协议码定义在 ProtoCode.cs

协议 协议码 用途
PROTO_CODE_CHAT_QUERY_HISTORY_REQ 80130001 请求聊天历史
PROTO_CODE_CHAT_QUERY_HISTORY_RESP 80130002 聊天历史响应
PROTO_CODE_CHAT_SEND_MSG_REQ 80130003 发送聊天消息
PROTO_CODE_CHAT_SEND_MSG_RESP 80130004 发送消息响应
PROTO_CODE_CHAT_MSG_PUSH 80130005 服务端推送新消息
PROTO_CODE_CHAT_CLEAR_PRIVATE_REQ 80130006 清除私聊会话
PROTO_CODE_CHAT_CLEAR_PRIVATE_RESP 80130007 清除私聊响应
PROTO_CODE_CHAT_QUERY_PRIVATE_LIST_REQ 80130008 请求私聊联系人列表
PROTO_CODE_CHAT_QUERY_PRIVATE_LIST_RESP 80130009 私聊联系人列表响应

2. 打开聊天界面流程

打开聊天界面的入口是 ChatSystemEvents.UI_OPEN_VIEW,由 ChatSystemManager.OnOpenView 处理:

csharp 复制代码
UIManager.Instance.OpenUI(_viewLogic, () =>
{
    _viewLogic.ApplyOpenChannel(channel);
    RequestInitialHistory(channel, friend);
});

流程大致如下:

  1. 外部通知 UI_OPEN_VIEW,传入频道和可选私聊对象。
  2. ChatSystemManager.PrepareOpen 先准备 Model 状态。
  3. UI 打开后,ChatSystemViewLogic.ApplyOpenChannel 同步页签和 UI。
  4. RequestInitialHistory 判断是否有缓存。
  5. 无缓存则通知 Server 拉取历史,有缓存则直接刷新列表。

这里有一个重要设计点:私聊会话是按好友 uid 做缓存的,因此从玩家详情页直接打开某个玩家私聊时,Manager 会先把该玩家放入好友列表并选中,再切换到对应私聊会话。

3. 请求历史消息

历史消息请求由 ChatSystemServer.RequestHistory 统一处理:

csharp 复制代码
int start = resetViewToLatest ? 0 : model.HistoryNextFetchStart;
int stop = start + ChatSystemModel.ViewPageSize - 1;
var req = ChatSystemProtoMapper.BuildHistoryReq(channel, businessId, start, stop);

关键参数:

  • channel_type:公共、联盟、私聊。
  • business_id:私聊时为目标 uid,公共频道为空。
  • start / stop:分页范围。

当前主界面每次拉取的数量由 ChatSystemModel.ViewPageSize 控制,值为 20

4. 处理历史响应

历史响应进入 OnQueryHistoryResp 后,不是无脑写入当前列表,而是先校验这个响应是否仍然属于当前会话:

csharp 复制代码
if (!IsPendingHistoryRequestApplicable(pending))
{
    // 过期响应直接忽略
    return;
}

这是为了避免玩家快速切换频道或私聊对象时,旧请求后返回,把错误会话的数据写进当前界面。

真正落库由 ApplyHistoryResp 完成:

csharp 复制代码
var rows = ChatSystemProtoMapper.ToMessageList(resp?.msg_list, resp?.player_list, SelfUid);
model.AppendHistoryMessages(rows, pending.Start, resetViewToLatest: true);
EventManager.Notify(ChatSystemEvents.UI_MESSAGE_LIST_CHANGED);

ChatSystemProtoMapper 负责把服务端的 ChatMsgPlayerInfo 转换成客户端展示用的 ChatSystemMessageData

5. 发送消息流程

发送入口在 ChatSystemViewLogic.OnClickSend

  1. 判断是否处于发送冷却。
  2. 规范化输入内容。
  3. 走敏感词检测。
  4. 构造 ChatSystemSendRequest
  5. 通知 SERVER_REQUEST_SEND

真正发协议在 ChatSystemServer.C2SSend

csharp 复制代码
var req = ChatSystemProtoMapper.BuildSendReq(
    request.Channel,
    request.BusinessId,
    request);
TrySendMessage(ProtoCode.PROTO_CODE_CHAT_SEND_MSG_REQ, req);

私聊发送时必须有 BusinessId,也就是目标玩家 uid。没有选择私聊对象时,会直接 toast 提示并触发发送失败事件。

6. 推送消息流程

新消息推送由 PROTO_CODE_CHAT_MSG_PUSH 进入:

csharp 复制代码
void OnMsgPush(ChatMsgPush push)
{
    HandlePopMsgPush(push);
    HandleViewMsgPush(push);
}

推送处理分两路:

  • 简略条:转成 CellEnum.ChatSystemSimple 数据,写入 ChatSystemPopModel
  • 主界面:判断是否属于当前会话,属于则刷新当前列表,不属于则写入对应会话缓存。

私聊推送还有额外处理:

csharp 复制代码
HandlePrivateFriendFromPush(model, push);

这一步会确保发送方出现在顶部私聊好友列表中,并在非当前会话时设置未读状态。

四、Model 设计:全量缓存、可见窗口与多会话

ChatSystemModel 是整个模块最关键的数据结构。它同时维护:

csharp 复制代码
public readonly List<BaseCellData> MessageList = new();
public readonly List<BaseCellData> ViewMessageList = new();
readonly Dictionary<string, ChatSessionState> _sessions = new();

1. MessageList

MessageList 是当前会话已经加载的全量消息,按时间升序保存。它不直接等同于 UI 当前显示内容。

2. ViewMessageList

ViewMessageList 是当前 ScrollRectLoop 绑定的数据窗口。打开界面时并不会把全量消息全部展示出来,而是默认展示最新的 ViewPageSize 条,并插入时间分隔行。

csharp 复制代码
_viewStartIndexInFull = Math.Max(0, MessageList.Count - ViewPageSize);
RebuildViewMessageListFromWindow();

这种窗口化设计可以减少 UI 重绘压力,避免历史消息很多时一次性创建大量 cell。

3. ChatSessionState

ChatSessionState 是单个频道或私聊对象的缓存:

csharp 复制代码
sealed class ChatSessionState
{
    public readonly List<BaseCellData> Messages = new();
    public int ViewStartIndexInFull;
    public int HistoryNextFetchStart;
    public int HistoryTotal;
    public bool HistoryOlderExhausted;
    public bool HasLoaded;
}

会话 key 的生成规则:

  • 公共、联盟频道:直接用频道 id。
  • 私聊:使用 频道id:好友uid

例如:

csharp 复制代码
if (channel == ChatSystemChannelType.Private)
    return $"{(int)channel}:{friendUid}";

切换会话时,Model 会先 PersistActiveSession 保存当前会话,再尝试从 _sessions 恢复目标会话。

4. 为什么私聊需要独立会话缓存

公共频道和联盟频道天然只有一个会话。但私聊是多个玩家一对一会话,如果只维护一个 MessageList,切换 A、B、C 三个玩家时就必须频繁重新请求服务器。

现在的设计是:

  • 正在看的私聊对象数据放在 MessageList
  • 切走时保存到 _sessions
  • 切回来时优先恢复缓存。
  • 缓存不足 ViewPageSize 且没有标记历史耗尽时,再补拉服务器。

这正是 ShouldFetchHistoryForCurrentCache 的作用:

csharp 复制代码
return FullMessageCount < ViewPageSize && !HistoryOlderExhausted;

五、私聊功能细节

1. 私聊好友列表

私聊好友数据结构是 ChatSystemFriendData

csharp 复制代码
public class ChatSystemFriendData
{
    public string Uid { get; set; }
    public string DisplayName { get; set; }
    public int AvatarId { get; set; }
    public int ProfessionId { get; set; }
    public bool HasUnread { get; set; }
}

顶部横向好友列表由 ChatFriendItemComp 渲染,主要显示:

  • 头像。
  • 是否选中。
  • 是否好友。
  • 删除按钮。
  • 未读标记。

当前代码中 ImgUnread 的显示逻辑被注释掉了,如果后续要打开红点,只需要恢复:

csharp 复制代码
ImgUnread.gameObject.SetActive(data.HasUnread);

2. 选择私聊对象

选择好友时进入 ChatSystemViewLogic.OnFriendSelected

csharp 复制代码
model.SelectFriend(friend.Uid);
RefreshFriendSelection(model, prevFriendUid, friend.Uid);
RefreshTitle(model);

然后判断:

  • 如果选中的是同一个好友,不做任何请求。
  • 如果目标会话已有缓存,直接切换并刷新。
  • 如果没有缓存,通知 Server 请求该好友的历史。

这块逻辑避免了每次点击都请求服务器。

3. 私聊推送与未读状态

收到私聊推送时,HandlePrivateFriendFromPush 会先解析对端 uid:

csharp 复制代码
var friendUid = ChatSystemProtoMapper.ResolvePrivateFriendUid(push, SelfUid);

如果对端不在好友条里,会通过 EnsureFriend 加进去,并且插入到列表前面。若消息不是自己发的,且当前正在看的不是这个好友,则设置:

csharp 复制代码
added.HasUnread = true;

当玩家选中该好友时,SelectFriend 会把 HasUnread 清掉。

这里还有一个体验点:收到 B 的私聊推送时,如果玩家正在和 A 聊天,系统不会强行切到 B,只会更新好友条和未读状态。真正属于当前会话的推送才会刷新当前消息列表。

六、消息渲染链路

1. ScrollRectLoop 虚拟列表

主聊天列表使用 ScrollRectLoop。初始化时注册:

csharp 复制代码
scroll.RegisterCell(CellEnum.ChatSystem, typeof(ChatSystemViewCell));

刷新列表时,Logic 会先确保列表布局已经初始化:

csharp 复制代码
ChatSystemScrollRefreshUtil.EnsureReady(scroll, nameof(ChatSystemViewLogic))

这是一个很实用的保护,因为 ScrollRectLoop 的 Content 上需要 LoopHorizontalOrVerticalLayout 和对象池完成初始化,过早刷新容易出现空引用或布局错乱。

2. Cell 到消息内容

渲染链路如下:

text 复制代码
ScrollRectLoop
  -> ChatSystemViewCell
    -> ChatContentItem
      -> ChatLeftItem / ChatRightItem
        -> ChatSystemSideItemComp
          -> ChatTextItemComp / ChatEmojiItemComp / ChatPhotoItemComp / ...

ChatContentItem 是一个行容器。它根据 data.IsSelf 判断使用左侧还是右侧气泡:

csharp 复制代码
var side = data.IsSelf ? EnsureSide(false) : EnsureSide(true);

如果消息类型是 Time,则渲染成时间分隔行,不走左右气泡。

3. 内容预制体动态加载

具体消息内容不是写死在一个 prefab 里,而是由 ChatSystemPrefabs 管理:

csharp 复制代码
public const string TextItem = "UI/ChatSystem/Prefab/Item/ChatTextItem";
public const string FindGameItem = "UI/ChatSystem/Prefab/Item/ChatFindGameItem";
public const string PhotoItem = "UI/ChatSystem/Prefab/Item/ChatPhotoItem";
public const string StoryItem = "UI/ChatSystem/Prefab/Item/ChatStoryItem";
public const string EmojiItem = "UI/ChatSystem/Prefab/Item/ChatEmojilItem";

ChatSystemSideItemComp.RefreshContent 会根据 MessageType 选择对应 prefab,然后调用具体组件的 SetData

这种方式的优点是扩展消息类型比较自然。新增一种消息,只要补:

  1. ChatSystemMessageType 枚举。
  2. ChatSystemPrefabs.GetContentPath 映射。
  3. 对应 Item prefab 和组件。
  4. ChatSystemSideItemComp.BindContent 分支。

4. 文本气泡布局

ChatTextItemComp 负责文本消息的气泡尺寸计算。它会:

  • 根据最大宽度计算文本换行。
  • 按左右气泡调整文字锚点。
  • 对自己发送的单行短文本右对齐。
  • 支持内嵌表情文本,例如把表情标记解析成图文混排。
  • 将最终高度写入 ContentLayoutHeight,供父级同步行高。

父节点高度同步依赖 IChatSystemContentLayoutHeight

csharp 复制代码
public interface IChatSystemContentLayoutHeight
{
    float ContentLayoutHeight { get; }
}

这样虚拟列表可以拿到准确高度,避免气泡内容撑开后列表行高度不一致。

七、历史分页与上滑加载

聊天历史分页分为两层:

1. 本地缓存里还有更早消息

当玩家上滑到顶部附近时,ScrollRectLoop.PullDownRequest 触发:

csharp 复制代码
TryPrependOlderPage();

如果 MessageList 中还有未展示的更早消息,Model 会从本地缓存截取一页,插入到 ViewMessageList 前面:

csharp 复制代码
model.TryGetOlderPageEntries(out var entries, out int rawMessageCount, out int removeCountFromViewStart)

列表侧用 ReplaceStartAndPrependModels 做头部插入,尽量保持滚动位置稳定。

2. 本地缓存耗尽,向服务器拉更早消息

如果本地已经没有更早数据,但服务端还没标记历史耗尽,则发:

csharp 复制代码
EventManager.Notify(ChatSystemEvents.SERVER_REQUEST_QUERY_OLDER);

Server 根据 HistoryNextFetchStart 继续向后分页。若服务端返回空数据,则调用:

csharp 复制代码
model.ClampHistoryTotalToLoadedCount();

最终会设置 HistoryOlderExhausted,避免反复请求无效历史。

八、时间分隔行

时间分隔不是服务端消息,而是客户端展示数据。ChatSystemMessageType.Time 用来表示列表中的时间行。

ChatSystemTimeDividerUtil.AppendSliceWithTimeDividers 会在以下情况插入时间:

  • 当前窗口第一条真实消息前。
  • 相邻两条消息时间间隔超过频道配置中的阈值。

显示格式:

  • 今天:HH:mm
  • 今年:MM-dd HH:mm
  • 跨年:yyyy-MM-dd HH:mm

这样时间分隔逻辑集中在工具类中,消息渲染组件只需要识别 MessageType.Time

九、公共聊天和私聊的差异

公共聊天和私聊共用同一套历史请求、消息模型和渲染列表,但差异主要体现在:

公共聊天 私聊
business_id 目标玩家 uid
会话数量 一个频道一个会话 每个好友一个会话
顶部好友条 不显示 显示
标题 来自频道配置 当前好友名称或私聊页签名
推送处理 当前频道即可刷新 需要判断是否当前好友
未读状态 通常不需要 非当前私聊对象收到消息时标记
头像昵称展示 可显示对方昵称 私聊中可简化显示

这也是为什么私聊会比公共聊天更容易出现缓存、切换、未读和强制跳转等问题。公共频道只有一个上下文,而私聊是多会话模型。

十、几个值得注意的实现细节

1. 自己发送的消息如何判断

ChatSystemProtoMapper.ToMessageData 会根据服务端消息的 sender_uid 和当前玩家 uid 比对:

csharp 复制代码
isSelf: !string.IsNullOrEmpty(selfUid) && msg.sender_uid == selfUid

渲染层再根据 data.IsSelf 选择左侧或右侧气泡。

2. 推送不属于当前会话时只写缓存

HandleViewMsgPush 中如果推送不属于当前会话:

csharp 复制代码
AppendPushToSession(model, push);
return;

这能保证后台会话数据不丢,但不会打断玩家当前正在看的聊天对象。

3. 本地回显替换

Model 里有 TryReplaceMatchingLocalEchoContainsMatchingOfficialMessage。这套逻辑用于处理"客户端先显示一条本地发送消息,之后收到服务端正式消息"时的去重替换。

判断依据包括:

  • 是否都是自己发送。
  • 消息类型和频道是否一致。
  • 文本或表情内容是否一致。
  • 时间戳是否在一定范围内。

这样可以避免同一条消息在发送成功后显示两遍。

4. 列表刷新延后重试

CoRefreshMessageList 中如果发现 ScrollRectLoop 未就绪,会调度重试:

csharp 复制代码
ScheduleRefreshMessageListRetry(scrollToEnd, smoothScroll);

这对 Unity UI 很重要,因为 prefab 刚创建出来时,ScrollRect、Content、Layout、对象池的 Awake/初始化顺序可能还没全部完成。

5. 资源加载集中管理

聊天内容 prefab 由 ChatSystemPrefabs.EnsureLoadedAsync 统一预加载并缓存。渲染时不直接 Resources.Load,而是复用项目的 ResLoader

这符合项目的 YooAsset 资源体系,也减少了每条消息渲染时重复加载 prefab 的成本。

十一、一次典型私聊切换流程

假设玩家当前正在和 A 聊天,点击顶部好友 B:

  1. ChatFriendItemComp.OnClickSelect 调用 OnFriendSelected
  2. ChatSystemViewLogic 更新选中状态和标题。
  3. ChatSystemModel.SelectFriend(B) 清除 B 的未读状态。
  4. 调用 model.SwitchSession(Private, B)
  5. 如果 B 的会话缓存存在且数量足够,直接刷新列表。
  6. 如果没有缓存或缓存不足,通知 SERVER_REQUEST_QUERY_FRIEND
  7. ChatSystemServer.C2SQueryFriend 组装历史请求。
  8. 服务端返回后,ApplyHistoryResp 写入 Model。
  9. UI_MESSAGE_LIST_CHANGED 触发 Manager 刷新 UI。
  10. ChatSystemViewLogic.RefreshMessageList 重建虚拟列表显示。

这条链路体现了 ChatSystem 的核心思想:切换 UI 状态和拉取服务端数据分离,缓存优先,服务端补齐。

十二、可以继续优化的方向

1. 私聊切换时减少整列表重绘

当前切换会话时主要依赖 ResetModelsAndRefresh,这会触发虚拟列表重建。如果频繁切换好友,可能会感觉闪动或卡顿。

可选优化方向:

  • 缓存命中时尽量复用当前 LoopModels 引用。
  • 对列表刷新做轻量 loading 或局部遮罩。
  • 延迟清空旧列表,等新数据到达后一次替换。
  • 对相同消息类型的 content prefab 做更细粒度复用。

2. 未读红点显示

ChatSystemFriendData.HasUnread 已经有数据字段,Server push 也会更新它。但 ChatFriendItemCompImgUnread 的显示代码目前被注释,打开后即可形成完整闭环。

3. 发送本地回显

当前代码已经有本地回显替换能力,但发送入口是否立即插入本地消息,需要结合实际业务策略。如果希望发送后立刻显示,可在发送请求发出时先构造 IsLocalEcho = true 的消息写入 Model,等 push 或 history 回来后替换。

4. 历史分页 total 可信度

代码里 HistoryOlderExhausted 不完全依赖 total,而是以"服务端没有返回更早数据"为停止依据。这是比较稳妥的做法。如果服务端 total 不稳定,也不会导致客户端无限拉取。

总结

ChatSystem 的实现重点不在单个 UI,而在"多会话、多来源、多消息类型"的组织方式。

它通过 ChatSystemModel 把当前会话、私聊会话缓存、可见窗口、分页游标、历史耗尽状态统一管理;通过 ChatSystemServer 把协议请求、响应校验、push 路由收束在网络层;通过 ChatSystemViewLogic 承接页签切换、好友选择、输入发送、列表刷新等 UI 行为;最后用 ChatContentItem 和一组消息组件完成具体渲染。

这套结构的优点是职责清晰、缓存可控、扩展消息类型方便。缺点是私聊切换和列表刷新链路较长,后续优化时要特别注意不要把数据层和渲染层混在一起,否则很容易引入"缓存有数据但 UI 没刷新""收到非当前会话推送却强制切页签""历史响应写入错误会话"等问题。

从工程实践角度看,ChatSystem 是一个典型的 Unity 游戏聊天模块案例:它既有 MMO/社交系统常见的频道和私聊能力,也有移动端 UI 性能约束下的虚拟列表、缓存窗口、资源预加载和异步刷新保护。