Unity 面试经验汇总

文章目录

  • 前言
  • 一、自我介绍
  • 二、C#部分
  • 三、项目经验
    • 1.事件系统
    • 2.红点系统
    • 3.A*寻路系统
    • 4.新手引导系统
    • 5.活动模块
    • 6.任务系统
    • 7.网络模块
    • 8.设计模式
  • 四、图形渲染
    • 1.渲染流水线
    • 2.简单光照模型
  • 五、项目优化
    • 1.代码优化
    • 2.DrawCall优化
    • 3.OverDraw优化
    • 4.资源优化


前言

以下内容是我在找工作过程中做的一些准备,记录下来方便自己随时查阅。
如果有处于同一阶段找工作的小伙伴,欢迎一起探讨,互相学习。

一、自我介绍

一般面试官的第一个问题都是“简单的做个自我介绍吧” 这种,所以这个必须得准备下。

面试官你好,我叫XXX,XXX年软件工程毕业。目前有三段工作经历。
第一段工作经历是一家初创公司实习,做一款区块链游戏项目。
项目的客户端使用CocosCreator制作,结合区块链技术,将游戏中的角色和道具上链交易。
我在工作中主要负责两块内容。第一:负责游戏Demo的实现,在项目中主要实现了A*寻路,每日任务,pve新手引导等模块。第二,使用Solidity语言开发智能合约,在以太坊测试链实现了一套简单的虚拟货币。然后负责和国内区块链项目Conflux对接,共同制定Conflux的代币标准。

第二段工作经历也是一家初创公司,项目是一款少儿钢琴陪练软件。
项目底层是基于人工智能的语音识别技术,能够实时的识别钢琴音。客户端使用CocosCreator实现。我在项目中前期负责一些UI功能的制作,比如曲目搜索、乐理题库、任务中心、签到系统等。后期负责维护识别引擎,做一些后处理工作(比如钢琴中的三连音,底层识别引擎的匹配度较低,小盆友弹钢琴遇到三连音识别不通过,这是就进行一些后处理,提升它的通过率)。然后我还负责项目的热更和新项目的上架和过审工作。

第三段工作经历是一个主做MMORPG游戏的成熟商业公司。
在工作中我经历了两个项目,第一个是一款挂机养成类游戏。我在项目中负责UI功能、和一些公共模块的实现。比如红点系统,活动模块,任务系统,功能开放系统,面板跳转模块等。
第二个项目拿一个老项目换皮,我的工作内容是:第一,负责界面功能调整优化,添加一些副本活动,修复bug等。第二,配合策划写一些工具处理图片和表格。第三,负责维护Jenkins进行自动打包,自动打图集等。

在工作之余,我会看一些技术书籍来稳固自己的基础。然后也会看一些技术博客,和阅读一些开源项目的代码来提升自己的技术能力。然后我会学习一些图形渲染的知识,对渲染过程有一个基本的了解。在生活中,我是一个比较宅的人。但是偶尔也会出去爬山,旅游,放松自己的身心。使工作和生活达到一个平衡。

以上就是我的一个基本情况,谢谢。

二、C#部分

1.值类型和引用类型
1)值类型:值类型继承自System.ValueType,所有的值类型都是隐式密封的。值类型包括int、bool、float、char等基元类型以及结构体和枚举。
内存一搬分配在栈上,赋值或者传参的时候会发生复制。
2)引用类型:包括类、string、委托、数组和接口,内存一般分配在堆上,赋值或传参的时候只复制引用(地址)。

2.栈和堆
1)栈:栈是一片连续的内存域,由系统自动分配和维护,大小固定(默认1M,可配置)
内存分配效率高(速度快),栈顶元素使用完毕,立马释放。
2)堆:堆内存是无序的,内存大小动态分配(有上限)。所有的对象都从托管堆分配,CLR维护了一个NextObjPtr指针,指向下一个对象在堆中的分配位置。
当程序需要更多的堆空间时,由GC(垃圾回收机制)帮助我们清理内存。内存分配效率和速度都较低。

