15.总结
15.1 知识点

知识点回顾

回顾概述内容






总结

15.2 核心要点速览
模块总览
- 单例与 Mono 调度打底,往上叠对象池、事件、资源,再接到音效、UI、场景、输入、计时器;文本、加密、数学是横切工具。
| 类别 | 模块 |
|---|---|
| 基础设施 | 单例、MonoMgr |
| 运行时常用 | 对象池、事件中心、资源、音效、UI、场景、输入、计时器 |
| 数据与工具 | 文本、加密、数学 |
单例
- 目标只有一个:外部只通过
Instance拿对象,不要在外面再new出第二份。教程里三条线:Singleton<T>、SingletonMono<T>、SingletonAutoMono<T>。
| 基类 | 典型场景 | 实现上别漏 |
|---|---|---|
| Singleton | 纯 C# 管理器,不挂场景 | 子类 private 无参构造;基类反射 NonPublic 构造;多线程首次访问就加双检锁 |
| SingletonMono | 想先在场景里摆承载物 | FindObjectOfType,没有就建;Awake 抢实例、DontDestroyOnLoad、重复则 Destroy(gameObject) |
| SingletonAutoMono | 需要 Mono 生命周期又不想手摆 | 写法接近上者,语义是懒创建、自动挂物体 |
new()约束若配公开构造,谁都能new T(),单例名存实亡;教程走私有构造 + 反射。- Mono 单例默认只在主线程碰引擎 API,一般不加锁;若有子线程抢
Instance,要单独看,别背「Mono 永不加锁」当教条。
// 纯 C# 懒初始化:外判空省锁,内判空防并发下重复创建
if (instance == null)
{
lock (LockObject)
{
if (instance == null)
{
// 反射调用 T 的私有无参构造
}
}
}
MonoMgr
- 不想每个管理器各挂一个
MonoBehaviour只为蹭Update:Hierarchy 难看,引擎更新登记也多。MonoMgr 自己跑三个生命周期,外面注册UnityAction分发,相当于项目里自管的一小段帧循环。 - 协程就是宿主上的
StartCoroutine,没有额外封一层「MonoMgr 协程 API」。
| 项 | 说明 |
|---|---|
| 注册 | AddUpdateListener / RemoveUpdateListener 成对;只加不移除会变成野回调,对象逻辑上不用了仍被每帧调用 |
| 协程 | 起停要和模块生命周期对齐;StopAllCoroutines 会断掉所有挂在本宿主上的协程,通常只用于关游戏或整包卸载 |
对象池 PoolMgr
- 抽屉模型:一个 key 一个池,高频
Instantiate/Destroy换成借还,省分配和 GC 压力。
| 概念 | 行为 |
|---|---|
| GameObjPool 空闲区 | Stack:Pop / Push,刚还的先被拿走,和官方 ObjectPool 习惯一致 |
| GetObj | 栈非空 Pop,否则 Instantiate,再 SetActive(true);开布局时取出后 SetParent(null) |
| ReturnObj | SetActive(false) 后 Push;开布局时挂回池根 |
| 容量上限 | _usedList 记在用;满时从链表头拿最早借出的实例抢占;归还要从链表摘掉 |
ObjPool<T>用Queue管引用;new T()复用会带脏字段,在OnGetObject/OnReturnObject里洗掉。ObjPoolMgr:Dictionary<Type, object>,支持RegisterPool注入自定义池。GameObjPoolMgr.isOpenLayout:调试把失活物体归到GameObjPoolRoot;发包可关,少做SetParent。LoadPrefab抽象掉;默认Resources.Load,线上换 AB、Addressables 等按项目键名约定。
事件中心 EventCenter
- 用枚举
E_EventType比裸字符串好重构;字典里用EventInfoBase装桶,里面实际是无参EventInfo或有参EventInfo<T>。
| 结构 | 作用 |
|---|---|
| 无参 / 有参两条包装 | 无参侧挂 UnityAction;有参侧挂带类型参数的 UnityAction,由泛型包装类承载 |
| EventInfoBase | 同一字典值类型;触发时按 T 转回具体 EventInfo<T> 再 Invoke |
- 同一枚举成员别混挂不同
T,as失败容易空引用;约定一成员对应一种载荷。 Clear()清全表;Clear(E_EventType)清单路。监听挂在MonoBehaviour上要在OnDestroy里Remove。- 后续可演进:struct / 接口载荷、订阅 Token,减轻「必须同一委托引用才能 -=」的负担。
资源管理
- 大量资源长期放
Resources会撑包体、难控常驻内存;教程用它讲同步、异步、合并请求、引用计数。上线多走 AB、Addressables、YooAsset。
ResMgr(Resources)
| 能力 | 要点 |
|---|---|
| 同步 | Resources.Load |
| 异步 | MonoMgr 协程里 yield return 到 ResourceRequest |
| 合并请求 | path + "_" + typeof(T).Name 作 key,同路径同类型共一条协程,结束统一回调 |
| 引用计数 | Load / LoadAsync 加引用,UnloadAsset 减;到 0 且允许再 UnloadAsset;异步未完成别硬卸 |
| 同步打断异步 | StopCoroutine 后同步 Load:已跑异步作废,实现简单,CPU 可能白跑 |
其它管线
| 类 | 备注 |
|---|---|
| EditorResMgr | 仅编辑器 AssetDatabase.LoadAssetAtPath,路径从 Assets/ 起 |
| ABMgr | abDic[name] == null 表示异步占位;协程里等到非 null;失败必须删占位打日志,否则死等 |
| Android / WebGL | StreamingAssets 常在包内压缩;示例 UnityWebRequestAssetBundle,或首启拷到 persistentDataPath 再 LoadFromFile |
| ABResMgr | 编辑器 isDebug 可走 EditorResMgr,少打 AB 也能调 |
| UWQResMgr | 按类型分支拉数据;用完在对应路径 Dispose |
音效 MusicMgr
- BGM 单独一个
AudioSource,loop和音量单独管。音效列表在FixedUpdate里扫:非循环且停了就回收;池化路径里clip = null再ReturnGameObj,比反复Destroy少 GC 尖峰。 soundList.Contains防同一音源重复进表。循环音要玩法或StopSound显式关。3D 在回调里设spatialBlend、min/maxDistance、rolloffMode;跟单位就SetParent。
| 顺序 | 原因 |
|---|---|
先 ClearSound,再清对象池 |
soundList 指着池里物体;先 PoolMgr.Clear 会把物体销毁,列表变悬垂,后面遍历必炸 |
UI:BasePanel 与 UIMgr
BasePanel
Awake里GetComponentsInChildren<T>(true),用节点名做Dictionary<string, UIBehaviour>的 key。defaultNameList把纯装饰Image、TMP 文本滤掉,避免当成交互控件。Button/Slider/Toggle自动绑到ClickBtn、SliderValueChange、ToggleValueChange,子类按控件名switch。
UIMgr
| 行为 | 说明 |
|---|---|
| 初始化 | UI 相机、Canvas、EventSystem,DontDestroyOnLoad;子层 Bottom / Middle / Top / System |
| ShowPanel | 类名与预制体名一致;panel == null 表示加载中,重复 Show 只叠回调;Hide 置 isHide 并清回调,若加载完已隐藏则不实例化并删字典项 |
| HidePanel true | 销毁并移出字典 |
| HidePanel false | 仅失活,下次打开省 Awake,占常驻内存,按机型和界面频率用 Profiler 权衡 |
AddCustomEventListener:每控件一个EventTrigger,动态加Entry,给Image等补指针类事件。
场景 SceneMgr
| 路径 | 注意 |
|---|---|
| 异步 | MonoMgr 协程 yield return AsyncOperation;循环发 ao.progress,结束前再手动发 1f,减轻条卡在 0.9x(和 allowSceneActivation 有关);「加载完成」仍建议业务单独收口 |
| 同步 LoadScene | 当前场景立刻卸掉;同脚本后续代码若依赖旧场景物体,往往已无效 |
| 预加载 | LoadSceneAsync + allowSceneActivation = false,配合 priority、RT 转场,要和相机、UI 一起设计 |
| 示例骨架 | LoadSceneWithCleanup 若调 HideAllPanels,工程里没有就自实现或遍历 HidePanel |
输入 InputMgr
CheckKeyCode挂在 MonoMgr 的Update,集中读键鼠后EventTrigger;改键逻辑也集中在一处。ChangeKeyboardInfo/ChangeMouseInfo先按旧类型从字典Remove再Add,避免同一事件键鼠两条记录互踩。GetInputInfo首帧返回null:用isStart跳过第一帧,减轻改键当帧读写交错;不能接受空窗就改成立即刷新字典或返回缓存配置。
// 首帧跳过:改键当帧字典可能处于中间态
public InputInfo GetInputInfo(E_EventType type)
{
if (!isStart)
{
isStart = true;
return null;
}
// 再查 Dictionary …
}
计时器 TimerMgr
- 固定步长(示例
0.1f)的while(true)协程,轮询「游戏时间」「真实时间」两套字典;到期先把项记入delList,本轮结束再Remove,避免遍历字典时改集合。 TimerItem可走对象池减分配。游戏时间用WaitForSeconds(吃timeScale),真实时间用WaitForSecondsRealtime。- 类里
const float intervalTime必须写在引用它的WaitForSeconds字段初始化之前,否则字段初始化顺序直接编译报错。 - 两套循环共用一个
delList容易在写入和消费上打架;上线更干净是每字典独立延迟删除列表,或单协程顺序处理两个字典。
| 点 | 说明 |
|---|---|
| 驱动 | 固定间隔 tick + 双字典 |
| 删除 | 延迟删除列表,避免 foreach 中 Remove |
| 暂停 | 游戏时间 vs 真实时间两套语义不要混用 |
加密 EncryptionUtil
- 定位是抬高内存修改器搜明文成本,不是密码学意义上的安全。固定几步异或和加减 key,
UnLockValue逆序撤销;GetRandomKey让搜固定模式难一点。 - 属性对外像普通字段,
get解密set加密。密文0直接返回是「槽位未写」的约定;若合法密文也可能为 0,要换哨兵或 bool 标志。 - 经济、存档仍以服务端校验、签名为准;本地加密只是纵深里的一层。
| 项 | 说明 |
|---|---|
| 算法 | 可逆变换,加解密互逆 |
| 密文 0 | 与「未初始化」混淆风险,设计时要分开 |
| 预期 | 拖慢随手改内存,不能替代服务端权威 |
文本 TextUtil
- 静态工具:拆配置、补零、时间串、大数缩写。
SplitStr先把全角分号逗号冒号换成半角再按type拆,避免策划表导出全角导致Split整行不切开。 SplitStrToIntArr直接Parse,脏表会抛,线上常加TryParse。两轮拆时内层不足两段就continue,防ints[1]越界。- 静态共享
StringBuilder:主线程顺序、无嵌套调用同一路径时问题不大;嵌套或多线程改局部 builder、[ThreadStatic]或小池。
| 场景 | 常用 API |
|---|---|
| 配表 id,count 形式 | SplitStrToIntArrTwice |
| 倒计时 UI | SecondToHMS2 |
| 战力金币展示 | GetBigDataToString(展示用,不是精度计算) |
数学 MathUtil
Deg2Rad/Rad2Deg包一层Mathf,少写反乘除。GetObjDistanceXZ/XY先把y或z抹零再Distance,分别是水平面和 2D 平面距离。IsWorldPosOutScreen只看屏幕矩形不够:目标在相机后方时z常为负,x/y仍可能落在矩形内;要看screenPos.z或视锥;多相机别写死Camera.main。IsInSectorRangeXZ:angle是整扇开口,和forward夹角用半角angle * 0.5f;forward与坐标同一空间。
射线与 OverlapBox / OverlapSphere:RayCast 重载回调可能是 Hit、GameObject 或组件;没有组件时回调 null。Overlap* 用 typeof(T) 分支 Collider、GameObject、其它组件,统一传 layerMask 和 QueryTriggerInteraction.Collide。
// XZ 扇形:半径 + 相对 forward 的半角
return Vector3.Distance(pos, targetPos) <= radius
&& Vector3.Angle(forward, targetPos - pos) <= angle * 0.5f;
15.3 面试题精选
基础题
1. 泛型单例为什么要用反射创建实例,而不是 new() 约束后 new T()?
题目
new() 约束和「禁止外部 new」能不能同时成立?子类构造应该怎么写?
深入解析
new()约束通常要求可访问的无参构造,编译器允许在任何地方new T(),单例入口和外部构造并存,约束落空。- 子类改成
private无参构造后,基类常规语法调不到,只能用反射取NonPublic构造Invoke一次。 - 子类若漏写私有无参构造,
GetConstructor拿不到会直接抛,属于配置错误尽早暴露。
答题示例
目的是封死外部
new,只留Instance。new()约束会逼出公开构造,外面还能造第二份。子类私有构造后,基类用反射调非公开无参构造创建唯一实例。
参考文章
- 2.单例模块
2. MonoMgr 解决的是什么问题?注册回调时要注意什么?
题目
为什么不建议每个管理器各自挂一个 MonoBehaviour 写 Update?
深入解析
- 每个
MonoBehaviour都会在引擎侧登记更新;管理器一多,Hierarchy 和调度成本都难看。 - MonoMgr 只跑一份
Update/FixedUpdate/LateUpdate,用委托分发给纯 C# 逻辑;协程也借同一个宿主MonoBehaviour。 AddUpdateListener与RemoveUpdateListener必须成对,否则野回调每帧空转;StopAllCoroutines会断掉所有挂在本宿主上的协程。
答题示例
把帧循环集中到一个 Mono 上分发,比一堆脚本各自
Update干净。注册和注销要成对;协程起停也要和模块生命周期对齐,别用
StopAllCoroutines当日常手段。
参考文章
- 3.公共Mono模块
3. 事件中心为什么用 EventInfoBase 字典配合 EventInfo / EventInfo<T>?
题目
能不能直接用 Dictionary<E_EventType, Delegate> 存所有事件?
深入解析
- 无参、有参、不同载荷类型的委托不是同一种静态类型,塞进非泛型
Delegate会丢具体签名,触发时难以类型安全地Invoke。 - 用
EventInfoBase做统一容器,实际装EventInfo或EventInfo<T>,触发时再按T转回,监听端可以用UnityAction<T>,减少全程object和装箱。 - 代价:同一枚举成员不能混挂多种
T,否则as失败;项目里宜约定「一成员一种载荷」。
答题示例
一个字典要同时装无参、有参和不同参数类型,用基类指针装桶,触发时再按泛型转回去。
这样监听方法签名在编译期能对上;别在同一枚举值上混用不同载荷类型。
参考文章
- 5.事件中心模块
4. 切场景时为什么要先 ClearSound 再清对象池?
题目
顺序反了会发生什么?
深入解析
soundList保存的是音效所在的GameObject;若先PoolMgr.Clear把池里物体销毁,列表里引用变悬垂,后续遍历停播或回收会崩。ClearSound先停播、clip = null、归还并清空列表,让管理器状态和场景里实际物体一致,再动池字典。
答题示例
音效列表还指着池里的物体,先清池等于先把物体干掉,列表变成野指针。
先把音效管干净,再清对象池。
参考文章
- 7.音效模块
进阶题
1. 双重检查锁定里为什么要判断两次 instance == null?
题目
多线程懒初始化单例,少一次判断会怎样?
深入解析
- 外层判空:实例已存在则不进
lock,减少锁竞争。 - 内层判空:多个线程可能先后通过外层并在锁外排队;第一个线程创建完后,后续线程拿到锁必须再判空,否则会再执行一次反射创建,破坏唯一性。
- 双检锁主要服务纯 C# 懒初始化;Mono 单例多数只在主线程访问
Instance,一般不照搬同一套锁策略。
答题示例
外层判空是为了有了实例就别抢锁;内层再判是因为等锁期间可能已经被别的线程创建好了。
纯 C# 懒单例常用双检锁;Mono 单例要另看线程模型。
参考文章
- 2.单例模块
2. GameObject 池空闲区用 Stack、池满时又从在用链表头抢占,这两套逻辑各解决什么?
题目
空闲区能不能换成 Queue?抢占和「热实例复用」是不是一回事?
深入解析
- 空闲
Stack后进先出:刚归还的实例下一轮最先被取走,局部性更好,也和 Unity 官方ObjectPool用栈的习惯一致;Queue会先拿到池里躺最久的实例,多数项目收益不明显。 - 池满时从
_usedList链表头拿「最早借出」的对象,是容量上限下的抢占策略,和空闲栈的复用顺序无关;抢占等于可能强行复用仍在业务手里的对象,需要规范或OnPreemptObject等配套。 - 引用类型小对象池仍要在
OnGet/OnReturn洗字段,否则复用带脏状态。
答题示例
空闲用栈是为了优先复用刚还回来的热实例;链表头抢占是池满时的另一套策略,别和栈混成一句话。
抢占时可能抢还在用的对象,业务要么别长期私自缓存池对象,要么在抢占回调里做重置和通知。
参考文章
- 4.对象池模块
3. UIMgr 里 PanelInfo.isHide 主要挡的是哪种异步竞态?
题目
加载过程中用户已经关了界面,没有 isHide 会怎样?
深入解析
- 异步加载未完成时若用户已关闭界面,没有标志位,加载完成回调仍可能
Instantiate并进字典,浪费资源且可能闪屏。 Hide时置isHide并清回调;加载结束若已隐藏则移除字典项、不创建实例。再次Show可把isHide拉回并叠回调。
答题示例
异步面板最怕「加载完了但用户已经不要了」;
isHide让加载完成分支能直接放弃实例化。再次打开时把标志和回调接回去即可。
参考文章
- 8.UI模块
深度题
1. 纯 C# 单例加锁、Mono 单例不加锁,工程上怎么判断?
题目
什么情况下你会给 Mono 单例也加锁,或给纯 C# 单例省锁?
深入解析
- 判据是是否存在多个线程可能首次访问
Instance的路径(网络、线程池等);有则纯 C# 侧倾向双检锁等方案。 - Unity 引擎 API 须在主线程调用,给 Mono 单例加锁并不能解决「工作线程直接调 Unity API」的问题,正确做法是切回主线程执行。
- 子线程只读、且数据不可变的配置型单例是少数例外;公共框架库往往宁可保守。
答题示例
会不会多线程同时第一次碰
Instance,决定纯 C# 要不要锁。Mono 单例问题多半在「谁在线程上调引擎 API」,靠加锁治标不治本;该主线程派发就派发。
参考文章
- 2.单例模块
2. 客户端里做内存异或加密,边界在哪里?还要配什么手段?
题目
能不能靠客户端加密替代服务端校验?
深入解析
- 算法和内存都在玩家机器上,会逆向的人仍能还原或改调用点;异或层主要提高「搜明文」成本,不是密码学意义上的保密。
- 经济、排行、关键进度应以服务端权威为准;本地可叠加签名校验、异常检测、关键行为上报等纵深。
- 与
EncryptionUtil里密文0当哨兵类似:设计时要分清「未初始化」与「合法密文」,避免误判。
答题示例
客户端加密是拖时间和加门槛,挡不住决心够的逆向,更不能当经济系统主防。
值钱的数据服务端验、存档签名;本地加密只是多层防护里的一层。
参考文章
- 12.加密模块
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com