面试题
你在项目中提到使用单例模式和观察者模式,能否具体说明它们的应用场景?
- 在2D类银中使用单例模式管理全局系统,如音效管理器,技能管理器,确保全局唯一实例以及方便各处调用。观察者模式则是用于解除模块间的耦合程度,比如当玩家的血量发生变化时,UI系统会根据事件自动更新血条,而不需要直接引用玩家对象。
追问:单例模式如何避免线程安全问题?观察者模式与Unity事件系统有和异同?
- 可以通过加锁或者静态构造函数实现。观察者模式更加灵活,可以自定义事件参数,Unity事件适合编辑器的可视化配置
| 维度 | 观察者模式(经典) | Unity 事件系统 |
|---|---|---|
| 实现方式 | 通常通过接口定义(如 Observer/Subject),手动维护订阅列表。 |
基于 UnityEvent 类,可视化配置(Inspector 中可直接绑定)。 |
| 类型安全 | 编译期类型检查严格(需显式定义事件参数类型)。 | 支持泛型 UnityEvent<T>,但通过反射实现,部分错误可能在运行时暴露。 |
| 使用场景 | 通用设计模式,适用于任何编程语言 / 框架。 | 深度集成 Unity 生态,支持与组件(MonoBehaviour)直接绑定。 |
| 扩展性 | 需手动实现事件管理逻辑(如批量取消订阅)。 | 内置序列化、持久化支持,可与 Unity 编辑器无缝协作。 |
| 性能 | 轻量,无额外开销。 | 因反射和序列化,性能略低于纯代码实现的观察者模式。 |
C#的协程在项目中的实际应用是什么?
- 在2D类银中使用协程来临时修改玩家的状态,比如在项目中玩家受到易伤的情况时,临时改变玩家的护甲,并且在等待一段时间后,恢复回去,使用协程可以简单实现这种状态修改的逻辑。()
追问:yield return null 和 yield return waitForSecond的区别,协程的底层原理是什么?
- yield return null 是暂停协程,等待下一帧后继续执行后续,yield return waitForSecond等待具体时间后继续执行。协程底层通过迭代器和状态机实现,依托 Unity 帧循环机制运行,是一种 “伪并发” 而非真多线程,适合处理游戏中需要分阶段或延迟执行的逻辑。
你熟悉委托和事件,能否用代码实例说明如何实现一个简单的回调系统?
public class DamageSystem : MonoBehaviour { // 1. 定义委托(规定回调函数的格式) public delegate void DamageDelegate(int damage); // 2. 定义事件(基于委托,作为回调的触发点) public static event DamageDelegate OnDamageTaken; // 受到伤害时调用 public void TakeDamage() { Debug.Log("受到伤害!"); // 3. 触发事件(调用所有注册的回调函数) // ?. 确保没有订阅者时不会报错 OnDamageTaken?.Invoke(10); } } // 订阅者1:处理伤害数值显示 public class UIDisplay : MonoBehaviour { private void OnEnable() { // 注册回调 DamageSystem.OnDamageTaken += ShowDamageUI; } private void OnDisable() { // 取消注册(必须做,否则可能报错) DamageSystem.OnDamageTaken -= ShowDamageUI; } // 回调函数(符合DamageDelegate的格式) void ShowDamageUI(int damage) { Debug.Log($"UI显示:受到 {damage} 点伤害!"); } }
追问:事件和普通委托的区别?为什么事件更加安全?
- 事件只能在声明它的类内部触发(
Invoke),外部代码无法强制调用事件,委托外部通过 = 赋值会直接清空正之前的所有订阅者。同时事件不允许外部使用=赋值,只能通过+=/-=添加 / 移除订阅者,避免了意外覆盖原有回调的风险。事件 = 委托 + 访问权限控制
你在2D-RPG项目中用有限状态机(FSM)管理角色状态,具体是如何实现的?
- 使用一个PlayerState公共类以及PlayerStateMachine作为中间类(不同状态切换的中间件)进行管理,在PlayerStateMachine中定义一个currentState变量作为当前玩家活跃的状态,提供一个Initialize方法来实现初始化玩家的默认状态为Idle,以及实现玩家状态的切换ChangeState方法(包含当前状态的退出,根据玩家输入来设置新的活跃状态currentState,以及当前活跃状态的enter方法也就是进入该状态的方法),每个状态的update方法中实时检测玩家的输入情况,根据玩家的输入来改变人物的状态机(也就是调用PlayerStateMachine中的ChangeState方法)。PlayerState中则是定义每个状态的基本结果和行为,每个不同的状态都封装为一个类并且继承PlayState类。在检测到玩家状态发生改变时,调用PlayerStateMachine中ChangeState方法进行状态的切换,包括原来状态的退出以及新状态的进入,同时执行相应的动画播放以及行为逻辑
追问:如果状态过多导致代码臃肿,你会如何优化?状态模式(State Pattern)和FSM的区别?
- 状态过多时改用状态模式(每个状态封装成类)层级状态模式,将不同的状态进行分类,设置父级,根据父级来实现状态机的转换,FSM适合简单逻辑,状态模式更易扩展。
Cinemachine在项目中用于相机跟随,能否说明其核心组件(如Virtual Camera)的作用?
- 在Virtual Camera中的核心组件CinemachineVirtualCamera存在一个Follow用于实现相机跟随,将player拖入Follow中则实现了相机的跟随
追问:如何实现相机平滑跟随和边界限制?
Damping控制跟随延迟,Dead Zone防止微小抖动。
你提到使用NavMeshAgent实现敌人寻路,NavMesh是如何生成的?动态障碍物如何处理?
- 通过烘焙场景静态物体生成NavMesh。动态障碍:添加
NavMeshObstacle组件,设置Carve属性实时更新网格。
你在项目中使用了ScriptableObject存储物品数据,为什么选择它而不是JSON或Excel?
ScriptableObject适合存储游戏配置数据,因为:
编辑器友好:可以直接在Unity Inspector中编辑,无需额外工具。
运行时高效:数据直接存储在内存中,读取速度快于JSON反序列化。
类型安全:强类型检查,避免Excel的字符串解析错误。
持久化问题:通过
EditorUtility.SetDirty()标记修改,或结合JsonUtility保存到文件追问:
ScriptableObject在运行时修改后如何持久化?如何避免多个实例数据不一致?
避免数据不一致:用ScriptableObject.CreateInstance创建运行时实例,原始数据作为模板。
Cinemachine的Impulse功能如何实现屏幕震动?如果想让不同强度的震动叠加,你会怎么设计?
- 给需要添加震动的CinemachineImpulseSource组件,设置Vector3变量来设置震动强度,针对不动攻击来调用不同的方法控制震动强度
问题:NavMeshAgent的Avoidance Priority和Obstacle Avoidance Type参数对AI行为有什么影响?
Avoidance Priority:优先级高的Agent会优先避让(0最高,99最低)。Obstacle Avoidance Type:None:完全无避障,性能最佳。Low/Med/High Quality:计算精度递增,CPU消耗递增。
- 实战:大量敌人用
Low+优先级分流,Boss用High。
Unity的Addressables系统如何实现依赖加载?与AssetBundle手动管理依赖有何优劣?
Unity 的 Inspector 是如何动态显示和修改 GameObject 的 Transform 或其他组件的属性的?
- 通过反射机制动态读取和修改组件的属性值
- Unity Inspector 通过 反射(Reflection)动态读取/修改组件字段,结合 序列化(Serialization)持久化数据,并利用 Property Drawers 和 Custom Editors 优化渲染,最终实现动态显示和编辑 GameObject 属性。
你在CrossRoad项目中用对象池优化敌人生成,对象池的实现原理是什么?
在项目中使用Dictionary<string,List
>来实现对象池模块,如果需要某个对象,先从对象池中寻找空闲的对象,如果存在则直接取出服用,不存在,则创建新的对象放入缓存中。当对象不需要使用时,不需要销毁而是放入对象池中。 追问:对象池的容量如何动态调整?哪些场景不适合用对象池?
动态扩容 + 渐进式销毁 或者 上线控制 + 自动清理。对象需频繁创建 / 销毁、状态重置简单、复用率高时,对象池才能发挥最大价值;如果创建难度大,创建,销毁频率低的不建议使用。
UGUI的背包系统如何避免频繁重建(Rebuild)?
减少布局重建,使用固定尺寸的背包格子 + 预制体复用,同时关闭不必要的布局组件。
减少图像重建,避免频繁修改Graphic属性,对不变的UI元素使用静态
追问:Canvas的渲染层级和合批规则是什么?如何减少DrawCall?
AssetBundle的同步/异步加载有什么区别?如何解决AB包的内存泄漏问题?
AssetBundle.Unload(true)释放资源。引用计数,确保无残留引用。
2D-RPG的ScriptableObject数据管理方案,相比传统Excel配置表有何优势?
无需解析Excel,直接编辑器配置;支持运行时修改([CreateAssetMenu])
- 追问:如何实现ScriptableObject的运行时修改和持久化?
- t通过
EditorUtility.SetDirty标记修改,或结合JSON序列化。
CrossRoad的无限地形生成算法具体如何实现?随机算法是否会导致重复地形?
将各种不同的地形存入列表,根据列表的长度随机生成的不同的序号,将序号使用变量进行存储,下次生成随机地形时,比较序号与上传是否相同,相同则重新生成,这样确保不会重复地形。
- 追问:如何优化地形加载性能(如分块加载)?
- 将整个地形分割为多个独立的地形块(Chunk),根据玩家位置动态加载 / 卸载周围的块,只保留视野范围内的地形数据,从而减少内存占用和渲染压力。
PlayFab网络排行榜的实现中,如何保证数据安全(防作弊)?
数据校验,服务端验证分数的合理性(如时间和操作次数)
- 追问:如果网络请求失败,本地和云端数据如何同步
- 本地缓存临时数据,网络恢复后优先同步云端。
假设你的2D-RPG游戏在移动端运行时卡顿,如何定位和优化?
- 考察点:Profiler分析(CPU/GPU/内存)、GC触发原因、资源加载策略。
设计一个支持多人联机的俯视角射击游戏,简述网络同步方案
- 追问:如何处理网络延迟和玩家预测(Client-side Prediction)?
状态同步和帧同步的原理
状态同步是指服务器将所有玩家的状态进行汇总,然后广播给所有客户端。客户端收到广播后,更新自己的状态,以保持和服务器一致。状态同步适用于对实时性要求不是很高的游戏,例如策略游戏、棋牌游戏等。
帧同步是指服务器以一定的帧率(通常为每秒 30 帧或 60 帧)将游戏状态广播给所有客户端。客户端按照服务器广播的状态进行模拟,然后将自己的操作发送给服务器,服务器再将所有玩家的操作进行汇总,计算新的游戏状态并广播给所有客户端。帧同步适用于对实时性要求比较高的游戏,例如射击游戏、竞速游戏等。
在帧同步中,为了避免网络延迟导致的卡顿和掉帧,常常采用平滑插值、延迟补偿、预测等技术。平滑插值是指在两帧之间,将玩家的状态进行插值,以平滑过渡;延迟补偿是指服务器根据玩家的网络延迟,向后推迟一定的帧数来接收玩家的操作,以避免操作过期;预测是指客户端预测自己的操作结果,以提高游戏的响应速度。
上面两个在反作弊、断线重连、实时性等等场合,用哪种同步策略好
在反作弊、断线重连和实时性等场合,帧同步是更好的选择。
在帧同步中,客户端只能执行服务器发送过来的操作,减少了作弊的可能性。而状态同步是客户端主动更新自己的状态,容易被作弊者恶意利用。此外,断线重连也更适合在帧同步中实现,因为客户端可以重新连接到服务器并接收新的帧数据来进行同步,而状态同步需要重新将所有状态数据发送到客户端,增加了网络带宽的负担。
跳跃到最高点自动开枪,这个功能应该怎么做
定义一个布尔变量 jumping 表示玩家是否正在跳跃。在每帧中检测玩家是否按下了跳跃键,如果按下了,则将 jumping 设为 true。如果 jumping 为 true,则检测玩家是否已经到达最高点。可以通过判断玩家的竖直速度是否小于等于0来判断是否到达最高点。如果到达了最高点,则自动开枪。可以调用开枪的函数或发送一个开枪的指令给服务器。
角色可以穿脱装备,每个装备对角色的血量有不同的buff,该如何设计这个功能装备属性的实现
装备可以为角色提供属性加成(例如加血、加攻击力等)。可以通过设计一个装备属性接口,让装备继承该接口,并实现对应的属性加成函数。在角色穿戴或卸下装备时,调用对应的属性加成函数即可。
装备buff的实现:当角色穿戴某个装备时,可以获得该装备的buff(例如加血量)。可以在装备属性接口中添加一个获取buff的函数,在角色穿戴该装备时,调用该函数获取buff,并将buff应用到角色上。
简述TCP3次握手,四次挥手的过程
三次握手:
客户端发送 SYN:客户端向服务器发送一个 SYN 包,并随机初始化一个序列号 seq = x,以此表明客户端希望建立连接并请求同步初始序列号。
服务器回复 SYN + ACK:服务器收到 SYN 包后,会向客户端发送一个 SYN + ACK 包。其中,SYN 包的序列号 seq = y 是服务器随机生成的,ACK 包的确认号 ack = x + 1,用于确认客户端的 SYN 包。
客户端发送 ACK:客户端收到服务器的 SYN + ACK 包后,会向服务器发送一个 ACK 包,确认号 ack = y + 1,表示客户端已收到服务器的 SYN 包。此时,连接建立成功。
四次挥手:
客户端发送 FIN:客户端向服务器发送一个 FIN 包,seq = u,表示客户端想要关闭连接。
服务器回复 ACK:服务器收到 FIN 包后,会向客户端发送一个 ACK 包,ack = u + 1,确认客户端的 FIN 包。此时,服务器到客户端的连接仍处于开放状态。
服务器发送 FIN:服务器处理完数据后,向客户端发送一个 FIN 包,seq = v,表示服务器也想要关闭连接。
客户端回复 ACK:客户端收到服务器的 FIN 包后,会向服务器发送一个 ACK 包,ack = v + 1,确认服务器的 FIN 包。此时,连接彻底关闭。
为什么TCP连接需要3次握手,断开需要四次挥手
三次握手的原因:
三次握手的主要目的是同步客户端和服务器的初始序列号,确保双方都有发送和接收数据的能力。
第一次握手让服务器知道客户端有发送数据的能力;第二次握手让客户端知道服务器有接收和发送数据的能力;第三次握手让服务器知道客户端有接收数据的能力。
两次握手无法保证双方初始序列号的同步,也不能确保双方都具备数据收发能力。
四次挥手的原因:
TCP 连接是全双工的,这意味着双方可以同时进行数据的发送和接收。因此,关闭连接时需要分别关闭两个方向的连接。
四次挥手将关闭连接的过程分为两个阶段:首先由客户端请求关闭发送方向的连接,然后服务器请求关闭接收方向的连接。由于服务器在收到客户端的 FIN 包后,可能还有数据需要发送,所以 ACK 和 FIN 通常分开发送,这就导致了四次挥手的过程
客户端通过三次握手建立连接后,怎样维持这个连接?
TCP 连接建立之后,主要通过以下几种方式来维持连接:
心跳机制:应用层可以实现心跳机制,定期发送心跳包,以此确认对方是否在线。例如,在长连接的场景中,客户端和服务器会定期交换 PING/PONG 消息。
TCP Keep-Alive:TCP 协议本身提供了 Keep-Alive 机制。在连接长时间没有数据传输时,TCP 会发送 Keep-Alive 包。如果对方没有响应,经过多次重试后,TCP 会认为连接已经断开,并关闭该连接。
滑动窗口机制:通过滑动窗口协议,接收方会不断向发送方确认已接收的数据,保证数据的可靠传输,同时也间接维持了连接的状态。
超时重传:发送方发送数据后会启动定时器,如果在规定时间内没有收到确认,就会重传数据,确保连接的可靠性
lua热更新的流程

1.集成Lua解释器
- •选择xLua或uLua等成熟解决方案
- •将Lua虚拟机嵌入到Unity项目中
- •建立C#与Lua的交互桥梁
2. 导出Unity接口
- •通过特性标记或配置文件指定需要暴露给Lua的C#类和方法
- •生成包装代码,使Lua能够调用Unity引擎功能
- •示例:将GameObject、Transform等常用组件接口导出
3. 开发Lua脚本
- •使用Lua语言编写游戏逻辑:UI控制、角色行为、业务逻辑等
- •利用导出的Unity API操作游戏对象和资源
- •保持模块化设计,便于热更新替换
4. 编译与打包
- •将Lua源码编译为字节码(提高加载效率和安全性)
- •将字节码文件打包到AssetBundle中
- •建立版本管理系统,每个AB包包含版本信息
5. 热更新执行
- •游戏运行时检测服务器版本与本地版本差异
- •下载更新的AssetBundle包到持久化目录
- •加载新AB包,替换旧的Lua字节码
- •Lua解释器执行更新后的代码,实现热更新
lua实现热更新的具体步骤
Lua 实现热更新的具体步骤
Step 1: 嵌入 Lua 解释器
- •核心操作:将 Lua 解释器(虚拟机)集成到 Unity 宿主程序中。
- •技术方案:使用
xLua或uLua等成熟的插件,它们已经封装好了与 Unity 的交互接口,无需从零开始实现。 - •目的:为游戏提供一个能够运行时解析和执行 Lua 脚本的环境。
Step 2: 导出 Unity 接口给 Lua
- •核心操作:将 Unity 引擎的 C# API(如创建对象、获取组件、触发动画等)暴露给 Lua 脚本,使其能够操作游戏。
- •技术方案:
- •在 C# 代码中使用
[LuaCallCSharp]特性标记需要暴露的类和方法。 - •工具会自动生成这些接口的“包装代码”,作为 C# 和 Lua 之间的桥梁。
- •在 C# 代码中使用
- •目的:让 Lua 脚本拥有控制游戏逻辑和改变游戏状态的能力。
Step 3: 开发并解释执行 Lua 脚本
- •核心操作:开发者使用 Lua 语言编写游戏逻辑(如 UI、角色行为、关卡流程等)。
- •执行流程:Unity 内置的 Lua 解释器会加载并运行这些脚本。
- •目的:将需要频繁变动或希望热更的业务逻辑从 C# 转移到 Lua 端。
Step 4: 编译并打包代码资源
- •核心操作:将编写好的 Lua 源代码编译成字节码,并作为资源打包。
- •技术流程:
Lua 源代码-> 编译为Lua 字节码-> 作为代码资源-> 打入AssetBundle (AB 包)。 - •目的:
- •字节码:加载效率更高,并有一定的代码保护作用。
- •AB 包:Unity 官方的资源管理机制,便于对代码资源进行版本管理和增量下载。
Step 5: 运行时下载与更新
•核心操作:游戏运行时,从服务器检查并下载最新的代码资源 AB 包,由 Lua 解释器加载执行。
•完整流程:
游戏启动后,对比本地和服务器上的资源版本号。
2.下载版本更新的 AB 包到本地持久化路径。
3.加载新的 AB 包,获取其中的 Lua 字节码。
4.通过
require等方式让 Lua 解释器执行新的代码。
•最终效果:游戏内容或逻辑被更新,无需重新安装应用,实现了热更新。
总结:首先在Unity项目中嵌入lua解释器,比如xlua。将Unity的接口比如组件,想要修改的类脚本暴露给lua,使用lua语言进行编写并且执行,将编译好的lua脚本使用AB包进行打包,并且上传到资源服务器。运行游戏是,从资源服务器下载AB包,并且由下载的lua解释器进行编译执行,检查下载的资源与本地的版号进行比较,并且持久化,
在Unity项目中集成xLua等Lua解释器后,需通过特性标记(LuaCallCSharp)将Unity组件和业务逻辑类暴露给Lua环境。使用Lua编写动态逻辑,编译为字节码后打包成AssetBundle并上传至资源服务器。游戏运行时需执行以下严格流程:首先校验资源完整性(MD5比对),下载新版AB包至持久化存储路径,清除Lua模块缓存(package.loaded),加载并执行新脚本,最后释放旧资源(AssetBundle.Unload)。必须确保版本控制严格、内存管理规范,并建立完善的错误回滚机制,方可实现安全可靠的热更新功能。
插值
在Unity中,**插值(Interpolation)**是一种平滑过渡数值的技术,常用于动画、移动、颜色渐变等场景。Unity提供了多种插值方法,主要包括 线性插值(Lerp)、球形插值(Slerp) 和 阻尼插值(SmoothDamp),每种方法适用于不同的需求。
Lerp(线性插值)
在两个值之间进行线性过渡,速度均匀稳定,适合大多数平滑移动和颜色渐变场景。比如相机的跟随,UI颜色渐变,数值平滑过渡
Slerp(球形插值)
专门用于旋转的球面最短路径插值,避免旋转抖动,确保3D物体转向自然流畅。3D物体的自然转向
SmoothDamp(阻尼插值)
带弹簧缓冲效果的智能插值,自动计算过渡速度,特别适合摄像机跟随和物体平滑追踪。
AnimationCurve(曲线插值)
通过自定义曲线控制插值节奏,实现非线性变化,完美支持复杂动画和弹性效果。
框架模块
1. 单例模式(Singleton)
技术:使用 static变量 + DontDestroyOnLoad确保全局唯一实例。
优点:
提供全局访问点,避免重复创建管理器类(如
GameManager)。减少
FindObjectOfType或GetComponent的性能开销。缺点:
滥用会导致代码耦合(如
AudioManager.Instance.Play()散落在各处)。多线程环境下需额外处理(Unity 主线程单线程,一般无需考虑)。
适用场景:全局管理器(如音效、存档、场景加载)。
2. 对象池(Object Pooling)
技术:通过 Queue/Stack缓存对象,复用 GameObject而非频繁 Instantiate/Destroy。
优点:
大幅降低GC压力,避免内存碎片。
提升性能(如子弹、特效等高频创建对象)。
缺点:
需手动管理对象生命周期(如
ReturnObject)。池大小不足时需动态扩容,可能引发瞬时卡顿。
适用场景:高频创建/销毁的对象(如子弹、敌人、粒子特效)。
使用对象池 + 资源加载模块,在项目中提前设置好一批预制体,在场景加载前提前加载好使用Resources.Load加载好预制体资源,并且存入对象池模块中。使用时从对象池中拿取即可,不使用了,再次存入对象池,不进行销毁
3. 预加载(Preloading)
技术:Resources.Load/ Addressables.LoadAssetAsync提前加载资源。
优点:
避免运行时卡顿(如进入战斗场景时突然加载贴图)。
支持异步加载,不阻塞主线程(
Addressables)。缺点:
占用额外内存,需权衡预加载量。
Resources文件夹难以维护,推荐Addressables。
适用场景:关键资源(如角色模型、场景贴图、UI素材)。
4. 事件管理器(Event Manager)
技术:Action/event或观察者模式(Dictionary<Type, Action>)。
优点:
彻底解耦,避免
GetComponent硬依赖(如UI与逻辑分离)。支持多订阅(如多个系统监听玩家死亡事件)。
缺点:
需手动取消订阅,否则导致内存泄漏。
过度使用会使调试困难(事件流难以追踪)。
适用场景:跨系统通信(如成就系统、UI更新、游戏状态变更)。
5. 资源加载(Resource Loading)
技术对比:
| 方式 | 优点 | 缺点 |
|---|---|---|
Resources.Load |
简单快捷 | 内存不可控,无法热更新 |
AssetBundle |
支持热更新 | 需手动管理依赖 |
Addressables |
自动化依赖管理 | 学习成本较高 |
最佳实践:
- 小型项目用
Resources+ 对象池。 - 大型项目用
Addressables+ 异步加载。
6. 空场景加载(Empty Scene Loading)
技术:先加载轻量级空场景,再异步加载目标场景。
优点:
避免直接切换大场景时的黑屏/卡顿。
可在空场景显示加载进度条,提升体验。
缺点:
增加场景切换步骤(需多一次加载)。
需合理设计空场景(如仅含UI和背景)。
适用场景:开放世界/大型场景切换。
总结对比表
| 技术 | 核心优势 | 主要缺点 | 适用场景 |
|---|---|---|---|
| 单例 | 全局访问,避免重复创建 | 高耦合,难测试 | 管理器类 |
| 对象池 | 减少GC,提升性能 | 需手动回收 | 子弹/特效 |
| 预加载 | 避免运行时卡顿 | 内存占用 | 关键资源 |
| 事件管理器 | 解耦系统通信 | 需取消订阅 | UI/逻辑交互 |
| 资源加载 | 动态加载/卸载 | 管理复杂 | 大型项目 |
| 空场景加载 | 平滑过渡 | 额外步骤 | 场景切换 |
核心思想:
- 性能优化:对象池、预加载、空场景。
- 代码架构:单例、事件管理器。
- 资源管理:
Addressables>AssetBundle>Resources。
合理组合这些技术,可显著提升游戏性能和可维护性! 🎮
对象池实现中,对象回收时的状态重置如何处理?
这是对象池实现的关键细节。回收对象时必须彻底重置其状态,否则会产生极其诡异的Bug。
重置逻辑通常在一个 OnReset或 Recycle方法中完成:
以子弹对象池为例,回收时 (OnReset) 会:
- 1.停止所有协程:
StopAllCoroutines()。防止回收后,上一轮生命周期中开启的协程(如飞行协程)继续运行。 - 2.重置物理状态:如果使用
Rigidbody,必须将速度清零 (velocity = Vector3.zero),角速度清零 (angularVelocity = Vector3.zero)。物理引擎不会自动帮你做这个。 - 3.重置Transform:设置
localPosition = Vector3.zero,localRotation = Quaternion.identity。有时也会设置SetParent(null)或将其放回池的根节点下。 - 4.禁用所有可能的效果:取消粒子效果播放、取消拖尾渲染器 (
TrailRenderer) 并清除其已有轨迹 (Clear())。 - 5.设置初始状态:将子弹的伤害、生命周期等业务逻辑参数重置为默认值。
- 6.禁用对象:最后调用
gameObject.SetActive(false)。
核心思想:让对象从内到外(从数据到表现)恢复到它刚从Prefab实例化出来的那一刻的状态。
ScriptableObject存储的数据和PlayerPrefs有什么区别?分别适合存什么类型的数据?
| 特性 | ScriptableObject (SO) | PlayerPrefs |
|---|---|---|
| 本质 | 配置文件,存在于项目/资源文件夹中 | 系统注册表(Windows)或.plist文件(Mac) |
| 存储位置 | 应用包内(只读)或可读写路径 | 系统特定的持久化存储位置 |
| 数据类型 | 复杂对象(可自定义Class/Struct) | 仅限int, float, string |
| 用途 | 游戏配置:角色属性、技能数据、物品信息 | 用户偏好:音量设置、语言选择、最高分数 |
| 读写速度 | 快(内存中) | 慢(需要读写磁盘) |
简单说:ScriptableObject用于配置游戏本身的数据,PlayerPrefs用于保存玩家的偏好和进度数据。
AB包分包按类型/场景/逻辑分,卸载时需考虑是否卸载资源,那如果AB包之间有依赖(如A包依赖B包的材质),卸载B包会导致A包资源失效吗?该如何处理依赖包的卸载?
会失效。
如果包A依赖包B中的材质,当你卸载了包B (AssetBundle.Unload(true)),那么包A中所有使用该材质的物体会变成洋红色(丢失材质)。
正确处理依赖卸载的策略:
- 1.引用计数:为每个AssetBundle维护一个引用计数器。
- 2.加载依赖:加载包A时,自动加载其依赖的包B,并增加包B的引用计数。
- 3.卸载检查:当想要卸载包B时,检查其引用计数。
- 如果引用计数 > 0(即还有其他包在使用它),则不能卸载。
- 如果引用计数 == 0,则可以安全卸载。
状态机有进入/更新/退出状态,行为树有可视化节点,那在控制怪物AI时,什么时候适合用状态机,什么时候适合用行为树?比如SLG游戏中怪物巡逻-攻击,用哪种更灵活?
- 有限状态机(FSM):
- 适用:状态明确、转换简单的AI。
- 例子:SLG游戏中怪物的“巡逻 -> 发现敌人 -> 攻击 -> 死亡”这种线性、清晰的状态循环。FSM实现简单,直观,性能开销小。
- 缺点:状态多了后,状态转换会变得极其复杂(“蜘蛛网”),难以维护。
- 行为树(BT):
- 适用:复杂、需要频繁调整、需要高层决策的AI。
- 例子:开放世界RPG中的敌人,它的决策流程可能是:“是否死亡? -> 是否发现玩家? -> 是否在攻击距离内? -> 血量低是否逃跑? -> 否则是否巡逻?”。行为树通过选择、序列、并行等节点组合,能优雅地表现这种带条件判断的复杂逻辑。
- 优点:高可复用性、可读性、可维护性,策划可以通过工具自行调整AI逻辑。
- 缺点:实现相对复杂,性能开销比FSM稍大。
结论:对于简单的SLG怪物,FSM足够且更高效。如果预期AI会变得越来越复杂,需要频繁迭代,那么从一开始就使用行为树会更灵活。
背包界面有大量物品时如何设计优化?
- 1.对象池:核心优化手段。只创建可视区域内的物品UI,滚动时循环复用。
- 2.分帧加载:在几帧内陆续创建物品,避免一次性实例化大量UI导致的卡顿。
- 3.虚拟化列表:只对可视区域的物品进行数据绑定和更新,滚动时动态更新复用UI的内容。
- 4.合并DrawCall:确保所有物品UI使用相同的图集和材质,促进UGUI合批。
- 5.简化UI:减少物品UI上的组件数量(如不必要的Layout Element、Shadow等)。
UGUI常见的优化方法有哪些?Mask与Rect Mask 2D的区别?
常见优化:
- 合批:打图集,减少材质和纹理切换。
- 填充率优化:避免全屏透明UI叠加。
- 隐藏静态UI:将看不到的UI(如移出屏幕)的
CanvasRenderer设置为cull=true。 - 分离动态/静态UI:将频繁更新的UI放在单独的Canvas下,避免触发整个Canvas的重建。
- 禁用Raycast:对不需要交互的UI禁用
Raycast Target。
Mask vs Rect Mask 2D:
| 特性 | Mask | Rect Mask 2D |
|---|---|---|
| 原理 | 使用模板缓存(Stencil Buffer) | 使用矩形裁剪,不涉及Stencil |
| 性能 | 较低(每Mask增加一个DrawCall) | 较高(几乎无额外开销) |
| 形状 | 支持任意形状(依赖Alpha通道) | 仅支持矩形 |
| 子物体 | 子物体需参与合批,否则失效 | 无此限制 |
结论:如果只需要矩形裁剪,无条件选择Rect Mask 2D。
是否了解结构体是否可以实现接口?为什么?
可以。结构体(struct)在 C# 中是值类型,虽不能继承类,但允许实现接口,以扩展其功能。这是因为接口本质是行为契约,与类型是值类型还是引用类型无关。
在什么情况下,Awake函数可能不会执行?
- 脚本所在的 GameObject 从未被实例化(如仅在 Assets 目录中,未放入场景)。
- 脚本被删除或未挂载到任何 GameObject 上。
- 游戏运行中,GameObject 在 Awake 调用前被销毁(如在其他脚本的 Awake 中销毁该对象)
请解释在Unity中,禁用脚本或物体不活动时,Start函数的执行情况
- 若脚本被禁用(
enabled = false):Start 不会执行,直至脚本被启用。 - 若 GameObject 被设置为不活动(
SetActive(false)):Start 不会执行,直至 GameObject 被激活。
(Start 的执行需满足两个条件:对象激活且脚本启用,且尚未执行过。)
如果在Unity中隐藏一个物体,协程是否还会继续执行?
会继续执行。隐藏物体(SetActive(false))仅影响渲染和碰撞,不影响脚本逻辑(包括协程),除非脚本被禁用或物体被销毁
在Unity中销毁一个物体后,其协程是否还会继续执行?
不会。当 GameObject 被销毁(Destroy(gameObject)),其身上所有脚本的协程会被强制终止,剩余逻辑不再执行。
Unity中的Image和RawImage组件有什么区别?
- Image:用于显示 Sprite 类型资源,支持切片(Sliced)、平铺(Tiled)等拉伸模式,适合 UI 元素(如按钮、图标)。
- RawImage:直接显示 Texture 类型资源(如 Texture2D),无 Sprite 的拉伸优化,适合显示动态生成的纹理(如相机渲染结果)。
核心差异:Image 依赖 Sprite(经过 UI 优化),RawImage 依赖原始纹理。
为什么热更新选择使用lua,而不是C#
热更新本身对于资源热更新是非常容易的,Unity自带的AB包就可以轻松解决,难的是代码热更新,因为Unity中的C#是编译型语言,Unity在打包后,会将C#编译
成一种中间代码,再由Mono虚拟机编译成汇编代码供各个平台执行,它打包以后就变成了二进制了,会跟着程序同时启动,就无法进行任何修改了。
LUA是解释型语言,并不需要事先编译成块,而是运行时动态解释执行的。这样LUA就和普通的游戏资源如图片,文本没有区别,因此可以在运行时直接从WEB服
务器上下载到持久化目录并被其它LUA文件调用。
资源热更新只需要打成AB包上传资源服务器提供玩家下载
热更新流程
- 打包热更资源的对应的md5信息(涉及到增量打包)
- 上传热更 ab 到热更服务器
- 上传版本信息到版本服务器
- 启动游戏。
- 根据当前版本号,和平台号去版本服务器上检查是否有热更。
- 从热更服务器上下载 MD5 文件,比对需要热更的具体文件列表。
- 从热更服务器上下载需要热更的资源,解压到热更资源目录。
- 游戏运行加载资源,优先到热更目录中加载,再到母包资源目录加载。
