[原创]Unity面试经验分享

面试的这一个月时间,拿了几家大厂的offer(包含自己最心仪的公司),即将去新的工作岗位,所以总结一下这一个月的面经,希望对读到这篇文章的你,有些许帮助

letcode 肯定要刷一刷,这属于送分题。你不刷,到时候问你就只能挠头,刷完以后,你再临场发挥就有思路了。哪怕面试官问的题,你没刷到,你也不至于犯憷。最起码,如果是数组那么左右指针试试?

刷题也有技巧,不是照着 letcode 来一遍,力扣探索页 就有很多大厂高频算题,你可以把微软、腾讯、字节、还有初级中级的题都刷刷;30分钟以内想不出来,建议直接找答案(我是用 js 刷的,vscode + js 简直不要太爽)

下面就是一些我被问到,我觉得可以跟大家分享的问题,具体是哪个厂的面试官问题,就不方便说了(以下答案,是我自己的理解,如有错误还请告知,以免误人)

问:A*算法了解吗?大概说说

A*是比较经典的寻路算法,它的重点就是启发函数:总代价 = 当前点到起点的代价+ 当前点到终点的代价(代价可以是距离或者权重等)。算法的核心是两个列表,开启列表和关闭列表,未走过的节点放入开启列表,已走过的节点放入关闭列表。每个节点的核心元素:当前点的总代价,当前点的父节点

问:C# 字典的具体实现讲讲?跟哈希表有什么区别?

字典的实现,主要是两个数组;桶数组以及对象数组。对象是个结构体(hashcode,同哈希值的下一个对象的下标索引,key,value);如果 new 字典的时候传入了字典大小,那么会初始化,否则要等到添加对象的时候初始化

ADD:有个 freeCount和freeList来标记,当前对象数组,是否有空位置。如果有,优先放入空位置。然后判断哈希值,如果对应的桶内已经有值,那么头插法;桶的索引值变成当前新添加对象所在对象数组的索引,而它的next就是桶原来的索引值

REMOVE:最巧妙的地方,删除以后,该对象的next值等于freeList,然后新的freeList等于这个被删的对象的索引,这样的话,根据freeList就能找到所有被删以后的空位置

扩展:注意,字典没有容量因子的说法,一定要对象数组满了才会扩容。而且它扩容是当前长度*2,然后选中大于这个值的素数,之所以用素数,是减少哈希冲突。当哈希冲突次数超过100以后,才会执行重哈希

问:谈谈 Unity 的 GC

标记阶段:收集器从根对象开始进行遍历,对从根对象 ( 正在运行的局部变量、静态变量、重写 Finalize 方法的变量、正在调用的函数传递的参数 ) 可以访问到的对象的同步索引块标标记为可达

清除阶段:收集器会对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记则将其回收(回收之前,会先执行它的 Finalize() 方法,如果重写了话)

缺点:容易造成内存碎片化

2019.3增加了增量GC优点:避免出现GC时CPU峰值,导致掉帧或者卡顿 ; 缺点:1. 因为是分帧执行遍历,所以得采用写屏障(可能导致bug),2. GC总时常变长 

CG触发:1.向托管堆申请连续内存块,但是托管堆中的连续内存块大小不足2.GC 自动触发3.手动调用GC

CG优化: Cache变量,使用对象池,闭包,匿名函数和协程会有托管堆内存分配,配置表拆分,字符串,手动调用GC

问: Editor 和真机环境下,profiler 看内存是否有差异?为什么?

有差异,一般有以下原因:

  • 一些引擎API在Editor和真机环境下,实现原理不一致。比如加载ab.LoadFromFile, Editor下会加载整个AB,而真机只加载AB头部;GetComponent在Editor会造成GC Alloc, 真机不会
  • 我们自己写的或者第三方插件代码实现不一致
  • 编辑器可能缓存了某些资源(比如你选中某个纹理,该纹理会一直在内存中)

问:谈谈资源卸载?

非托管资源,手动卸载;托管资源,清空引用

  • 1. 文件,网络套接字,数据库连接等采用Dispose + Finalize() 释放 ;
  • 2. 资源(纹理,网格, ab中LoadAsset() 等) 用Resources.UnloadAsset(obj) 来卸载
  • 3. AssetBundle内存 (非托管内存 ),如果有指向该资源的变量( 假设名为:ab),则可使用  ab.Unload ( false/true ) 来卸载
  • 4. 最后调用Resources.UnloadUnusedAssets()来释放(此方法非常耗时)

问为什么说 MOBA 要用帧同步? 帧同步用什么网络协议?以及为什么?

