14.总结
14.1 知识点

主要内容

学习的主要内容
从该实践应该总结出什么


14.2 核心要点速览
项目概述
工具目标:对 Hierarchy 中选中的面板根物体,自动生成 ***Base.cs**(控件声明、Find 绑定、监听与空 virtual 桩),逻辑类继承 Base,减轻 Inspector 大量拖引用。
主流程:MenuItem 打开 EditorWindow → InitInfo 读 Selection、多类型 FindControl 拼 ControlStrInfo → string.Format 灌 .txt 模板 → OnGUI 双滚动区预览 → 写盘 → AssetDatabase.Refresh →(可选)编译完成后反射 **AddComponent**。
运行时行为:派生类 override Start 且调用 **base.Start()**;基类在 Start 里 transform.Find("路径").GetComponent<T>() 绑定 uGUI / TMP 等。
需求与入口
| 环节 | 要点 | 注意 |
|---|---|---|
| 入口 | GameObject → UI →「自动生成面板脚本文件」 | [MenuItem] |
| 窗口 | GetWindow,标题 自动生成面板脚本工具 |
Show 后可 InitInfo |
| 预览 / 存盘 | OnGUI 滚动预览;选择保存路径(早期 EditorUtility.SaveFilePanel) |
默认从 Application.dataPath 选 .cs |
| 痛点 | 多子物体时 Inspector 连线成本高 | 用路径查找代码替代 |
思路:入口贴近 Unity 创建 UI 的习惯路径;工具侧把「扫描 Hierarchy → 出代码」集中在 InitInfo 一次做完。
菜单、窗口与模板骨架
实现思路:
MenuItem里GetWindow+ **Show**,与课程一致可在打开时调用 **InitInfo()**,避免 OnGUI 首帧才拼串。UIConfigBase.txt/UIConfig.txt用string.Format填入类名与四段生成串。- 派生类模板里
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**(如重名同类型)→ 中止,不灌装模板。
扫描、过滤与嵌套路径
实现思路:
- 根物体:**
Selection.activeGameObject;收集:GetComponentsInChildren<T>()**(T : UIBehaviour)。 - defaultName 黑名单、与根同名子物体:不生成。
- **
Dictionary<string, Type>**:同名同类型 → 弹窗 + **return null**;同名不同类型 → 保留先收录,后续 **continue**。 - 嵌套:用
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 已有且 类型相同 | DisplayDialog → return 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 += |
| 条件 | arg1 含 Assembly-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 / panelScript;controlType.Clear()**;滚动位置归零 |
窗口不关时防串上一面板数据 |
panelBaseScript == "" |
只提示 「存在同名同类型控件,请先解决该问题」 | 不展示代码区 |
制作流程建议
- 菜单 + EditorWindow,接通
InitInfo与早期 SaveFilePanel(或直接进入 File 保存)。 - 配置
UIConfigBase.txt/UIConfig.txt占位符与 Base / 派生 结构。 - 实现
ControlStrInfo与按类型的FindControl累加链。 - 补 defaultName、Dictionary 重名规则与 **
GetPath**。 - LoadAssetAtPath 灌装与 OnGUI 双区预览。
- WriteAllText、子类存在性判断、Refresh。
- 需要自动挂脚本时:CompilationPipeline 回调 + LoadFrom + **
AddComponent**。 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;立刻取类型往往 null,AddComponent 失败。
Type.GetType("ClassName")默认还要带程序集限定名等行为细节,简单类名不可靠;从刚编好的 dll 用Assembly.GetType更贴近「刚落盘的新类」。- 订阅后须在成功路径 **
+=对应-=**,避免多次保存重复挂载或多次回调。
答题示例
文件刚 Write 进去,Unity 还没编译进程序集,类型在内存里还不存在,GetType 拿不到。
所以订阅编译完成事件,等 Assembly-CSharp.dll 好了再 LoadFrom 那个程序集,用 GetType 取类型 AddComponent。用完要取消订阅,不然每次编译都会再跑一次。
参考文章
- 12.脚本动态添加到面板
深度题
1. ControlStrInfo 的 operator+ 实现,对「不可变 / 副作用」有什么隐患?若要做成线程安全或多次复用拼接,你会怎么改?
题目
课程里 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**),仅panelName与 GameObject 名一致时才能命中。- 项目使用 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