简介UGUIDOTS 是一套面向数据的 UI 技术堆栈目标是将传统 UGUI 转换为兼容 DOTS 的体系同时保留原有工作流开发者无需重建 UI 设计流程。它并非 UGUI 的替代品而是在原生 UI 之上构建数据导向层使 UI 元素的更新与渲染可被 ECS 系统接管同时兼容 Scriptable Render Pipeline并针对移动端与桌面端做低开销设计支持 Android、Linux、macOS、Windows 等平台适合已有 UGUI 使用经验、正在评估 DOTS 转型路线的 Unity 开发者。压缩包共 245 个文件体积仅 186KB核心由 138 个 meta 配置、69 个 C# 源码、12 个程序集定义及 16 个 Markdown 说明文档组成另含 Shader、HLSL、材质、图片与 JSON 配置目录按 Core、Controls、Common、Editor、Tests 等模块划分便于按需引用或二次开发。目前已有 326 人学习/下载适合作为 DOTS UI 方向的入门参考。资源的价值在于可直接参照其中的程序集划分与示例实现理解 UGUIDots 如何用 Entity Component System 组织 UI 数据并结合渲染管线完成兼容改造从而为自研 UI 框架或迁移评估提供落地参考。 UGUI转DOTS这事我在项目里折腾了快两个月。最开始只是想解决列表滚动卡顿的问题结果一路挖到了UI框架的底层索性把一套完整的UGUI转DOTS方案给磨了出来。今天就把这套方案的设计思路、核心转换流程、以及我踩过的那些坑一次性讲清楚。先说结论UGUI和DOTS的冲突本质上不是“不能一起用”而是“数据组织方式完全不在一个频道”。UGUI是典型的GameObject/Component模型一个按钮就是一个GameObject上面挂着RectTransform、Image、Button、EventTrigger等一堆组件运行时靠Canvas的Rebuild机制生成网格。DOTS则是Entity/Component/System模型数据连续存放、System批量处理、Burst编译加速。两边最核心的矛盾在于UGUI的性能瓶颈恰恰是DOTS最想消灭的东西——GameObject的分散内存、Transform层级遍历的缓存不友好、以及CanvasRebuild带来的主线程峰值开销。我这套UGUIDOTS方案做的事情很简单提供一个转换层把场景里的UGUI元素采样成数据映射到Entity和Component上然后用System驱动UI的数据更新和网格生成。最终效果是UI的整体逻辑跑在DOTS的Job/Burst管线里渲染上仍然复用UGUI的Canvas和渲染管线避免自己从零写UI网格合批系统。1. 项目背景与核心痛点拆解1.1 为什么一定要动UGUI先聊聊我最初遇到的问题。项目里有一个公会成员列表界面同时显示几十个玩家头像、名字、等级、战力、在线状态。用原生UGUI实现的时候滑动起来帧率掉得厉害。Profiler一抓卡顿集中在两块一是Update里每帧同步玩家数据时要逐个修改Text组件每个Text赋值都会触发字符网格重建二是滚动的时候RectTransform的层级变更导致Canvas的脏区域计算量暴涨。这个问题不是简单的代码优化能解决的根源在于UGUI的数据绑定太“重”。你在DOTS里更新5000个实体的位置那就是对着一块连续内存做简单数学运算Burst编译后几微秒跑完。但在UGUI里更新5000个Text产生的是5000次组件通知、几千次GC分配字符串拼接、顶点数据、以及Canvas的大规模Rebuild。UI一多这些开销会直接吃掉主线程预算。还有一个很少被人提到的点UGUI的层级结构天然是树形递归的查询一个子节点要逐级向上Traverse。DOTS是扁平化数据存储没有任何递归查询。这两种模型在项目做大后UGUI的维护成本会指数级上升。1.2 DOTS给UI带来什么DOTS对于UI这层带来的核心价值有三个批量数据处理、多线程并行、以及数据驱动带来的架构清晰度。批量数据处理很好理解。比如一个滚动列表的适配逻辑传统写法是for循环逐个设置RectTransform。在DOTS里这就是一个IJobEntity遍历所有标记为“列表项”的Entity并行计算位置和尺寸。500个列表项Burst下一次遍历几百微秒完全无感。更重要的是这些计算可以放在子线程上不再占用主线程。第二点是多线程并行。DOTS的System调度器可以把不同System分配给不同线程执行。UI位置计算、属性变化检测、网格数据生成这些彼此无依赖的System可以并行跑。这意味着UI系统的计算压力不再是一门心思压在主线程上。第三点是数据驱动。UI的状态不再是散落在各个组件的字段里而是统一收拢到Entity的ComponentData上。状态变化只是写一个或多个Component字段然后由System扫描这些变化统一处理。这个模式让UI状态管理清晰很多也比事件回调链靠谱得多。2. 整体设计转换层架构与数据流2.1 转换层定位不是重写UGUI而是桥接我最开始也动过“用DOTS重写一套UI框架”的念头。但是评估之后放弃了UI软件的复杂度远超预期从零实现文本排版、富文本、图文混排、图集合批、布局系统、事件系统这个工作量足以让整个团队扑进去一年。况且Unity官方后续的UI Toolkit也还在持续迭代投入产出比太差。所以最终方案定为“转换层桥接”保留UGUI的GameObject作为编辑器和渲染入口同时把数据镜像一份到DOTS Entity上运行时逻辑全部走DOTS渲染仍然通过Canvas完成。要理解这个架构先看核心数据流。提示转换层不是把UGUI“替换掉”而是把UGUI的“逻辑部分”搬到DOTS里跑。渲染层暂时保留后续如果需要彻底DOTS化也能平滑切换到自定义Mesh渲染。数据流是这样的编辑器阶段把场景中的UGUI物体树采样成二进制数据位置、旋转、缩放、锚点、颜色、图集、字体、文本内容等存成ScriptableObject或DOTS BlobAsset。运行阶段根据采样数据生成对应的Entity层级把每个UGUI节点映射为一个EntityUGUI组件数据逐一映射到Entity组件。更新阶段DOTS的System监听业务逻辑写入的数据变更计算新位置、尺寸、文本内容然后通过转换通道反馈给UGUI组件GameObject去刷新。渲染阶段UGUI的Canvas负责生成网格和合批DOTS负责数据计算互不干扰。2.2 数据层UGUI组件的Entity化映射整个转换方案里最核心的设计就是UGUI组件怎么映射到Entity的Component。先看最简单的RectTransform。一个RectTransform的核心数据包括anchoredPosition、sizeDelta、anchorMin、anchorMax、pivot、rotation、scale。这些其实都是以float为基本单位的数据完全可以拆成几个struct组件。我用UniTask先写了一版最后定型为这样几个组件LocalTransformComponent位置、旋转、缩放float3 quaternionRectTransformComponent锚点、枢轴、尺寸偏移float2/float4LayoutElementComponent布局参与标记、最小/首选尺寸float/float2GraphicComponent颜色、材质索引、图集索引、精灵索引int/float4TextComponent富文本标记、字体大小、对齐方式、文本哈希这些Component全部是unmanaged类型可以放进Entity的ComponentData里。文本内容本身变化非常频繁我用了一个DynamicBuffer 来存储UTF-8编码的字符字节。2.3 System层从事件驱动到数据驱动UGUI里最典型的模式是事件回调按钮点击后触发OnClick内部再自己处理逻辑。DOTS里没有事件回调取而代之的是System扫描数据变化。这套方案里我定义了三个层次的System变化检测System扫描所有UI Entity的脏标记组件发现变化后写入一个待处理队列计算System处理布局变化、文本重排、列表滚动位置等回写System把计算结果写回UGUI组件触发Canvas局部重建事件方面我用了InputSystem的事件流通过EntityCommandBuffer生成临时的UI事件实体再被UI事件处理System消费。3. 核心实现细节与关键代码路径3.1 采样器把UGUI场景转成Entity数据转换的第一步是在编辑器里把一个UGUI界面“快照”下来。我实现了一个Editor工具遍历UGUI的Transform层级同时读取所有UGUI组件的数据然后序列化成结构化的二进制数据。这一段最花时间的是处理不同组件的差异。Image有SourceImage、Color、MaterialText有Font、FontSize、FontStyle、LineSpacing、RichText、Alignment、Text内容Button有Transition类型、TargetGraphic、Colors数值。每个组件的字段类型都不一样写采样逻辑时要把每个字段都做明确归类。采样产物我存成了ScriptableObject。运行时加载时遍历这个SO每遇到一条节点记录就生成一个Entity同时按类型挂载对应的Component。转换后的Entity层级用Parent组件保持父子关系这个很关键——后续做布局计算的时候得靠父子关系做层级遍历。3.2 核心ECS组件包的设计这套方案里UI Entity的Archetype实体原型基本长这样public struct UITag : IComponentData { } public struct RectTransformComponent : IComponentData { public float2 AnchoredPosition; public float2 SizeDelta; public float2 AnchorMin; public float2 AnchorMax; public float2 Pivot; public float2 OffsetMin; public float2 OffsetMax; } public struct LocalTransformComponent : IComponentData { public float3 Position; public quaternion Rotation; public float3 Scale; } public struct UILayoutDirty : IComponentData { } public struct UITextComponent : IComponentData { public int FontAssetIndex; public float FontSize; public float LineSpacing; public int Alignment; public bool RichText; public bool WordWrap; } public struct UIGraphicComponent : IComponentData { public int AtlasIndex; public int SpriteIndex; public float4 Color; public int MaterialIndex; }使用上有一个技巧把UITag作为共享组件分块标记这样所有UI Entity天然归组到一个Chunk里。遍历UI Entity的时候Job系统能高效地连续读取同一块内存。实测下来500个列表项在Burst下完成一遍位置更新耗时不到0.05ms。3.3 文本和字形处理文本是UGUI里最重的部分。每个Text组件都有自己的顶点生成逻辑字体、字号、字间距、行间距、对齐方式都会影响顶点结果。在DOTS里文本处理有两个瓶颈一是字体数据本身在DOTS的unmanaged环境下不好直接访问二是字形网格生成涉及复杂的TMP/UGUI内部逻辑难以安全地在Job中调用。我的方案是把字体相关的只读数据FontAsset的纹理Id、字符宽度表、行间距参数提出来存入BlobAsset。在System里计算文本的字符布局位置、尺寸、字形索引这部分是纯数学计算可以在Job里跑。计算完成后System把每个字符的布局数据写入一个字节流回写阶段再交给UGUI的Text组件刷新。这样做的效果是文本的“排字计算”在DOTS侧并行完成UGUI侧只负责最后的顶点构建和渲染。相比原版每次Text赋值都要从头排字省掉了大量重复计算。3.4 事件系统的转换思路UGUI的事件系统基于EventSystem和Raycast逐帧检测鼠标位置下有没有Graphic命中。DOTS化的事件系统不能这么写——RaycastAll本身是主线程逻辑而且命中测试依赖Graphic的顶点数据不能直接搬到System里。我的处理方式是把UGUI的Raycast结果“镜像”到DOTS侧。具体来说每一帧UI事件System会读取UGUI的EventSystem产生的当前悬停UI元素和点击目标然后写入对应的Entity的InteractionComponent。业务逻辑侧通过EntityQuery查询挂载了“悬停”标记的Entity就能拿到当前事件作用的对象。这里有一层取舍事件命中检测仍然走了UGUI的原生管线。真正DOTS化的只有事件分发的下游。不过实际做下来这层取舍是值得的——事件检测本身不是UI性能瓶颈瓶颈在数据更新和网格构建。public struct InteractionComponent : IComponentData { public Entity HoverEntity; public Entity PressEntity; public Entity ClickEntity; public float PressTime; public float2 PointerPosition; }3.5 渲染管线的对接渲染这块我选择了保留UGUI的CanvasRenderer。每个UI Entity在回写阶段会把最新的RectTransform数据同步回对应的CanvasRenderer然后触发UI的网格重建。这里有个坑需要特别注意UE的RectTransform和CanvasRenderer本身是GameObject侧的组件不能在Job中直接写入。必须在主线程上处理而且最好在UGUI的Canvas.Update之后统一执行。所以我在回写System里用了一个特殊处理——把写入操作收集到NativeList然后在主线程的LateUpdate中统一Apply。这样可以保证不破坏UGUI的渲染时序也避免跨线程访问Unity对象的崩溃风险。提示不要在[UpdateInGroup(typeof(LateSimulationSystemGroup))]这种System里直接操作GameObjectUnity会崩溃。做个代理类把主线程操作放到MonoBehaviour的Update/LateUpdate里执行。4. 实操要点字段对应关系与性能调优4.1 最实用的坐标映射一个高频疑问UGUI的RectTransform和DOTS的LocalTransform怎么对应。我最终的做法是Entity上同时保留RectTransformComponent和LocalTransformComponent。RectTransformComponent存储UGUI侧的节点数据LocalTransformComponent存储供DOTS计算用的世界坐标结果。计算System负责把RectTransform的锚点计算映射到LocalTransform。锚点映射的公式是var anchorPos rectTransformComponent.AnchorMin * parentRect.sizeDelta; var localPos new float2( anchorPos.x rectTransformComponent.AnchoredPosition.x - rectTransformComponent.Pivot.x * rectTransformComponent.SizeDelta.x, anchorPos.y rectTransformComponent.AnchoredPosition.y - rectTransformComponent.Pivot.y * rectTransformComponent.SizeDelta.y );这段公式看着简单实际调起来很多细节——父级尺寸变化时所有子节点都要重算所以祖先的SizeDelta一旦变化需要打上脏标记级联重算所有后代。这个级联逻辑是UI DOTS化最大的复杂度来源之一。4.2 Layout布局的DOTS化UGUI的LayoutGroupHorizontal、Vertical、Grid都是主线程逐帧计算的。DOTS化之后我用一个LayoutSystem统一处理这些布局逻辑。每个Entity若带有LayoutComputeTag和LayoutType就会被对应的布局Job处理。拿VerticalLayoutGroup举例布局的计算分为三步计算每个子项的内容高度读取子项的PreferredHeight累加所有子项高度得到总高度按顺序排列子项位置并写入下一步这个逻辑在DOTS里实现起来不比UGUI复杂。重点在于LayoutSystem要利用ParallelJob并行处理同一层级的大量兄弟节点。一个包含300个子项的滚动列表布局计算从UGUI的几十毫秒降到了不到1毫秒。4.3 Scaler自适应的处理Canvas Scaler在UGUI里负责屏幕尺寸适配。DOTS化的UI同样绕不开这个问题。做法是运行时读取当前屏幕分辨率换算出一个全局的ScaleReference组件存到全局Entity上。UI的尺寸计算System在读RectTransform数据时会额外乘以ScaleReference的缩放系数。这样设备的宽高比变化只需要更新一个全局组件所有UI位置自动完成适配。需要注意Canvas Scaler还会影响字体大小和间距这部分要同步进FontScale否则会出现文字大小和控件尺寸不匹配的尴尬。4.4 图集和资源的DOTS侧管理UI资源图集、字体、材质在DOTS侧不能直接加载UnityEngine.Object引用。我做了两级资源管理第一级为每个UI资源实例分配一个int型索引索引值存到Entity的组件字段里。加载时Unity侧维护一个AssetIndex到Object的映射表。第二级System在处理时通过索引访问一个Texture或Sprite的元数据宽高、图集坐标、UV范围——这些元数据直接放进BlobAsset进入Job后无需锁和主线程交互。这套机制的优势是Job里可以安全地读取Sprite信息来计算UV顶点不用转主线程。5. 常见问题与排查技巧实录5.1 坐标跳动或错乱特别是首帧原因几乎都是Anchor数据没同步完整。检查三个地方父节点RectTransform是否已在父级变化时被正确打脏标记AnchorMin/Max是否在采样时正确保存Pivot有没有在采样时被遗漏。解决方式在采样工具中增加校验步骤对比每一个节点的anchoredPosition、sizeDelta和RectTransform面板上的数值是否一致。5.2 UI事件点了没反应或者命中错位多半是事件转换层的时间序没对齐。DOTS的事件Entity生成时机要晚于UGUI的Raycast结果。如果事件System执行得太早要么读不到悬停数据要么读到的是上一帧的旧数据。解决方式在UI事件System前加一个WriteGroup确保Raycast结果写入后再执行事件分发。5.3 文本不显示或显示乱码大概率是TextComponent里的字符串没同步。DOTS侧用的是DynamicBuffer 存UTF-8字节回写到UGUI时需要用Encoding.UTF8.GetString转换一遍。这个转换发生在主线程回写阶段千万别在Job里做。5.4 性能没提升反而更卡了最普遍的问题转换层只是把UGUI的GameObject换成了Entity但更新路径没有真正DOTS化。最典型的错误循环里每个Entity都去调用了Transform.position的读写把主线程调用隐藏在DOTS外壳里性能自然会倒退。判断标准很简单用Profiler看主线程耗时。如果回写System占了大量时间说明数据计算还是回到了主线程上。真正DOTS化的UI主线程耗时应该集中在最后的GameObject同步和Canvas更新这两个是固有点无法再优化。5.5 Misc原生UGUI的Canvas重建依然有影响这是需要提前做的心理建设只要渲染还走Canvas就逃不过CanvasRebuild的开销。DOTS能优化的是计算侧渲染侧的Canvas重建是由UGUI元素的实际变更驱动的。所以尽量控制每帧变更的UI数量——只在数据真正变化时打脏标记不做无谓的全量刷新。6. 这套方案还能往哪个方向扩展这套UGUIDOTS转换层目前做到了“逻辑DOTS化、渲染保留UGUI”。后续如果项目有更极致的性能需求可以走两条路线。第一是彻底DOTS渲染。去掉CanvasRenderer改为采集Entity数据生成Mesh用RenderMesh或者更高层的ECS渲染管线直接出图。这样的话UI元素不再依赖UGUI的GameObject Mesh系统理论上整个UI不再受Canvas的Rebuild限制。但要做的事情很多——合批、材质管理、渲染排序都要从头来。第二是做UI的AssetBundle与Addressable的DOTS侧集成。把资源加载流程也纳入DOTS的管理体系里加载完成后再以Entity的形式生成UI元素。这样做的好处是游戏运行内存中不再有大量常驻的UI GameObject而是按需加载的Entity数据。我个人在实际操作中的体会是UGUI转DOTS这件事核心不在于“你用了多少DOTS”而在于“你有没有把数据组织方式彻底改过来”。只要数据流向是连续、扁平、可并行计算的哪怕渲染还留在UGUI性能提升也已经非常可观了。反过来如果只是挂个羊头卖狗肉把GameObject查询包进Job里那踩的坑比收益多得多。最后再分享一个小技巧做转换工具时一定要把采样器和运行时加载器分开做。采样器只负责“把场景变成数据”运行时加载器只负责“数据变成Entity”。这两个流程的边界清晰后后续任何UI界面都可以走同一套管线新增页面只需要拖进工具采样就行代码几乎不用改。本文还有配套的精品资源点击获取