3.装箱和拆箱
1)装箱:将值类型转换为引用类型的过程。装箱时,首先在托管堆中分配内存;然后将值类型的字段复制到新分配的堆内存;最后返回对象引用。
频繁装箱可能会触发GC,造成卡顿(GC会将所有线程挂起)
2)拆箱:将引用类型转换为值类型的过程。拆箱时,先获取已装箱对象中各个字段的地址;然后将字段包含的值从堆内存复制到栈上。
装箱和拆箱都是比较耗时的操作,应尽量避免。

4.GC(垃圾回收机制)
1)什么是GC
GC,即Garbage Collection,意为垃圾回收,区别于像原生C++这种需要程序员手动管理内存的机制,垃圾回收机制可以让程序员不再过于关心内存管理问题,
在需要进行垃圾回收时,CLR会收集需要释放掉的对象,进行内存释放。
2)标记压缩法
标记阶段:引用跟踪算法只关心引用类型的变量,包括类的静态和实例字段,或者方法的参数和局部变量。我们称所有引用类型的变量为根。
CLR开始GC时,首先暂停进程中所有的线程,然后CLR会先遍历堆中的所有对象,并全部设置为可回收状态,然后检查所有活动根,查看他们引用了哪些对象,如果一个根包含null,
CLR会忽略这个根并检查下一个根。
任何根如果引用了堆上的对象,CLR都会标记那个对象,并检查这个对象中的根,继续标记它们引用的对象,如果过程中发现对象已标记,则不重新检查,避免循环引用而造成的死循环。
检查完毕后,已标记的对象不能被垃圾回收,因为至少有一个根在引用它。我们说这种对象是可达的,因为可通过仍在引用它的变量访问它,是不能回收的。未标记的对象则是不可达的,可以回收的。
压缩阶段:标记完成后进入压缩阶段,这个阶段是一个碎片整理的过程,通过移动幸存的对象,使它们占用连续的内存空间。移动完成后CLR将每个根减去所引用对象在内存中偏移的字节数,
保证它们还引用原来的对象。
压缩好内存后,托管堆的NextObjPtr指向最后一个幸存对象之后的位置,然后CLR恢复应用程序的所有线程。
3)分代回收
根据经验以及研究证明,对象越新生存期越短;对象越老生存期越长;回收堆的一部分,速度快于回收整个堆。
基于此,GC使用分代回收算法:托管堆在初始化时不包含对象。这时候添加到堆的对象称为第0代对象。当分配一个新对象时,若第0代内存超过预算,就会触发GC进行垃圾回收。不可达的对象被释放,
幸存的对象成为第1代对象。
下一次垃圾回收只检查第0代对象,直到第1代内存也达到预期。这个时候GC会同时检测第0代和第1代对象,第1代中幸存的对象提升到第二代,第0代的提升为第一代。托管堆只支持012三代,如果第二代也达到预期,
GC会执行一次完整的回收,如果内存还是不够,就会抛出OutOfMemoryException异常。

总结:引用追踪,标记压缩,分代回收

5.委托
定义委托编译器会自动生成一个类派生自System.MulticastDelegate,这个类包含4个方法:一个构造器、Invoke、BeginInvoke、EndInvoke。
调用委托的时候实际上执行的是 Invoke方法。
MulticastDelegate类有三个重要字段:

  1. _target(System.Object)
    当委托对象包装的是一个静态方法,这个字段为null。当委托对象包装一个实例方法,这个字段引用的是回调方法要操作的对象。
  2. _methodPtr(System.IntPtr)
    一个内部的整数值,CLR用它标识要回调的方法。
  3. _invocationList(System.Object)
    该字段通常为null,构造委托链时它引用一个委托数组。若不为null,Invoke的时候会遍历委托数组依次调用。
    可以使用GetInvocationList接口获取这个数组。

用委托回调多个方法(委托链)
使用Delegate.Combine 或者用 += 可以将多个委托合并成委托链。每次添加时 _invocationList都会新建一个新的委托数组,数组里存放所有委托,之前引用的数组等待被GC回收。
使用Delegate.Remove 或者 -= 可以从委托链中删除一个委托,通过 _target和_methodPtr找到匹配的委托后,_invocationList同样会引用一个新建数组,新数组不包含移除的那个委托。
若委托链中只剩一个委托,删除成功后返回null。

