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

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

八、关键启发

  1. 协议先行:使用 protobuf 定义统一的消息结构,optional 字段使得多类型消息共享带宽
  2. 继承复用MsgBaseItem 定义了所有 Item 的共同契约(尺寸、索引、填充、重置),子类只需差异逻辑
  3. 一个 Prefab 多种形态PTMsgItem 通过激活/禁用子控件,同时满足文本和图片两种消息
  4. 双缓冲对象池:一级临时 + 二级就绪,既支持批量清理又保证取用速度
  5. 虚拟滚动四标志位:精确追踪视图边界,用最少的 Item 实例覆盖无限的聊天数据
  6. 委托解耦:ChatPanel 不依赖具体数据类型,后续接入新协议或本地消息历史零改动
相关推荐
unityのkiven2 小时前
Unity UGUI 中 EventTrigger 导致 ScrollRect 鼠标滚轮失效的问题分析与解决
unity·计算机外设·游戏引擎
SelectDB6 小时前
快手从 ClickHouse 到 Apache Doris 的百 PB 数据、200+集群迁移实践
数据库·性能优化·开源
丁小未7 小时前
Unity动态图集方案
unity·游戏引擎
元气少女小圆丶9 小时前
停止单机,开始学一点后台通信吧
unity
Ashley的成长之路10 小时前
前端性能优化实战手册·第1篇:从 Lighthouse 60 到 95 的完整路径
前端·性能优化
丁小未10 小时前
Unity高效的DOTS动画方案Rukhanka深度剖析
unity·游戏引擎·rukhanka·dots动画·gpu动画·gpuecsanimation
unityのkiven13 小时前
Unity 中的 Mesh 和 Renderer 详解:从模型数据到画面显示
unity·游戏引擎