Unity 高效的 IM(即时通讯)设计方案原理教程
概述
本教程基于一个使用 NGUI + protobuf-net 构建的 Unity IM 聊天系统,深入分析其核心设计原理。该系统在 消息协议定义、组件复用、对象池、虚拟滚动 四个方面做到了高度优化,适合作为 Unity 中长列表聊天框的参考架构。

UGUI的版本也有实现一模一样的效果,这里就不赘述,原理是一样的。
一、消息协议层:基于 Protocol Buffers 的多类型定义
1.1 Proto 文件定义
位置:<Protocol/IMMsg.proto>
系统使用 protobuf-net 将 .proto 文件编译为 C# 类。消息类型通过枚举 MsgType 严格区分:
protobuf
enum MsgType {
Text = 1; // 文本消息
Image = 2; // 图片消息
Audio = 3; // 语音消息
Time = 4; // 时间提示消息
JoinOrLeave = 5; // 加入/离开提示
World_Share = 6; // 世界坐标分享
UserData = 7; // 用户数据
}
1.2 ChatMessage 的统一结构
ChatMessage 是核心消息体,采用 optional 字段 + 多类型承载 的方式,将多种消息类型统一为一个数据结构:
protobuf
message ChatMessage {
required int32 id = 1;
required int32 chatTime = 2;
required ChatChannel channel = 3;
required MsgType msgType = 4;
optional int32 roomId = 5;
optional User sender = 6;
optional string msg = 7; // 文本类型
optional VoiceMsg voiceMsg = 8; // 语音类型
optional PicMsg picMsg = 9; // 图片类型
optional SiteMsg siteMsg = 10; // 位置类型
}
高效之处 :所有消息(文本、图片、语音、位置、时间、系统提示)共享同一个 ChatMessage 结构,通过 msgType 区分,避免为每种消息定义独立的网络包结构。配合 protobuf 的 optional 字段,未使用的子消息体不占网络带宽。
1.3 消息状态机
枚举 MsgStatus 定义了消息的完整生命周期状态:
Created → Sending → Sent → Read
↘ SendingFailed
这为后续的消息发送重试、未读计数、已读回执等业务提供了清晰的状态基础。
二、组件继承体系:极致的复用设计
整个系统的 UI 组件采用 三层继承结构,每一层解决一个维度的通用性问题。