moba类游戏小兵多玩家少且固定;对实时性、流畅性 、公平性要求较高。用帧同步的优势是回放制作方便,流量消耗少,制作离线战斗方便 ( 新手战斗 ),而难度则如下:

  • 1. 保证一致性
  • 2. 开发难度比较高,经常会有以主角为主类的想法。也有可能会直接拿玩家的位置等属性作为参数等的情况 ( 一定要注意逻辑代码和表现分离 )
  • 3. 流畅性(性能,网络)

一般来说,都会采用UDP协议,因为帧同步方案,会有高频次的上报和下发逻辑帧信息,所以对网络要求很高 而它的优点协议简单,包头小。缺点是无序,不可靠,所以需要额外信息来保证传输数据的可靠性。因此,我们需要在传输的数据上增加一个包头。用于确保数据的可靠、有序主要使用两种策略来决定是否需要重传数据包 : 超时重传、快速重传

超时重传:发送数据包在一定的时间内没有收到相应的ACK,等待一定的时间,就会重新发送。这个等待时间被称为RTO,即重传超时时间

快速重传:明确丢了哪个包,那么不用等超时,直接快速重传

问:帧同步有遇到过不同步的问题吗?怎么解决?

原则就是:就是保证客户端的输入一致,计算一致

  1. 主要的点就是随机数(服务器发随机种子)和浮点数运算(采用定点数)
  2. 战斗逻辑不要使用一些排序不固定的容器,比如:Dictionary
  3. 逻辑层不要使用协程(协程是根据Unity的Update去驱动,而不是我们自己的帧逻辑)
  4. 第三方插件的某些实现导致不同步(比如:Update不受帧控制,导致某些计算两个客户端不是在同一个逻辑帧,就会导致异常)
  5. 逻辑层和表现层一定不能交叉调用,表现层根据Update去轮询逻辑层的帧

问:说说深度缓冲(z-buffer)与w缓冲(w-buffer)

z-buffer 保存的是经过投影变换后的 z 坐标,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小。所以投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错

w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体

问:什么是法线贴图,缺点是什么?为什么法线贴图一般都是偏蓝色?

法线贴图是简单的视觉欺骗,一旦凹凸太明显的模型,使用了法线贴图,太靠近的时候,就穿帮了。法线贴图主要适用于凹凸不太明显,细节很多,需要表现实时光照效果,不会太靠近观察的物体的情况

生成法线贴图,一般都是采取纹理的灰度图。根据两个像素间的灰度差,形成U,V两个向量,然后两个向量的叉积就是法线的方向。在切线坐标系里,定义顺序是Tangent、Binormal、Normal。也就是说,Normal处于z这个方向。而对于一个三角形而言,绝大多数时候,法线值都是垂直于这个面的。显而易见,法线贴图的法线值大多数时候是接近于(0,0,1)的,当然是接近于蓝色了

问:为什么 png,jpg 不能直接用,而要采用etc,astc格式?

jpg , png 是针对硬盘的压缩格式,使用的时候需要CPU解压。最大的问题他们都是基于整张图片的压缩,像素与像素之间在解码过程中存在依赖关系,无法实现单个像素级别的解析;而且,png,jpg 解码以后都是 RGBA 的纹理格式,无法减少显存的占用率。而 etc 和 astc 这种是针对图形接口而设计的压缩格式,不需要 CPU 解压而 GPU 可以直接采样

问:AOP , IOC 懂吗?简单说说

 AOP:与传统OOP对比,面向切面,传统的OOP开发中代码逻辑是自上而下的,在自上而下的过程中会产生一些横切性的问题,这些横切性的问题和我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,AOP的思想就是把业务逻辑和横切的问题进行分离,从而达到解耦的目的,是代码的重用性和开发效率提高

IOC :主要是针对接口,抽象编程,而不是具体实现。要做到绝对的依赖注入,就要配置文件,使用反射,针对接口,外部dll实现功能(这一块,可以去B站找找相关的视频,那个老师废话很多,但是得耐心看下去)

问:UGUI  优化

Canvas负责将其子节点的UI元素的网格合并,并生成相应的渲染命令发送到Unity的图形管道,当UI发生了变化,它就要执行一次Batch给GPU进行渲染。Canvas只影响其子节点,但是不会影响子Canvas

重建:

  1. 重新计算一个Layout组件子节点的适当位置,或者可能的大小
  2. Graphic发生了变化,比如:大小,旋转,文字的变化,图片的修改等,都会引起Rebuild

优化:

  1. 动静分离
  2. 少对Canvas增加、删除、显示、隐藏;少对Canvas进行颜色,材质,纹理等的改变
  3. 预加载UI模块
  4. 不直接关闭,设置UI界面为其他的layer
  5. 不需要进行事件接收的组件,取消勾选Raycaster Target
  6. 不适用富文本的Text,取消勾选Rich Text,不使用Best Fit
  7. 用TextMashPro 代替OutLine,shadow组件

