peropero面试
基础差(主要是拷打八股文)
1.协程原理以及底层实现
Unity协程核心本质都是迭代器 + 状态调度。
总结:Unity 协程的底层本质上就是“迭代器 + 状态机调度”机制。它依赖 C# 的 IEnumerator 接口和 yield return 语法实现,当我们通过 StartCoroutine() 开启一个协程时,Unity 并不会创建新线程,而是把这个协程注册到主线程的调度系统中执行。编译器会把带有 yield return 的协程函数自动转换成一个状态机类,这个类会保存协程执行时的上下文信息,比如当前执行到哪一步、局部变量的值以及当前等待条件等。当协程运行到 yield return 时,会先暂停当前执行,并把等待信息返回给 Unity;之后 Unity 在合适的时机继续调用这个迭代器的 MoveNext() 方法,使协程从上一次暂停的位置继续往下执行,直到函数执行结束或再次遇到新的 yield return。因此,协程本质上仍然是在主线程中分段执行的函数,它能够把逻辑拆分到多个帧里完成,但不能真正解决耗时计算造成的主线程阻塞问题。
2. 调度器的 “心跳” 作用
协程不会自己执行,必须靠调度器驱动:
调度器是一个循环执行的 “管理器”(比如 Unity 的
Update每帧执行),会维护一个 “待执行协程列表”;调度器每一次循环(每帧 / 定时)都会遍历列表:
- 如果协程有 “等待条件”(如延时、等待帧更新),则先检查条件是否满足(比如延时是否结束);
- 条件满足时,调用迭代器的
MoveNext(),让协程从暂停点继续执行; - 协程执行到末尾(或
yield break)时,调度器将其从列表中移除,协程结束。
3. 核心本质
这种协程是单线程内的 “分时执行”—— 没有多线程,只是调度器把一个函数拆成多个片段,在不同时间点(满足等待条件后)逐段执行,看起来像 “暂停后恢复”。
2.数组和链表的区别
首先在内存空间上:在内存的分配上,数组的内存是需要连续分配的,而链表的内存是可以不连续的,但是消耗的内存会更大,因为链表的元素结构是元素 + 下一个元素的地址。
在使用情况下:数组的查询效率较高,因为可以直接通过元素的下标索引去访问,但是插入和删除的效率较低,其中也有例外,如果是删除最后的元素或者后面插入元素还是较快,因为不需要将元素的地址进行偏移。 而链表的元素查询效率较低,无法根据索引去查询,需要从头节点开始往后去遍历得到,但是插入和删除效率较高,因为存在前一个和后一个节点的指针。
数组不支持动态的内存分配,但是链表是支持动态的内存分配到,数组的需要在创建指定元素大小,无法进行动态扩容。
你在项目中的对于链表和数组的使用情况?
在项目中链表这种数据结构的使用情况较小,而数组类型的数组,一般使用动态数组这种数据结构类型,在Unity中一般是使用List,因为List支持泛型,同时也可以进行自动扩容,在使用场景上的范围较广
3.各种设计模式的优缺点
单例模式:提供一个全局访问点,只提供一个全局的静态实例,使用的时候,只需要通过这个全局访问点来使用。
优点:因为只有一个静态实例,所以避免了重复创建对象造成的资源浪费。
缺点:因为存在一个全局的静态实例,可能导致很多模块都能够使用到单例模式,这样会导致各个模块之间的耦合度较高,不容易进行代码维护。(高耦合,扩展性差,违法单一职责原则)
观察者模式:观察者模式本质上是 一种事件通知机制,也可以理解为发布-订阅模式。一个对象状态发生变化时,会通知所有订阅了该事件的观察者对象,让它们自动执行各自对应的处理逻辑。
优点:降低对象之间的耦合度。比如在游戏里,角色死亡后,可以通过事件通知 UI、任务系统、音效系统、成就系统分别做出响应,而不需要角色代码直接依赖这些模块。
缺点:观察者过大时,通知链可能带来性能开销。事件触发过于发散(一个事件的变化造成多个观察者执行逻辑)当发生错误时,不好进行问题的排查。依赖关系设计不当,可能造成循环通知或逻辑混乱
工厂模式:将对象的创建过程封装起来,让调用方无需关心对象的具体创建细节(比如类的实例化、初始化参数等),只需通过 “工厂” 获取所需对象。也就你有一个工厂,你想要什么产品,工厂就根据你的需要生成对应的产品,你不需要知道具体的生产流程
优点:降低耦合度,将产品的创建工程与使用逻辑进行分开。调用方只依赖工厂和抽象类型,便于统一管理对象创建。
缺点:产品类型过多,可能会造成的工厂的职责过重。增加了一层工厂类,系统结构更为复杂。当新增产品时,会违法设计原则的开闭原则。
**抽象工厂模式:**将对象的相应的一类产品的创建工程封装起来,你不需要知道这类产品的创建的具体细节。
优点:适合创建一整套相互关联或相互依赖的对象。保证同一产品族中的对象风格一致、互相兼容。调用方只依赖抽象接口,便于替换整个产品族。
缺点:系统更复杂;新增产品等级结构困难,扩展麻烦(违法相应的设计原则)。
4.SOLID原则
单一职责原则:一个类只负责一项职责,只有一个引起它变化的原因。比如说:UserServer不要让他干很多工作,减少代码的耦合
优点:代码的逻辑能更清楚,每个类专职一个功能。代码的复用性更好。
缺点:类的数量会变的更多
**开闭原则:**对原有代码的修改关闭,对扩展新功能开启。对于一个类如果需要增加功能,不要修改原有的代码,而是增加新的代码来实现功能。
优点:不会破坏原有的类结构,减少bug。扩展性更强,新增功能不需要修改源代码。
缺点:需要设计依赖(设计接口,抽象类)
**里氏替换原则:**子类必须能够替换父类,并且不出现程序错误。所有引用父类的地方,必须能够透明地使用其子类,而不会导致程序出错。如果子类继承了父类,但行为和父类不一致,替换后程序逻辑就会出问题,这就违反了里氏替换原则。
优点:保证继承关系正确,必须行为一致才能继承。父类写好的逻辑,子类可以安全替换使用
缺点:
**依赖倒置原则:**高层模块不应该依赖低层模块,二者都应该依赖抽象。不要直接依赖具体实现,要依赖接口。(多用接口,减少具体实现的类)
优点:降低代码的耦合度,高层业务逻辑不依赖底层的实现。易于扩展,更换类实现影响更小
缺点:
**接口隔离原则:**接口尽力做的小而专,不需要大又胖。不要把很多不相关的方法都塞到一个接口里,让实现类被迫实现自己根本用不到的方法。
优点:降低耦合度,不需要依赖无关的方法。如果类的功能发生变化,影响范围更小。
缺点:接口数量会变多。设计难度会增加(更细的划分)
**合成复用原则:(组合优于继承)**尽量使用组合/聚合,而不是继承。也就是说:
复用代码时,优先考虑“把对象作为成员放进去”,而不是“直接继承它”。因为继承是强耦合,子类和父类绑定很紧,父类修改后,子类很容易受影响。而组合则是需要什么能力就组合对象,可以动态的替换内部组件。
优点:灵活性更高,需要什么能力就组合什么对象。耦合度更低,不直接依赖父类内部实现。
缺点:功能分散在多个组合对象中
5.AB包和Addressable
AB主要的应用在于资源加载以及热更新的场景中,我们可以从AB包加载出需要使用的资源并且实例化进场景中。首先加载包含所有AssetBundle依赖关系信息的核心文件,通常命名为与文件夹同名但带有.manifest的扩展名。从加载的主包中,查询出即将加载的目标AB包所依赖的其他AB包列表。按照查询到的依赖列表,预先加载所有依赖的AB包,这一步是避免出现“资源引用丢失”(Missing Reference)的关键。在所有依赖就绪后,再加载真正需要的AB包,并从中加载出具体的资源(如Prefab、Texture等)进行实例化。
Addressables
6.UnityGC(托管和非托管)
在Unity中内存大致分为2大类:托管内存和非托管内存
托管内存和非托管内存
托管内存:C#/.Net运行时进行内存管理。包括常创建的引用类型对象(class,string,array,List
非托管内存:这部分内存不归UnityGC进行管理的内存,而是由Unity引擎底层(C++),操作系统,驱动或者是原生插件来管理,包括(Texture2D的像素数据,Mesh顶点/索引缓冲,AudioClip 音频数据,AnimationClip,RenderTexture,Shader/材质底层资源,场景资源、原生引擎对象,原生插件里 malloc/new 出来的内存NativeArray / NativeList / UnsafeUtility.Malloc 这类 Native 容器背后的内存),Unity不会直接回收这部分内存
| 维度 | 托管堆内存 | 非托管堆内存 |
|---|---|---|
| 谁管理 | GC / 运行时 | Unity原生层 / 系统 / 插件 / 手动管理 |
| 典型内容 | C# 引用类型对象、字符串、数组、List、闭包等 | Texture、Mesh、Audio、Material底层资源、NativeArray、插件内存 |
| 是否被 UnityGC 直接回收 | 是 | 否 |
| 主要风险 | GC 卡顿、频繁分配导致掉帧 | 内存泄漏、资源未释放、Native 占用过高 |
| 常见处理思路 | 减少临时分配、对象池、缓存复用 | Destroy、Unload、Release、Dispose、生命周期管理 |
UnityGC回收机制
其中UnityGC主要负责是托管堆内存的回收,并且采用的是标记-清除回收的方法。GC会从所有对象的根起点开始,顺着引用关系寻找,如果能从根对象一路找到,说明这个对象还在被使用中,不能进行回收,如果找不到,那么这个没有被使用的对象则是“垃圾”,GC会释放这部分的托管内存。
在Unity中Destroy不等于立刻发生了GC操作,Destroy主要是告诉Unity这个引擎对象被销毁了,它主要影响的是Native对象的生命周期,即使Native对象被销毁了,C#那个包装对象也仍然是托管对象,它什么时候彻底从托管堆消失,还要看是否还需要使用这个对象,GC什么时候执行
附加(C#GC,LuaGC)
C#GC则是采用的分代回收,同时主要管理的托管堆的内存包括引用变量对象,不直接管理栈空间的变量以及非托管堆的对象。采用的依旧是从根对象出发,找到不使用的对象将其判断为“垃圾”,然后释放这部分的内存。
栈内存存储值类型、方法参数和局部变量引用,其生命周期与作用域严格绑定——当方法调用结束时,对应的栈帧自动弹出,内存瞬时释放,这个过程是确定且即时的。
托管堆内存存储所有引用类型对象,由垃圾回收器(GC)全权管理,采用分代回收策略将堆划分为三代:新创建的小对象(≤85KB)首先进入第0代;当第0代堆空间耗尽时,GC触发一次第0代回收,从根对象出发标记所有可达对象,然后回收未标记的不可达对象,接着将存活对象压缩并晋升至第1代;若第1代空间也满,则触发第1代回收,同时处理第0代和第1代;第2代存放长期存活对象,其回收(Full GC)通常只在系统内存严重不足或显式调用时发生,开销较大。超过85KB的大对象直接进入大对象堆(属于第2代但单独管理),避免复制开销。整个GC过程完全自动,开发者无需手动释放
Lua 使用增量标记-清除垃圾回收机制,采用三色标记算法(白-灰-黑)。对象被创建时标记为白色;GC 运行时,从根集合出发,将可达对象标记为灰色并放入待处理列表;递归扫描灰色对象引用的白色对象,将其变为灰色;扫描完毕后,存活对象变为黑色,剩余的白色对象被清除。GC 过程分步执行,避免长时间停顿,可自动或手动触发,支持调节 GC 速度和内存阈值。
7.ScriptableObject对比配置表和预制体的优势
1.ScriptableObject是编辑器友好类型,是Unity原生支持的资源类型,能够直接在编辑器中创建,并且序列号支持也比较晚上。对比配置表,它不需要额外解析文本,二进制或表结构,数据可以直接以资源形式被Unity读取和管理
2.ScriptableObject是不需要实例化的,不会产生像预制体一样的实例化开销
3.数据和对象分离,内存利用率更高。预制体都是数据和表现对象绑定的,ScriptableObject只保存数据,不包含场景中的实例逻辑。可以减少内存浪费,一份数据可以被多个对象共享
4.ScriptableObject适合存在运行就确定好的数据(角色熟悉,技能数值,道具等),不需要承担像预制体样承担游戏对象的显示,挂载组件的职责
ScriptableObject 相比配置表和预制体的优势主要有:第一,作为 Unity 原生资源类型,编辑器友好,读取方便,运行时访问速度较快;第二,它不需要像预制体那样实例化,减少了性能开销;第三,它只保存数据、不绑定场景对象,能够被多个对象共享,减少内存浪费;第四,它更适合管理静态配置数据,并且能很好地融入 Unity 的资源和序列化工作流。