2.1 层级结构
MonoBehaviour
├── MsgBaseComponent (已废弃/预留基类)
└── MsgBaseItem (核心基类,所有消息 Item 的父类)
├── PTMsgItem (图文消息:文本 + 图片)
├── TipMsgItem (提示消息:时间 + 加入离开)
└── VoiceMsgItem (语音消息)
2.2 MsgBaseItem:所有消息 Item 的根基
位置:<MsgBaseItem.cs>
这是整个系统的核心抽象基类,定义了每个消息 Item 必须拥有的属性和行为:
csharp
public class MsgBaseItem : MonoBehaviour
{
[RequireComponent(typeof(UIWidget))]
// === 布局属性 ===
public float Height { get; set; } // 当前 Item 高度
public float StartPosY { get; set; } // 当前 Item Y 坐标
public string ItemPrefabName { get; set; } // 对应的 Prefab 名称(用于对象池查找)
// === 索引体系 ===
public int ItemId { get; set; } // 全局唯一 ID(由对象池递增分配)
public int ItemIndex { get; set; } // 在数据列表中的索引
public int ViewIndex { get; set; } // 在 ScrollView 可见队列中的索引
// === 虚方法 ===
public virtual void Awake() // 获取 UIWidget 组件引用
public virtual void SetItemSize() // 根据内容计算并设置尺寸
public virtual void FillItem(protocol.ChatMessage msgData, int index) // 填充数据
public virtual void Reset() // 重置到初始状态(回收时调用)
}
设计亮点:
- 所有 Item 强制要求
UIWidget组件(通过RequireComponent),这是 ChatPanel 统一计算布局的前提 ItemPrefabName关联对象池,使得不同类型 Item 可以被统一管理- 三维索引(
ItemId+ItemIndex+ViewIndex)分别服务于 对象池追踪、数据定位、视图排序,职责分明
2.3 PTMsgItem:图文消息组件
位置:<PTMsgItem.cs>
处理 文本(Text) 和 图片(Image) 两种消息类型。内部持有以下 UI 引用:
csharp
public UISprite headIcon; // 头像
public UILabel lblName; // 发送者名称
public UILabel lblMessage; // 消息文本
public UISprite bg; // 气泡背景
public UISprite line; // 翻译分隔线
public UIButton btnTranslate; // 翻译按钮
public UITexture tetPic; // 图片
public UISprite translated; // "已翻译"标记
复用技巧 :同一个 Prefab(PTMsgItem)通过 FillItem 内部的分支逻辑,可以渲染两种完全不同的 UI 形态:
- Text 模式 :激活
bg+lblMessage+btnTranslate,关闭tetPic - Image 模式 :激活
tetPic,关闭lblMessage+btnTranslate+bg
这意味着编辑器里只需要拖拽配置 一个 Prefab,就能展示文本和图片两种消息,不需要创建两个独立的 Prefab。
翻译功能实现了动态高度调整 :翻译完成后重新计算 SetItemSize(),然后调用 ParentView.RePosition(this, deltaHeight) 通知 ChatPanel 刷新下方所有 Item 的位置。
2.4 TipMsgItem:提示消息组件
位置:<TipMsgItem.cs>
处理 时间(Time) 和 加入/离开(JoinOrLeave) 两种系统提示消息。逻辑极简:
csharp
public override void FillItem(ChatMessage msgData, int index)
{
if (msgData.msgType == MsgType.JoinOrLeave)
lblMsaage.text = msgData.msg;
else if (msgData.msgType == MsgType.Time)
lblMsaage.text = ConvertTimeIntervalToDayOfWeekAndTime(msgData.chatTime);
bg.width = lblMsaage.width + 10; // 背景自适应文本宽度
bg.height = lblMsaage.height + 10;
SetItemSize();
}
复用技巧 :同样是一个 TipMsgItem,通过 msgType 分支显示时间文本 或用户行为文本。两种提示共用一套居中布局,不需要分开。
2.5 VoiceMsgItem:语音消息组件
位置:<VoiceMsgItem.cs>
处理 语音(Audio) 消息。特色在于气泡宽度随语音时长动态变化:
csharp
public override void FillItem(ChatMessage msgData, int index)
{
lblTime.text = string.Format("{0}''", msgData.voiceMsg.duration);
int duration = Mathf.Clamp(msgData.voiceMsg.duration, 3, 50);
bg.width = Mathf.FloorToInt(Mathf.Lerp(105f, 500f, (duration / 50f)));
SetItemSize();
}
Mathf.Lerp(105f, 500f, duration / 50f) 将 3-50 秒的语音时长映射到 105-500 像素宽度,给用户直观的"时长视觉暗示"。
三、对象池系统:消除运行时 GC
位置:<ChatPanel.cs> 中的 ItemPool 类
3.1 为什么需要池
IM 聊天框是高频动态列表 :新消息不断加入,用户滚动时旧消息不断离开视野。如果每次都用 Instantiate/Destroy,会产生大量 GC Alloc,在移动设备上导致卡顿。
3.2 两级缓存设计
csharp
public class ItemPool
{
List<MsgBaseItem> mTmpPooledItemList = new List<MsgBaseItem>(); // 一级缓存(临时)
List<MsgBaseItem> mPooledItemList = new List<MsgBaseItem>(); // 二级缓存(就绪)
}
工作流程:
GetItem() RecycleItem()
用户 ──────────→ ┌─────────┐ 滚动出视野 ──────────→ ┌──────────┐
│ 一级缓存 │ │ 一级缓存 │
│ (Temp) │ │ (Temp) │
└────┬────┘ └────┬─────┘
│ 为空时 fallback │ ClearTmpRecycledItem()
↓ ↓
┌──────────┐ ┌──────────┐
│ 二级缓存 │ │ 二级缓存 │
│ (Ready) │ │ (Ready) │
└────┬─────┘ └──────────┘
│ 为空时创建新实例
↓
GameObject.Instantiate
- 一级缓存(Temp) :Item 刚被滚动移出视野时放入此处,下一帧被
ClearAllTmpRecycledItem()转移到二级缓存 - 二级缓存(Ready) :完全回收就绪的 Item,可立即被
GetItem()取出复用
这种设计避免了频繁的 Instantiate/Destroy,所有 Item 实例在整个生命周期中只创建一次,反复循环使用。
3.3 预创建机制
csharp
public class ItemPrefabConfData
{
public GameObject mItemPrefab = null;
public int mInitCreateCount = 0; // 初始预创建数量
}
初始化时,每种 Item 按 mInitCreateCount 预先创建 N 个实例并直接回收到池中。这样用户首次滑动时,Item 直接从池中取出,零延迟。
3.4 池的生命周期管理
csharp
// 初始化
pool.Init(data.mItemPrefab, data.mInitCreateCount, mContainerTrans);
// 取出(从池或新建)
MsgBaseItem item = pool.GetItem();
// 回收(回到一级缓存)
pool.RecycleItem(item);
// 清理一级缓存(转移到二级缓存)
pool.ClearTmpRecycledItem();
// 销毁所有
pool.DestroyAllItem();
3.5 ChatPanel 中的池管理
csharp
// 按 Prefab 名称索引的池字典
Dictionary<string, ItemPool> mItemPoolDict = new Dictionary<string, ItemPool>();
public MsgBaseItem NewMsgItem(string itemPrefabName)
{
ItemPool pool = mItemPoolDict[itemPrefabName];
var item = pool.GetItem();
item.Reset(); // 重置到初始状态
item.ParentView = this; // 关联父视图
return item;
}
NewMsgItem 是 ChatPanel 对外暴露的唯一获取 Item 的方法。上层(ChatDemo)只需传入 Prefab 名称,不需要关心池的内部逻辑。