问:讲讲UGUI的Draw Call

UGUI 主要是根据 depth 来判断 Draw Call 数量(还有纹理,材质,shader等)

  1. 按照 Hierarchy 节点顺序,从上往下进行深度分析(深度优先)
  2. 且没有任何其他渲染元素与它相交深度 = 0
  3. 有其他元素跟它相交,则找到相交渲染元素的最大深度的渲染元素,判断是否能够与它合批,如果可以,则它等于最大深度,否则深度= 最大深度+ 1
  4. 按照 Hierarchy 顺序来的对深度进行排序,然后根据材质的 Instance ID,纹理的 Instance ID 排序

优化点:

  1. 一个Canvas组建下的元素才会合批,不同Canvas不会合批
  2. 有时候会为了合并层级,我们需要给Text垫高层级
  3. 一个节点的RectTransform的值不会影响合批,Image和rawImage如果引用了相同的texture,可以合批

问: Lua GC 的垃圾回收机制?

lua使用的是经典的标记清扫算法;Lua所有类型的对象都统一为Tvalue;所有动态分配的对象串连成一个链表(或多个);Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构

Lua的垃圾回收周期共分为四个阶段:标记、整理、清扫、收尾
标记阶段:Lua会首先将根集合中的对象标记为活跃,然后将可以通过根节点访问到的对象也标记为活跃
整理阶段:Lua会遍历所有的userdata,找出未被标记且有__gc元方法的userdata,将它们标记为活跃,并放入单独的列表中。再根据所有的弱引用table,删除那些未被标记为活跃的key或者value
清扫阶段:Lua遍历所有对象,如果当前对象未被标记,就收集它,否则清除它的标记
收尾阶段:根据上面生成的userdata列表来调用终结函数(类似C#的析构函数)

问:Lua 如何实现协程?

线程:抢占式多任务机制,是一个相对独立的、可调度的执行单元,是系统独立调度和分配CPU的基本单位。它由操作系统来决定执行哪个任务,在运行过程中需要调度,休眠挂起,上下文切换等系统开销,而且最关键还要使用同步机制保证多线程的运行结果正确

协程:协作式多任务机制,协程之间通过函数调用来完成一个既定的任务。它由程序自己决定执行哪个任务,只涉及到控制权的交换(通过resume-yield),同一时刻只有一个协程在运行,而且无法外部停止。通俗来说,协程就是可以用同步的方式,写出异步的代码

协程(Coroutine)拥有4种状态:

  • 运行(running)如果在协程的函数中调用status,传入协程自身的句柄,那么执行到这里的时候才会返回运行状态
  • 挂起(suspended)调用了yeild或还没开始运行,那么就是挂起状态
  • 正常(normal)如果协程A重启协程B时,协程A处于的状态为正常状态
  • 停止(dead)如果一个协程发生错误结束,或正常终止。那么就处于死亡状态(不可以再重启)

Lua的协程是一种非对称式协程,又或叫半协程,因为它提供了两种传递程序控制权的操作:1. 重启调用协程,通过coroutine.resume实现;2. 挂起协程并将程序控制权返回给协程的调用者,即通过coroutine.yield实现。对称式协程,只有一种传递程序控制权的操作,即将控制权直接传递给指定的协程

协程(Coroutine)具有两个非常重要的特性:1. 私有数据在协程间断式运行期间一直有效;2. 协程每次yield后让出控制权,下次被resume后从停止点开始继续执行

问:lua中的闭包懂吗?说说看

闭包主要由2个元素组成;1. 函数原型:一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令 2.上下文环境:在Lua里主要是Upvalues和env

  1. Upvalues是在函数闭包生成的时候(运行到function时)绑定的
  2. Upvalues在闭包还没关闭前(即函数返回前),是对栈的引用,这样做的目的是可以在函数里修改对应的值从而修改Upvalues的值
  3. 闭包关闭后(即函数退出后),Upvalues不再是指针,而是值

问:类(class)和结构(struct)的区别是什么?它们对性能有影响吗?在自定义类型时,您如何选择是类还是结构?

Class是引用类型,Struct是值类型。Struct不可以被继承,但是可以Override它基类的方法;也可以实现接口。比如:如果想用Struct当作字典的key,但是又想避免有装箱和拆箱等操作,则可以实现接口IEquatable的Equals方法,则可以避免装箱和拆箱操作,以下是一些详细的区别

  1. 值类型对象未装箱和已装箱两种形式,而引用类型总是处于已装箱形式
  2. 值类型从System.ValueType派生,重写了Equals和GetHashCode,由于默认的实现存在性能问题,所以在自定义值类型的时候,如果有用到,应重写这两个方法
  3. 自定义的值类型不应该有虚方法
  4. 引用类型的变量的值是堆上的一个对象的地址,值类型包含的是值
  5. 引用类型赋值是复制内存地址,而值类型赋值则是逐一字段复制
  6. 未装箱的值类型,不在堆上分配内存。而引用类型在堆上分配内存
  7. 因为未装箱的值类型,没有同步索引块,所以不支持多线程同步
  8. 虽然未装箱的值类型,没有类型对象指针,但是仍可以调用由类型继承或重写的虚方法(比如:Equals,GetHashCode或ToString),但是请注意:调用这些方法可能会产生装箱。具体如下:
  9. 如果结构体重写了虚方法,并且虚方法内没有内部调用父类的方法,那么结构体实例调用这个重写的虚方法就不会装箱
  10. 如果调用父类的非虚方法,那么一定会产生装箱,一定得需要一个类型对象指针,以定位父类类型的方法表才能执行该方法

以下情况适合值类型:

  1. 类型具有基元类型的行为,意思就是结构十分简单,属性没有可变类型
  2. 类型不会派生子类,也不从其他类继承
  3. 类型的实例较小 ( 因为值作为参数传递或者作为方法返回值的时候,会拷贝整个结构,对性能有损害,所以类型的实例大小是个重要考虑指标 )

关于值类型的装箱问题,发生装箱操作时,在内部发生的事情如下:

  1. 在托管堆中分配好内存,分配的内存量是值类型各个字段需要的内存量加上两个额外(类型对象指针和同步索引块)需要的内存量
  2. 将值类型的字段复制到新分配的内存中
  3. 返回对象的内存地址,现在值对象变成了引用类型

问:泛型的作用是什么?它有什么优势?它对性能有影响吗?

面向对象的好处是代码重用,对一个类来说,可以继承基类的所有能力,同时可以重写虚方法,或者添加一些新方法,就可以定制该类的行为。而泛型,则是支持另外一种形式的代码重用,即”算法重用”

泛型具有如下优势:1. 源代码保护  2. 类型安全(在编译期就能过滤不符合的类型数据)3. 更佳的性能 ( CLR不再需要执行装箱操作;也不需要类型转换,这对于提高代码运行速度很有帮助 )

问:抽象类和接口有什么区别?使用时有什么需要注意的吗?如何选择是定义一个抽象类,还是接口?什么是接口的“显式实现”?

接口:描述的是Can-do,是一类行为,而不是针对具体某个类。比如: IDispose,IEnumerable等都是描述一类行为

抽象类:为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那就没有任何意义。抽象类不可用来实例化

拿个具体的事例,比如说动物Animal ,这是一个高度抽象的概念。说到动物,有些人想到的是狗,有些人想到的是猫。所以Animal这个类没办法具体实例化出来,应该设计为抽象类,不继承它就没有意义。那有些动物会飞,有些动物会游,这表示有些动物具有某些行为,应该设计成接口,比如:Ifly, Iswim等

在考虑是使用抽象类还是接口的时候,还要考虑易用性和后期版本控制。因为如果要修改接口(比如,添加新的方法),那么实现了该接口的所有类都必须实现这个新方法。所以改动很大 接口的显示实现,是指继承的接口的方法 与类本身的方法重名而采取的措施,就是将定义方法的那个接口的名称作为方法名的前缀(例如:IDispose.Dispose)。注意,在C#中定义一个显示接口方法时,不可以指定可访问性(比如:public或privtae),也不能标记为virtual,所以它不能被重写。这是因为显示实现的方法并非是该类类型的对象模型的一部分,它是将一个接口连接到一个类型上

问:谈谈 Unity 中的协程

协程其实是分帧执行的类似语法糖。协程方法,在编译以后,会生成一个对应名字的类,继承了IEnumerator<object>, IEnumerator, Idisposable 这三个接口。最主要实现了 MoveNext() 方法,Current 属性。而实现原理则是在MonoBehaviour 内的Update和LateUpdate方法之间,根据Current的值是否调用该类的MoveNext()方法

当MonoBehaviour被摧毁以后,协程也会被终止。但是enabled =false,协程会继续执行

协程的使用规范:需要的时候使用,用完就销毁。不要用对象池保存,因为协程会引用MonoBehaviour类(包括变量,方法等),所以如果不销毁,这些引用会一直存在,从而导致无法被GC。有点类似闭包,以及委托

问:说说关于 Unity 内存,显存,CPU 的优化

这一部分太庞大了,如有有需要可以邮件我,我会把整理过的文档发给你们。邮箱地址:lingzelove2008@163.com

发表评论

电子邮件地址不会被公开。 必填项已用*标注