C#自带的委托
1)Action:无返回值委托,最多支持16个泛型参数。
2)Func:返回一个泛型,最多支持16个泛型参数,最后一个参数必须是返回值类型。
Unity自带的委托
1)UnityAction:无返回值,最多支持4个泛型参数。
2)UnityEvent:Unity对UnityAction的封装。

泛型委托的协变/逆变参数类型
使用 in 关键字表示逆变,参数可以使用类型的派生类。使用 out 关键字表示协变,参数可以使用类型的基类。

6.事件
事件是委托的包装器,内部维护了一个私有的委托链。向事件注册监听的时候其实就是往委托链里面添加一个委托,当事件触发的时候,会遍历委托链依次调用。
如果不需要继续监听的时候要及时注销,因为对象只要向事件登记了他的一个方法就不能被垃圾回收(委托也是一样)。

7.ref 和 out 关键字
CLR所有方法都默认传值。值类型传递的是一个副本,引用类型传递的是地址的拷贝。使用ref和out关键字可以实现按引用传递。
值类型按引用传递避免了复制,节约了性能,但修改形参的同时,实参也会改变。引用类型按引用传参则可以在方法内改变原引用的指向。
ref和out生成的IL代码完全一致,只有一个bit不一样,记录了使用的是哪一个关键字。不同的是ref修饰的参数需要提前初始化,out修饰的参数使用时需要在方法内赋值。

8.协程
1)与进程和线程的关系
进程是应用程序的实例要使用的资源(代码和数据)的集合,每个进程有自己独立的虚拟地址空间,一般一个应用程序对应一个进程,进程的切换一般由操作系统来调度。
每个进程至少有一个线程,线程有自己独立的线程栈,同一个进程的线程共享堆内存。
线程切换上下文时,需要消耗一定的性能。
在线程里可以开启协程,避免了无意义的调度,可以提高性能。
2)yield关键字
yield是C#的关键字,其实就是快速定义迭代器的语法糖。
只要是yield出现在其中的方法就会被编译器自动编译成一个迭代器,对于这样的函数可以称之为迭代器函数。迭代器函数的返回值就是自动生成的迭代器类的Current对象。
3)协程原理
在Unity运行时,调用协程就是开启了一个IEnumerator(迭代器),协程开始执行,在执行到yield return之前和其他的正常的程序没有差别,但是当遇到yield return之后会立刻返回,
并将该函数暂时挂起。然后每帧判断yield return后边的条件是否满足,如果满足向下执行。协程常用来执行一些比较耗时的工作。

9.抽象类和接口
1)抽象类和接口都不能实例化。
2)类只能单继承,接口可以多继承。
3)抽象类可以定义实例字段。接口不能定义实例字段。
4)抽象类与派生类的关系一般是 IS ,接口和派生类的关系一般是 HAS

10.结构体和类
1)结构体是隐式密封的,不能被继承;类可以被继承(除密封类)。
2)结构体成员变量申明不能指定初始值,而类可以。
3)结构体是值类型,存在栈中;类是引用类型,存在堆中。
4)结构体不能显示声明无参构造,且声明构造函数时,所有成员变量必须初始化;类随意。
5)结构体不能被静态static修饰(不存在静态结构体);而类可以。
6)什么时候使用结构体?
当类型的实例较小(<=16字节)或类型实例较大但不作为方法实参传递,也不从方法返回时(使用时不需要复制)或者没有成员会修改类型的任何实例字段时(类型不可变)

11.Stringbuilder
1)为什么要使用SB?
因为string类型是不可变的,每次赋值或者修改一个string变量都会在堆上复制一个实例,效率很低,而且容易触发GC。所以引入了Stringbuilder类。
2)SB的实现原理
SB内部采用链表结构,记录了当前节点的上一个节点。同时SB内部维护了一个字符数组,数组的长度等于默认的最大容量(这个最大容量可以在构造的时候指定);
当构造一个SB或者使用Append追加字符串时,如果追加的字符长度加上已有字符的长度没有超过数组容量,则采用指针复制的方式,把Append的字符串,复制到数组的剩余位置。
若超过容量,则会通过计算将一部分字符复制到原有数组中。然后通过一个构造函数将自己作为参数将当前对象的信息复制到上一个节点中去(新建一个SB),并且清空当前的字符数组。
之后,将剩余的字符复制到当前的字符数组中。当调用ToString()时,会递归寻找之前的节点,把字符都取出来返回。

