本文基于 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 | ChatSystemModel、ChatSystemPopModel |
保存聊天缓存、会话状态、好友列表 |
| Manager | ChatSystemManager |
打开 UI、分发 UI 刷新 |
| Server | ChatSystemServer |
发送协议、处理回包和推送 |
| Logic | ChatSystemViewLogic |
主界面交互、页签切换、列表刷新 |
| UI | ChatSystemViewUI |
UI 节点绑定 |
| Component / Cell | ChatContentItem、ChatSystemSideItemComp、ChatTextItemComp 等 |
具体消息渲染 |
| Util | ChatSystemProtoMapper、ChatSystemTimeDividerUtil 等 |
协议转换、时间分隔、滚动刷新辅助 |
事件划分
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);
});
流程大致如下:
- 外部通知
UI_OPEN_VIEW,传入频道和可选私聊对象。 ChatSystemManager.PrepareOpen先准备 Model 状态。- UI 打开后,
ChatSystemViewLogic.ApplyOpenChannel同步页签和 UI。 RequestInitialHistory判断是否有缓存。- 无缓存则通知 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 负责把服务端的 ChatMsg、PlayerInfo 转换成客户端展示用的 ChatSystemMessageData。
5. 发送消息流程
发送入口在 ChatSystemViewLogic.OnClickSend:
- 判断是否处于发送冷却。
- 规范化输入内容。
- 走敏感词检测。
- 构造
ChatSystemSendRequest。 - 通知
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。
这种方式的优点是扩展消息类型比较自然。新增一种消息,只要补:
ChatSystemMessageType枚举。ChatSystemPrefabs.GetContentPath映射。- 对应 Item prefab 和组件。
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 里有 TryReplaceMatchingLocalEcho 和 ContainsMatchingOfficialMessage。这套逻辑用于处理"客户端先显示一条本地发送消息,之后收到服务端正式消息"时的去重替换。
判断依据包括:
- 是否都是自己发送。
- 消息类型和频道是否一致。
- 文本或表情内容是否一致。
- 时间戳是否在一定范围内。
这样可以避免同一条消息在发送成功后显示两遍。
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:
ChatFriendItemComp.OnClickSelect调用OnFriendSelected。ChatSystemViewLogic更新选中状态和标题。ChatSystemModel.SelectFriend(B)清除 B 的未读状态。- 调用
model.SwitchSession(Private, B)。 - 如果 B 的会话缓存存在且数量足够,直接刷新列表。
- 如果没有缓存或缓存不足,通知
SERVER_REQUEST_QUERY_FRIEND。 ChatSystemServer.C2SQueryFriend组装历史请求。- 服务端返回后,
ApplyHistoryResp写入 Model。 UI_MESSAGE_LIST_CHANGED触发 Manager 刷新 UI。ChatSystemViewLogic.RefreshMessageList重建虚拟列表显示。
这条链路体现了 ChatSystem 的核心思想:切换 UI 状态和拉取服务端数据分离,缓存优先,服务端补齐。
十二、可以继续优化的方向
1. 私聊切换时减少整列表重绘
当前切换会话时主要依赖 ResetModelsAndRefresh,这会触发虚拟列表重建。如果频繁切换好友,可能会感觉闪动或卡顿。
可选优化方向:
- 缓存命中时尽量复用当前
LoopModels引用。 - 对列表刷新做轻量 loading 或局部遮罩。
- 延迟清空旧列表,等新数据到达后一次替换。
- 对相同消息类型的 content prefab 做更细粒度复用。
2. 未读红点显示
ChatSystemFriendData.HasUnread 已经有数据字段,Server push 也会更新它。但 ChatFriendItemComp 中 ImgUnread 的显示代码目前被注释,打开后即可形成完整闭环。
3. 发送本地回显
当前代码已经有本地回显替换能力,但发送入口是否立即插入本地消息,需要结合实际业务策略。如果希望发送后立刻显示,可在发送请求发出时先构造 IsLocalEcho = true 的消息写入 Model,等 push 或 history 回来后替换。
4. 历史分页 total 可信度
代码里 HistoryOlderExhausted 不完全依赖 total,而是以"服务端没有返回更早数据"为停止依据。这是比较稳妥的做法。如果服务端 total 不稳定,也不会导致客户端无限拉取。
总结
ChatSystem 的实现重点不在单个 UI,而在"多会话、多来源、多消息类型"的组织方式。
它通过 ChatSystemModel 把当前会话、私聊会话缓存、可见窗口、分页游标、历史耗尽状态统一管理;通过 ChatSystemServer 把协议请求、响应校验、push 路由收束在网络层;通过 ChatSystemViewLogic 承接页签切换、好友选择、输入发送、列表刷新等 UI 行为;最后用 ChatContentItem 和一组消息组件完成具体渲染。
这套结构的优点是职责清晰、缓存可控、扩展消息类型方便。缺点是私聊切换和列表刷新链路较长,后续优化时要特别注意不要把数据层和渲染层混在一起,否则很容易引入"缓存有数据但 UI 没刷新""收到非当前会话推送却强制切页签""历史响应写入错误会话"等问题。
从工程实践角度看,ChatSystem 是一个典型的 Unity 游戏聊天模块案例:它既有 MMO/社交系统常见的频道和私聊能力,也有移动端 UI 性能约束下的虚拟列表、缓存窗口、资源预加载和异步刷新保护。