ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于QFramework的文字飘动模块设计与对象池优化实践

基于QFramework的文字飘动模块设计与对象池优化实践 最近做类幸存者游戏到了第 041 步正好处理到一个很影响手感的功能文字飘动。先直接说结论文字飘动就是游戏里那些“伤害数字”“金币 1”“经验 5”的浮动文本。类幸存者这类游戏里怪物密度高、战斗频率高飘字触发非常频繁如果每个飘字都直接 Instantiate/Destroy性能会很快出问题。这次我基于 QFramework 的事件驱动和对象池思路把文字飘动做成了一个可复用模块后续所有 UI 反馈都能走同一个入口。本文会讲清楚这个模块怎么从零搭建、如何用 QFramework 的 System 和事件解耦、如何用对象池管理高频生成与回收以及实际运行中要观察哪些性能指标。如果你正在用 QFramework 做类幸存者或者类似的高频战斗场景这篇文章可以直接收藏。1. 文字飘动在类幸存者游戏中的定位文字飘动Floating Text在类幸存者游戏里几乎是标配常见使用场景包括普通伤害数字每次攻击命中后在怪物头顶显示伤害值。暴击数字放大、变色、加粗用于反馈高光时刻。治疗数字角色回血时显示绿色数字。金币/经验拾取拾取物进入角色后在角色头顶或 HUD 区域显示“10”。技能冷却提示“冷却完成”“可以释放”等文字反馈。升级提示升级时弹出一组带动画的 UI 文字。这些效果的共同特点是触发频率高、生命周期短、视觉位置跟随目标移动。如果没有统一的模块管理很容易出现以下问题飘字代码散落在各个战斗脚本里每个脚本各自创建文本对象。频繁 new GameObject 和 Destroy产生大量 GC 开销。Canvas 上的动态文本频繁打断 UI 批处理Draw Call 上涨。文字位置、颜色、动画效果不统一视觉质量参差不齐。所以文字飘动不是“写个小协程动一下”那么简单。它需要有一个统一入口、统一管理回收、统一控制表现的组件层。用 QFramework 来组织的价值就在这系统层负责接收请求对象池负责复用对象视图层只管播放动画和回收入池。2. 核心能力速览能力项说明项目类型Unity 游戏开发框架模块基于 QFramework适用引擎Unity 2021 LTS 或更高版本低版本需单独验证框架依赖QFramework 框架TMPTextMeshPro或 UGUI Text主要功能伤害数字、拾取反馈、技能提示等浮动文字展示启动方式在 QFramework 架构中注册 System通过事件触发是否支持批量支持配合对象池可高频生成与回收是否支持对象池支持自定义 Pool 管理避免反复 Instantiate/Destroy渲染方式UGUI Canvas 或 TMPUI 层表现扩展方式事件、命令、System 分层接入适合场景类幸存者、动作游戏、RPG、策略游戏等高频战斗反馈注意不同 Unity 版本的 UGUI 和 TextMeshPro 包版本有差异实际使用以工程当前引用的包为准。QFramework 的版本也需要和项目当前路径保持一致建议用 Package Manager 或源码目录导入。3. 环境准备与工程前置在开始写代码前先把工程基础准备好。这套方案已经在类幸存者项目里跑通下面的内容按 QFramework 常规接入流程整理。3.1 Unity 工程准备建议使用 Unity 2021 LTS 或更高版本。原因TMP 在 2021 LTS 里已经作为默认包可选导入不需要额外处理资源引用。新版 UGUI 对 Canvas 批处理的表现更稳定。后续如果要接 DOTween、输入系统等兼容性更好。版本不强制但至少要有 Unity 2019.4 以上否则部分 API 需要替换。3.2 导入 QFrameworkQFramework 的导入方式很灵活常见有四种通过 Unity Package Manager 添加 Git URL直接下载源码放到 Assets 目录从 Asset Store 导入使用已有的工程模板如果使用 Git URL 方式在 Package Manager 中添加地址时按你的网络环境选择可用源通常格式如下https://github.com/liangxiegame/QFramework.git?pathAssets/QFramework导入完成后建议先在空场景里写一个最小脚本验证框架是否可用例如using QFramework; using UnityEngine; public class QFrameworkSmokeTest : MonoBehaviour { private void Start() { Debug.Log(QFramework Version Check: this.GetArchitecture()); } }如果编译通过且启动不报错说明框架基本正常。3.3 导入 TextMeshPro文字飘动用 TMP 会比 UGUI Text 表现更好因为 TMP 的动态字体系统在频繁修改文本时更可控并且自带粗体、描边等效果适合做伤害数字和暴击数字。在 Unity 中通过 Window TextMeshPro Import TMP Essential Resources 导入基础资源。如果没有 TMP 资源也可以直接用 UGUI Text只是效果和性能方面相对弱一些。3.4 Canvas 层级组织建议单独建一个 FloatingTextCanvas不要把所有 UI 都塞到同一个 Canvas 里。这样做的目的是把“高频动态文本”和“低频静态 UI”分开避免飘字动画频繁触发 Canvas 重建影响其他 UI 的批处理。FloatingTextCanvas 需要配置Render Mode: Screen Space - Camera 或 Overlay根据项目 UI 方案选择。Sorting Order: 比主 UI 高保证飘字显示在面板上面。CanvasGroup: 可以不加但如果要做整体淡出预留一个也行。子节点结构一个空的 Container 节点飘字对象全部挂在这个节点下面。场景层级大致如下FloatingTextCanvas (Canvas) └── FloatingTextContainer ├── FloatingText_001 ├── FloatingText_002 └── FloatingText_003这样管理层级清晰回收和复位时也方便。4. 基于 QFramework 的文字飘动架构设计在 QFramework 里做这个模块我推荐分成三层数据层定义飘字需要的数据结构包括文本内容、颜色、字号、目标位置、是否暴击等。系统层QFramework 的 System负责监听事件、分配对象池对象、触发视图播放。视图层挂在飘字对象上的 MonoBehaviour负责播放动画、处理坐标转换、完成后回收入池。整体流程战斗逻辑 ↓ 发送 FloatingTextRequestEvent FloatingTextSystem ↓ 从对象池分配 FloatingTextView FloatingTextView ↓ 设置文本、位置、颜色、动画 ↓ 播放完毕 回收到对象池用事件而不是直接调用的核心原因解耦。战斗模块不需要知道飘字 UI 怎么实现只需要发送一个“我要显示这个数字”的事件。FloatingTextSystem 收到事件后统一处理对象分配、对象回收和视图刷新。后续如果要扩展飘字样式只改 System 和 View不影响战斗模块。5. 代码实现下面是核心代码实现代码基于 QFramework 的常用结构命名空间和类名请根据自己项目调整。我尽量保持代码可复制、可运行但需要你把 prefab 引用和对象池配置填好。5.1 定义飘字数据与事件using UnityEngine; namespace QFramework.Example { public struct FloatingTextRequestEvent { public string Content; public Vector3 WorldPosition; public Color TextColor; public int FontSize; public bool IsCritical; } }这里是事件入口。战斗逻辑只需要创建一个这样的结构体发出去不需要关心谁接收、谁来显示。Content 是显示文本可以是“-12”“10”这种带符号的字符串。WorldPosition 是目标的世界坐标因为飘字要显示在怪物头顶或角色头顶需要由 View 层转换成屏幕坐标。TextColor 和 FontSize 控制表现。IsCritical 用来区分普通伤害和暴击后续可以单独控制字体缩放、抖动、描边效果。5.2 飘字视图层飘字对象上的核心组件我写成 FloatingTextView。它挂在 prefab 根节点上使用 TextMeshProUGUI 显示文本用协程播放动画。using System.Collections; using QFramework; using TMPro; using UnityEngine; namespace QFramework.Example { public class FloatingTextView : MonoBehaviour, IPoolable { [SerializeField] private TextMeshProUGUI m_Text; [SerializeField] private CanvasGroup m_CanvasGroup; private Camera m_WorldCamera; private Camera m_UICamera; private System.ActionFloatingTextView m_OnRecycle; public void Init(Camera worldCamera, Camera uiCamera, System.ActionFloatingTextView onRecycle) { m_WorldCamera worldCamera; m_UICamera uiCamera; m_OnRecycle onRecycle; } public void Show(FloatingTextRequestEvent e) { gameObject.SetActive(true); m_Text.text e.Content; m_Text.color e.TextColor; m_Text.fontSize e.FontSize; if (e.IsCritical) { m_Text.transform.localScale Vector3.one * 1.3f; } else { m_Text.transform.localScale Vector3.one; } // 世界坐标转屏幕坐标 Vector3 screenPos RectTransformUtility.WorldToScreenPoint(m_WorldCamera, e.WorldPosition); Vector2 uiPos; RectTransformUtility.ScreenPointToLocalPointInRectangle( transform.parent as RectTransform, screenPos, m_UICamera, out uiPos ); (transform as RectTransform).anchoredPosition uiPos; StartCoroutine(PlayAnimation()); } private IEnumerator PlayAnimation() { float duration 0.8f; float elapsed 0f; Vector3 startPos (transform as RectTransform).anchoredPosition; Vector3 endPos startPos Vector3.up * 60f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); // 向上移动 渐隐 (transform as RectTransform).anchoredPosition Vector3.Lerp(startPos, endPos, t); if (m_CanvasGroup ! null) { m_CanvasGroup.alpha 1f - t; } else { var color m_Text.color; color.a 1f - t; m_Text.color color; } yield return null; } // 动画结束回收 m_OnRecycle?.Invoke(this); } public void OnRecycled() { StopAllCoroutines(); gameObject.SetActive(false); } public bool IsRecycled { get; set; } } }这段代码的关键点Show 方法接收事件数据设置文本内容和颜色。世界坐标转屏幕坐标用的是 RectTransformUtility避免每帧转换只在触发时算一次。动画用协程持续 0.8 秒向上飘 60 像素同时渐隐。播完回调 m_OnRecycle把对象返回对象池。实现 IPoolable方便接入 QFramework 的 SafeObjectPool也可以不用这个接口自己写回调。坐标转换这里要注意如果你用的是 Screen Space - OverlayUI 相机传 null 即可如果用 Screen Space - Camera则需要把 UI 相机传进来。5.3 对象池管理对象池的作用是避免高频创建销毁 GameObject。这里用一个最简单的 Queue 实现不算复杂但足够用。using System.Collections.Generic; using UnityEngine; namespace QFramework.Example { public class FloatingTextPool { private readonly FloatingTextView m_Prefab; private readonly Transform m_Parent; private readonly QueueFloatingTextView m_Cache new QueueFloatingTextView(); public FloatingTextPool(FloatingTextView prefab, Transform parent, int prewarmCount 10) { m_Prefab prefab; m_Parent parent; for (int i 0; i prewarmCount; i) { var item CreateNew(); item.gameObject.SetActive(false); m_Cache.Enqueue(item); } } public FloatingTextView Allocate() { var item m_Cache.Count 0 ? m_Cache.Dequeue() : CreateNew(); item.gameObject.SetActive(true); return item; } public void Recycle(FloatingTextView view) { view.OnRecycled(); m_Cache.Enqueue(view); } private FloatingTextView CreateNew() { var go Object.Instantiate(m_Prefab, m_Parent); return go; } } }这个实现很简单但有一个隐患预创建时如果 prefab 是 active 状态实例化出来后必须立即 SetActive(false)。再取出来时 Allocate 再 SetActive(true)。这样就不会出现“隐藏对象被误显示”的问题。如果你已经用了 QFramework 的 SafeObjectPool可以考虑换成框架自带的池SafeObjectPoolFloatingTextView.Instance.Init(m_Prefab, 10, m_Parent);但 SafeObjectPool 需要实现 IPoolable 接口并且 prefab 的实例化方式需要自己适配。如果只做文字飘动自定义 Queue 池已经够用。5.4 System 层System 是 QFramework 架构里的一个核心概念。我在 FloatingTextSystem 里注册事件监听收到请求后从池里取对象并调用 Show。using QFramework; using UnityEngine; namespace QFramework.Example { public class FloatingTextSystem : AbstractSystem { private FloatingTextPool m_Pool; private FloatingTextView m_ViewPrefab; private Transform m_Container; protected override void OnInit() { // 这里需要你在启动时传入 prefab 和 container // 一般可以在 Game 的 Init 阶段调用 InitPool this.RegisterEventFloatingTextRequestEvent(e { if (m_Pool null) { Debug.LogWarning(FloatingTextSystem Not Initialized); return; } var view m_Pool.Allocate(); view.Init(Camera.main, UICamera.Instance, m_Pool.Recycle); view.Show(e); }); } public void InitPool(FloatingTextView prefab, Transform container, int prewarmCount 10) { m_ViewPrefab prefab; m_Container container; m_Pool new FloatingTextPool(prefab, container, prewarmCount); } } }这里 UICamera.Instance 是我项目里的 UI 相机单例你的项目如果用的是 Overlay Canvas直接传 null 即可。注册事件用 this.RegisterEvent这是 QFramework 提供的扩展方法。关键在于System 只需要在 OnInit 里注册一次之后所有战斗代码只需要发事件不需要持有任何 UI 引用。5.5 Architecture 接入把 FloatingTextSystem 接入项目架构。假设项目已经有一个继承自 Architecture 的类例如 GameApp 或 GameRoot。using QFramework; namespace QFramework.Example { public class GameArchitecture : ArchitectureGameArchitecture { protected override void Init() { RegisterSystemFloatingTextSystem(new FloatingTextSystem()); } } }然后在启动场景里调用一次public class GameRoot : MonoBehaviour { [SerializeField] private FloatingTextView m_FloatingTextPrefab; [SerializeField] private Transform m_FloatingTextContainer; private void Start() { var system this.GetSystemFloatingTextSystem(); system.InitPool(m_FloatingTextPrefab, m_FloatingTextContainer, 20); } }这样启动流程就完整了GameRoot 在 Start 时初始化对象池System 注册事件监听后续任何战斗逻辑都可以发事件。6. 功能测试与效果验证模块写完下一步就是验证。这里给出一套可以直接照做的测试流程。6.1 搭建测试场景步骤新建场景添加 Main Camera。创建 FloatingTextCanvas设置 Render Mode 为 Screen Space - Overlay如果项目用 Camera 模式则拖入 UI Camera。在 FloatingTextCanvas 下创建 Container 空节点。在 Container 下创建 FloatingTextView 的 prefab挂上 FloatingTextView 脚本添加 TextMeshProUGUI 子物体设置文字为居中。把 prefab 拖到 GameRoot 的对应字段。6.2 编写测试脚本新建一个测试脚本在 Update 里按空格发送一次飘字事件using QFramework; using UnityEngine; namespace QFramework.Example { public class FloatingTextTest : MonoBehaviour { public Transform enemy; private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { this.SendEvent(new FloatingTextRequestEvent { Content -12, WorldPosition enemy.position Vector3.up * 2f, TextColor Color.white, FontSize 36, IsCritical false }); } if (Input.GetKeyDown(KeyCode.C)) { this.SendEvent(new FloatingTextRequestEvent { Content -36, WorldPosition enemy.position Vector3.up * 2f, TextColor Color.yellow, FontSize 48, IsCritical true }); } } } }注意如果要模拟多个敌人同时飘字可以循环调用 SendEventfor (int i 0; i 50; i) { var pos new Vector3( enemy.position.x Random.Range(-1f, 1f), enemy.position.y Random.Range(0f, 2f), enemy.position.z ); this.SendEvent(new FloatingTextRequestEvent { Content Random.Range(5, 20).ToString(), WorldPosition pos, TextColor Color.white, FontSize 36 }); }6.3 预期效果与成功标准按下空格后enemy 头顶出现“-12”白色文本。文本在 0.8 秒内向上飘动并逐渐变透明。动画结束后Hierarchy 中 FloatingTextContainer 下的子节点数量不会持续增长。按下 C 键出现黄色且字体更大的“-36”表示暴击分支生效。连续触发 50 个飘字后运行依然流畅没有大量 Instantiate/Destroy 日志。如果以上都满足这个模块就跑通了。6.4 常见验证失败原因文字不显示检查 Canvas 层级、Sorting Order、文字颜色是否与背景太接近。位置不对检查 WorldToScreenPoint 用的相机是否是 Main CameraUI 相机传参是否正确。对象不回收检查协程是否被异常打断或者 OnRecycled 调用时机是否提前。旋转和缩放异常检查 prefab 自身 RectTransform 的锚点设置建议锚点居中。7. 性能观察与优化文字飘动做得再好性能验证不过关到了类幸存者这种满屏怪物的场景一样会卡。这里重点看几个指标。7.1 观察 Profiler 中的 GC Alloc打开 Window Analysis Profiler选 CPU Usage 模块。在连续触发 50 个飘字事件后观察 GC Alloc 曲线。如果曲线在事件触发的瞬间出现明显峰值说明有频繁的装箱或字符串拼接。优化点字符串拼接用 StringBuilder 或者 string.Format 的固定格式。事件结构体定义避免使用接口类型导致的装箱。文本内容尽量用固定池化字符串比如伤害值可以用数字 ToString而不是拼中文。示例var content damage.ToString();如果伤害值带正负号建议先算好再 ToString不要每次 new。7.2 观察 Draw Call 和 Canvas 重建在 Profiler 中切换到 Rendering观察 Dynamic Batching 和 Canvas 重建情况。如果飘字 Canvas 和其他 UI 共用一个 Canvas那么每次飘字动画都会触发整张 UI 的脏矩形重建导致其他 UI 也跟着批量重建。优化方案就是前面说的单独 FloatingTextCanvas让漂字动态文本尽量不影响主 UI 的批处理。7.3 控制同时存在的最大飘字数类幸存者的怪物密度可能很高如果所有怪同时飘字Canvas 上飘字数量可能上百。此时建议设置对象池最大容量超出容量后强制复用最早的飘字对象。控制触发频率比如伤害数字只显示“最近一次受击”避免同一目标每秒触发十几次飘字。做 LOD远处的怪物不显示精准伤害值只显示一个“-XX”提示或完全不显示。可以在 FloatingTextSystem 里加一个简单的计数private int m_ActiveCount; private const int MaxActiveCount 30;在 Allocate 前检查if (m_ActiveCount MaxActiveCount) { return; }并对回收逻辑做计数减一。上限值根据项目实际设备性能调整。7.4 TMP 的 SetText 优化如果一段文本频繁变化直接用 m_Text.text value 可能会触发 TMP 的字符缓存重新构建。高频飘字场景下建议使用m_Text.SetText({0}, value);SetText 不会像 text 属性那样每次都生成一个新字符串GC 压力更小。尤其当飘字是纯数字时效果明显。7.5 避免协程数量过多每个飘字一个协程如果同屏 30 个飘字就是 30 个协程虽然 Unity 能承受但并不是最优解。可以考虑用统一驱动的动画管理器所有飘字由同一个 Update 驱动根据每一帧的 deltaTime 统一更新位置和透明度。这样协程数量不随飘字数增长更稳定。如果追求更简洁的实现也可以引入 DOTweenm_Text.DOFade(0f, 0.6f); m_TextRect.DOAnchorPosY(m_TextRect.anchoredPosition.y 60f, 0.8f);DOTween 内部有全局驱动性能相对可控但引入第三方库需要你自己评估依赖成本。8. 常见问题与排查方法问题现象可能原因排查方式解决方案文字完全不显示Canvas 层级遮挡、Sorting Order 低检查 FloatingTextCanvas 的 Sorting Order 是否大于主 UI调高 Sorting Order文字显示位置偏移WorldToScreenPoint 相机选错检查 Main Camera 是否场景主相机UI 相机是否正确传入统一使用 Camera.main 或缓存主相机飘字不回收Hierarchy 节点越来越多协程被提前终止、回收回调未注册打断点停在 OnRecycled 看是否被调用确保 Show 后不会重复 StartCoroutineRecycle 回调必挂暴击数字没有变大IsCritical 分支未触发检查事件里 IsCritical 是否传 true在 Show 中打日志确认缩放分支同时触发几十个飘字后卡顿Canvas 重建频繁、字符串 GC 高Profiler 看 GC Alloc 和 Canvas 重建独立 Canvas、对象池上限、TMP SetText对象池取出的对象显示上一次的内容Recycle 时没有清理文本检查 OnRecycled 是否清空文字、复位缩放和透明度OnRecycled 中 m_Text.text string.Empty、颜色恢复默认事件注册但没收到System 未初始化或事件发送过早检查 FloatingTextSystem 是否注册InitPool 是否被调用在事件发送前确保 System 初始化完成字体重叠看不清飘字频率过高、位置随机性不够查看同屏飘字数增加位置随机偏移、控制最大活跃数这块的排查思路比较通用。真正遇到问题时第一步永远是在系统入口和回收回调里加日志确认事件流是否完整走到 View 层。9. 最佳实践与扩展方向9.1 给飘字加样式预设现在的代码里普通伤害和暴击是硬编码判断的。真实项目里建议做一个 FloatingTextStyle 配置类[System.Serializable] public class FloatingTextStyle { public string styleName; public Color textColor; public int fontSize; public float floatDistance; public float duration; public bool useOutline; }在事件里加一个 styleId 字段System 收到事件后查找对应配置然后赋给 View。这样后续加“治疗”“金币”“经验”“暴击”时只需要新增配置不需要改系统逻辑。9.2 支持屏幕空间和世界空间两种模式当前实现是 Screen Space Canvas。如果后续要做世界空间头顶名字、地面伤害数字需要改成 World Space Canvas 或者使用 SpriteRenderer 方案。建议在 FloatingTextView 里加一个枚举public enum FloatingTextSpace { Screen, World }Screen 类型走当前逻辑World 类型则直接把 prefab 放到目标世界坐标位置不需要坐标转换。9.3 接 QFramework 的 UIKit 系统如果项目大量使用 UIKit可以考虑让 FloatingTextSystem 负责管理 UIKit 里的某个 Panel飘字对象挂在该 Panel 下。这样统一使用 UIKit 的层级和事件系统减少自己维护的 Canvas 数量。9.4 与音频、震动等反馈联动飘字不只是视觉反馈很多时候需要和音效、手柄震动配合。可以在同一个 FloatingTextRequestEvent 里附带音频资源 IDSystem 收到事件后同时触发飘字和音效。这样战斗打击感会更完整代码也不会散落多处。9.5 批量事件合并类幸存者场景里一次 AOE 可能同时命中 20 个敌人。如果每个敌人都发送一个独立事件性能压力偏大。可以做一个批量版本public struct BatchFloatingTextRequestEvent { public ListFloatingTextRequestEvent Items; }System 收到批量事件后统一在同一个 Canvas 下分配对象减少多处事件触发的管理开销。但这个优化要结合项目实际数据量如果一帧最多 5 个飘字就没必要做批量。9.6 合规使用提醒飘字模块本身不涉及版权和隐私问题但如果后续加入“玩家名字”“排行榜文本”“聊天内容”等动态文字需要特别注意玩家输入内容的显示要过滤敏感词。涉及真实用户名称、头像、语音时要获得授权。游戏内广告、第三方 SDK 的文本展示要符合平台合规要求。素材字体、图片资源确保不侵犯授权。这些边界问题在模块上线前就需要处理不要等到发布后被用户反馈才发现。10. 总结与下一步文字飘动这个功能看起来小但在类幸存者项目里做扎实了收益很明显战斗反馈更清楚、手感更直观、性能可控。这次这套方案的核心结构是一个事件 一个 System 一个 View 一个对象池。事件负责解耦System 负责统一分配View 负责表现和回收对象池负责压住 GC 和实例化开销。下一步的扩展方向很明确先把普通伤害和暴击两种样式跑通确认对象池回收数量稳定。再接入金币、经验、治疗等不同样式预设。然后用批量接口扛住 AOE 场景。最后如果项目要考虑移动端低端机再做 Canvas 分组和 TMP SetText 的进一步优化。建议动手时先复制本文第 5 节的代码搭一个最小可运行的 Demo把对象池、坐标转换和协程动画三个关键点验证完再往战斗系统里接入。先跑通最小闭环后面加曲线、加样式、加接口都很快。
返回列表