14.UnityEditor实践项目总结

14.总结


14.1 知识点

主要内容

学习的主要内容

从该实践应该总结出什么



14.2 核心要点速览

项目概述

工具目标:对 Hierarchy 中选中的面板根物体,自动生成 ***Base.cs**(控件声明、Find 绑定、监听与空 virtual 桩),逻辑类继承 Base,减轻 Inspector 大量拖引用。

主流程MenuItem 打开 EditorWindowInitInfoSelection、多类型 FindControlControlStrInfostring.Format.txt 模板OnGUI 双滚动区预览 → 写盘AssetDatabase.Refresh →(可选)编译完成后反射 **AddComponent**。

运行时行为:派生类 override Start 且调用 **base.Start()**;基类在 Starttransform.Find("路径").GetComponent<T>() 绑定 uGUI / TMP 等。

需求与入口

环节 要点 注意
入口 GameObject → UI →「自动生成面板脚本文件」 [MenuItem]
窗口 GetWindow,标题 自动生成面板脚本工具 Show 后可 InitInfo
预览 / 存盘 OnGUI 滚动预览;选择保存路径(早期 EditorUtility.SaveFilePanel 默认从 Application.dataPath.cs
痛点 多子物体时 Inspector 连线成本高 用路径查找代码替代

思路:入口贴近 Unity 创建 UI 的习惯路径;工具侧把「扫描 Hierarchy → 出代码」集中在 InitInfo 一次做完。

菜单、窗口与模板骨架

实现思路

  1. MenuItemGetWindow + **Show**,与课程一致可在打开时调用 **InitInfo()**,避免 OnGUI 首帧才拼串。
  2. UIConfigBase.txt / UIConfig.txtstring.Format 填入类名与四段生成串。
  3. 派生类模板里 base.Start() 不能删,否则基类 Find / AddListener 不执行。
[MenuItem("GameObject/UI/自动生成面板脚本文件")]
private static void CreateToolPanel()
{
    UIPanelTool win = EditorWindow.GetWindow<UIPanelTool>("自动生成面板脚本工具");
    win.Show();
    win.InitInfo();
}

UIConfigBase 占位符

占位 填入
{0} 面板名 / 类名
{1} 控件 public 声明
{2} Find + GetComponent
{3} AddListener
{4} protected virtual 空响应函数

ControlStrInfo 与按类型累加

关键点:每个控件贡献四段字符串;多控件用 operator+ 合并成整面板的 **nameStr / findStr / listenerStr / funcStr**。

字段 含义
nameStr 声明段
findStr 查找段
listenerStr 监听段
funcStr 响应函数桩
public static ControlStrInfo operator +(ControlStrInfo one, ControlStrInfo two)
{
    one.nameStr += two.nameStr;
    one.findStr += two.findStr;
    one.listenerStr += two.listenerStr;
    one.funcStr += two.funcStr;
    return one;
}

类型扫描顺序(依次 FindControl<T>strInfo +=):Button → Toggle → Slider → InputField → Dropdown → ScrollRect → Text → TextMeshProUGUI → Image → RawImage

失败处理:任一步 **FindControl 返回 null**(如重名同类型)→ 中止,不灌装模板。

扫描、过滤与嵌套路径

实现思路

  1. 根物体:**Selection.activeGameObject;收集:GetComponentsInChildren<T>()**(T : UIBehaviour)。
  2. defaultName 黑名单、与根同名子物体:不生成
  3. **Dictionary<string, Type>**:同名同类型 → 弹窗 + **return null**;同名不同类型 → 保留先收录,后续 **continue**。
  4. 嵌套:用 GetPath 从控件向上拼到面板根的 **父/…/子**,生成 **Find(完整路径)**,避免只在根上 Find(单名) 失效。
private string GetPath(Transform obj, Transform panelTrans)
{
    string path = obj.name;
    while (obj.parent != panelTrans)
    {
        path = obj.parent.name + "/" + path;
        obj = obj.parent;
    }
    return path;
}
过滤规则 行为
名在 defaultName(含 Scrollbar Horizontal/Vertical 等) 跳过
子物体名 与根面板同名 跳过
Dictionary 已有且 类型相同 DisplayDialogreturn null
同名 不同类型(如 Button + Image 保留先扫到的类型,其余 continue

模板灌装与窗口预览

思路:**AssetDatabase.LoadAssetAtPath<TextAsset>** 读模板 → string.Format 得到 panelBaseScript / panelScript → 两个 **EditorGUILayout.BeginScrollView**(各持 **Vector2**)+ **GUILayout.Label**,中间分隔,底部保存按钮。

nowPos = EditorGUILayout.BeginScrollView(nowPos);
GUILayout.Label(panelBaseScript);
EditorGUILayout.EndScrollView();

落盘策略

顺序 说明 注意
1 File.WriteAllText*Base.cs 基类可反复生成
2 路径去 Base 得子类 .cs 已存在则不写子类,防覆盖手写逻辑
3 AssetDatabase.Refresh 触发导入与编译

关键点:先保证 Base 落盘;子类仅在不破坏已有手写逻辑的前提下首次生成。

编译后挂脚本

思路:新脚本写入后类型尚未进域;监听 Assembly-CSharp.dll 编完再 **LoadFrom + GetType(panelName) + AddComponent**;成功路径 -= 取消订阅,避免重复回调。

步骤 内容
订阅 CompilationPipeline.assemblyCompilationFinished +=
条件 arg1Assembly-CSharp.dll
反射 Assembly.LoadFrom(工程根 + 回调路径);GetType(panelName)
挂载 Selection.activeGameObject.AddComponent(...)
收尾 -= 取消订阅
// 与同文件其它编辑器代码一并:using UnityEditor; using UnityEditor.Compilation;
private void CompilationPipeline_assemblyCompilationFinished(string arg1, CompilerMessage[] arg2)
{
    if (arg1.Contains("Assembly-CSharp.dll"))
    {
        var assembly = System.Reflection.Assembly.LoadFrom(
            Application.dataPath.Substring(0, Application.dataPath.LastIndexOf("Assets")) + arg1);
        Selection.activeGameObject.AddComponent(assembly.GetType(panelName));
        CompilationPipeline.assemblyCompilationFinished -= CompilationPipeline_assemblyCompilationFinished;
    }
}

状态清理与异常提示

时机 操作 注意
InitInfo 开头 清空 **panelBaseScript / panelScriptcontrolType.Clear()**;滚动位置归零 窗口不关时防串上一面板数据
panelBaseScript == "" 只提示 「存在同名同类型控件,请先解决该问题」 不展示代码区

制作流程建议

  1. 菜单 + EditorWindow,接通 InitInfo 与早期 SaveFilePanel(或直接进入 File 保存)。
  2. 配置 UIConfigBase.txt / UIConfig.txt 占位符与 Base / 派生 结构。
  3. 实现 ControlStrInfo 与按类型的 FindControl 累加链。
  4. defaultNameDictionary 重名规则与 **GetPath**。
  5. LoadAssetAtPath 灌装与 OnGUI 双区预览。
  6. WriteAllText、子类存在性判断、Refresh
  7. 需要自动挂脚本时:CompilationPipeline 回调 + LoadFrom + **AddComponent**。
  8. InitInfo 入口统一清空状态,避免重复打开工具时字典与脚本串场。

14.3 面试题精选

进阶题

1. 为什么要生成 *Base.cs 与派生类两份脚本?子类已存在时为什么不覆盖?

题目

工具生成 ***Base.cs**(工具反复覆盖)和 派生面板类(手写逻辑)分离,有什么好处?保存时若子类 .cs 已存在 就跳过写入,是出于什么考虑?

深入解析
  • Base:字段声明、Find、默认监听与空 virtual 桩都由工具维护,改 Hierarchy 后重生成 Base 即可对齐引用,避免与手写逻辑混在同一文件里被冲掉。
  • 派生类:业务写在 override 与自定义方法里;不覆盖已存在子类 是为了保护用户已写的 Start 外逻辑,只通过 Base 更新 + 手动合并解决差异。
  • 若每次都覆盖子类,一次误点保存就会清空面板业务代码,工程上不可接受。
答题示例

Base 专门放「工具生成的样板」:声明、Find、监听绑定。子类继承 Base,只写真正的业务。

子类文件如果已经存在,说明人写过逻辑了,再覆盖会把业务删掉,所以只写 Base,子类用 File.Exists 判断,存在就跳过。

参考文章
  • 3.脚本模板配置
  • 11.脚本保存

2. 嵌套子物体下为什么用 GetPath 拼完整路径再 Find,而不是只用控件名字?

题目

生成代码里用 **transform.Find("父/子/…")**,和只在根上 Find(控件名) 相比,解决什么问题?

深入解析
  • Transform.Find 默认从当前 transform 的子层级按路径查找;若同名节点出现在不同分支,单名可能找错或找不到。
  • GetPath 从控件一路向上拼到面板根,得到唯一相对路径,生成的 Find 与 Hierarchy 结构一致,重名兄弟较少冲突。
  • 局限:仍依赖路径字符串,改名、挪层级后要重新生成或维护路径(与 SerializeField 拖拽相比各有利弊)。
答题示例

子物体一深,不同分支下可能有同名节点,只写名字 Find 容易找错。

GetPath 从控件往上爬到面板根,拼出像 Panel/BtnArea/OK 这种路径,Find 就稳定对到那一个。

参考文章
  • 7.子对象嵌套
  • 5.控件查找

3. 保存后为什么要等 Assembly-CSharp 编完再用反射 AddComponent,而不是立刻 Type.GetType

题目

课程里用 **CompilationPipeline.assemblyCompilationFinished**,在 Assembly-CSharp.dll 编完后 LoadFrom + GetType(panelName) 再 **AddComponent**。为什么不能刚写完脚本就 **Type.GetType(panelName)**?

深入解析
  • 新生成的类型要等 C# 编译完成 才进入 Assembly-CSharp;立刻取类型往往 nullAddComponent 失败。
  • Type.GetType("ClassName") 默认还要带程序集限定名等行为细节,简单类名不可靠;从刚编好的 dllAssembly.GetType 更贴近「刚落盘的新类」。
  • 订阅后须在成功路径 **+= 对应 -=**,避免多次保存重复挂载或多次回调。
答题示例

文件刚 Write 进去,Unity 还没编译进程序集,类型在内存里还不存在,GetType 拿不到。

所以订阅编译完成事件,等 Assembly-CSharp.dll 好了再 LoadFrom 那个程序集,用 GetType 取类型 AddComponent。用完要取消订阅,不然每次编译都会再跑一次。

参考文章
  • 12.脚本动态添加到面板

深度题

1. ControlStrInfooperator+ 实现,对「不可变 / 副作用」有什么隐患?若要做成线程安全或多次复用拼接,你会怎么改?

题目

课程里 operator+ 对 **one 的各字段做 += 并返回 one**。这在多次 strInfo += controlInfo 时语义上可行,但从 API 设计看有什么问题?如何改进?

深入解析
  • 变异(mutation)a + b 在数学上常期望新值,这里却修改了左操作数;若外部仍持有 one 的引用,可能被意外改状态,调试困难。
  • +=:依赖「始终在同一份 strInfo 上累加」,与「返回新实例的不可变拼接」相比,可读性略差但分配更少,编辑器工具里可接受。
  • 改进方向:**static 合并函数**返回新 **ControlStrInfo**,或 StringBuilder 四段 最后再 ToString,语义更清晰;多线程下编辑器侧一般单线程,若扩展需避免共享可变结构。
答题示例

现在的加号重载其实是「往左边的对象里继续追加字段」,不是生成一个新的 ControlStrInfo。别人如果还拿着左边那个引用,可能被悄悄改掉。

更干净的做法是每次合并 new 一个,或者用 StringBuilder 拼完再一次性塞进结构里,语义是「加完得到新结果」,副作用小。

参考文章
  • 4.拼接数据结构
  • 8.结构数据拼接

2. 用 assembly.GetType(panelName) 挂脚本时,命名空间、程序集拆分后可能踩哪些坑?

题目

面板类若在 namespace,或运行时类型不在 Assembly-CSharp(如 Asmdef 拆程序集),当前「类名 = 根物体名 + 从 Assembly-CSharp 取类型」的方案会出什么问题?

深入解析
  • GetType 对嵌套类型、namespace 需要完整类型名(如 **My.UI.LoginPanel**),仅 panelNameGameObject 名一致时才能命中。
  • 项目使用 Assembly Definition 后,面板脚本可能不在 Assembly-CSharp.dll,监听 Assembly-CSharp 完成等不到该类型,需改为按目标程序集TypeCache/遍历程序集 查找。
  • 工程上更稳的做法:约定命名空间、配置表里存 类型全名,或 MonoScript / AssetDatabase 由脚本资产反查类型,而不是仅凭物体名猜。
答题示例

GetType 传简单类名,只有默认命名空间且在 Assembly-CSharp 里才好用。

一旦加了 namespace 或把 UI 放到单独 asmdef,dll 名字变了,Assembly-CSharp 编完也拿不到那个类型,就要改成全名,或者去对的程序集里 GetType,甚至从 MonoScript 资源反查类型。

参考文章
  • 12.脚本动态添加到面板


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