12.async/await异步操作
通过async关键字将方法声明为异步方法,在遇到await关键字前会执行方法内的同步代码,遇到await后跳出去执行方法外的同步代码,异步执行await的代码。
等到await代码执行完毕,继续执行await后面的同步代码。异步方法会返回一个Task实例,可以在一个死循环里通过Task的WhenAny方法监听Task是否执行完毕。

13.const和readonly的区别
const可以修饰成员变量和局部变量,一经确定就不可更改。
readonly只能修饰成员变量,可以在类的构造函数中对他进行赋值。

14.for和foreach的区别
1)foreach语法简洁,它是一个语法糖,底层是迭代器。
2)for循环的时候可以对数据进行读写,foreach只能读不能写。
3)foreach获取迭代器的时候可能有额外GC Alloc

三、项目经验

1.事件系统

事件系统是基于委托和观察者模式实现的。
当注册一个事件时,将该事件的消息ID和回调函数成对的放到一个委托列表里面。然后建立消息ID和委托列表的映射,将已注册的事件存储到一个静态字典里,并提供注册和注销事件的方法。
当触发某一个消息ID的事件时,通过字典查找委托列表,执行回调事件。

2.红点系统

一个完整红点系统分为结构层,驱动层和表现层。
我所实现的红点系统结构上是扁平化的设计,每个红点都是一个简单的枚举。
通过一个单例管理类,使用字典保存所有的红点枚举和红点状态。
每个红点状态由自己的红点逻辑驱动,游戏初始化的时候,向服务器请求主界面所有的红点状态。
当玩家点击某一个养成界面时,再向服务器请求该界面内部的红点。
当红点状态发生改变时,通过消息分发的形式通知表现层。
表现层有一个UIRedpint类,内部有一个它所关注的红点的枚举数组。会在类初始化的时候去监听数组中红点的变化。
当需要显示红点时,就可以通过各种表现提醒玩家去点击。

3.A*寻路系统

首先有四个概念
1.每个格子的寻路消耗公式
f(寻路消耗)= g(离起点的距离,非斜向方向移动的距离定为10,斜向为14)+ h(离终点的曼哈顿距离)
2.开启列表 (OpenList)
存放着所有的待检测的节点(坐标),每次都会从其中寻找出符合某个条件的节点。
3.关闭列表 (ClosedList)
存放着所有不会被检测的节点(坐标),每次检测都会忽略它们。
4.Node节点
包括f,g,h,父节点father,节点类型type,节点坐标x,y

寻路算法实现:
1.首先将起点加入关闭列表,然后开启循环。
2.将起点周围的可行走点加入开启列表,并记录起点为他们的父节点。
3.计算开启列表里每个点的寻路消耗,将消耗最小的点作为新的起点。并将其从开启列表移到关闭列表。然后进入下一轮循环。
4.当找到终点的时候退出循环,通过回溯父节点,找出路径并返回。当开启列表中没有节点的时候表示终点不可达,退出循环。

4.新手引导系统

新手引导系统是通过监听一些触发条件,然后打开一个引导面板去引导玩家进行一系列的点击操作。让玩家熟悉功能玩法。

首先有两张配置表
第一个配置表GuideGroup,每一个功能的引导对应一项数据
配置唯一标识GuideID,引导触发类型(等级触发,任务触发,功能开放触发),触发参数(等级,任务id,面板名),
最大触发等级,引导步骤(以及,强弱引导,引导语音等额外配置)

第二个配置表GuideStep,每一个引导步骤对应一项数据
配置唯一标识StepID、引导的位置、引导框大小、引导面板名以及引导按钮路径等。

通过一个单例引导管理类监听引导的触发事件,
当每次有触发引导的事件发生时,遍历GuideGroup表,通过触发参数判断是否触发引导。
如果触发了某一个引导,就打开引导面板,根据表中配置开始引导。

引导的表现通过镂空的Shader实现,根据引导按钮的位置确定镂空位置和镂空大小。
然后我们封装了一个UIGuideClickListener类继承EventTrigger,并重写OnPointerClick接口,为引导界面添加点击监听。
当玩家点击镂空区域时,使用EventSystem.RaycastAll进行点击穿透。
如果穿透GameObject对象为引导按钮,就触发该按钮的点击事件。
这个过程中还可以添加文字描述和语音提示等。

