ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity UGUI聊天气泡自适应:自定义ContentSizeFitter实现宽度限制与自动换行

Unity UGUI聊天气泡自适应:自定义ContentSizeFitter实现宽度限制与自动换行 做聊天界面 UGUI 的人基本都懂聊天气泡这玩意儿看着简单真要调好自适应比想象中麻烦得多。文本长度不固定、既要贴合内容宽度、又要在超过某个宽度后自动换行Unity 自带的 ContentSizeFitter 很难一次搞定。更难受的是它压根没有“最小/最大尺寸限制”这种概念气泡很容易在长文本下无限撑开或者在字数很少时缩成一条细线。这篇文章我就把自定义 ContentSizeFitter 的思路和完整实现拆开讲清楚重点解决两个问题一是给尺寸加上最小值和最大值二是让聊天气泡文本在达到指定宽度时自动换行。适合正在做聊天、弹幕、评论、消息中心这类 UGUI 界面的同学参考。我不是第一次被 ContentSizeFitter 坑。早期项目里为了偷懒直接在气泡节点上挂一个原生 ContentSizeFitterText 设成自动换行结果跑起来问题一堆英文长单词把气泡撑出屏幕中文标点换行位置奇怪冒泡高度忽高忽低。后来我去翻 UGUI 的源码才意识到 ContentSizeFitter 本质上只是“把子物体期望的尺寸抄到自己的 sizeDelta 上”它对最小和最大尺寸完全没有约束也不理解“文本换行后高度要重新算”这件事。所以与其继续在那个黑盒外面打补丁不如自己写一个能控制边界的 fitter。1. 聊天气泡自适应的最大坑原生 ContentSizeFitter 为什么不够用1.1 我遇到的聊天文本宽度失控现场先还原一个最常见的场景。你搭了一个气泡结构大概是“气泡背景图 子文本”然后给气泡挂上 ContentSizeFitterHorizontal Fit 和 Vertical Fit 都选 PreferredSize。短文本“在吗”显示正常气泡刚好包住文字。可一旦来了一百多字的回复麻烦就来了气泡宽度跟着文本一直往右涨直到冲出屏幕边界根本不换行。为什么会这样因为 UGUI 的 Text 在计算 preferredWidth 时默认是“不考虑换行”的。也就是说哪怕你把 Horizontal Overflow 设成了 WrapText 的 preferredWidth 依然会把整段文字按单行宽度算出来。原生 ContentSizeFitter 拿到这个巨大的 preferredWidth只能老老实实把尺寸设置过去于是气泡就成了一个超长条形。你以为自己在用 Wrap实际内容却在被 preferredWidth 绑架。1.2 UGUI 布局系统里“宽高互相拉扯”的问题更隐蔽的是宽高耦合。聊天气泡理想的工作方式应该是先算文本单行需要多宽把它限制在一个最大宽度内如果单行宽度超过上限就把气泡固定为最大宽度让文本换行换行后高度会增加气泡高度跟着重新计算。这里宽度决定高度高度又依赖宽度是个典型的“鸡生蛋”问题。原生 ContentSizeFitter 在布局管线里做的事情非常直接SetLayoutHorizontal 阶段设置宽度SetLayoutVertical 阶段设置高度。听起来顺序没问题但它完全没有考虑“宽度被 clamp 之后子 Text 需要重新计算 preferredHeight”这件事。你手动把气泡宽度压下来Text 的 preferredHeight 却还是按旧宽度算出来的最终高度要么不够、文字被裁剪要么多出一大截空白。如果你再观察得细一点会发现原生组件也不给你设 minWidth 和 maxWidth 的入口哪怕你想做规则约束也没地方写。所以解决方案只能是自己实现一套符合气泡场景的布局控制逻辑。2. 自定义 ContentSizeFitter核心原理和设计取舍2.1 布局三件套ILayoutElement / ILayoutController / LayoutRebuilder要自己写 fitter至少得先知道 UGUI 布局管线的三个关键角色。ILayoutElement 是“报告自己需要多大”的接口Text、LayoutGroup、LayoutElement 都实现了它。它提供 minWidth、preferredWidth、flexibleWidth 这些属性供父级布局计算尺寸。ILayoutController 是“拿到空间后自己怎么摆”的接口包含 SetLayoutHorizontal 和 SetLayoutVertical 两个方法。原生 ContentSizeFitter 就实现了这个接口它会在布局计算时被回调然后把 RectTransform 的尺寸改掉。LayoutRebuilder 则是布局重建的调度器负责在合适的时机调用所有相关节点的 ILayoutController。当你调用 LayoutRebuilder.MarkLayoutForRebuild(rectTransform) 时它会从该节点向上找到根布局节点执行一次完整的布局刷新。所以自研 fitter 的核心套路并不复杂实现 ILayoutController在 SetLayoutHorizontal 里算宽度并设置给自己在 SetLayoutVertical 里算高度并设置给自己。区别在于我们可以在设置前后加上最小值和最大值的 clamp。2.2 为什么用 maxWidth 做限制后文本就会自动换行这里值得多说一句因为很多人对“指定宽度自动换行”的原理有误解。Text 在渲染时如果 Horizontal Overflow 是 Wrap那么它内部会把传入的绘制区域宽度当成换行的边界。当文本宽度超出区域边界时TextGenerator 就断行并把超出部分放到下一行。所以要让气泡自动换行关键是保证 Text 实际渲染区域的宽度等于你想要的 maxWidth而不是把 Text 的 preferredWidth 改小。这就解释了为什么很多人试过给 Text 设置 maxWidth其实没有这个属性或者修改 RectTransform 都没用。你得保证布局系统最终把气泡宽度约束到 maxWidth并且子 Text 的 RectTransform 跟随气泡宽度变化文本才会按新宽度重新换行。2.3 宽高处理的顺序到底怎么安排我刚才提到的“宽高互相拉扯”在处理顺序上必须谨慎。正确顺序是先计算宽度把宽度 clamp 到 [minWidth, maxWidth]设置到 RectTransform 上等子 Text 的渲染宽度更新完毕再计算 preferredHeight把高度 clamp 到 [minHeight, maxHeight]设置到 RectTransform 上。这样气泡的宽度和高度是一先一后、有依赖关系的不是同时算出来的。但在 UGUI 的布局管线里SetLayoutHorizontal 和 SetLayoutVertical 是两个独立阶段中间并不会自动“等一等”子元素重新计算。所以我会在代码里加一个延迟重建标记当宽度或高度被实际修改时先记录下来等到 LateUpdate 再触发一次 LayoutRebuilder.MarkLayoutForRebuild。这样看起来多一帧刷新但能避免 SetLayoutVertical 里直接用旧宽度算高度。有人会问晚一帧会不会闪一下实测下来对聊天消息这种低频更新的界面用户几乎感知不到。而且如果不加这个保护复杂列表里很容易出现布局抖动甚至循环重建那才是真的灾难。这个取舍我后面在踩坑部分还会细说。3. 支持最小/最大尺寸限制的完整实现代码3.1 BubbleContentSizeFitter 完整脚本下面的脚本是完整实现直接挂到气泡根节点即可。它继承 UIBehaviour 并实现 ILayoutController核心逻辑就是我们在前面分析的横向上计算 preferredWidth 并 clamp纵向上计算 preferredHeight 并 clamp。using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; [RequireComponent(typeof(RectTransform))] public class BubbleContentSizeFitter : UIBehaviour, ILayoutController { public enum FitMode { Unconstrained, MinSize, PreferredSize } [SerializeField] private FitMode m_HorizontalFit FitMode.PreferredSize; [SerializeField] private FitMode m_VerticalFit FitMode.PreferredSize; [SerializeField] private float m_MinWidth 32f; [SerializeField] private float m_MaxWidth 260f; [SerializeField] private float m_MinHeight 24f; [SerializeField] private float m_MaxHeight 0f; private RectTransform m_RectTransform; private bool m_NeedRebuild; public FitMode horizontalFit { get m_HorizontalFit; set { if (m_HorizontalFit value) return; m_HorizontalFit value; MarkForRebuild(); } } public FitMode verticalFit { get m_VerticalFit; set { if (m_VerticalFit value) return; m_VerticalFit value; MarkForRebuild(); } } public float minWidth { get m_MinWidth; set m_MinWidth value; } public float maxWidth { get m_MaxWidth; set m_MaxWidth value; } public float minHeight { get m_MinHeight; set m_MinHeight value; } public float maxHeight { get m_MaxHeight; set m_MaxHeight value; } protected override void OnEnable() { base.OnEnable(); m_RectTransform GetComponentRectTransform(); m_NeedRebuild false; LayoutRebuilder.MarkLayoutForRebuild(m_RectTransform); } protected override void OnDisable() { m_NeedRebuild false; LayoutRebuilder.MarkLayoutForRebuild(m_RectTransform); base.OnDisable(); } private void LateUpdate() { if (!m_NeedRebuild) return; m_NeedRebuild false; if (isActiveAndEnabled) LayoutRebuilder.MarkLayoutForRebuild(m_RectTransform); } public virtual void SetLayoutHorizontal() { if (m_HorizontalFit FitMode.Unconstrained) return; EnsureRectTransform(); float desiredWidth LayoutUtility.GetPreferredWidth(m_RectTransform); float targetWidth ClampAxis(desiredWidth, m_MinWidth, m_MaxWidth); if (!Mathf.Approximately(m_RectTransform.rect.width, targetWidth)) { m_RectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, targetWidth); m_NeedRebuild true; } } public virtual void SetLayoutVertical() { if (m_VerticalFit FitMode.Unconstrained) return; EnsureRectTransform(); float desiredHeight LayoutUtility.GetPreferredHeight(m_RectTransform); float targetHeight ClampAxis(desiredHeight, m_MinHeight, m_MaxHeight); if (!Mathf.Approximately(m_RectTransform.rect.height, targetHeight)) { m_RectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, targetHeight); m_NeedRebuild true; } } private static float ClampAxis(float value, float min, float max) { if (max 0f) value Mathf.Min(value, max); value Mathf.Max(value, min); return value; } private void EnsureRectTransform() { if (m_RectTransform null) m_RectTransform GetComponentRectTransform(); } private void MarkForRebuild() { if (!isActiveAndEnabled) return; if (m_RectTransform null) m_RectTransform GetComponentRectTransform(); LayoutRebuilder.MarkLayoutForRebuild(m_RectTransform); } #if UNITY_EDITOR protected override void OnValidate() { base.OnValidate(); EnsureRectTransform(); if (isActiveAndEnabled) LayoutRebuilder.MarkLayoutForRebuild(m_RectTransform); } #endif }要注意一点m_MaxWidth 和 m_MaxHeight 如果填 0我会把它当成“不限上限”来处理所以 ClampAxis 里只有 max 大于 0 时才做最大值限制。这也是为什么默认值里 MaxHeight 是 0因为聊天气泡高度通常不需要上限但宽度一定要给个 cap。3.2 关键方法逐个拆解SetLayoutHorizontal 做的事是通过 LayoutUtility.GetPreferredWidth(m_RectTransform) 拿到所有子物体中最大的 preferredWidth。这个函数的实现是遍历当前 RectTransform 下的子物体取所有 ILayoutElement 的 preferredWidth 最大值。所以你不需要手动去找到某个 Text只要气泡内只有一个主要文本拿到的就是文本的单行宽度。拿到 desiredWidth 后用 ClampAxis 夹到 min/max 范围内再通过 SetSizeWithCurrentAnchors 设置矩形尺寸。这里特意用了 SetSizeWithCurrentAnchors而不是直接改 sizeDelta因为它会正确处理不同锚点情况下的大小计算。如果锚点是 stretch直接操作 sizeDelta 容易出幺蛾子。SetLayoutVertical 的逻辑同理。所谓“延迟重建标记” m_NeedRebuild本质上是在尺寸确实发生变化时才触发避免每帧无条件重建布局。3.3 想要被父级 LayoutGroup 正确识别再实现 ILayoutElement上面的脚本胜任“独立气泡”场景。但聊天界面经常要把气泡放进 HorizontalLayoutGroup 或者 VerticalLayoutGroup 里比如“头像 气泡”左对齐排列。这时候父级 LayoutGroup 会给每个子物体分配空间而它分配空间依据的是子物体上的 ILayoutElement。可惜 BubbleContentSizeFitter 没有实现 ILayoutElement父级布局组根本不知道它“想要多大”大概率会把气泡拉宽撑满整行。解决方法有两个。第一个最简单在父级 LayoutGroup 上关掉 childControlWidth甚至 childForceExpandWidth让气泡自己控制宽度。第二个更通用让气泡节点额外实现 ILayoutElement把 clamp 后的期望宽度和高度上报给父级。我实际项目中更倾向于第二种因为消息列表里常常还要混排头像和状态标签完全关掉 childControlWidth 会让其他子物体也跟着失控。你可以把这段逻辑单独拆成一个辅助脚本挂到同一个节点上using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(RectTransform))] public class BubbleLayoutElement : UIBehaviour, ILayoutElement { [SerializeField] private float m_MinWidth 32f; [SerializeField] private float m_MaxWidth 260f; [SerializeField] private float m_MinHeight 24f; [SerializeField] private float m_MaxHeight 0f; private RectTransform m_RectTransform; private float m_CachedPreferredWidth -1f; private float m_CachedPreferredHeight -1f; public float minWidth m_MinWidth; public float preferredWidth { get { if (m_CachedPreferredWidth 0f) ComputeWidth(); float max m_MaxWidth 0f ? m_MaxWidth : float.MaxValue; return Mathf.Clamp(m_CachedPreferredWidth, m_MinWidth, max); } } public float flexibleWidth -1f; public float minHeight m_MinHeight; public float preferredHeight { get { if (m_CachedPreferredHeight 0f) ComputeHeight(); float max m_MaxHeight 0f ? m_MaxHeight : float.MaxValue; return Mathf.Clamp(m_CachedPreferredHeight, m_MinHeight, max); } } public float flexibleHeight -1f; public int layoutPriority 1; public void CalculateLayoutInputHorizontal() { ComputeWidth(); } public void CalculateLayoutInputVertical() { ComputeHeight(); } private void ComputeWidth() { EnsureRectTransform(); m_CachedPreferredWidth LayoutUtility.GetPreferredWidth(m_RectTransform); } private void ComputeHeight() { EnsureRectTransform(); m_CachedPreferredHeight LayoutUtility.GetPreferredHeight(m_RectTransform); } private void EnsureRectTransform() { if (m_RectTransform null) m_RectTransform GetComponentRectTransform(); } protected override void OnRectTransformDimensionsChange() { base.OnRectTransformDimensionsChange(); m_CachedPreferredHeight -1f; } }这个脚本的核心思路是把宽度和高度缓存起来避免每次被父级查询时都重新计算一遍 Text 的 preferred 值。尤其是高度它是强依赖当前宽度的宽度一旦变化就必须失效重算所以我在 OnRectTransformDimensionsChange 里清掉了高度缓存。4. 聊天气泡自动换行的 UI 搭建实战4.1 气泡层级与 Text 组件参数设置代码写完了现在讲搭建。我的标准气泡结构是两层气泡根节点挂 Image背景九宫格、BubbleContentSizeFitter文本节点挂 TextRectTransform 锚点铺满气泡文本节点不要用“居中”之类的锚点要把 anchorMin 设为 (0, 0)anchorMax 设为 (1, 1)left/right/top/bottom 四个边距按你想要的 padding 来填。比如左边距 12、右边距 12、上边距 8、下边距 8。这样气泡尺寸一变Text 的可用渲染区域就会跟着变文本才能根据新宽度统一换行。Text 本身的参数也很关键。Horizontal Overflow 必须选 WrapVertical Overflow 我建议选 Overflow。选 Overflow 不是为了显示溢出内容而是避免 Text 在换行后被自己的 RectTransform 高度卡住渲染区域。因为我们稍后会用 Fitter 通过 preferredHeight 把气泡整体高度撑起来Text 自己不需要参与纵向尺寸调整。字体、字号、行间距按你的设计规范来但我建议优先用 Unity 自带动态字体或者 TMP。TMP 和 UGUI 的布局系统兼容性很好preferred 尺寸计算也准中文标点断行规则比普通 Text 好不少。如果项目还在维护能上 TextMeshPro 就别用老 Text。4.2 在 ScrollRect 消息列表里放置多个气泡聊天气泡几乎必然放在 ScrollRect 里。最简单的列表结构是一个 VerticalLayoutGroup 挂在 Content 上然后每个消息条目作为一个子物体。如果你每个消息条目本身就是气泡那直接在条目上挂 BubbleContentSizeFitter并给 VerticalLayoutGroup 设置 childControlWidth false、childForceExpandWidth false。否则 VerticalLayoutGroup 会把所有条目横向拉伸到和 Content 一样宽你的 maxWidth 永远触发不了。如果你做的是“头像靠左/靠右 气泡 昵称/时间”这种复杂条目建议一个条目用一个 HorizontalLayoutGroup 包起来左侧放头像固定尺寸中间放气泡右侧放可选信息。HorizontalLayoutGroup 同样要关掉 childForceExpandWidth并且气泡节点上挂一个 BubbleLayoutElement 用于上报 preferred 尺寸。这样 HorizontalLayoutGroup 才知道给气泡分配多大空间。给 VerticalLayoutGroup 的 spacing 和 padding 留够避免两条消息紧贴在一起看着压抑。接着把气泡背景图的 Sprite 设为九宫格模式Image Type 选 Sliced四个 corner 的像素要适配圆角。Sliced 模式是气泡自适应尺寸的前提不能用 Simple 或者 Filled。4.3 动态改文本后触发重排聊天界面里消息是异步进来的你一定会动态修改 Text.text。改完之后布局不会自动重算必须手动叫一次重建。using TMPro; using UnityEngine; using UnityEngine.UI; public static class BubbleTextHelper { public static void SetBubbleContent(TMP_Text text, string content) { text.text content; LayoutRebuilder.MarkLayoutForRebuild(text.rectTransform); } public static void SetBubbleContent(Text text, string content) { text.text content; LayoutRebuilder.MarkLayoutForRebuild(text.rectTransform); } }这里有一个实践心得MarkLayoutForRebuild 传 Text 自己的 RectTransform 就行它会自动向上冒泡到根布局节点并触发整条链路重建。不需要手动去找气泡根节点再 mark那是多此一举。如果你用对象池复用来聊天消息记得在消息出池时把文本设成空串并且 mark 一次否则上一条消息的尺寸可能被带到下一条消息上。5. 踩坑实录换行、抖动、无限宽这几个老问题5.1 文本死活不换行这是我被问得最多的问题。明明气泡最大宽度限制已经生效了文本还是缩在一个很窄的范围内或者直接不换行。排查顺序是这样先看 Text 的 Horizontal Overflow 是不是 Wrap。如果你用的是普通 Text默认是 Overflow它会把超出渲染区域的字硬画出来自然不换行。再看 Text 节点是不是真的被气泡宽度约束了。如果 Text 的锚点没有铺满气泡或者你手动设置了 Text 的 width气泡宽了它也不会跟着宽换行边界自然就不对。最后检查父级布局组。如果 VerticalLayoutGroup 开了 childControlWidth它会强行把每个子物体拉伸到 Content 宽度这时气泡的 maxWidth 被父级覆盖Fitter 设了也白设。记住关掉 childControlWidth 或者让气泡在父布局组中始终占据固定布局插槽。5.2 布局抖动和循环重建最经典的现象是气泡在 A 宽度和 B 宽度之间来回跳或者 Text 内容不变但每帧都在触发重建。这通常是因为宽高互相改变导致 Unity 不断 mark 布局。我们用了 LateUpdate 延迟重建就是为了降低这个概率。但你还得保证 clamp 逻辑有足够的“收敛性”。最简单的方法是在尺寸比较时用 Mathf.Approximately不要每帧都精确赋值相同数值。还有一点容易被忽略preferredHeight 对宽度的响应是离散的换行后宽度没变高度却可能增加一行。这个时候不要把 minHeight 和 maxHeight 设置得太苛刻否则高度在临界点来回抖。如果气泡在一个复杂的 LayoutGroup 里建议打开 Profiler 观察 LayoutRebuilder 的调用次数。要是发现同一帧内 rebuild 超过 3 次基本就能确定是宽高循环依赖优先检查子 Text 是否也挂了 ContentSizeFitter 或者 LayoutElement产生了多个 fitter 互相打架的情况。5.3 性能与缓存别频繁 MarkLayoutForRebuild聊天列表的文本更新频率其实挺高如果每条消息进入都触发一次从根节点开始的全量布局气泡多了之后滚动会卡。性能上我建议做三件事。第一在 BubbleLayoutElement 里缓存 preferred 值避免布局系统在同一个 pass 里反复调 LayoutUtility.GetPreferredWidth。第二在 Text 内容不变的时候不要无脑 MarkLayoutForRebuild。只有新消息、对象池复用、字体变化这三种情况才需要重建。第三如果消息量几百上千条不要只靠 ScrollRect用复用机制配合固定气泡高度模板把最大宽度相同的消息尽量缓存布局结果。对动态换行文本来说最消耗性能的就是 TextGenerator 重新生成网格能少重建就少重建。5.4 常见问题速查表现象可能原因解决方案气泡无限变宽HorizontalFit 选成了 PreferredSize 但 maxWidth 为 0设置明确的 maxWidth比如 260文本不换行Text 的 Horizontal Overflow 是 Overflow改成 Wrap气泡被父级强制拉伸VerticalLayoutGroup 或 HorizontalLayoutGroup 开了 childControlWidth关掉 childControlWidth / childForceExpandWidth高度没有随换行增加Text 的 Vertical Overflow 是 Truncate 或手动限制了高度改成 Overflow让 fitter 控制高度内容变化但气泡不变改完 text 没有触发布局重建调用 LayoutRebuilder.MarkLayoutForRebuild偶尔闪烁一帧LateUpdate 延迟重建造成可接受如果追求极致可以改成在 SetLayoutVertical 末尾立即重建但要做好防抖布局循环重建宽高互相依赖或同一节点有多个 fitter检查节点组件统一用一套尺寸约束最后再分享一个实际心得。这个自定义 ContentSizeFitter 我从第一个版本到现在大概迭代了三次最大的一次改动就是把“尺寸 clamp”和“延迟重建”分开处理。早期版本直接在 SetLayoutVertical 里 mark结果在 ScrollRect 里偶发死循环找了两天才定位到是 Text 换行后 preferredHeight 变化、又反过来触发宽度计算形成了振荡。后来换成 LateUpdate 标记重刷问题直接消失。所以我个人建议除非你的动画效果要求气泡尺寸必须同帧响应否则保留这个延迟重建机制稳定优先。另外一个很实用的技巧把最大宽度做成配置项而不是写死在组件里。不同聊天气泡可能因为内容类型不同有不同的上限比如纯文本气泡 280带图片消息 420。直接在 Inspector 上暴露一次调参就能看出效果比在代码里到处改常量高效得多。做聊天 UI 就是这样很多问题看起来是布局问题实际是组件边界没想清楚把约束说明白剩下的交给代码就行。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进