Unity运行时检视器核心架构解析:InspectorField与HierarchyData设计深度剖析
1. 项目概述
如果你在Unity项目里做过调试工具,或者想给玩家一个运行时修改游戏参数的能力,那你肯定想过一个问题:怎么在游戏运行的时候,也能像在Unity编辑器里那样,直观地查看和修改场景里任意物体的属性?这就是Runtime Inspector(运行时检视器)要解决的核心问题。它不是一个简单的UI组件,而是一个完整的、在运行时动态构建的、能够反射任何对象内部结构的复杂系统。今天,我们就来深度解析一个在Unity社区里非常知名、功能强大且设计精良的开源解决方案——yasirkula的UnityRuntimeInspector。我们不会停留在“怎么用”的层面,而是直接深入到它的心脏,剖析其最核心的两个设计:InspectorField(检视器字段)和HierarchyData(层级数据)。理解这两个部分,你不仅能用好这个工具,更能学到一套在Unity中构建动态、高效、可扩展的运行时UI系统的设计哲学。
2. 核心架构与设计思想拆解
在深入代码之前,我们必须先理解这个项目的顶层设计思路。它本质上是在运行时模拟Unity编辑器的Inspector和Hierarchy面板。这听起来简单,但实现起来有几个巨大的挑战:第一,性能。运行时频繁使用反射(Reflection)来获取和设置对象属性,是GC(垃圾回收)的主要来源,必须极致优化。第二,通用性。它要能处理Unity内置的数百种类型(Vector3, Color, GameObject, Component等),还要能处理用户自定义的序列化类和结构体。第三,扩展性。开发者必须能轻松地为自己的特殊类型定制显示和编辑逻辑。
UnityRuntimeInspector的解决方案非常清晰:基于Drawer(绘制器)的插件化架构。整个检视器的UI不是硬编码的,而是由一系列名为InspectorField的预制件动态组装而成。每个InspectorField负责处理一种或一类特定类型的可视化与交互。而HierarchyData则是另一条线,它负责高效地管理场景中所有GameObject的树状结构数据,并与UI同步,解决动态增删、搜索、多选等场景下的性能问题。这种将“数据管理”与“UI表现”分离,并通过标准接口连接的设计,是系统保持清晰和高效的关键。
2.1 为什么选择Drawer模式?
你可能会问,为什么不用Unity原生的UI组件(如InputField, Toggle)直接绑定?原因在于类型处理的异构性。一个int字段可以用一个InputField,一个bool字段可以用一个Toggle,但一个Vector3字段需要三个InputField,一个Transform引用字段需要一个能拖拽赋值的按钮,一个数组或列表则需要一个可以展开折叠、动态增减元素的复杂面板。如果为每种情况都写一套特殊的UI生成和绑定逻辑,代码会迅速膨胀成难以维护的“面条代码”。
Drawer模式将每种类型的处理逻辑封装到一个独立的InspectorField子类中。BoolField只关心如何显示和修改布尔值,Vector3Field只关心三个浮点数的输入。上层的RuntimeInspector组件不需要知道int和Color有什么区别,它只做一件事:遍历目标对象的字段和属性,为每个成员找到合适的Drawer,然后命令Drawer去“绘制”自己。这完美符合“开闭原则”——对扩展开放,对修改关闭。要支持一个新类型?你只需要创建一个新的InspectorField子类,并将其注册到系统中,核心流程一行代码都不用改。
2.2 数据与UI的同步策略
另一个核心设计是按需刷新与对象池。想象一下,如果你的游戏有60帧,难道检视器每帧都要用反射遍历所有字段并更新UI吗?这无疑是性能灾难。UnityRuntimeInspector采用了“刷新间隔(Refresh Interval)”机制。默认情况下,检视器和层级视图每0.25秒(4次/秒)刷新一次。对于层级视图,刷新主要是移除已销毁的对象、添加新对象、同步变换关系。对于检视器,刷新则是调用每个活跃InspectorField的Refresh()方法,让它们从绑定的对象中读取最新值。
更重要的是对象池。每次你展开一个包含10个元素的数组,系统并不是实例化10个新的UI元素;而是从一个预先创建好的对象池中取出10个可重用的Drawer实例。当你折叠数组时,这些Drawer不会被销毁,而是被还回池中,等待下一次使用。这极大地减少了Instantiate和Destroy带来的性能开销和内存碎片。Pool Capacity参数就是用来控制每个类型Drawer的池子大小的,在PC平台调大这个值可以进一步提升性能。
3. InspectorField:运行时检视的基石
InspectorField是所有类型绘制器的抽象基类。它是连接反射数据与具体UI表现的桥梁。理解它,就理解了整个检视器系统是如何工作的。
3.1 InspectorField的生命周期与核心属性
一个InspectorField实例从被创建到销毁,会经历几个关键阶段,每个阶段都有对应的虚函数供子类重写,这是典型的“模板方法”模式。
- 初始化 (
Initialize): 替代Awake或Start。在这里,你应该获取并缓存UI组件(如InputField,Text,Image)的引用,设置初始状态。系统会在Drawer被从池中取出或首次创建后调用此方法。 - 类型支持判断 (
SupportsType和CanBindTo): 当检视器需要为一个字段创建Drawer时,它会遍历所有已注册的Drawer(包括内置的和你通过Settings添加的自定义预制件),依次询问:“你能处理这种类型吗?”SupportsType(Type type)是第一个过滤器,它进行快速类型匹配。如果返回true,则会进一步调用CanBindTo(Type type, MemberInfo variable)。第二个方法更精细,例如NumberRangeField会在这里检查字段是否带有[Range(min, max)]属性,以决定是否由自己来绘制。 - 绑定 (
OnBound): 当Drawer被确定用于某个特定字段后,OnBound被调用,并传入该字段的MemberInfo信息。子类可以在这里进行一次性设置,比如根据字段名设置标签,或者读取一些元数据(如[Tooltip],[Range])。 - 皮肤应用 (
OnSkinChanged): UnityRuntimeInspector支持换肤。当检视器的Skin属性改变时,所有Drawer的OnSkinChanged方法都会被调用。在这里,你必须将UI元素(颜色、字体、间距)调整到与新皮肤一致。这是保证UI视觉统一性的关键。 - 深度调整 (
OnDepthChanged): 用于实现嵌套缩进。当绘制一个对象的内部成员(如一个类的字段也是一个自定义类)时,子Drawer的Depth会比父Drawer大。OnDepthChanged被调用时,子Drawer应该给自己的内容添加左侧内边距(padding),以形成视觉上的层级关系。 - 刷新 (
Refresh): 这是最核心的方法,在每次检视器的刷新间隔中被调用。Drawer必须在这里从Value属性(它绑定了实际的对象字段)读取当前值,并更新其UI元素的状态。例如,BoolField需要根据Value是true还是false来更新Toggle的isOn状态。 - 值提交 (用户交互): 当用户通过UI修改了值(例如在
InputField里输入了文字并按了回车),Drawer需要负责将这个UI值解析、验证,并写回到绑定的字段中。这通常通过监听UI组件的事件(如InputField.onEndEdit)并在事件处理函数中调用Value的setter来完成。 - 解绑 (
OnUnbound): 当Drawer不再需要(例如对象被销毁,或用户检视了另一个对象)时被调用。这里应该进行清理工作,如取消事件监听,但通常不需要销毁UI组件,因为它们会被回收到对象池。
这里有一个非常重要的细节:Value属性。它被设计为object类型。对于值类型(如int,float,Vector3),这里存储的是装箱后的对象。每次get和set都涉及装箱和拆箱操作,这是运行时反射无法避免的GC来源之一。这也是为什么作者强调,适当增大Refresh Interval能显著减少GC压力的原因。
3.2 从简单到复杂:剖析几个内置Drawer
让我们看几个具体例子,感受一下InspectorField子类的设计。
BoolField: 最简单的Drawer之一。它内部包含一个Toggle组件。在Refresh()中,它将(bool)Value赋值给Toggle.isOn。在Toggle.onValueChanged事件监听器中,它将新的bool值赋值给Value属性。
NumberField: 用于处理所有数字类型(int,float,double等)。它内部使用一个BoundInputField(一个自定义的、增强的输入框组件)。Refresh()时,它将Value转换为字符串显示。当用户输入完成,BoundInputField会尝试将字符串解析回对应的数字类型。这里有一个关键点:NumberField需要处理多种数字类型,所以它的SupportsType方法会检查传入的Type是否是int,float,double等之一。
Vector3Field: 一个典型的“复合Drawer”。它本身继承自ExpandableInspectorField(可展开的检视字段)。在GenerateElements()中,它会创建三个子Drawer,分别对应x, y, z分量。这三个子Drawer也是NumberField。Vector3Field的Refresh()方法会读取Vector3值,然后调用其三个子NumberField的Refresh()方法。当任何一个子字段的值被用户修改时,Vector3Field需要收集三个子字段的值,组合成一个新的Vector3,然后赋值给Value。
ObjectReferenceField: 用于处理所有派生自UnityEngine.Object的引用类型(如GameObject,Transform,Material)。它的UI通常是一个显示对象名称的按钮,旁边可能有一个“定位”小箭头。点击按钮可以弹出对象选择器(ObjectReferencePicker),也支持从Hierarchy中拖拽对象来赋值。它的核心方法是OnReferenceChanged(Object reference),当引用被改变时调用。
3.3 ExpandableInspectorField:管理动态子项的艺术
ExpandableInspectorField是InspectorField的一个抽象子类,专门用于需要动态生成和管理子Drawer的情况,比如数组、列表、以及自定义的类/结构体。它比普通Drawer多了两个核心职责:展开/折叠状态管理和子Drawer列表维护。
它内部维护一个List<InspectorField> elements,用来存储当前展开的所有子Drawer。Length属性表示它“应该”有多少个子项(例如数组的长度)。在Refresh()时,它会检查elements.Count是否等于Length。如果不相等,说明数据发生了变化(比如数组元素增删了),就需要调用GenerateElements()重新生成子项,或者调用ClearElements()进行清理。
生成子项的逻辑在GenerateElements()虚函数中。以ArrayField为例,它的GenerateElements会做以下事情:
- 获取当前数组对象的长度,赋值给
Length。 - 清空现有的
elements列表。 - 使用一个循环,为数组的每个索引创建一个子Drawer。这里的关键是绑定方式。数组元素没有对应的
MemberInfo(字段或属性信息),所以不能使用简单的BindTo(MemberInfo)。ArrayField使用了BindTo的另一个重载:BindTo(Type variableType, string variableName, Getter getter, Setter setter)。它传入两个委托:getter委托调用Array.GetValue(index)来获取指定索引的元素值;setter委托调用Array.SetValue(value, index)来设置值。这样,子Drawer就能通过这两个委托与数组的特定索引进行交互,而无需知道自己是数组的一部分。
ExpandableInspectorField还负责处理UI上的展开/折叠箭头点击事件,并根据IsExpanded状态来显示或隐藏其drawArea(一个通常用于存放子Drawer的RectTransform)。
3.4 创建自定义Drawer的两种途径
根据你的需求,有两种方式可以扩展检视器的能力。
第一种:创建自定义Drawer预制件(高自由度)这是最强大、最灵活的方式。你需要:
- 创建一个新的
InspectorField子类脚本(例如MyCustomField)。 - 设计一个UI预制件,将上面的脚本挂上去,并配置好所需的UI组件引用(如
Variable Name Text,Layout Element等)。 - 将这个预制件添加到RuntimeInspector组件的
Settings资产中的Standard Drawers或Reference Drawers列表里。
系统在寻找Drawer时,会从列表的底部向上搜索,使用第一个SupportsType返回true的Drawer。这意味着你可以用自定义Drawer覆盖系统内置的默认行为。这种方式适合需要复杂自定义UI的类型,比如你想为一个Color类型做一个色轮选择器,而不是四个RGBA滑块。
第二种:实现IRuntimeInspectorCustomEditor接口(更简便)如果你不需要改变UI布局,只是想控制某个类型哪些字段应该显示,或者想根据条件显示/隐藏某些字段,这种方式更简单。你只需要:
- 创建一个类,实现
IRuntimeInspectorCustomEditor接口(GenerateElements,Refresh,Cleanup三个方法)。 - 用
[RuntimeInspectorCustomEditor(typeof(YourType), editorForChildClasses)]属性修饰这个类。 - 在
GenerateElements方法中,使用传入的parent(一个ObjectField)参数来创建你想要的子Drawer。你可以使用parent.CreateDrawersForVariables(“field1”, “field2”)来只显示特定字段,或者使用parent.CreateDrawer来创建完全自定义的绑定。
这种方式的好处是无需制作预制件,所有逻辑用代码完成。文档中给出的CameraEditor例子非常经典:它根据摄像机是否是正交投影,来动态显示orthographicSize或fieldOfView字段。
实操心得:对于99%的自定义需求,第二种方式就足够了。它干净利落,维护简单。只有当你需要像内置的
BoundsField或GameObjectField那样,有完全独特的UI交互时,才需要考虑第一种方式。制作自定义预制件时,一定要仔细参考内置的Drawer预制件,确保正确连接了Variable Name Text、Layout Element等关键引用,否则可能会出现显示错位或功能异常。
4. HierarchyData:高效管理运行时场景树
如果说InspectorField是检视器的心脏,那么HierarchyData及其相关类就是层级视图的脊柱。它的任务是在运行时高效地维护一个与Unity场景同步的、可搜索、可选择、可拖拽的树形结构。
4.1 数据与表现分离
RuntimeHierarchy组件是层级视图的UI控制器,但它并不直接管理场景中的Transform。它依赖一个核心的数据结构(在源码中通常是HierarchyData或类似的内部类)来维护所有需要显示的Transform的引用、父子关系、展开状态、选中状态等。UI层(HierarchyItem,每个游戏对象对应的UI行)只是这个数据模型的“视图”。
这种分离带来了巨大好处:
- 性能:刷新时,数据层可以快速计算出哪些节点需要更新(新增、删除、移动),然后只通知对应的UI项进行最小程度的更新。而不是每帧都销毁并重建所有UI。
- 状态管理:搜索、过滤、多选等复杂状态可以纯粹在数据层处理,UI层只负责渲染最终结果。
- 可测试性:数据逻辑可以脱离UI进行单元测试。
4.2 伪场景(Pseudo-Scenes)的设计
这是UnityRuntimeInspector一个非常巧妙的功能。除了显示真实的Unity场景,你还可以创建“伪场景”,将任意Transform分组显示在其中。这在制作游戏内编辑器、调试面板或MOD工具时极其有用。比如,你可以创建一个“调试物体”伪场景,把所有调试用的辅助物体都丢进去,和真实的游戏场景分开。
从实现上看,伪场景本质上是一个虚拟的根节点。所有添加到伪场景的Transform,在数据层会被视为这个虚拟根节点的子级。RuntimeHierarchy提供了AddToPseudoScene和RemoveFromPseudoScene等API来管理它们。还有一个辅助组件PseudoSceneSourceTransform,你可以把它挂在一个GameObject上,它的所有子物体就会自动出现在指定的伪场景中,并且会随子物体的增删而自动同步,非常方便。
4.3 搜索、多选与拖拽的实现
搜索:当用户在搜索框输入时,RuntimeHierarchy并不会立即遍历所有对象。它有一个独立的Search Refresh Interval。到达刷新点时,它会遍历数据模型中的所有Transform,检查其名称是否包含搜索词(不区分大小写)。匹配的项会被收集到一个列表中,然后UI根据这个列表来显示/隐藏对应的HierarchyItem。这里有一个优化:访问GameObject.name属性会产生GC,所以搜索刷新间隔通常比普通的层级刷新间隔要长。
多选:在PC上,多选通过Ctrl和Shift键实现,与标准文件管理器逻辑一致。在移动设备上,则可以通过开启Show Multi Selection Toggles来显示复选框。RuntimeHierarchy内部维护一个HashSet<Transform>或类似的集合来存储当前选中的所有对象。Select和Deselect方法会修改这个集合,并触发OnSelectionChanged事件。
拖拽:这是最复杂的交互之一。长按一个HierarchyItem会创建一个DraggedReferenceItem(一个跟随鼠标的UI图标)。这个拖拽项可以携带一个或多个Transform的引用。当你将它拖到检视器中的一个ObjectReferenceField上并松开时,ObjectReferenceField的OnReferenceChanged会被调用,传入拖拽项所携带的引用。此外,如果开启了Can Reorganize Items,将拖拽项拖到另一个HierarchyItem上,可以改变父子关系,这模拟了编辑器内的拖拽 parenting 操作。这一整套拖拽逻辑,包括命中测试、视觉反馈、数据传递,都是通过Unity的EventSystem(IPointerDownHandler,IDragHandler等)和自定义的DraggedReferenceSource组件协作完成的。
4.4 性能优化要点
层级视图的优化主要集中在减少不必要的操作和GC上:
- 对象池:
HierarchyItem的UI行也使用对象池,滚动时复用。 - 差异更新:刷新时,通过对比当前数据快照和上一帧快照,精确找出需要添加、移除或排序的项,而不是全量刷新。
- 延迟更新:
GameObject.name的访问和搜索匹配这类会产生GC的操作,都被放在独立的、频率更低的刷新周期中。 - 按需渲染:通常结合
ScrollRect使用,只创建和更新视口内的HierarchyItem,视口外的项被回收。这是处理大量物体的关键。
注意事项:如果你的游戏场景中有成千上万个动态生成和销毁的对象,频繁地将其添加到运行时层级视图中可能会带来开销。你可以利用
GameObjectFilter委托或RuntimeInspectorUtils.IgnoredTransformsInHierarchy静态集合,将不需要显示的对象(如粒子系统、特效等)过滤掉,这是提升性能最直接有效的方法。
5. 核心工具类与扩展点解析
除了两大核心系统,UnityRuntimeInspector还提供了一系列精良的工具类和扩展点,让集成和定制变得非常容易。
5.1 BoundInputField:智能的输入绑定
这不是一个普通的UnityInputField。BoundInputField是专门为检视器设计的,它解决了两个痛点:
- 实时验证:通过
OnValueChanged委托,你可以在用户每输入一个字符时进行验证。例如,对于NumberField,可以实时检查输入是否为有效数字,如果不是,则将输入框背景变红提示错误。 - 值缓存与回滚:当用户正在编辑输入框时,检视器的
Refresh()方法可能被调用,试图用字段的当前值更新输入框文本。BoundInputField的Text属性setter会判断:如果输入框当前有焦点(用户正在编辑),则忽略这次设置,并将传入的值缓存起来。当用户结束编辑(焦点离开)时,如果输入无效,则可以根据CacheTextOnValueChange设置,决定是回滚到最后一次有效输入的值,还是回滚到编辑开始前的值。这个设计避免了用户输入到一半时被自动刷新打断,体验非常好。
5.2 脚本API与事件钩子
RuntimeInspector和RuntimeHierarchy暴露了丰富的属性和事件,让你可以从代码完全控制它们。
Inspect(object obj)/StopInspect(): 动态切换检视的对象。Select(Transform selection)/Deselect(): 以编程方式控制层级视图中的选择。OnSelectionChanged(Hierarchy): 当选择变化时触发。OnInspectedObjectChanging(Inspector): 在检视对象即将改变前触发。这是一个委托,你可以挂接自己的函数,甚至返回一个新的对象来替换即将被检视的对象。文档中的例子是只允许检视带有Renderer组件的物体,否则返回null取消检视。ComponentFilter(Inspector): 一个委托,允许你过滤GameObject上显示的组件列表。比如,你可以隐藏所有MeshCollider组件。GameObjectFilter(Hierarchy): 一个委托,允许你过滤哪些Transform会显示在层级视图中。[RuntimeInspectorButton]属性:这是一个神来之笔。你可以把它加到任何自定义类的公共方法上。当这个类的对象被检视时,方法就会变成一个按钮显示在检视器中。参数isInitializer如果为true,方法返回的对象会被赋值回被检视的字段,这可以用来初始化空引用或修改结构体的字段。
5.3 内置拾取器:ColorPicker与ObjectReferencePicker
这两个是独立的、可重用的弹出式工具。
ColorPicker.Instance.Show(...): 显示颜色选择器。你需要传入颜色改变和确认时的回调、初始颜色和参考画布。它的实现本身也是一个复杂的UI,包含色相、饱和度、亮度等选择区域。ObjectReferencePicker.Instance.Show(...): 显示对象引用选择器。你需要传入一个对象数组、初始选择、以及如何获取对象显示名称的委托。当你在ObjectReferenceField上点击按钮时,内部调用的就是这个拾取器。
它们都被设计为单例,并且可以独立于检视器使用,你完全可以在游戏的其他UI中调用它们,比如让玩家自定义角色颜色。
6. 实战:集成与高级定制指南
理解了原理,我们来看看如何在实际项目中用好它,并解决一些常见问题。
6.1 基础集成步骤
- 安装:通过Asset Store、Git URL(
https://github.com/yasirkula/UnityRuntimeInspector.git)或OpenUPM安装包。 - 放置UI:将
RuntimeHierarchy和RuntimeInspector预制件拖入你的UI Canvas。 - 建立连接(可选):将Inspector拖到Hierarchy的
Connected Inspector属性上,实现选择联动。反之亦然。 - 配置皮肤:在预制件上指定
Skin,可以使用内置的LightSkin或DarkSkin,也可以自己创建。 - 调整设置:根据平台调整
Refresh Interval(移动端可以设大点,如0.5秒)、Pool Capacity等参数。
6.2 性能调优实战
- GC优化:这是重中之重。将检视器的
Refresh Interval从默认的0.25秒提高到0.5秒或1秒,可以立即减少一半以上的装箱操作带来的GC。对于变化不频繁的调试信息,这完全可接受。 - 过滤不需要的字段:在Inspector的
Settings资产中,使用Hidden Variables列表。你可以输入类型名(如“UnityEngine.Rigidbody”)并添加一个星号*,来隐藏该类型的所有字段。然后,在Exposed Variables中显式列出你真正需要看到的字段。这能显著减少反射遍历和UI构建的开销。 - 谨慎使用实时检视:不要在每帧都
Inspect一个高速变化的对象(如刚体的速度)。考虑在需要时(如点击某个调试按钮后)再开始检视,或者使用快照模式。 - 层级视图优化:对于大型场景,考虑禁用
Expose Unity Scenes,只使用伪场景来显示关键的调试物体。或者,使用GameObjectFilter委托过滤掉大量不重要的物体(如地形块、草等)。
6.3 常见问题与排查
问题一:检视器里某些字段不显示或显示为“Not Supported”。
- 排查:首先确认该字段是否是
public,或者是否有[SerializeField]属性。RuntimeInspector默认只显示可序列化的字段(Expose Fields设置为Serializable Only时)。如果你需要显示非序列化字段,需将Expose Fields改为All。 - 排查:检查该字段的类型。RuntimeInspector支持所有Unity可序列化类型和标记了
[System.Serializable]的自定义类/结构体。如果不支持,你需要为其创建自定义Drawer。 - 排查:检查是否被
Hidden Variables列表过滤掉了。
问题二:在检视器中修改了值,但游戏对象没有反应。
- 排查:最常见的原因是修改了值的副本而非引用。对于结构体(
struct),如Vector3、Color,在检视器中修改的是该结构体字段的一个副本。修改完成后,需要将这个副本赋值回原字段。确保你的自定义Drawer或IRuntimeInspectorCustomEditor正确地实现了值的回写(setter)。 - 排查:检查字段是否有属性设置器(
set)。如果只有get,那么它是只读的。 - 排查:对于某些Unity组件,某些属性在运行时是只读的(例如
Transform.position,你应该修改localPosition)。
问题三:自定义Drawer的UI显示不正常(错位、事件不响应)。
- 排查:确保你的Drawer预制件根节点上有正确的
Layout Element组件,并且Preferred Height设置合理。InspectorField基类会依赖这个来统一行高。 - 排查:确保
Variable Name Text和Variable Name Mask等关键引用在Inspector中正确赋值。参考内置的BoolField预制件是如何连接的。 - 排查:UI交互事件(如点击)是否被其他更大的UI元素(如背景)阻挡?检查RectTransform的层级和Raycast Target设置。
问题四:在IL2CPP等AOT平台上出现运行时错误。
- 排查:RuntimeInspector重度依赖反射。在IL2CPP下,需要通过链接XML文件或使用
[Preserve]属性来确保必要的类型和方法不被代码剥离(Strip)。确保你的自定义类型和使用了[RuntimeInspectorButton]的方法都被正确保留。 - 排查:对于通过字符串名称查找字段/属性的功能,在AOT下可能更脆弱。尽量使用
CreateDrawerForVariable这种传入MemberInfo的方式,而不是在运行时通过字符串动态查找。
6.4 高级定制案例:为技能系统创建专属检视器
假设我们有一个复杂的技能数据类SkillData,包含冷却时间、伤害、效果列表等。我们不想在默认检视器中展开所有细节,而是想提供一个更清晰、游戏策划专用的视图。
方案一:使用IRuntimeInspectorCustomEditor
[System.Serializable] public class SkillData { public string skillName; public float cooldown; public int damage; public List<Effect> effects; // Effect是一个自定义类 } [RuntimeInspectorCustomEditor(typeof(SkillData), false)] public class SkillDataEditor : IRuntimeInspectorCustomEditor { private NumberField cooldownField; private NumberField damageField; private ExpandableInspectorField effectsField; public void GenerateElements(ObjectField parent) { SkillData data = (SkillData)parent.Value; // 1. 显示一个只读的技能名标签 parent.CreateDrawer(typeof(string), "技能名称", () => data.skillName, null, false); // 2. 用更友好的标签显示冷却和伤害 cooldownField = (NumberField)parent.CreateDrawer(typeof(float), "冷却时间(秒)", () => data.cooldown, (val) => data.cooldown = (float)val); damageField = (NumberField)parent.CreateDrawer(typeof(int), "基础伤害", () => data.damage, (val) => data.damage = (int)val); // 3. 效果列表,但折叠显示,且标题自定义 effectsField = (ExpandableInspectorField)parent.CreateDrawer(typeof(List<Effect>), "效果列表", () => data.effects, (val) => data.effects = (List<Effect>)val); effectsField.HeaderVisibility = RuntimeInspector.HeaderVisibility.Collapsible; // 可以在这里进一步定制effectsField内部的显示... } public void Refresh() { // 可以在这里根据其他字段的值,动态改变某些字段的显示状态 // 例如,如果damage为0,则将cooldownField置灰 if (damageField != null) { damageField.SetInteractable((int)damageField.Value > 0); } } public void Cleanup() { // 清理自定义的引用 cooldownField = null; damageField = null; effectsField = null; } }方案二:使用RuntimeInspectorButton提供快捷操作
public class SkillData { // ... 字段同上 ... [RuntimeInspectorButton("重置为默认值", false, ButtonVisibility.InitializedObjects)] public void ResetToDefault() { cooldown = 5f; damage = 100; effects.Clear(); // 注意:对于结构体SkillData,这个修改可能不会自动反映到原字段! // 如果SkillData是某个类的字段,此方法修改的是这个SkillData实例本身,是有效的。 // 但如果被检视的就是一个SkillData结构体变量,则需要isInitializer为true并返回新值。 } [RuntimeInspectorButton("计算总伤害", false, ButtonVisibility.InitializedObjects)] public void CalculateTotalDamage() { int total = damage; foreach (var effect in effects) { total += effect.extraDamage; } Debug.Log($"技能 {skillName} 总伤害: {total}"); } }通过这样的深度定制,RuntimeInspector就从一个通用的调试工具,转变为你游戏数据编辑的强大助力。它背后的InspectorField与HierarchyData设计,提供了一套稳定、高效、可扩展的底层框架,让你能专注于业务逻辑的呈现,而不必担心UI系统的复杂性。这套设计模式的价值,已经远远超出了一个运行时检视器插件本身,值得你在构建任何复杂的动态配置UI时借鉴。