然后进行下一步引导步骤直到引导完成。

5.活动模块

游戏中有各种副本活动,所有的活动需要用一个活动面板来显示。
功能实现中主要注意游戏中的时区问题

UTC时间是0时区的时间,例如国内为东8区即UTC +8,UTC0点对应国内早上8点

1秒(s) = 1000毫秒(ms);1毫秒(ms) = 10000 Ticks 所以1秒等于1000w Ticks
时间戳是指UTC1970年1月1日0时0分0秒(北京时间1970年1月1日8时0分0秒)起至现在的总秒数(也可以是毫秒)

C# 获取时间戳(秒):
(DateTime.UtcNow.Ticks - 621355968000000000) / 10000000
621355968000000000 是 1年1月1日0时0分0秒 至 1970年1月1日0时0分0秒 的 Tick 数

为了避免时区问题,可以通过服务器下发服务器时区和服务器时间更新客户端时间
可以通过TimeZoneInfo.Local.BaseUtcOffset.Hours 获取客户端本机时区

6.任务系统

由于任务系统需要和其他系统进行交互,所以设计一个单例类来管理任务。
用一个字典将服务器下发的任务保存下来。
管理类内部封装更新任务,和获取任务的方法。
然后有一个任务面板来显示任务,并根据任务类型和任务状态排序。
任务面板是一个滑动列表,点击不同的任务会根据任务类型,任务状态等执行不同的逻辑。
比如弹出某一个面板,寻路到目标地图等。

7.网络模块

网络模块使用C#自带的TcpClient类和服务器通信,自带包体的校验、包体的拆分,以及丢包重发机制。

使用Protobuf进行消息协议的制定以及消息体的序列化/反序列化。

使用环形缓冲区来处理分包粘包,同时避免频繁申请内存。

使用双队列,一个消息缓存队列用于子线程接受网络消息并分离包体和包头。另一个待解析队列用于解析包体,避免了共享队列的锁开销。

包头包含4个字节的包长,4个字节的消息ID,以及2个字节的字节序。

在主线程的updata函数中监听待解析队列中是否有消息包体,如果有的话就进行解析并广播。

避免收到大量服务器消息时卡顿,设置每帧处理最大消息量 。

8.设计模式

游戏中用到的设计模式
1)单例模式:
只允许创建一个类的一个实例,一般用于各种管理类
2)观察者模式:
游戏内的事件系统使用了观察者模式
3)策略模式:
游戏中角色有一个攻击前摇PreAttack方法,角色攻击时根据职业执行不同的前摇方法。
4)组合模式:
比如游戏中的场景管理器类,由角色,NPC,怪物,采集物等其他类组合而成。
5)模板(方法)模式
父类中的虚方法,子类可以用base. 调用。

四、图形渲染

1.渲染流水线

渲染流水线
渲染流水线就是给定光源、摄像机从三维场景生成2D图像的过程。
主要分为三个阶段。

应用阶段,在CPU上执行
1.准备场景中的基本数据:物体网格数据(顶点、uv)、摄像机数据、光照阴影数据。
2.进行一个粗粒度的剔除,将看不到的物体剔除出去。
3.设置渲染状态(物体的材质,使用的贴图、shader等),将物体排序
4.调用DrawCall输出渲染图元到显存

几何阶段,在GPU上执行
1.顶点着色器:接受来自CPU的顶点数据进行坐标转换和逐顶点光照。通过MVP矩阵将顶点数据从模型空间转换到裁剪空间。
2.投影:通过透视除法,将裁剪空间下的顶点转换到NDC坐标。
3.裁剪:根据图元和摄像机视野的关系,剔除和裁剪掉视野外的图元。
4.屏幕映射:将NDC空间下的图元映射到屏幕坐标下,根据屏幕分辨率对x,y坐标进行缩放。

光栅化阶段,在GPU上执行
1.三角形设置:根据屏幕映射输出的顶点数据,计算边方程,生成三角形网格数据。
2.三角形遍历:遍历三角形网格,找到被网格覆盖的像素,使用三个顶点的信息对内部像素进行插值运算。
3.片元着色器:将插值后的像素进行逐像素的纹理着色和光照着色,输出颜色值。
4.透明度测试、模板测试、深度测试、混合,将颜色输出到帧缓存。

