Unity 通用运行时框架:UI、Addressables、计时器、事件、音频、存档、网络、相机、小地图、调试与常用扩展。
本文档面向"新加入的开发者或另一个 AI",目标是凭这一份 README 在 30 分钟内拼出最小可运行接入代码。各模块的详细说明文档作为延伸阅读,只在末尾索引。
维护原则:源码签名和实际行为优先于本文档;如发现不一致,以源码为准并 PR 修正。
- 场景里放
EventSystem(UI 输入必需)。 - 打开
Example/Editor Resources/UI/UIdemo.unity看一个完整可跑的场景;引导脚本是GameLauncher.cs,对应面板在Example/Editor Resources/UI/Panels/。 - 在 Addressables 里把每个面板 Prefab 的 Key 配成与
[UIPanelInfo(Key = "UI/xxx")]一致;UI/UpdatePanel必须放在 Local 组(它是热更界面本身)。 - 配置
GameLauncher上的Server Url/App Key/Update Label/Ask Before Download/Allow Offline Fallback,运行即可。
如果只想在自己的工程里接入,跳到第 5 节"最小接入模板"。
- 轻量:业务架构零侵入,按需使用任意模块。
- 可组合:核心服务通过接口抽象:
IAssetLoader、IUIService、ISaveService、ISoundService、IHttpClient、ICameraController、IUITransition。 - 异步优先:UI、Addressables、网络、计时器等待接口全部基于
UniTask,提供CancellationToken。 - 生命周期集中:管理器大多继承
SingletonBehaviour<T>,自动创建并跨场景保留;提供HasInstance判活。 - 编辑期/运行时并存:UI Panel 自动创建工具、UIDebugger、EventTrigger 工具、示例场景与 Prefab。
统一命名空间:
using WManager;| 依赖 | 版本 / 说明 |
|---|---|
| Unity | 2021+(ExampleHelpers.cs 用 UNITY_2022_2_OR_NEWER 判断 destroyCancellationToken) |
| UniTask | WManager.Core.asmdef 已显式引用 |
| Addressables | 2.x |
| UGUI | 内置 |
| DOTween | 可选 —— 仅在挂 UITweenTransition / UIPopupMask 时使用,未挂则面板瞬时显示/隐藏 |
| UnityWebRequest | 内置 |
| TextMeshPro | Toast / Loading / UpdatePanel 用 TMP_Text |
前置检查清单:
- ✅ Addressables 已安装并构建过 Catalog
- ✅ 场景中存在
EventSystem - ✅ Resources 根下放了一份
UISettings.asset(用UI/Editor/UIPanelCreatorWindow菜单创建面板时会自动生成;找不到时框架有默认值兜底)
WManager/
├─ Core/ SingletonBehaviour / Result / IServiceLocator / DefaultServiceLocator / AsyncCompat
├─ Timer/ TimerFactory / TimerScheduler / TimerPool / TimeTool + Alarm/Chronometer/Clock/Countdown/EveryFrames/EverySeconds
├─ UI/
│ ├─ Core/ UIManager / UIRoot / UIPanel(+Generic) / UI(静态门面) / UIContext / UILayer / UIShowMode / UIPanelInfo / UIFactory / UIInputBlocker / UIPopupMask / IUITransition / IUIService / UISettings
│ ├─ Transition/ UITweenTransition (DOTween) / UIAnimatorTransition (Animator)
│ ├─ PageLayoutSwitch/ UIAdaptivePanel / UIOrientationBinder / UIOrientationService / UISafeAreaFitter
│ ├─ MessageBox/ MessageBoxPanel / MessageBoxRequest (+MessageBoxStyle / MessageBoxResult)
│ ├─ Widgets/ UIToastService / UILoadingView
│ └─ Editor/ UIPanelCreatorWindow / UIPanelAutoBinder / UIDebuggerWindow
├─ AssetsResource/ AddressablesManager / IAssetLoader / StreamingAssetsLoader
├─ Sound/ SoundManager / Audio / VolumeController / GlobalVolumeController / ISoundService
├─ Save/ SaveManager / SaveEncryption / SaveDataClasses(Save_TransformData 等) / ISaveService
├─ Event/ EventManager / DataGroup / EventsGroup + Editor Window
├─ EventTrigger/ EventTriggerManager + CustomEventTrigger 扩展 + AddBoxCollider 工具
├─ WebRequest/ WebRequest + IHttpClient + HttpCallBackArgs
├─ Camera/ SurroundingCamera / FirstPersonController / RoamCameraController + ICameraController
├─ MiniMap/ MiniMap(世界 X/Z → UI RectTransform 映射)
├─ Debug/ Debugger + DebugPanel / FpsPanel / MemoryPanel / SystemPanel
├─ Extension/ String / Array / List / Dictionary / Bool / Class / IEnumerable / Queue 扩展
├─ Example/ 示例场景 + Prefab + 13 个示例脚本(详见 Example/README.md)
└─ Scene/ 加载图标动画 / 进度条资源
UI 一律走
UI静态门面(UI.OpenAsync<T>/UI.PushAsync<T>/UI.PopAsync()/UI.CloseAsync<T>()/UI.ShowMessageBoxAsync/UI.ShowToast/UI.ShowLoading)。UIManager.Instance只用于框架扩展(如SetAssetLoader)和需要IUIService接口的低层调用。
| 场景 | 一行代码 |
|---|---|
| 打开普通 UI 页面 | await UI.OpenAsync<MainMenuPanel>(); |
| 打开带强类型参数 | await UI.OpenAsync<BagPanel, BagPanelArgs>(new BagPanelArgs { Tab = 0 }); |
| 压栈跳转 | await UI.PushAsync<PushDemo2Panel>(); |
| 返回上一页 | await UI.PopAsync(); |
| 关闭指定页 | await UI.CloseAsync<UpdatePanel>(); |
| 关闭全部 UI | await UI.CloseAllAsync(); |
| 弹消息框 | bool ok = await UI.ShowMessageBoxAsync(new MessageBoxRequest { Title = "提示", Message = "确定?", Style = MessageBoxStyle.Confirm }); |
| Toast | UI.ShowToast("保存成功"); |
| 加载 Sprite | var icon = await AddressablesManager.Instance.LoadAsync<Sprite>("Icon/Bag"); |
| 释放资源(引用计数 -1) | AddressablesManager.Instance.Release("Icon/Bag"); |
| 实例化 Prefab | var go = await AddressablesManager.Instance.InstantiateAsync("FX/Hit", parent); |
| 释放 Prefab 实例 | AddressablesManager.Instance.ReleaseInstance(go); |
| 检查资源更新大小 | long size = await AddressablesManager.Instance.GetDownloadSizeAsync("default"); |
| 下载资源(带进度) | await AddressablesManager.Instance.DownloadUpdateAsync("default", (cur, total) => progress = total > 0 ? (float)cur / total : 0f); |
| 倒计时(先 Launch 再 WaitAsync) | var t = TimerFactory.Countdown(3f); t.Launch(); await t.WaitAsync(ct); |
| 周期任务 | TimerFactory.EverySeconds(2f, () => Tick()).Launch(); |
| 发事件 | EventManager.EmitEvent("OnPlayerLevelUp"); |
| 监听事件(带优先级) | EventManager.StartListeningWithPriority("OnPlayerLevelUp", OnUp, priority: 0); |
| 播放音乐 | SoundManager.PlayMusic(bgm, volume: 1f, loop: true, persist: true); |
| 播放音效 | SoundManager.PlayEff(clickClip); |
| HTTP GET | var r = await WebRequest.Instance.GetAsync(url, ct); |
| HTTP POST JSON | var r = await WebRequest.Instance.PostJsonAsync(url, json, ct); |
| 存档(文件 + AES) | SaveManager.Save(data, "Player/MainSave"); |
| 读档(文件) | var d = SaveManager.Load<PlayerSaveData>("Player/MainSave"); |
| 存档(PlayerPrefs) | SaveManager.SaveToPrefs(data, "PlayerQuickSlot"); |
| 注册服务(实例) | ServiceLocatorExample.Locator.Register<IHttpClient>(WebRequest.Instance); |
| 注册服务(工厂) | ServiceLocatorExample.Locator.Register<ILoggerService>(() => new UnityLoggerServiceExample()); |
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using WManager;
public class Bootstrap : MonoBehaviour
{
[SerializeField] private string firstUIPanel = nameof(MainMenuPanel);
[SerializeField] private float musicVolume = 0.8f;
private async void Start()
{
// 1. EventSystem 兜底(场景里已有就跳过)
if (FindAnyObjectByType<EventSystem>() == null)
{
var go = new GameObject("EventSystem");
go.AddComponent<EventSystem>();
go.AddComponent<StandaloneInputModule>(); // 或 InputSystemUIInputModule
DontDestroyOnLoad(go);
}
// 2. 首次访问 UI.Manager 触发 UIManager.Awake → UIRoot.Create()
_ = UI.Manager;
// 3. 应用音量
SoundManager.GlobalMusicVolume = musicVolume;
// 4. 初始化 Addressables(幂等)
if (!await AddressablesManager.Instance.InitializeAsync())
{
Debug.LogError("[Bootstrap] Addressables 初始化失败");
return;
}
// 5. 打开首屏 UI(用 [UIPanelInfo] 里的默认 Key/Layer/Cache)
await UI.OpenAsync<MainMenuPanel>();
}
}说明:以上用
MainMenuPanel(来自Example/Editor Resources/UI/Panels/)做演示,自己接入时换成继承UIPanel的业务面板即可。
更复杂的启动(含登录、热更、UpdatePanel 进度条)请直接看 Example/Editor Resources/UI/GameLauncher.cs。
using UnityEngine;
using UnityEngine.UI;
using WManager;
[UIPanelInfo(Key = "UI/MyMainPanel", Layer = UILayer.Normal, Cache = true)]
public class MyMainPanel : UIPanel
{
[SerializeField] private Button closeBtn;
protected override void OnCreate()
{
// BindClick 内部自动捕获异常、防重入;面板销毁时自动解绑
BindClick(closeBtn, OnClickCloseAsync);
}
protected override void OnWillOpen(UIContext context)
{
string user = context?.Get<string>("user", "World") ?? "World";
Debug.Log($"Hello, {user}!");
}
protected override void OnDidOpen(UIContext context)
{
EventManager.StartListening("OnServerReady", OnServerReady);
}
protected override void OnDidClose()
{
EventManager.StopListening("OnServerReady", OnServerReady);
}
private async UniTask OnClickCloseAsync() => await CloseAsync();
private void OnServerReady()
{
Debug.Log("Server ready, UI 可继续后续操作");
}
}MyMainPanel.prefab
└─ MyMainPanel (挂上面脚本 + RectTransform 自动加)
├─ Canvas (自己挂;UIRoot 会处理层级与排序)
├─ CloseButton (Button) ← 拖到 MyMainPanel.closeBtn
└─ ... 其他子节点 ...
把 Prefab 注册到 Addressables Group,Key 与 [UIPanelInfo(Key = "UI/MyMainPanel")] 一致。
// 不带参数(用 [UIPanelInfo] 里声明的默认 Key/Layer/Cache)
await UI.OpenAsync<MyMainPanel>();
// 带参数
await UI.OpenAsync<MyMainPanel>(new UIContext().Set("user", "Albert"));
UIManager.Instance.OpenAsync<T>(...)仍然可用,但参数顺序为context, key, layer, cache, bringToTop, showMode, ct,新手建议直接走UI.OpenAsync<T>()让[UIPanelInfo]当默认配置。
UIPanel 只 [RequireComponent(typeof(RectTransform))];过渡动画是可选的 IUITransition 实现(UITweenTransition 用 DOTween,UIAnimatorTransition 用 Animator),没挂就瞬时显示/隐藏。
| 钩子 | 何时调用 | 适用 |
|---|---|---|
OnCreate() |
仅首次实例化(面板第 1 次被加载时) | 一次性绑定事件、查找子节点 |
OnWillOpen(UIContext) |
每次打开前(过渡动画开始前) | 拿参数、刷新数据 |
OnDidOpen(UIContext) |
每次打开完成(过渡动画结束后) | 注册业务事件、播放音效 |
OnPause() |
被新页面 Push 覆盖时 | 暂停刷新、停掉本页面计时器 |
OnResume() |
Push 后返回栈顶时 | 恢复刷新 |
OnWillClose() |
关闭前 | 提示用户保存 |
OnDidClose() |
关闭后 | 解绑业务事件 |
OnHidden() |
缓存面板隐藏完成(Cache = true) |
释放重资源 |
OnRelease() |
实例销毁前(Cache = false) |
释放所有引用 |
protected internal virtual bool OnBackPressed() |
Esc / 返回键;返回 true 拦截 | 防误关(如 UpdatePanel 用这个) |
Push 覆盖时只走 OnPause;返回栈顶走 OnWillOpen → OnDidOpen → OnResume。
把下面任一组件挂到 UIPanel Prefab 上即可(可选):
UITweenTransition:用 DOTween 驱动,可配置duration / delay / easeType / enableScale/startScale/targetScale / enableFade / startAlpha/targetAlpha / enableMove / startPosition/targetPosition。事件OnShowStart / OnShowComplete / OnHideStart / OnHideComplete由UIPanel自动驱动。UIAnimatorTransition:用AnimatorController 驱动,自己配置showTrigger / hideTrigger。
不挂任何 transition 组件时,UIPanel 走瞬时显示/隐藏(gameObject.SetActive),适合不需要动效的页面。
IAssetLoader接口当前只有 3 个方法:InstantiateAsync/ReleaseInstance/PreloadAsync<T>。LoadAsync<T>/Release是AddressablesManager的实现细节,不要在自定义 loader 里写。
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using WManager;
public class ResourcesAssetLoader : IAssetLoader
{
public async UniTask<GameObject> InstantiateAsync(
string key,
Transform parent = null,
bool worldPositionStays = false,
CancellationToken cancellationToken = default)
{
await UniTask.Yield(cancellationToken);
var prefab = Resources.Load<GameObject>(key);
return prefab == null ? null : Object.Instantiate(prefab, parent, worldPositionStays);
}
public void ReleaseInstance(GameObject instance)
{
if (instance != null) Object.Destroy(instance);
}
public async UniTask<bool> PreloadAsync<T>(string key) where T : class
{
await UniTask.Yield();
return Resources.Load<UnityEngine.Object>(key) != null;
}
}
// 第一次访问 UIManager.Instance 触发初始化,再注入自定义 loader
_ = UIManager.Instance;
UIManager.Instance.SetAssetLoader(new ResourcesAssetLoader());约束:
SetAssetLoader必须在首次OpenAsync/PreloadAsync之前调用。- 实现必须保证
InstantiateAsync/ReleaseInstance配对;引用计数由调用方负责。
using WManager;
var locator = new DefaultServiceLocator();
locator.Register<IHttpClient>(WebRequest.Instance);
locator.Register<ISaveService>(new MySaveAdapter());
locator.Register<IMySingleton>(() => MySingleton.Instance); // 延迟创建
var http = locator.Require<IHttpClient>(); // 未注册抛 InvalidOperationException
var save = locator.Resolve<ISaveService>(); // 未注册返回 null
locator.Unregister<ISaveService>();Resolve vs Require:
Resolve<T>:未注册返回 null;适合"可选服务"。Require<T>:未注册直接抛InvalidOperationException;业务模块推荐用这个,配置缺失早暴露比晚崩溃好。
适用场景:跨模块共享服务(HTTP、存档、玩家数据、自定义业务服务),避免业务脚本之间相互引用具体类。
UIContext 内部是字符串 key → object value 的字典;类型由 Get<T> 自动强转。Set 链式写法。
var ctx = new UIContext()
.Set("tab", 2)
.Set("title", "背包")
.Set("user", playerData);
await UI.OpenAsync<BagPanel>(ctx);
// 面板内读取
protected override void OnWillOpen(UIContext ctx)
{
int tab = ctx.Get<int>("tab", 0);
string title = ctx.Get<string>("title", "");
PlayerData user = ctx.Get<PlayerData>("user");
if (ctx.Has("debug")) { /* ... */ }
}强类型参数推荐用 UI.OpenAsync<T, TArgs>(args),编译期就能查错。
UISettings 是 ScriptableObject,框架启动时通过 Resources.Load("UISettings") 读取,找不到时使用默认值。用 UISettings.Override(...) 注入自定义实例(适用于没有 Resources 的工程)。
| 字段 | 默认 | 说明 |
|---|---|---|
referenceResolution |
(1920, 1080) |
Canvas Scaler 参考分辨率 |
landscapeMatch / portraitMatch |
0.35 / 0.8 |
横竖屏 Match 值 |
rootSortingOrder |
1000 |
UI 根起始 sortingOrder |
layerSortingStep |
100 |
每层之间的 sortingOrder 步长 |
defaultKeyPrefix |
"UI/" |
[UIPanelInfo] 未指定 Key 时拼到类名前面 |
bottomLayerIgnoresSafeArea |
true |
Bottom 层是否绕过安全区 |
enablePopupMask / popupMaskColor / popupMaskFadeDuration / popupCloseOnMaskClick |
true / 黑半透 / 0.2s / true |
弹窗遮罩 |
messageBoxKey / messageBoxDefaultTitle / messageBoxDefaultConfirmText / messageBoxDefaultCancelText |
空 / "提示" / "确定" / "取消" | MessageBox |
toastDuration / toastMaxVisible / toastFadeDuration / toastBottomOffset / toastBackgroundColor / toastTextColor / toastFontSize |
2 / 3 / 0.25 / 180 / 黑色82% / 白 / 32 |
Toast |
loadingDefaultText / loadingBackgroundColor / loadingTextColor / loadingFontSize |
"加载中..." / 半透黑 / 白 / 32 | Loading |
切场景、登出或退出账号前,按这个顺序清理避免野回调与资源泄漏:
using Cysharp.Threading.Tasks;
using WManager;
public async UniTask TeardownAsync()
{
// 1. 关闭所有 UI 页面(页面内 OnDidClose 自动解绑事件)
if (UI.IsAlive)
{
await UI.CloseAllAsync();
UI.ReleaseAllCached(); // includePersistent = false;Persistent 面板不会释放
}
// 2. 停掉所有计时器
if (TimerScheduler.HasInstance)
TimerScheduler.Instance.StopAllAndRemove();
// 3. 释放所有 Addressables 实例和资源
if (AddressablesManager.HasInstance)
AddressablesManager.Instance.ReleaseAll();
// 4. 停止/淡出音频
if (SoundManager.HasInstance)
SoundManager.StopAll(musicFadeOutSeconds: 0.5f);
// 5. 清空事件总线
EventManager.StopAll();
EventManager.DisposeAll();
}补充:
- 上述调用不会销毁
UIManager/AddressablesManager/SoundManager/Debugger这些单例 GameObject;它们跨场景保留。 - 整个应用退出时可在
OnApplicationQuit追加AddressablesManager.Instance.ReleaseAll()+EventManager.DisposeAll()。 WebRequest内部用UnityWebRequest,在finally自动Dispose,业务侧不必手动清理。
using WManager;
// 静态属性读写(便捷;底层走 VolumeController)
SoundManager.GlobalVolume = 1f; // 全局音量(与各通道音量相乘)
SoundManager.GlobalMusicVolume = 0.8f;
SoundManager.GlobalEffsVolume = 1f;
SoundManager.GlobalUISoundsVolume= 1f;
// 事件订阅(多端 UI 同步用)
VolumeController.VolumeChanged += channel => { /* 同步 Slider */ };
VolumeController.ResetToDefault(); // 全部通道重置为 1
// 暂停/恢复
SoundManager.PauseAllMusic();
SoundManager.ResumeAllMusic();
SoundManager.StopAll(); // 立即停止
SoundManager.StopAll(musicFadeOutSeconds: 1.5f); // 带淡出
// 在 Inspector 上挂 GlobalVolumeController 组件可手动拖动实际播放音量 = GlobalVolume × 通道音量,只调一个通道不会"静音全部";要静音全部请设 GlobalVolume = 0。
新接入者(含 AI)第一次用这个框架时最容易踩的坑。
- 不要手动在场景挂
UIRootPrefab。 首次访问UI.Manager/UIManager.Instance会触发Awake → UIRoot.Create();重复创建会出错。UIManager没有公开的Create()静态方法,走SingletonBehaviour<T>.Instance懒加载。 - 场景里必须放
EventSystem。 否则 UGUI 点击、EventTrigger、UIPanel 按钮全部失效。 UI.OpenAsync<T>()失败返回null。 业务侧必须判空,不要.GetComponent强转。AddressablesManager.InstantiateAsync创建的对象必须ReleaseInstance。 直接Destroy会导致 Addressables 内部计数错乱。EventTriggerManager.RemoveAllEvent(go)会清空该 GameObject 上的整个EventTrigger组件,包括 Inspector 或其他脚本添加的条目;只想清本管理器的事件请用RemoveEvent。EventTriggerManager.AddEvent/RemoveEvent必须传相同的方法引用(UnityAction / UnityAction);lambda 传不同实例解绑不到,保存引用或改用具名方法。EventManager.EmitEvent(name, delay)是 fire-and-forget。StopListening不会撤销延迟发射;如需取消请PauseListening(name)。- Timer
WaitAsync()必须在Launch()之后调用。 否则会卡住直到CancellationToken。 TimerPool.Return<T>之前不要手动Stop。 池内部会 Stop;重复 Stop 是 no-op 但浪费。SaveManager.Load<Transform>/Load<RectTransform>会创建临时 GameObject。 用完务必Destroy;或者直接Load<Save_TransformData>自行应用,避免临时对象。WebRequest.PostJsonAsync直接发送原始 JSON。 不要JsonUtility.ToJson包一层{"value": ...},那是旧版本行为。AddressablesManager.InitializeAsync是幂等的。 重复调用安全,不要自己加bool inited标志。UIPanel.OnCreate只在首次实例化调用。 不要把每次打开都要做的逻辑放这里;放OnWillOpen/OnDidOpen;被 Push 回来的页面放OnResume。UIAdaptivePanel在方向切换后会回调OnOrientationChanged。 想拿当前方向用CurrentOrientation(Landscape/Portrait);同一时刻只有一套布局是激活的,OnCreate里用BindClickAll一次性绑好所有按钮即可。Debugger用 OnGUI +FindObjectsByType(Transform)。 物体很多时层级面板会卡顿,正式包不要保留。- 所有
SingletonBehaviour<T>单例在退出阶段停止创建。OnApplicationQuit中访问Instance可能为null,先用HasInstance判活。 UI.PushAsync内部已自动处理页面栈。 不要手动OpenAsync<UIShowMode.Stack>重复压栈;同一页面已在栈里时再PushAsync会直接返回到该页。MessageBoxRequest默认使用MessageBoxStyle.Confirm(只有确定按钮)。 要"确定+取消"设Style = MessageBoxStyle.ConfirmCancel。VolumeController.VolumeChanged事件只通知通道,不带旧值。 要 old/new 请自行缓存。UIAdaptivePanel<TArgs>的OnWillOpen(args)接收强类型参数,UIPanel.OnWillOpen(UIContext context)接收字典。 混用会编译不过。IAssetLoader接口只有InstantiateAsync / ReleaseInstance / PreloadAsync<T>三个方法。 不要写LoadAsync<T>/Release/IsLoaded,那是AddressablesManager的私有实现。
- 事件监听必须配对注销(
OnDisable/OnDestroy)。 - Addressables
Load/Release、InstantiateAsync/ReleaseInstance必须成对;实例化对象不得直接Destroy。 - UI 关闭时取消请求、计时器和方向订阅。
- 对象池对象在
OnEnable绑定、OnDisable清理。 - 计时器停止后再归还
TimerPool。 - 网络请求始终处理错误、超时和取消(
CancellationToken透传到最底层)。 - 调试器、编辑器工具和示例资源不要无意带入正式场景。
- 切换账号或退出游戏时按第 13 节顺序清理。
新增模块建议:
- 公共能力抽象为接口;
- 异步方法提供
CancellationToken; - 明确创建、使用、释放的所有权;
- 在本 README 与对应模块文档同步更新;
- API 改动后先修正源码注释与示例,再更新文档。
文档范围:Assets/Scripts/WManager/ 目录当前源码与随附示例资源。
维护原则:源码签名、序列化字段和实际运行行为优先于历史说明文档。