四、虚拟滚动(Loop Scroll)引擎:万条消息不卡顿
位置:<ChatPanel.cs> 的 OnPanelLoopClipMove 方法
4.1 核心思想
不将全部消息生成为 GameObject,而是只生成视野可见区域 + 前后缓冲区 中的 Item。滚动时,移出视野的 Item 被回收,新进入视野的消息复用池中 Item。不管数据列表有多少条(100 还是 10000),场景中同时存在的 Item 数量基本恒定。
4.2 四标志位追踪
系统使用 4 个 MsgBaseItem 引用追踪视图状态:
csharp
// {最上面的prefab, 视野范围内最上面prefab, 视野范围内最下面prefab, 最下面prefab}
MsgBaseItem[] mFlagItems = new MsgBaseItem[] { null, null, null, null };
数据索引增大方向(新消息在下方)
──────────────────────────────→
mFlagItems[0] ← 当前已生成的最上面 Item
|
mFlagItems[1] ← 视野中可见的最上面 Item
| ↑ 视口可见区域 ↑
mFlagItems[2] ← 视野中可见的最下面 Item
|
mFlagItems[3] ← 当前已生成的最下面 Item
为什么要 4 个:这 4 个标志位让系统的判定条件非常精确:
mFlagItems[3].ItemIndex < mChatMsgs.Count - 1:底部还有未加载的数据mFlagItems[1].ItemIndex - mFlagItems[0].ItemIndex > CacheMsgPrefabNum:上方缓存过多,需要回收mFlagItems[3].ItemIndex - mFlagItems[2].ItemIndex < CacheMsgPrefabNum:下方缓存不足,需要加载
4.3 滑动检测与回收策略
csharp
void OnPanelLoopClipMove(UIPanel panel)
{
float moveDis = mScrollView.transform.localPosition.y - svLastPos;
if (Mathf.Abs(moveDis) >= DetectionDis) // 移动距离超过阈值才处理
{
bool isUp = moveDis > 0;
if (isUp) // 向上滑动(看新消息)
{
// 如果上方缓存过多 → 回收顶部 Item
if (mFlagItems[1].ItemIndex - mFlagItems[0].ItemIndex > CacheMsgPrefabNum)
{
var firstItem = mItemList[0];
mItemList.RemoveAt(0);
RecycleItemTmp(firstItem);
}
// 如果下方可见区域即将空白 → 加载新的底部 Item
if (mFlagItems[3].ItemIndex - mFlagItems[2].ItemIndex < CacheMsgPrefabNum)
{
var item = LoadOneItemAtLast(mFlagItems[3].ItemIndex + 1, ...);
mFlagItems[3] = item;
}
}
else // 向下滑动(看旧消息)
{
// 对称逻辑:回收底部,加载顶部
}
svLastPos = mScrollView.transform.localPosition.y;
}
}
DetectionDis (默认 0.1)控制灵敏度,值越小判定越精确但每帧计算次数越多,是性能与精度的权衡参数。
4.4 缓冲区的意义
csharp
[Header("上下不可见区域缓存消息的数量")]
public int CacheMsgPrefabNum = 4;
CacheMsgPrefabNum = 4 意味着视野上下方各多保留 4 个 Item 不回收。这样在快速滑动时,新 Item 能够无缝衔接,不会出现"白屏再出现"的视觉断层。
4.5 首次加载策略
csharp
void FirstLoadItems()
{
// 从第一条消息开始加载,直到铺满一屏幕 + CacheMsgPrefabNum
for (int i = 0; i < mChatMsgs.Count; i++)
{
LoadOneItemAtLast(i, i);
// 检查是否已超出视口高度
if (mCurrentLastPosY < -ViewPortHeight)
break;
}
// 如果所有消息都不够一屏,关闭循环滚动
if (mCurrentLastPosY >= -ViewPortHeight)
mLoopScroll = false;
}
如果消息总数很少(不够一屏幕),自动关闭循环滚动,避免不必要的判定开销。
五、组件与数据的解耦:回调工厂模式
5.1 OnGetItemByIndex 委托
csharp
System.Func<ChatPanel, int, MsgBaseItem> mOnGetItemByIndex;
ChatPanel 不关心消息数据长什么样,也不关心 Item 是哪种类型。当它需要某个索引的 Item 时,调用这个委托去获取:
csharp
MsgBaseItem GetNewItemByIndex(int index)
{
var newItem = mOnGetItemByIndex(this, index);
newItem.ItemIndex = index;
return newItem;
}
5.2 ChatDemo 中的实现
csharp
public class ChatDemo : MonoBehaviour
{
public ChatPanel mChatPanel;
void Start()
{
mChatPanel.InitListView(ChatMsgDataMgr.Instance.ChatMsgList, OnGetItemByIndex);
}
MsgBaseItem OnGetItemByIndex(ChatPanel listView, int index)
{
var itemData = ChatMsgDataMgr.Instance.GetChatMsgByIndex(index);
string prefabName = "";
switch(itemData.msgType)
{
case protocol.MsgType.Audio:
prefabName = (itemData.senderType == Self) ? "RightVoiceItem" : "LeftVoiceItem";
break;
case protocol.MsgType.JoinOrLeave:
case protocol.MsgType.Time:
prefabName = "TipItem";
break;
case protocol.MsgType.Text:
case protocol.MsgType.Image:
prefabName = (itemData.senderType == Self) ? "RightItem" : "LeftItem";
break;
}
var item = mChatPanel.NewMsgItem(prefabName);
item.FillItem(itemData, index);
return item;
}
}
设计价值:
- ChatPanel 完全不知道
ChatMessage数据结构,只知道有一个索引 - ChatDemo 完全不知道对象池和滚动的存在,只知道根据数据类型返回正确 Prefab
- 添加新消息类型只需要在 switch 中添加 case → 不需要修改 ChatPanel 一行代码
六、水平对比总结
| 对比维度 | 传统实现 | 本系统实现 | 效率提升 |
|---|---|---|---|
| 消息结构 | 每种消息独立网络包 | 统一 ChatMessage + 可选子消息 | 减少重复序列化代码,proto 带宽小 |
| Item 创建 | 每消息一个 Prefab,Instantiate | 对象池复用,两级缓存 | 消除 GC Alloc,帧率稳定 |
| 列表渲染 | 全部生成 GameObject | 虚拟滚动,只渲染可见 ± 缓存 | 万条消息内存恒定 |
| 组件复用 | 文本/图片分两个 Prefab | 一个 PTMsgItem 处理两种 | Prefab 数量减半 |
| 代码耦合 | ListView 持有数据类型 | 委托回调解耦 | 添加消息类型不修改引擎 |
| 高度计算 | 固定高度 | 动态计算 + RePosition | 支持翻译展开、图文混排 |
七、架构总览图
┌────────────────────────────────────────────────────────────┐
│ ChatDemo │
│ (入口:初始化 ChatPanel,实现 OnGetItemByIndex 回调) │
└──────────┬─────────────────────────────────┬───────────────┘
│ InitListView(callback) │ OnGetItemByIndex
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ ChatPanel │ │ ChatMsgDataMgr │
│ (Loop Scroll 引擎) │ │ (Singleton,数据管理) │
│ │ │ │
│ ┌────────────────┐ │ │ 持有 List<ChatMessage> │
│ │ ItemPool Dict │ │ │ 持有 Dictionary<id, User> │
│ │ │ │ └──────────────────────────────┘
│ │ "RightItem" → │ │
│ │ "LeftItem" → │ │ ┌──────────────────────────────┐
│ │ "TipItem" → │ │ │ IMMsg (protobuf) │
│ │ "RightVoice" → │ │ │ │
│ │ "LeftVoice" → │ │ │ ChatMessage, User, VoiceMsg,│
│ └────────────────┘ │ │ PicMsg, SiteMsg, MsgType │
│ │ └──────────────────────────────┘
│ mItemList: 可见队列 │
│ mFlagItems[0..3]: │ ┌──────────────────────────────┐
│ 四标志位追踪器 │ │ MsgBaseItem │
└──────────────────────┘ │ (基类) │
│ ↑ 继承 ↑ ↑ 继承 ↑ │
│ PTMsgItem TipMsgItem │
│ (文本+图片) (时间+提示) │
│ VoiceMsgItem │
│ (语音) │
└──────────────────────────────┘
八、关键启发
- 协议先行:使用 protobuf 定义统一的消息结构,optional 字段使得多类型消息共享带宽
- 继承复用 :
MsgBaseItem定义了所有 Item 的共同契约(尺寸、索引、填充、重置),子类只需差异逻辑 - 一个 Prefab 多种形态 :
PTMsgItem通过激活/禁用子控件,同时满足文本和图片两种消息 - 双缓冲对象池:一级临时 + 二级就绪,既支持批量清理又保证取用速度
- 虚拟滚动四标志位:精确追踪视图边界,用最少的 Item 实例覆盖无限的聊天数据
- 委托解耦:ChatPanel 不依赖具体数据类型,后续接入新协议或本地消息历史零改动