# UIFrame **Repository Path**: Jerry12186/uiframe ## Basic Information - **Project Name**: UIFrame - **Description**: 基于 Unity 的 UI 框架,旨在简化 UI 管理和组件创建,并提供一些常用的 UI 工具和功能 - **Primary Language**: C# - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 11 - **Forks**: 3 - **Created**: 2025-03-07 - **Last Updated**: 2026-09-18 ## Categories & Tags **Categories**: desktop-ui **Tags**: None ## README # UI Frame(Jerry UI Framework) 基于 UGUI 的轻量级 Unity UI 框架:窗口生命周期管理、类型安全事件总线、服务容器、红点树、扩展组件与代码生成器。 - 包名:`com.jerry.uiframe` 版本:`0.4.3` - Unity:`2022.3+` - 依赖:`com.unity.ugui 1.0.0`、`com.unity.textmeshpro 3.0.9`、[UniTask](https://github.com/Cysharp/UniTask)(异步窗口加载与资源加载) - 内置:DOTween(`Plugins/DOTween`,供 `JText` / `JTextMesh` 打字机使用) - AI支持:AI Skill,[uiframe-workspace.zip](uiframe-workspace.zip) --- ## 1. 安装 ### 方式一:Package Manager(推荐) `Window → Package Manager → + → Add package from git URL`,填入仓库地址。 ### 方式二:本地放置 把整个仓库放到 `Assets/UIFrame/` 下(`CreateUI` 的路径探测逻辑里写死了这个目录名)。 ### 安装后必做的两件事 框架运行时靠两个**固定路径**的资源取东西,缺一个就跑不起来: | 资源 | 位置 | 作用 | 怎么来 | |------|------|------|--------| | `UiPathConfig.asset` | `Assets/Resources/UiPathConfig.asset` | 存三个生成路径 | 菜单 `GameObject → 生成UI基类` 时会自动弹窗引导创建;也可手动建 `AllPaths` 资产 | | `2DCanvas.prefab` | `Assets/Resources/2DCanvas.prefab` | UI 根节点(Canvas + UICamera + AudioSource) | **需手动复制**:`Packages/UI Frame/Editor/UIPrefabs/2DCanvas.prefab` → `Assets/Resources/` | `AllPaths` 的三个字段(决定脚本与预制体生成到哪): ```csharp public string baseUiScriptsPath = "Assets/Scripts/UI/Base/"; // Base{名}.cs public string prefabPath = "Assets/Resources/UIPrefabs/"; // 窗口预制体 public string uiScriptsPath = "Assets/Scripts/UI/Win/"; // 业务窗口脚本 ``` > ⚠️ `prefabPath` 默认在 `Resources` 下——框架默认加载器走 `Resources.Load`。 > 若你的预制体不在 `Resources` 下(比如走 Addressables / AssetBundle), > 必须在启动时用 `UIConfig.SetLoaderExtension(typeof(MyLoader))` 换成自定义加载器(见 §9.1)。 --- ## 2. 三分钟上手 **① 摆界面**:`GameObject → 创建UI → UI容器` 建出窗口根物体,往里加按钮、文本等控件。 物体命名必须以 `UI` 开头、以 `Win` 结尾,例如 `UILoginWin`。 **② 生成代码**:选中该物体 → `GameObject → 生成UI基类`。会做三件事: - 生成 `Assets/Scripts/UI/Base/BaseUILoginWin.cs`(按子物体名字前缀自动声明字段 + `Awake` 里 `transform.Find` 赋值) - 生成 `Assets/Scripts/UI/Win/UILoginWin.cs`(业务脚本,**仅当文件不存在时创建**,不会被覆盖) - 把选中物体存成 `Assets/Resources/UIPrefabs/UILoginWin.prefab` **③ 写逻辑**:只写 `UI/Win/` 下的脚本,`BaseXXX.cs` 是生成产物,改了会被冲掉。 ```csharp using System; using Jerry.UiFrame; using UI.Base; namespace UI.Win { public class UILoginWin : BaseUILoginWin { protected override void OnInitData() // 只跑一次:拿引用、订阅事件 { Btn_Login.onClick.AddListener(OnLogin); AddEvent(OnGoldChanged); // 事件订阅只能写在这里 } protected override void OnOpen() // 每次打开都跑:刷数据 { Text_Title.text = "登录"; } private void OnLogin() { /* ... */ } private void OnGoldChanged(GoldChangedEvent e) => Text_Gold.text = $"金币:{e.Gold}"; } } ``` **④ 打开窗口** ```csharp UIManager.Instance.OpenWindow(); // 同步 await UIManager.Instance.OpenWindowAsync(); // 异步(UniTask) UIManager.Instance.CloseWindow(); ``` > **窗口类名必须等于预制体名**——`UIManager` 用 `typeof(T).Name` 拼路径加载, > `Resources.Load(prefabPath 去掉 "Assets/Resources/" + 窗口类名)`。 --- ## 3. 目录结构 ``` UIFrame/ ├── Runtime/ │ ├── AllPaths.cs # 路径配置资产(UiPathConfig.asset 的类型) │ ├── UI/Core/ │ │ ├── UIManager.cs # 窗口管理:打开/关闭/缓存/优先级/弹窗队列 │ │ ├── UIBase.cs # 窗口基类:生命周期、参数、事件订阅 │ │ ├── UIConfig.cs # 全局配置:最大窗口数、遮罩色、相机、加载器 │ │ ├── JLoader.cs # 默认资源加载器(Resources),可替换 │ │ ├── JTreeData.cs # 树组件的数据基类(抽象类,需继承) │ │ └── Component/ # 全部 UI 组件(见 §8) │ ├── UI/Shader/ # TextOutline / TextShadow / SequenceAnimation + 材质 │ └── Tools/ │ ├── EventBus/ # 类型安全事件总线(新) │ ├── EventMgr.cs # 旧字符串事件(已标记废弃) │ ├── Services/ # 服务容器 │ ├── RedPoint/ # 红点树系统 │ ├── Timer/ # 定时器 │ ├── CoroutineTool.cs # 全局协程宿主 │ └── NetImageCache.cs # 网络图片缓存 ├── Editor/ │ ├── CreateUI.cs # GameObject/创建UI/* 菜单 │ ├── GenUIBase.cs # GameObject/生成UI基类(代码+预制体生成) │ ├── CreateAsset.cs # UiPathConfig 创建窗口 │ ├── UIPrefabs/ # 菜单用的组件模板预制体 │ └── ComponentExtra/ # JList/JTree/JText 等的自定义 Inspector ├── Plugins/DOTween/ # 内置 DOTween ├── Samples/ # Samples.unitypackage、RedPointSample.unitypackage └── package.json ``` --- ## 4. 窗口系统 ### 4.1 生命周期 ``` 首次打开 Instantiate(prefab) → AddComponent() → Awake() 挂到 Canvas、归零 RectTransform、置底 ↓(下一帧) Start() ├── OnInitData() ← 只一次,之后禁止 AddEvent ├── 遮罩处理(根上有 JImage 时染遮罩色) └── OnOpen() ← 第一次打开 再次打开(窗口还在缓存里) ReceiveParams(p) → OnOpen() → SetActive(true) → SetAsLastSibling() 被别的窗口关闭、重新成为最顶层 OnReActivate() 窗口被回收/销毁 OnDestroy() → 自动退订本窗口所有 EventBus 订阅 ``` | 方法 | 时机 | 该干什么 | |------|------|----------| | `OnInitData()` | 仅一次 | 取组件引用、**订阅事件**、一次性初始化 | | `OnOpen()` | 每次打开 | 刷新数据显示(从 Service 拉快照) | | `OnReActivate()` | 上层窗口关闭后重新置顶 | 局部刷新 | > `AddEvent` **只能写在 `OnInitData` 里**。写在外面会被 `CheckAddEvent()` 拦下并输出 > `XXX AddEvent 建议在OnInitData()使用` 警告(返回空令牌,订阅不发生)。 ### 4.2 打开 / 关闭 ```csharp // 同步 T OpenWindow(bool bStay = false, params object[] p) T OpenWindow(params object[] p) // 异步(UniTask) UniTask OpenWindowAsync(bool bStay = false, params object[] p) UniTask OpenWindowAsync(params object[] p) void CloseWindow() // 隐藏(缓存,不销毁) void DestroyWindow() // [Obsolete] 框架会自动回收,别手动调 void DestroyAllWindow() T GetWindow() // 取已打开窗口,没打开返回 null ``` > ⚠️ **重载陷阱**:`OpenWindow(bool bStay = false, params object[] p)` 与 > `OpenWindow(params object[] p)` 并存。传**单个 `bool`** 会被优先匹配到 `bStay`, > 永远传不进 `Param`。要传 bool 参数,包一层或改传其它类型。 ### 4.3 参数传递 `OpenWindow` 的可变参数原样落到 `protected object[] Param`: ```csharp UIManager.Instance.OpenWindow(itemId, "来自背包"); protected override void OnOpen() { int id = (int)Param[0]; string from = (string)Param[1]; } ``` - 不传参时是 `params` 空数组,**零装箱零分配**;传值类型会有装箱开销。 - 参数只在 `OnOpen` / `OnInitData` 里有效——**不要把 `Param` 当数据通道**。 会变的数据放 Service,通过 EventBus 推送(见 §5、§6)。 ### 4.4 缓存、优先级与自动回收 - 关闭 = `SetActive(false)`,窗口留在 `_activeWindows` 里,**下次打开不再实例化**。 - 每次打开分配递增的 `Priority`;`bStay: true` 的窗口优先级是 `int.MaxValue`,**不会被自动回收**。 - 窗口数超过 `UIConfig.MaxExistWindows`(默认 10)时,自动销毁 `Priority` 最小的那个。 ```csharp UIConfig.MaxExistWindows = 15; // 缓存上限 UIConfig.UiMaskColor = new Color(0, 0, 0, 0.6f); UIConfig.UiCamera = someCamera; // 不设就用 2DCanvas 自带的 UICamera UIConfig.BtnClickSoundPath = "Audio/click"; // 全局按钮音效 UIConfig.UiAudioVolume = 0.8f; ``` ### 4.5 遮罩点击关闭 窗口根物体上挂 `JImage` 时会被当作遮罩:自动染成 `UIConfig.UiMaskColor`; 把 `IsClickMaskClose = true`(序列化字段,Inspector 可勾),点遮罩即 `CloseWindow()`。 ### 4.6 顺序弹窗队列 适合"登录奖励 → 升级弹窗 → 公告"这类排队弹的场景: ```csharp UIManager.Instance.AddPopWin(10); UIManager.Instance.AddPopWin(20); UIManager.Instance.AddPopWin(30); UIManager.Instance.ShowPopWin(); // 按 priority 从小到大依次弹出 ``` 关掉一个 PopUp 会自动弹下一个。 注意 `OpenPopWindow` 是**反射**找类型的:在所有程序集里查 `UI.Win.{窗口名}`, 所以走队列的窗口必须落在 `UI.Win` 命名空间下(生成器默认输出就是这里)。 ### 4.7 异步加载 `OpenWindowAsync` 用 UniTask + `Object.InstantiateAsync`,并做了并发保护: 同一个窗口在加载途中被重复请求,后来的请求会 `await` 等第一次加载完再复用,不会打出两份。 --- ## 5. 事件总线(EventBus) 类型安全、按事件类型分发的静态事件总线,用来替代旧的字符串 `EventMgr`。 **事件载体**:实现 `IEvent` 标记接口。推荐 `readonly struct`,零分配。 ```csharp public readonly struct GoldChangedEvent : IEvent { public readonly int Gold; public GoldChangedEvent(int gold) => Gold = gold; } ``` **发布**: ```csharp EventBus.Publish(new GoldChangedEvent(100)); // 有载荷 EventBus.Publish(); // 无载荷(要求 : IEvent, new()) ``` **订阅**(两种,任选): ```csharp // ① 委托方式:逻辑简单、只服务当前窗口 AddEvent(OnGoldChanged); // 窗口内(自动退订) var token = EventBus.Subscribe(OnGoldChanged); // 任意处 // ② 接口方式:逻辑复杂、要跨窗口复用 / 要脱离 Unity 单测 public class GoldHudPresenter : IEventHandler { private readonly Action _setText; public GoldHudPresenter(Action setText) => _setText = setText; public void Handle(GoldChangedEvent e) => _setText($"金币:{e.Gold}"); } AddEvent(new GoldHudPresenter(s => Text_Gold.text = s)); ``` **退订**: ```csharp token.Dispose(); // 单个(重复调用无害) EventBus.Unsubscribe(token); EventBus.UnsubscribeAll(); // 某事件全部 EventBus.UnsubscribeAll(); // 全局清空 ``` 窗口里用 `AddEvent` 订阅的会在 `OnDestroy` 自动退订,**不用手动管**。 其它性质:单个处理器抛异常会被 `try/catch` 吞掉并 `LogError`,不影响其余订阅者; 内部是数组 + 死槽标记,退订到一定比例自动压缩。 > `EventMgr`(字符串名 + `params object[]`,含装箱)仍在仓库里,属于**遗留实现**, > `EventEnum.RedPointUpdate` 已标 `[Obsolete]`。新代码一律用 `EventBus`。 --- ## 6. 服务容器(Services) 统一登记业务服务,替代"每个模块写一个 singleton"。 ```csharp public interface IGoldService : IServices // IServices : IDisposable { int Gold { get; } } public class GoldService : IGoldService { public int Gold { get; private set; } public void Dispose() { } } // 组合根(游戏启动处) Services.Register(new GoldService()); // 使用 var gold = Services.Get(); if (Services.TryGet(out var bag)) { /* 可选服务 */ } Services.Unregister(); // 会调 Dispose Services.Clear(); // 清空并逐个 Dispose ``` | API | 说明 | |-----|------| | `Register(T)` | 登记,重复登记抛 `InvalidOperationException` | | `RegisterOrReplace(T)` | 登记或替换,旧实例自动 `Dispose` | | `Get()` / `TryGet(out T)` | 获取;未登记时前者抛异常 | | `IsRegistered()` / `Count` / `GetRegisteredTypes()` | 查询(后者会分配数组,别在热路径调) | | `Unregister()` / `Clear()` | 注销,自动 `Dispose` | > ⚠️ **注册和获取必须用同一个类型实参**:`Register(new GoldService())` 之后 > 只能 `Get()`,`Get()` 会抛"尚未登记"—— > 字典 key 取的是泛型实参的静态类型。写错编译期不报错,运行期才炸。 已用 `[RuntimeInitializeOnLoadMethod]` 在进入播放模式时清空,关闭 Domain Reload 也不会残留。 --- ## 7. 红点系统 前缀树模型:**名字即路径**,父子用 `_` 分隔。 ```csharp // 1) 声明叶子节点(只填叶子,父节点会自动补出来) RedPoints.TreeNodes = new[] { "Root_Mail", "Root_Task_Daily", "Root_Task_Weekly" }; // 或运行时追加 RedPoints.AddNode("Root_Bag"); // 2) 启动处初始化 RedPointMgr.Instance.Init(); // 3) 改数量 RedPointMgr.Instance.AddPointCnt("Root_Mail", 5); // 4) 读取(父节点自动汇总:Root_Task = Daily + Weekly) int mail = RedPointMgr.Instance.GetRedPointCnt("Root_Mail"); int task = RedPointMgr.Instance.GetRedPointCnt("Root_Task"); int all = RedPointMgr.Instance.GetRedPointCnt("Root"); // 5) UI 侧订阅更新事件(无载荷,收到后自己拉) AddEvent(_ => RefreshRedPoint()); ``` 要点与坑: - **节点名必须以 `Root_` 开头**。`CreateNode` 靠最后一个 `_` 递归找父节点,名字不合规会抛 `ArgumentOutOfRangeException`。 - **`SetPointCnt` 是增量,不是赋值**(内部 `redPointCnt += num`)。清零要传**负增量**: `SetPointCnt("Root_Mail", -RedPointMgr.Instance.GetRedPointCnt("Root_Mail"))`。 - **只能给叶子节点设值**,给有 children 的节点设会 `LogError` 并忽略。 - **节点名写错静默失效**:`SetPointCnt` 找不到节点时**不报错不警告**,红点永远不亮。这是最难查的一类问题。 - 计数下限被钳在 `-1`(`redPointCnt < -1` 时归 `-1`),且 `== -1` 时不再向上冒泡。 - 更新事件 `RedPointUpdatedEvent` 是**空载荷 struct**——订阅方只能全量拉 `GetRedPointCnt`。 --- ## 8. 组件 所有组件实现 `IComponent`:`SetVisible(bool)`、`GetChild(string childName)`。 命名统一 `J` 前缀,都继承自对应 UGUI 原生类,可直接当原生组件用。 | 组件 | 继承 | 额外能力 | |------|------|----------| | `JButton` | `Button` | 长按、长按重复、按下/抬起、带 `GameObject` 参数的同名事件、点击音效 | | `JImage` | `Image` | 长按(`onPress`)、`ImgSrc` 直接填本地路径或 http(s) URL | | `JText` | `Text` | DOTween 打字机(点击跳过,支持 color/size 富文本标签) | | `JTextMesh` | `TextMeshProUGUI` | 同上 + 链接点击 `OnLinkClick` | | `JInput` / `JInputTMP` | `InputField` / `TMP_InputField` | — | | `JDropdown` / `JDropdownTMP` | `Dropdown` / `TMP_Dropdown` | — | | `JSlider` | `Slider` | — | | `JToggle` | `Toggle` | — | | `JList` | `ScrollRect` | 虚拟列表:只创建可视区 + 缓冲数量的格子,滚动时复用 | | `JListGrid` | `Selectable` | 列表项,单选/多选、选中态高亮 | | `JListLoop` | `JList` | 循环滚动,松手吸附到 `selectedRect` 定格位 | | `JTree` | `ScrollRect` | 树形展示,节点展开/折叠 | | `JTreeNode` | `UIBehaviour` | 树节点(要求子节点含 `Close`/`Open`/`Selected`) | | `JPanel` | `MonoBehaviour` | 容器,可勾选启用刘海屏安全区适配 | ### JButton ```csharp var btn = GetChild("Btn_OK") as JButton; btn.onClick.AddListener(OnOk); btn.onLongPress.AddListener(OnLongPress); // 按住 holdTime(默认1s) 触发 btn.onPressRepeat.AddListener(OnRepeat); // 之后每 repeatRate(默认0.3s) 触发 btn.onBtnDown.AddListener(OnDown); btn.onBtnUp.AddListener(OnUp); btn.onClickObj.AddListener(go => Debug.Log(go.name)); // 带 GameObject 参数版 btn.clickSound = "Audio/special"; // 留空则用 UIConfig.BtnClickSoundPath ``` ### JList ```csharp var list = GetChild("List_Item") as JList; list.OnItemRender += (grid, index) => // 只渲染可视范围内的格子 { grid.GetChild("Text_Name").text = _items[index].Name; }; list.OnItemClick += (grid, index) => Select(index); list.ItemCount = 200; // 赋值即触发布局与刷新 list.SelectedIndex = 3; // 单选,改完需重新赋 ItemCount 刷新 list.ScrollToIndex(50, bHasAnim: true); list.ResetContentPos(); // 数据换了重置滚动位置 list.ClearMulSelected(); // 清空多选 ``` - ``‼️注意‼️ ItemCount赋值前,必须先绑定渲染函数`` - 单选/多选由 Inspector 上的 `multiple`、`singleOff`(单选时允许全部不选中)控制。 - 列表项预制体放在 `grid` 字段上,模板会先 `SetActive(false)`。 - 格子的子物体名字按 `GetChild("名字")` 取,路径是**相对于该格子**的。 - 布局算不出来(比如 viewport 尺寸为 0)时,勾 `forceUpdateCanvas` 会延后一帧再初始化。 ### JTree 数据侧继承抽象类 `JTreeData`: ```csharp public class MyTreeData : JTreeData { } var root = new MyTreeData { Name = "Root" }; var dir = new MyTreeData { Name = "第一章" }; dir.AddChild(new MyTreeData { Name = "1-1" }); dir.AddChild(new MyTreeData { Name = "1-2" }); root.AddChild(dir); var tree = GetChild("Tree_Menu") as JTree; tree.OnTreeRender = (node, data) => node.GetChild("Text_Name").text = data.Name; tree.OnClick = (node, data) => Debug.Log($"点击 {data.Name}"); tree.Data = root; // 赋值即构建,默认只展开第一层 ``` > 节点预制体下需要有 `Close` / `Open` 两个子物体(折叠/展开图标), > 叶子下需要有 `Selected`(选中高亮),名字写死在 `JTreeNode.Awake` 里。 ### JText 打字机 Inspector 上把 `effectType` 设为 `Typewriter`,`delayTime` 是**每个字**的间隔。 运行时点一下文本可立即播完,播完回调 `OnTypewriterComplete`。 --- ## 9. 工具类 ### 9.1 JLoader(资源加载器,可替换) ```csharp public class MyLoader : JLoader { public override T LoadExternalSync(string path) // 同步 => Addressables.LoadExternalSync(path).WaitForCompletion(); public override async UniTask LoadExternalAsync(string path) // 异步 { var handle = Addressables.LoadExternalAsync(path); return await handle.Task; } } UIConfig.SetLoaderExtension(typeof(MyLoader)); // 启动处调用一次 ``` > 默认实现走 `Resources.Load` / `Resources.LoadAsync`。 ### 9.2 Timer ```csharp var t = TimerManager.AddTimer(1.5f, () => Debug.Log("到点了")); TimerManager.AddTimer(0.5f, OnTick, bLoop: true, bUseUnscaledTime: true); // 循环 / 不受 Time.timeScale 影响 TimerManager.PauseTimer(t); TimerManager.ResumeTimer(t); TimerManager.StopTimer(t); float p = t.Progress; // 0~1 ``` 首次 `AddTimer` 会自动建一个 `DontDestroyOnLoad` 的 `TimerManager` 物体驱动 `Update`。 ### 9.3 CoroutineTool ```csharp CoroutineTool.Instance.StartCoroutine(MyRoutine()); // 全局协程宿主,自动创建 + DontDestroyOnLoad ``` ### 9.4 NetImageCache / JImage.ImgSrc ```csharp img.ImgSrc = "https://xxx.com/icon.png"; // 自动下载 + 建 Sprite + 入缓存 img.ImgSrc = "Icons/sword"; // 本地:走 JLoader(默认 Resources 路径) img.IsAysncLoad = false; // 本地图改同步加载 img.OnImgDownloaded = sp => { /* ... */ }; NetImageCache.Instance.Get(url); // 手动查缓存 ``` ### 9.5 Shader `Runtime/UI/Shader/` 提供 `TextOutline`(描边)、`TextShadow`(阴影)、 `SequenceAnimation`(序列帧)三个 shader 与配套材质,直接赋给 `Graphic.material` 即可。 --- ## 10. 编辑器工具 ### 菜单:`GameObject → 创建UI /*` 一键建容器 / 按钮(普通 & TMP)/ 图片 / 列表(常规、循环、树)/ 滑动条 / 文本(普通 & TMP)/ 多选 / 单选 / 输入框(普通 & TMP)/ 下拉框(普通 & TMP)。 除"UI容器"外都要求先选中一个父物体。 ### 菜单:`GameObject → 生成UI基类` 选中**场景中的**窗口根物体(名字 `UI...Win`)后执行: 1. 递归收集所有子物体,按**名字前缀**映射成字段类型: | 前缀 | 字段类型 | 前缀 | 字段类型 | |------|----------|------|----------| | `Btn_` | `JButton` | `List_` | `JList` | | `Img_` | `JImage` | `ListLoop_` | `JListLoop` | | `Text_` | `JText` | `Tree_` | `JTree` | | `TMP_` | `JTextMesh` | `Panel_` | `GameObject` | | `Slider_` | `JSlider` | `Input_` / `InputTMP_` | `JInput` / `JInputTMP` | | `Toggle_` | `JToggle` | `Drop_` / `DropTMP_` | `JDropdown` / `JDropdownTMP` | 2. 生成 `Base{名}.cs`(字段声明 + `Awake` 里 `transform.Find(...)` 赋值,**每次都会覆盖**) 3. 生成业务脚本 `{名}.cs`(**仅在不存在时创建**) 4. 保存预制体到 `prefabPath` > **窗口预制体根上不要挂窗口脚本**——`UIManager` 会 `AddComponent()` 挂, > 挂了就会出现两套组件、两套生命周期。 ### Inspector 扩展 `JListEditor`、`JListGridEditor`、`JListLoopEditor`、`JTreeEditor`、 `JTextEditor`、`JTextMeshEditor` 提供更好用的面板。 --- ## 11. 架构约定 1. **数据不进 `Param`**。会变的状态放 Service,UI 通过 `EventBus` 订阅; 拉取逻辑抽成**幂等的 `Refresh()`**,`OnInitData` 和 `OnOpen` 各调一次。 2. **`AddEvent` 只在 `OnInitData`**。 3. **Service ↔ Service** 用构造注入接口;**EventBus 只服务 Service ↔ UI / UI ↔ UI**。 4. **逻辑只写 `UI/Win/`**,`Base*.cs` 是生成产物。 5. **组件取用的两种失败模式**:子物体缺失/改名 → `transform.Find(...).GetComponent<>()` 直接 NRE(进不了 `OnInitData`); 物体在但组件类型不对 → 字段为 `null`(能跑进 `OnInitData`)。 --- ## 12. 注意项 **🟠 易踩坑** - 节点名写错不报错;节点名不以 `Root_` 开头会抛异常(详见 §7)。 - `OpenWindow(bool)` 与 `OpenWindow(params object[])` 重载冲突,单个 `bool` 参数传不进去(详见 §4.2)。 --- ## 13. 许可 见仓库根目录 `LICENSE`。