2.简单光照模型

1)兰伯特模型
法线点乘光源方向,入射光越贴合法线方向,漫反射颜色越亮。
Cdiffuse = (Clight * mdiffuse)* max(0, n * l)
2) 半兰伯特模型
兰伯特模型背光区域全部为黑色,明暗效果不好
Cdiffuse = (Clight * mdiffuse)* (α * ( n * l) + β)
半兰伯特模型是基于兰伯特模型的改进,背光区域的明暗效果更好
大多数情况下α,β的值为0.5,他将漫反射颜色[-1,1]映射到[0,1]区间内
3)Phone模型
Unity 面试经验汇总_第1张图片
光的反射方向点乘视角方向,反射方向越贴合视角方向,高光反射越强。
4)Blinn-Phong模型
Unity 面试经验汇总_第2张图片

使用法线方向点乘 视角方向和光源方向的半角方向,当视角与光反射方向越贴近时,视角与光方向的半角h与法线n夹角越小,高光就越亮。
相比Phone模型,由于不用计算光的反射方向,性能更好。

五、项目优化

1.代码优化

1)使用成员变量缓存,空间换时间
比如UI界面的节点用成员变量缓存起来,只用在初始化的时候获取一次。
比如每次计算红点逻辑保存到红点管理类的字典里,避免重复计算。
比如从表里频繁获取某些特定数据,获取后缓存起来。避免每次遍历这个表。
2)for/while等循环内,以及帧更新中避免创建内存,避免使用foreach。
3)使用泛型方法,避免装箱拆箱。
4)创建List和Dictionary时指定大小,避免内部扩容创建新数组。
5)使用StringBuilder进行字符串拼接。
6)使用对象池,回收可重复利用的对象,避免频繁创建和销毁对象。
7)在在网络IO中使用单独的线程来收发网络数据,使用循环缓冲区缓存数据。

2.DrawCall优化

Canvas动静分离,合理划分,按游戏类型和UI数量划分,太多也有额外消耗。
举例:跑马灯效果,可以在动态节点的父节点添加一个单独Canvas。

减少节点层次和数量,使用相同材质贴图的UI尽量保持深度相同,减小合批计算量。
举例:可以将一个Text的层级下面放一个其他图集的透明图片,将其层级垫高,使其与相同depth的Text节点进行合批。

修改Image的Color属性,原理是修改顶点色,会引起网格Rebatch,同时触发Canvas.SendWillRenderCanvases。
好处在于修改顶点色材质不变,没有额外DC。修改shader颜色不会重绘,材质不变,没有Rebatch。
举例:颜色的渐变效果(颜色动画)可以通过动态修改材质的shader颜色来实现。

RectMask2D代替Mask。

少用layout,简单的布局RectTransform代替。

不显示的对象,不要SetActive,设置Canvas Group的alpha为0,scale为0,这样vbo不会被清除。
或者CanvasRenderer.cull设为true,表示当UI的透明度为0的时候删掉网格和顶点。

使用图集,同个界面、同个功能的图在同一个图集

慎用自带组件Outlien和Shaow,都是通过重复绘制多个Mesh实现的,其中Showdow绘制为原文本Mesh的2倍,而Outline为5倍,对渲染面数、顶点数,BuildBatch和SendWillRenderCanvases的耗时,Overdraw都有影响。若对于某种字体每次出现都需要这两种效果,可以让美术同学直接把阴影和描边做到字体里。

3.OverDraw优化

Rebatch:
Canvas把表示它UI元素的网格合并起来,并生成合适的渲染命令发送到Unity的图形管线中。这个过程的结果会被缓存起来复用,直到这个Canvas被标记为Dirty,当Canvas中任何一个网格发生变化时,就会被标记成Dirty状态。
Canvas的网格是从从那些Canvas下的CanvasRenderer组件中获取的,但不包括子Canvas。

4.资源优化

UI的图片一般关闭Read/Write和MipMaps。

未完待续。。。

你可能感兴趣的:(Unity,C#,心得分享,学习,面试,职场和发展)