简介UGUIDots 是一套面向数据技术堆栈DOTS的 UI 解决方案专为希望在 Unity 中兼顾 UGUI 工作流与 ECS 性能的开发者设计。它并非替代 Unity 原生 UI而是在其基础上做底层扩充让现有 UI 设计流程基本保持不变同时适配 Scriptable Render Pipeline并针对安卓、Windows 64 位、macOS、Linux 64 位等平台提供低开销的性能表现适合具备一定 Unity 与 DOTS 基础的中高级开发者研究使用。资源包共 245 个文件约 186KB其中 69 个 cs 脚本承载核心逻辑与控件实现12 个 asmdef 划分模块程序集16 个 md 提供说明文档另有 hlsl、shader、mat 等渲染相关文件及 meta、json、asset 等配置资源目录按 Core、Controls、Analyzers、Editor 等模块组织结构清晰。目前已有 329 人学习。通过该资源读者可了解如何将 UGUI 与 ECS 渲染管线衔接掌握移动端与桌面端的低开销 UI 实现思路并参考其程序集划分与着色器组织方式为自研 DOTS UI 方案提供可复用的工程范例。1. 把 UGUI 塞进 DOTS为什么“能跑”和“跑得对”是两回事很多人第一次听说要把 UGUI 转成兼容 DOTS 的方案时第一反应是UI 不就是画几个矩形、贴几张图、响应一下点击吗能有多难但真正在 Unity 里用实体组件系统ECS写过一帧逻辑的人会立刻意识到问题UGUI 的整套体系是围绕 GameObject 和 MonoBehaviour 长出来的它的布局、渲染、事件分发全都挂在托管对象上而 DOTS 的核心诉求恰恰是把这些东西从托管堆里赶出去变成连续内存里的纯数据。这两者的矛盾不是“加个适配层”就能糊过去的。UGUIDOTS 这个项目要解决的正是这个矛盾。它做的事情可以一句话概括让 UGUI 的声明式界面描述在 DOTS 的 World 里以 System 和 Component 的形式重新跑一遍并且尽量保留 UGUI 那套“我摆好位置、你负责画”的直觉。听起来像是个转换器但真正值得搞懂的是它为什么必须这么设计——因为一旦你理解了 UGUI 的 Canvas 更新为什么会在实体数量上去之后变成瓶颈你就明白这不是炫技而是被逼出来的。2. UGUI 的 Canvas 更新为什么它天生和 DOTS 合不来2.1 Canvas 的脏标记机制到底在做什么UGUI 的渲染核心是 Canvas。Canvas 会把所有子级的 Graphic 组件Image、Text、RawImage 等合并成批次然后交给 CanvasRenderer 去画。为了不每帧重建整个网格它引入了一套脏标记只有当某个 Graphic 的顶点、材质、层级发生变化时才把对应的 Canvas 标记为“需要重建”。这套机制在几十个 UI 元素时非常高效因为大部分帧里什么都没变Canvas 直接复用上一帧的网格。但问题出在“重建”的粒度上。Canvas 的重建不是只重建变化的那一个元素而是把整个 Canvas 下的所有 Graphic 重新走一遍布局计算、顶点生成和批次合并。也就是说你改了一个 Text 的文字内容整个 Canvas 下的 Image、Button、Slider 全都要重新参与一次网格生成。在 DOTS 的场景里UI 元素数量往往不是几十个而是几百上千个——比如一个策略游戏的单位状态面板、一个模拟经营的数据列表。这时候 Canvas 的脏标记就从优化手段变成了性能陷阱。2.2 托管对象带来的缓存不友好更深一层的问题在于数据布局。UGUI 的每个 Graphic 都是一个 MonoBehaviour它的顶点、颜色、UV 这些数据散落在托管堆的各个角落。Canvas 更新时CPU 要顺着 GameObject 的层级一路访问这些分散的对象缓存命中率极低。而 DOTS 的整个哲学是把同类数据连续排列让 CPU 一次拉一整条缓存行进来用 SIMD 或至少是顺序访问把吞吐拉满。UGUIDOTS 的做法是把 UGUI 的“描述”抽出来变成 ECS 里的 Component。比如一个 Image 在 DOTS 里不再是 MonoBehaviour而是一组数据位置、尺寸、颜色、纹理索引、层级。这些数据以 Archetype 的形式连续存储System 遍历时就是线性扫描。渲染时不再走 Canvas 的合并逻辑而是由自定义的 RendererSystem 直接根据这些 Component 生成网格并提交。这就绕开了 Canvas 的脏标记和托管对象访问把 UI 渲染拉回到 DOTS 的节奏里。2.3 事件系统为什么不能直接复用UGUI 的事件系统EventSystem StandaloneInputModule同样是托管对象驱动的。它靠射线检测和 GraphicRaycaster 来找到被点击的 UI 元素然后派发 IPointerClickHandler 之类的接口。在 DOTS 里你没法在 Job 里调用这些接口也没法把射线检测的结果直接映射回实体。UGUIDOTS 的思路是把点击检测也数据化把 UI 元素的矩形范围存成 Component用一个 System 去处理输入事件遍历所有可交互实体判断指针位置是否落在矩形内然后给命中的实体打上“被点击”的标签。后续的逻辑 System 再根据这个标签去执行对应的行为。这样整个事件流就变成了“输入数据 → 碰撞检测 → 标签写入 → 逻辑响应”的纯数据管道没有任何托管回调。3. 从 GameObject 到 Entity转换层到底转了什么3.1 组件映射表哪些 UGUI 概念被保留哪些被丢弃UGUIDOTS 并不是把 UGUI 的每个 MonoBehaviour 都一一对应到 ECS Component。它做了一次有取舍的映射。下面这张表是我根据项目里常见的实现思路整理的能帮你快速理解哪些东西被保留了下来UGUI 概念DOTS 中的对应是否保留说明RectTransformTranslation Scale UIElement部分保留只保留位置和尺寸锚点系统被简化ImageUIImage MaterialComponent保留纹理和颜色变成 Component 数据TextUIText TextData保留文字内容存为 FixedString 或 BlobAssetButtonUIInteractable OnClickTag保留点击变成标签不再有回调Canvas无直接对应丢弃由 RendererSystem 统一提交CanvasScalerUIScaleMode部分保留只保留一种缩放模式LayoutGroup无直接对应丢弃布局需要自己用 System 算EventSystemInputSystem RaycastSystem重写完全数据化这张表里最值得注意的是 LayoutGroup 被丢弃了。UGUI 的自动布局HorizontalLayout、VerticalLayout、GridLayout是托管代码里最重的部分之一它会在每次重建时递归计算子元素的位置。在 DOTS 里这种递归布局必须改写成基于 Component 的迭代计算否则就会把托管开销又带回来。UGUIDOTS 选择不提供自动布局而是让开发者用 System 自己算位置——这看起来是功能倒退但实际上是性能上的必要取舍。3.2 转换流程的代码骨架转换的核心逻辑通常长这样我把它简化成一个可读的骨架// 遍历场景中所有带 Canvas 的 GameObject // 对每个子级的 Graphic 组件提取数据并创建 Entity foreach (var graphic in canvas.GetComponentsInChildrenGraphic()) { var entity entityManager.CreateEntity(); // 提取 RectTransform 数据转成 DOTS 的 Translation Scale var rect graphic.rectTransform; entityManager.AddComponentData(entity, new Translation { Value new float3(rect.anchoredPosition.x, rect.anchoredPosition.y, 0) }); entityManager.AddComponentData(entity, new Scale { Value rect.sizeDelta.x // 只取宽度高度在 UIElement 里单独存 }); // 提取 Image 数据 if (graphic is Image image) { entityManager.AddComponentData(entity, new UIImage { Color image.color, SpriteIndex spriteLookup[image.sprite] // 纹理索引不是引用 }); } // 提取 Text 数据 if (graphic is Text text) { entityManager.AddComponentData(entity, new UIText { Content new FixedString128Bytes(text.text), FontSize text.fontSize }); } // 标记为可渲染 entityManager.AddComponentRenderableTag(entity); }这段代码的关键点在于它没有把 Sprite 或 Font 对象直接存进 Component而是存了索引。因为托管对象引用会破坏 DOTS 的 Blob 化存储也会让 Job 无法安全访问。纹理和字体在初始化时被收集到一个全局的 BlobAssetReference 里System 通过索引去查。这样整个 UI 数据就是纯值类型可以安全地在 Job 里并行处理。3.3 渲染提交为什么不用 Mesh 而是用 DrawCommandUGUI 最终是通过 CanvasRenderer 提交网格的。UGUIDOTS 不走这条路它通常会把每个 UI 元素的矩形转成一个 DrawCommand然后由 RendererSystem 统一提交给 Graphics.DrawMeshInstanced 或类似的批量接口。这样做的好处是所有 UI 元素共享同一个材质和纹理图集一次 DrawCall 就能画完几百个矩形。而 UGUI 的 Canvas 虽然也会合批但它的合批是在托管层做的元素一多就会在重建时产生明显的 CPU 峰值。4. 避坑转换过程中最容易翻车的几个地方4.1 锚点系统被简化后布局全乱了现象转换完成后UI 元素的位置和原来在 UGUI 里看到的完全不一样有的跑到屏幕外有的挤在一起。原因UGUI 的 RectTransform 锚点系统非常灵活一个元素的位置同时受 anchorMin、anchorMax、pivot、anchoredPosition 和 sizeDelta 影响。UGUIDOTS 通常只保留 anchoredPosition 和 sizeDelta把锚点简化为左上角对齐。如果你的原始 UI 依赖居中锚点或拉伸锚点转换后就会偏移。解决在转换前先把所有 RectTransform 的锚点统一成左上角或者自己在转换层里加一段锚点解算逻辑把 anchorMin/anchorMax 的影响提前算进 anchoredPosition 里。我一般会在转换脚本里加一个预处理步骤把所有非左上角锚点的元素先“烘焙”成绝对坐标再交给 DOTS。4.2 文字渲染变成方块或空白现象Text 组件转换后屏幕上显示的是方块、问号或者干脆什么都没有。原因UGUI 的 Text 依赖 Font 对象来生成字符网格。在 DOTS 里Font 是托管对象不能直接放进 Component。如果转换时只存了文字内容而没有把字体图集和字符 UV 信息一起烘焙进去RendererSystem 就不知道该从哪里采样。解决在初始化阶段把 Font 的字符图集烘焙成一个 Texture2D 和一张字符 UV 表存进 BlobAsset。UIText 组件里存的是字符的索引序列RendererSystem 根据索引去 BlobAsset 里查 UV 和尺寸。这样文字渲染就完全脱离了 Font 对象变成纯数据驱动的网格生成。4.3 点击事件穿透到下层现象点击一个按钮时按钮下面的另一个 UI 元素也同时收到了点击。原因UGUI 的 EventSystem 会根据层级和 Raycast Target 设置来决定哪个元素接收事件通常只有最上层的元素会响应。UGUIDOTS 的 RaycastSystem 如果只是简单遍历所有可交互实体并判断矩形包含就会让所有重叠的元素都命中。解决在 RaycastSystem 里加一个排序步骤按层级或按 Z 值从高到低遍历一旦找到第一个命中的实体就停止并把它标记为“本次点击的接收者”。其他实体即使矩形包含也不响应。这个逻辑在 Job 里可以用一个临时变量来记录当前最高层级遍历完后只给最高层级的实体打标签。4.4 动态增删 UI 元素时 Entity 泄漏现象反复打开和关闭面板后内存持续上涨Entity 数量只增不减。原因UGUI 里销毁一个 GameObject 会自动回收它占用的资源。但在 DOTS 里Entity 不会自动销毁如果你只是把 UI 元素隐藏了比如把 Scale 设为 0Entity 和它占用的 Component 内存仍然存在。反复创建新 Entity 而不销毁旧的就会泄漏。解决在关闭面板时显式调用 EntityManager.DestroyEntity 销毁对应的 Entity或者用一个对象池来复用 Entity。对象池的做法是关闭时不销毁而是给 Entity 打上 DisabledTagRendererSystem 跳过带这个标签的实体打开时移除标签并更新数据。这样避免了频繁的创建销毁开销也防止了泄漏。4.5 在 Job 里访问托管数据导致崩溃现象把 UI 更新逻辑放进 Job 后Unity 直接报错提示“InvalidOperationException: The NativeArray has been deallocated”或者“Access to managed object is not allowed”。原因DOTS 的 Job 系统要求所有访问的数据都是非托管的。如果你在 Job 里试图访问一个 string、一个 class 实例或者一个委托就会触发保护机制。UGUI 的 Text.text 是 stringImage.sprite 是 UnityEngine.Object这些都是托管类型。解决把所有需要在 Job 里访问的数据都转成非托管类型。文字用 FixedString 或 BlobAsset 里的 byte 数组纹理用索引而不是 Sprite 引用颜色用 float4 而不是 Color虽然 Color 是 struct但为了统一还是转成 float4。所有转换在初始化阶段完成运行时的 Job 只碰纯值类型。5. 设计取舍为什么不做完整的 UGUI 兼容UGUIDOTS 最值得琢磨的地方不是它转了什么而是它选择不转什么。它没有去实现 LayoutGroup 的自动布局没有去支持 Rich Text 的完整解析也没有去复刻 EventSystem 的所有接口。这些“缺失”在第一次使用时可能会让人感到不便但如果你从 DOTS 的性能模型往回推就会发现这些取舍几乎是必然的。自动布局的本质是递归计算父容器要知道子元素的大小子元素又要根据父容器的约束来调整自己。这种双向依赖在托管代码里可以用递归函数轻松表达但在 DOTS 里递归意味着无法并行也意味着数据依赖关系变得复杂。UGUIDOTS 把布局的责任交还给开发者让你用 System 显式地计算位置——这看起来是增加了工作量但实际上是把隐式的性能开销变成了显式的可控逻辑。你可以决定布局多久算一次、在哪个阶段算、要不要用 Job 并行算。这种控制权在 UGUI 里是被 Canvas 的脏标记机制藏起来的而在 DOTS 里你必须自己拿回来。另一个取舍是事件系统的简化。UGUI 的 EventSystem 支持拖拽、长按、多指触控等复杂交互这些在 DOTS 里如果全部数据化会引入大量的状态 Component 和状态机 System。UGUIDOTS 通常只保留最基础的点击和悬停把复杂交互留给开发者自己实现。这不是能力不足而是因为复杂交互的状态管理在 ECS 里本来就应该由业务逻辑层来负责而不是塞进一个通用的 UI 框架里。如果你正在做一个 UI 元素数量会随游戏进程动态增长的项目比如模拟经营、策略或者数据可视化工具UGUIDOTS 这种方案的价值会非常明显。它把 UI 从“托管对象树”变成了“数据流”让你能用 Profiler 清楚地看到每一帧的 UI 开销花在哪里而不是对着 Canvas.BuildBatch 的耗时干瞪眼。我自己的习惯是只要 UI 元素数量可能超过两百个就优先考虑 DOTS 化的方案哪怕前期要多写一些布局和事件代码后面调性能的时候会省下大量时间。希望帮到你。本文还有配套的精品资源点击获取