Unity 极致高效的IM设计方案教程

Unity 极致高效的IM设计方案教程

Unity 高效的 IM(即时通讯)设计方案原理教程

概述

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


UGUI的版本也有实现一模一样的效果,这里就不赘述,原理是一样的。


一、消息协议层:基于 Protocol Buffers 的多类型定义

1.1 Proto 文件定义

位置:Protocol/IMMsg.proto

系统使用 protobuf-net 将.proto文件编译为 C# 类。消息类型通过枚举MsgType严格区分:

enum MsgType { Text = 1; // 文本消息 Image = 2; // 图片消息 Audio = 3; // 语音消息 Time = 4; // 时间提示消息 JoinOrLeave = 5; // 加入/离开提示 World_Share = 6; // 世界坐标分享 UserData = 7; // 用户数据 }

1.2 ChatMessage 的统一结构

ChatMessage是核心消息体,采用optional 字段 + 多类型承载的方式,将多种消息类型统一为一个数据结构:

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 必须拥有的属性和行为:

publicclassMsgBaseItem:MonoBehaviour{[RequireComponent(typeof(UIWidget))]// === 布局属性 ===publicfloatHeight{get;set;}// 当前 Item 高度publicfloatStartPosY{get;set;}// 当前 Item Y 坐标publicstringItemPrefabName{get;set;}// 对应的 Prefab 名称(用于对象池查找)// === 索引体系 ===publicintItemId{get;set;}// 全局唯一 ID(由对象池递增分配)publicintItemIndex{get;set;}// 在数据列表中的索引publicintViewIndex{get;set;}// 在 ScrollView 可见队列中的索引// === 虚方法 ===publicvirtualvoidAwake()// 获取 UIWidget 组件引用publicvirtualvoidSetItemSize()// 根据内容计算并设置尺寸publicvirtualvoidFillItem(protocol.ChatMessagemsgData,intindex)// 填充数据publicvirtualvoidReset()// 重置到初始状态(回收时调用)}

设计亮点

  • 所有 Item 强制要求UIWidget组件(通过RequireComponent),这是 ChatPanel 统一计算布局的前提
  • ItemPrefabName关联对象池,使得不同类型 Item 可以被统一管理
  • 三维索引(ItemId+ItemIndex+ViewIndex)分别服务于对象池追踪、数据定位、视图排序,职责分明

2.3 PTMsgItem:图文消息组件

位置:PTMsgItem.cs

处理文本(Text)图片(Image)两种消息类型。内部持有以下 UI 引用:

publicUISpriteheadIcon;// 头像publicUILabellblName;// 发送者名称publicUILabellblMessage;// 消息文本publicUISpritebg;// 气泡背景publicUISpriteline;// 翻译分隔线publicUIButtonbtnTranslate;// 翻译按钮publicUITexturetetPic;// 图片publicUISpritetranslated;// "已翻译"标记

复用技巧:同一个 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)两种系统提示消息。逻辑极简:

publicoverridevoidFillItem(ChatMessagemsgData,intindex){if(msgData.msgType==MsgType.JoinOrLeave)lblMsaage.text=msgData.msg;elseif(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)消息。特色在于气泡宽度随语音时长动态变化:

publicoverridevoidFillItem(ChatMessagemsgData,intindex){lblTime.text=string.Format("{0}''",msgData.voiceMsg.duration);intduration=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 两级缓存设计

publicclassItemPool{List<MsgBaseItem>mTmpPooledItemList=newList<MsgBaseItem>();// 一级缓存(临时)List<MsgBaseItem>mPooledItemList=newList<MsgBaseItem>();// 二级缓存(就绪)}

工作流程

GetItem() RecycleItem() 用户 ──────────→ ┌─────────┐ 滚动出视野 ──────────→ ┌──────────┐ │ 一级缓存 │ │ 一级缓存 │ │ (Temp) │ │ (Temp) │ └────┬────┘ └────┬─────┘ │ 为空时 fallback │ ClearTmpRecycledItem() ↓ ↓ ┌──────────┐ ┌──────────┐ │ 二级缓存 │ │ 二级缓存 │ │ (Ready) │ │ (Ready) │ └────┬─────┘ └──────────┘ │ 为空时创建新实例 ↓ GameObject.Instantiate
  • 一级缓存(Temp):Item 刚被滚动移出视野时放入此处,下一帧被ClearAllTmpRecycledItem()转移到二级缓存
  • 二级缓存(Ready):完全回收就绪的 Item,可立即被GetItem()取出复用

这种设计避免了频繁的Instantiate/Destroy所有 Item 实例在整个生命周期中只创建一次,反复循环使用

3.3 预创建机制

publicclassItemPrefabConfData{publicGameObjectmItemPrefab=null;publicintmInitCreateCount=0;// 初始预创建数量}

初始化时,每种 Item 按mInitCreateCount预先创建 N 个实例并直接回收到池中。这样用户首次滑动时,Item 直接从池中取出,零延迟

3.4 池的生命周期管理

// 初始化pool.Init(data.mItemPrefab,data.mInitCreateCount,mContainerTrans);// 取出(从池或新建)MsgBaseItemitem=pool.GetItem();// 回收(回到一级缓存)pool.RecycleItem(item);// 清理一级缓存(转移到二级缓存)pool.ClearTmpRecycledItem();// 销毁所有pool.DestroyAllItem();

3.5 ChatPanel 中的池管理

// 按 Prefab 名称索引的池字典Dictionary<string,ItemPool>mItemPoolDict=newDictionary<string,ItemPool>();publicMsgBaseItemNewMsgItem(stringitemPrefabName){ItemPoolpool=mItemPoolDict[itemPrefabName];varitem=pool.GetItem();item.Reset();// 重置到初始状态item.ParentView=this;// 关联父视图returnitem;}

NewMsgItem是 ChatPanel 对外暴露的唯一获取 Item 的方法。上层(ChatDemo)只需传入 Prefab 名称,不需要关心池的内部逻辑。


四、虚拟滚动(Loop Scroll)引擎:万条消息不卡顿

位置:ChatPanel.cs 的OnPanelLoopClipMove方法

4.1 核心思想

不将全部消息生成为 GameObject,而是只生成视野可见区域 + 前后缓冲区中的 Item。滚动时,移出视野的 Item 被回收,新进入视野的消息复用池中 Item。不管数据列表有多少条(100 还是 10000),场景中同时存在的 Item 数量基本恒定

4.2 四标志位追踪

系统使用 4 个MsgBaseItem引用追踪视图状态:

// {最上面的prefab, 视野范围内最上面prefab, 视野范围内最下面prefab, 最下面prefab}MsgBaseItem[]mFlagItems=newMsgBaseItem[]{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 滑动检测与回收策略

voidOnPanelLoopClipMove(UIPanelpanel){floatmoveDis=mScrollView.transform.localPosition.y-svLastPos;if(Mathf.Abs(moveDis)>=DetectionDis)// 移动距离超过阈值才处理{boolisUp=moveDis>0;if(isUp)// 向上滑动(看新消息){// 如果上方缓存过多 → 回收顶部 Itemif(mFlagItems[1].ItemIndex-mFlagItems[0].ItemIndex>CacheMsgPrefabNum){varfirstItem=mItemList[0];mItemList.RemoveAt(0);RecycleItemTmp(firstItem);}// 如果下方可见区域即将空白 → 加载新的底部 Itemif(mFlagItems[3].ItemIndex-mFlagItems[2].ItemIndex<CacheMsgPrefabNum){varitem=LoadOneItemAtLast(mFlagItems[3].ItemIndex+1,...);mFlagItems[3]=item;}}else// 向下滑动(看旧消息){// 对称逻辑:回收底部,加载顶部}svLastPos=mScrollView.transform.localPosition.y;}}

DetectionDis(默认 0.1)控制灵敏度,值越小判定越精确但每帧计算次数越多,是性能与精度的权衡参数

4.4 缓冲区的意义

[Header("上下不可见区域缓存消息的数量")]publicintCacheMsgPrefabNum=4;

CacheMsgPrefabNum = 4意味着视野上下方各多保留 4 个 Item 不回收。这样在快速滑动时,新 Item 能够无缝衔接,不会出现"白屏再出现"的视觉断层。

4.5 首次加载策略

voidFirstLoadItems(){// 从第一条消息开始加载,直到铺满一屏幕 + CacheMsgPrefabNumfor(inti=0;i<mChatMsgs.Count;i++){LoadOneItemAtLast(i,i);// 检查是否已超出视口高度if(mCurrentLastPosY<-ViewPortHeight)break;}// 如果所有消息都不够一屏,关闭循环滚动if(mCurrentLastPosY>=-ViewPortHeight)mLoopScroll=false;}

如果消息总数很少(不够一屏幕),自动关闭循环滚动,避免不必要的判定开销。


五、组件与数据的解耦:回调工厂模式

5.1 OnGetItemByIndex 委托

System.Func<ChatPanel,int,MsgBaseItem>mOnGetItemByIndex;

ChatPanel 不关心消息数据长什么样,也不关心 Item 是哪种类型。当它需要某个索引的 Item 时,调用这个委托去获取:

MsgBaseItemGetNewItemByIndex(intindex){varnewItem=mOnGetItemByIndex(this,index);newItem.ItemIndex=index;returnnewItem;}

5.2 ChatDemo 中的实现

publicclassChatDemo:MonoBehaviour{publicChatPanelmChatPanel;voidStart(){mChatPanel.InitListView(ChatMsgDataMgr.Instance.ChatMsgList,OnGetItemByIndex);}MsgBaseItemOnGetItemByIndex(ChatPanellistView,intindex){varitemData=ChatMsgDataMgr.Instance.GetChatMsgByIndex(index);stringprefabName="";switch(itemData.msgType){caseprotocol.MsgType.Audio:prefabName=(itemData.senderType==Self)?"RightVoiceItem":"LeftVoiceItem";break;caseprotocol.MsgType.JoinOrLeave:caseprotocol.MsgType.Time:prefabName="TipItem";break;caseprotocol.MsgType.Text:caseprotocol.MsgType.Image:prefabName=(itemData.senderType==Self)?"RightItem":"LeftItem";break;}varitem=mChatPanel.NewMsgItem(prefabName);item.FillItem(itemData,index);returnitem;}}

设计价值

  • 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 │ │ (语音) │ └──────────────────────────────┘

八、关键启发

  1. 协议先行:使用 protobuf 定义统一的消息结构,optional 字段使得多类型消息共享带宽
  2. 继承复用MsgBaseItem定义了所有 Item 的共同契约(尺寸、索引、填充、重置),子类只需差异逻辑
  3. 一个 Prefab 多种形态PTMsgItem通过激活/禁用子控件,同时满足文本和图片两种消息
  4. 双缓冲对象池:一级临时 + 二级就绪,既支持批量清理又保证取用速度
  5. 虚拟滚动四标志位:精确追踪视图边界,用最少的 Item 实例覆盖无限的聊天数据
  6. 委托解耦:ChatPanel 不依赖具体数据类型,后续接入新协议或本地消息历史零改动