1.点乘与叉乘
参考链接:https://blog.csdn.net/hyy19990718/article/details/122972442
点乘,也叫数量积。 a · b = |a||b|cosθ
几何意义是一个向量在另一个向量方向上投影的长度,是一个标量。
点乘的应用:
可判断物体之间的夹角,在做攻击范围检测时使用,例如:玩家向前劈砍动作,而劈砍的范围为-45度到45度,长度为2,如图所示:
那么加入有个敌人刚好在面前,要判断他是否会受到伤害,就只需要判断两部分,一个是距离另一个是角度
距离可调用
Vector3.Distance(Vector3 a, Vector3 b);
首先判断距离是否小于攻击距离2,然后判断角度:
使用 点乘进行角度判断
float cosValue=Vector3.Dot(A.forward.normalized,(B.position-A.position).normalized);若cosValue<=π/2时,证明当前敌人处于范围内,即可对敌人造成伤害,反之处于范围外。
叉乘,也叫向量积。结果是一个和已有两个向量都垂直的向量(法向量)
a × b = |a||b|sinθ
作用:
假设a和b为两个向量
点乘:判断两个向量方向是否大体相同。
a · b > 0 两者夹角 0 ~ 90 (方向大致相同)
a · b = 0 两者夹角 90 (相互垂直)
a · b < 0 两者夹角 90 ~1 80 (方向大致相反)
叉乘:判断两个向量的位置关系:
a × b > 0 a 在b的顺时针方向
a × b = 0 a与b共线,但方向不确定
a × b < 0 a在b逆时针方向
叉乘的应用:
使用叉乘得出的向量可以直观的得出B物体是在A物体的左侧还是右侧,当为左侧时该向量的y值应该小于0,当为右侧时大于0。前提是锁定A的X轴情况下。
2.解释为何foreach遍历时不能增删元素:
在foreach中删除元素时,每一次删除都会导致集合的大小和元素索引值发生变化,从而导致在foreach中删除元素时会抛出异常System.InvaildOperationException。
3.C# List的底层原理
参考自:
《Unity3D高级编程之进阶主程》第一章,C#要点技术(一) - List 底层源码剖析 - 技术人生 - 编程技术 - JESSE人生 (luzexi.com)
List内部是用数组实现的,而不是链表,并且当没有给予指定容量时,初始的容量为0。
List使用数组形式作为底层数据结构,好处是使用索引方式提取元素很快,但在扩容的时候就会很糟糕,每次new数组都会造成内存垃圾,这给垃圾回收GC带来了很多负担。
每次增加一个元素的数据,Add接口都会首先检查的是容量还够不够,如果不够则用 EnsureCapacity 来增加容量。
每次容量不够的时候,整个数组的容量都会扩充一倍,_defaultCapacity 是容量的默认值为4。因此整个扩充的路线为4,8,16,32,64,128,256,512,1024…依次类推。
这里按2指数扩容的方式,可以为GC减轻负担,但是如果当数组连续被替换掉也还是会造成GC的不小负担,特别是代码中List频繁使用的Add时。另外,如果数量不得当也会浪费大量内存空间,比如当元素数量为 520 时,List 就会扩容到1024个元素,如果不使用剩余的504个空间单位,就造成了大部分的内存空间的浪费。
因此,我们建议提前告知 List 对象最多会有多少元素在里面,这样的话 List 就不会因为空间不够而抛弃原有的数组,去重新申请数组了。
Remove:删除的原理其实是用 Array.Copy 对数组进行覆盖
Insert:插入元素时,使用的用拷贝数组的形式,将数组里的指定元素后面的元素向后移动一个位置。
Clear:在调用时并不会删除数组,而只是将数组中的元素清零,并设置 _size 为 0 而已,用于虚拟地表明当前容量为0
Contain:使用的是线性查找方式比较元素,对数组进行迭代,比较每个元素与参数的实例是否一致,如果一致则返回true,全部比较结束还没有找到,则认为查找失败。
Find:使用的同样是线性查找,对每个元素都进行了比较,复杂度为O(n)。
ToArray:重新new了一个指定大小的数组,再将本身数组上的内容拷贝到新数组上,再返回出来。
Sort:继承自 Array.Sort接口,因此使用的也是快速排序方式进行排序,从而我们明白了 List 的 Sort 排序的效率为O(nlogn)。
4.Abstract class抽象类和interface接口的区别:
①接口能被多重继承,而子类只能继承一个抽象类
②接口不允许声明成员,抽象类可以
③抽象类可以包含非抽象方法,而接口只能包含抽象方法
常见用法:
Unity项目中一个常见的接口是IPointerEnterHandler,然后在继承的类中实现的就是光标进入后的方法,与之一起出现的常有IPointerExitHandler等,所以说能被多重继承,而且这个接口携带的方法必须被实现
5.GC:
zblade哥总结了一篇非常详细的文章:可以优先去看这篇
Unity优化之GC——合理优化Unity的GC - zblade - 博客园 (cnblogs.com)
以下内容参考自:Unity游戏的GC(garbage collection)优化-腾讯游戏学堂 (tencent.com)
Unity自动内存管理工作原理:
- Unity可以访问两个内存池:栈和堆(也称为托管堆)。栈用于短期存储小块数据,堆用于长期存储和较大数据段。Unity中值类型的局部变量分配在栈上,除此之外都分配在堆上。
- 当创建变量时,Unity从栈或堆中申请内存
- 只要变量在作用域内(仍然可以通过我们的代码访问),分配给它的内存仍然在使用中, 我们称这部分内存已被分配。 我们将栈中的变量称为栈对象,将堆中的变量称为堆对象。
- 当变量超出作用域,该内存不再被使用并可以归还给原来的内存池。当内存被归还给原有的内存池里,我们称该内存被释放。栈内存在变量超出作用域时被实时释放,而堆内存在变量超出作用域之后并没有被释放并保持被分配的状态,这部分的内存变为待回收的垃圾内存。
- 垃圾收集器(garbage collector==GC)负责识别和释放未使用的堆内存。 垃圾收集器定期运行以清理堆。
GC触发的时机:
- 堆分配时堆上的可用内存不足时触发GC。(因此每当无法从可用堆内存中实现堆分配时,就会触发GC,这意味着频繁的堆分配和释放可能导致GC频繁。)
- GC会不时的自动运行(频率因平台而异)。
- 手动强制调用GC
减少GC的做法:
①减少函数引用:
函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增加内存的使用和堆内存的分配。具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。
②缓存
如果我们的代码重复调用产生堆分配的函数,然后丢弃结果,这将产生不必要的垃圾。 对此,我们应该存储对这些对象的引用并复用它们。
③不要在频繁调用的函数中分配内存
如果我们需要在MonoBehaviour中分配堆内存,在频繁调用的函数里分配是最糟糕的。比如 每帧调用的函数Update()和LateUpdate(),在这些地方分配,垃圾将非常快的累积。我们应该尽可能在Start() 或 Awake() 里缓存这些对象的引用,或者确保分配内存的代码只在需要的时候被运行。
④尽量用清除一个已有的容器来代替创建新的容器
创建容器类会引起堆分配,如果在代码中发现多次创建同一个容器变量,则应该缓存该容器引用并在重复创建的地方使用 Clear()操作来替代
⑤避免使用foreach 循环(已修复)
在unity5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操作。因此早期的unity开发者都会避免使用foreach,毕竟foreach算是for循环的语法糖,用for也基本能实现一样的功能。
⑥注意协程的使用
调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建一些管理协程的实例的类。 所以,当游戏在交互时或在性能热点时应该限制对StartCoroutine()的调用。
⑦装箱
装箱是指当一个值类型变量被用作一个引用类型变量时所执行的操作。当我们将值类型的变量(如int或float)传递给具有object类型参数的函数时,通常会发生装箱。当一个值类型变量被装箱时,Unity在堆上创建一个临时的System.Object来包装值类型变量。 一个System.Object是一个引用类型的变量,所以当这个临时对象被处理掉时会产生垃圾。
⑧LINQ和正则表达式
LINQ和正则表达式由于在后台会有装箱操作而产生垃圾。
⑨对象池
即使减少了脚本中的堆分配,在运行时大量对象的创建和销毁依然会引起GC问题。 对象池是一种通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中广泛使用,最适合于频繁产生和销毁类似对象的情况;
6.不同平台推荐的贴图压缩方式
之前在公司找了很久的总结,就顺带贴在这里吧
总结
IOS平台:对于UI,优先使用RGB16+RGB PVRTC4,次选RGB PVRTC4+RGB PVRTC4。
对于场景,根据精度要求使用RGB PVRTC4+RGB PVRTC4或RGBA PVRTC4,如果精度要求更低,可考虑PVRTC2
Android平台:如果想全平台通用,压缩方式没什么可选择的,对于UI和场景,最通用的方式是RGB ETC4+RGB ETC4
压缩质量:仅比较RGB这三个通道的质量,RGBA32 > RBG16 > RGBA16 > RGB PVRTC4/ETC4 > RGB PVRTC2
参考自:Unity 贴图压缩方法和对比_unity 贴图压缩有哪几种方式-CSDN博客
7.几种游戏中常见的设计模式
①观察者模式 Observer
观察者的设计意图和作用是: 它将对象与对象之间创建一种依赖关系,当其中一个对象发生变化时,它会将这个变化通知给与其创建关系的对象中,实现自动化的通知更新。
当游戏角色属性更新,我们使用观察者模式监听了角色的属性后,那么角色属性的任何改变都将会通知其关联对象。
②单例模式 Singleton
单件模式的设计意图和作用是: 保证一个类仅有一个实例,并且,仅提供一个访问它的全局访问点。
所有的Manger类。例如UIManager,ResourceManager,MapManager,GoodManager等。
③组合模式
将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
将不同的功能用不同的脚本实现,然后使用拖拽的方式自由组合,来实现不同的目的。
在Unity中,一切物体都可当做组件(包括脚本),这就极有利于实现组合模式。
④抽象工厂模式
抽象工厂的设计意图和作用是: 封装出一个接口,这个接口负责创建一系列互相关联的对象,但用户在使用接口时不需要指定对象所在的具体的类。
游戏中抽象工厂模式的适用环境:
例如,创建一个Unit单位基类,这个单位基类派生出不同的类,有玩家角色,Npc,Monster。玩家角色实现了基类中创建对象的接口。最终用户只需要Unit.Create(path); 就可以创建新的对象
8.MonoBehaviour 的生命周期
参考自:【Unity3D】MonoBehaviour的生命周期 - 知乎 (zhihu.com)
Unity3D 中可以给每个游戏对象添加脚本,这些脚本必须继承 MonoBehaviour,用户可以根据需要重写 MonoBehaviour 的部分生命周期函数,这些生命周期函数由系统自动调用,且调用顺序与书写顺序无关。
MonoBehaviour 的继承关系:MonoBehaviour→Behaviour→Component→Object.
MonoBehaviour 的生命周期函数主要有:
- OnValidate: 确认事件,脚本被加载、启用、禁用、Inspector 面板值被修改时,都会执行一次
- Awake:唤醒事件,只执行 1 次,游戏一开始运行就执行,不同脚本的Awake的执行顺序跟ExcutionOrder有关,
- OnEnable:启用事件,只执行 1 次,当脚本组件被启用的时候执行一次。
- Start:开始事件,只执行 1 次。
- FixedUpdate:固定更新事件,每隔 0.02 秒执行一次,所有物理组件相关的更新都在这个事件中处理。
- Update:更新事件,每帧执行 1 次。
- LateUpdate:稍后更新事件,每帧执行 1 次,在 Update 事件执行完毕后再执行,通常跟随摄像机应始终在 LateUpdate 中实现, 因为它跟踪的对象可能已在 Update 中发生移动。。
- OnGUI:GUI渲染事件,每帧执行 2 次。
- OnDisable:禁用事件,只执行1 次,在 OnDestroy 事件前执行,或者当该脚本组件被禁用后,也会触发该事件。
- OnDestroy:销毁事件,只执行 1 次,当脚本所挂载的游戏物体被销毁时执行。
9.Destroy和DestroyImmediate的区别
1 使用Destroy删除游戏物体,游戏物体并不会立即被删除,而是异步执行的
2 使用DestroyImmediate删除游戏物体,游戏物体立即被删除,代码顺序执行
官方推荐使用Destroy代替DestroyImmediate,原因是DestroyImmediate是立即销毁,立即释放资源,做这个操作的时候,会消耗很多时间的,影响主线程运行,而Destroy是异步销毁,一般在下一帧就销毁了,不会影响主线程的运行。
10.虚函数和抽象函数的区别
①抽象函数只能在抽象类中声明,虚函数不是
②抽象函数必须被派生类实现,虚函数不用:虚函数是有代码的并明确允许子类去覆盖,但子类也可不覆盖,就是说可以直接用,不用重写;抽象函数是没有代码,子类继承后一定要重写
11.面向对象的三大特性:
面向对象编程三大特性——封装、继承、多态
①封装:
封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,尽可能地隐藏内部的细节,只保留一些对外接口使之与外部发生联系。系统的其他对象只能通过包裹在数据外面的已经授权的操作来与这个封装的对象进行交流和交互。也就是说用户是无需知道对象内部的细节,但可以通过该对象对外的提供的接口来访问该对象。
②继承:
父类和派生类
③多态:
函数重载和函数重写
12.unity中控制渲染顺序的方式
①不同Camera的Depth。 (大在前,小在后)
②同Camera的SortingLayer。 (下在前,上在后)
③同SortingLayer下的Order in Layer。 (大在前,小在后)
④同Order in Layer下的Z轴。(以及Shader中对Tags设置的“Queue”)
Unity中影响渲染顺序的因素总结 - 知乎 (zhihu.com)
13.FixedUpdate和Update的区别
参考自:
Unity3D Update和FixedUpdate的区别及深入探讨_unity update fixedupdate-CSDN博客
(作者说不让转载来着,点进去看吧)
①FixedUpdate(),固定更新方法,和物理相关的操作代码,最好写在此方法中。
固定更新的时间是0.02s,1秒执行50次,可在Edit--->Project Settings--->Time面板中的Fixed Timestep查看。
②Update(),每帧执行一次。画面每渲染一次,就是一帧,每帧的时间是不固定的
14.帧同步与状态同步
参考自:【网络同步】浅析帧同步和状态同步 - 知乎 (zhihu.com)
网络游戏研发,该选帧同步还是状态同步?对比之后你就懂了_元梦之星帧同步-CSDN博客
①帧同步【LockStep】
王者荣耀、FiFA、魔兽争霸及所有格斗类游戏
1.基本原理
- 帧同步的战斗逻辑在客户端
- 在帧同步下,通信就比较简单了,服务端只转发操作,不做任何逻辑处理。
- 客户端按照一定的帧速率(理解为逻辑帧,而不是客户端的渲染帧)去上传当前的操作指令,服务端将操作指令广播给所有客户端,
- 当客户端收到指令后执行本地代码,如果输入的指令一致,计算的过程一致,那么计算的结果肯定是一致的,这样就能保证所有客户端的同步,这就是帧同步。
2.帧同步缺陷
- 由于帧同步战斗逻辑都在客户端,服务器没有验证,带来的问题就是外挂的产生(加速、透视、自动瞄准、数据修改等)
- 网络条件较差的客户端会影响其他玩家的游戏体验。(优化方案:乐观帧锁定、渲染与逻辑帧分离、客户端预执行、指令流水线化、操作回滚等)
- 不同机器浮点数精度问题、容器排序不确定性、RPC时序、随机数值计算不统一
②状态同步
CSGO、LOL、守望先锋、qq飞车及大部分MMO
状态同步顾名思义就是同步各个客户端的状态,保证每一次操作后的状态是一致的
通过开发服务端程序,把用户的操作作为输入实时上传到服务端,服务端通过计算返回结果给各个客户端,这样的过程就是状态同步。
1 基本原理
- 状态同步的战斗逻辑在服务端
- 在状态同步下,客户端更像是一个服务端数据的表现层
- 一般的流程是
- 客户端上传操作到服务器,
- 服务器收到后计算游戏行为的结果,然后以广播的方式下发游戏中各种状态,
- 客户端收到状态后再根据状态显示内容。
2. 状态同步缺陷
- 状态同步做回放系统的话会是个灾难。
- 延迟过大、客户端性能浪费、服务端压力大
- 对带宽的浪费。对于对象少的游戏,可以用快照保存整个游戏的状态发送,但一旦数量多起来,数量的占用就会直线上升。(优化:增量快照同步,协议同步指定数据)
③帧同步和状态同步的区别
- 战斗核心逻辑:状态同步的战斗逻辑在服务端,帧同步的战斗逻辑在客户端。
- 流量:状态同步比帧同步消耗大,例如一个复杂游戏的英雄属性可能有100多条,每次改变都要同步一次属性,这个消耗是巨大的,而帧同步不需要同步属性;
- 回放&观战系统:帧同步的比状态同步好做得多,因为只需要保存每局所有人的操作就好了,而状态同步的回放&观战,需要有一个回放&观战服务器,当一局战斗打响,战斗服务器在给客户端发送消息的同时,还需要把这些消息发给放&观战服务器,回放&观战服务器做储存,如果有其他客户端请求回放或者观战,则回放&观战服务器把储存起来的消息按时间发给客户端。
- 安全性:状态同步比帧同步高很多,因为状态同步的所有逻辑和数值都是在服务端的,如果想作弊,就必须攻击服务器,而攻击服务器的难度比更改自己客户端数据的难度高得多,而且更容易被追踪,被追踪到了还会有极高的法律风险。而帧同步因为所有数据全部在客户端,所以解析客户端的数据之后,就可以轻松达到自己想要的效果。
- 服务器压力:状态同步服务器压力比较大,因为要做更多运算。
④帧同步或状态同步如何抉择
15.结构体与类
参考自:Unity开发之C#基础-结构体_unity 数据结构 struct 继承-CSDN博客
相同点:C#中结构类型和类类型在语法上非常相似,他们都是一种数据结构,都可以包括数 据成员和方法成员
不同点:结构是值类型,它在栈中分配空间;而类是引用类型,它在堆中分配空间,栈中保 存的只是引用
结构体和类的适用空间
当堆栈的空间很有限,且有大量的逻辑对象时,创建类要比创建结构好一些
对于点、矩形和颜色这样的轻量对象使用结构体
在表现抽象和多级别的对象层次时,类是最好的选择,因为结构不支持继承
16.字符串"HELLO".ToLower()产生了几个对象
参考自:DotNet面试题解析03-string与字符串操作 | syxdevcode博客
String:
String 是一个特殊的引用类型,其对象值存储在托管堆中。
在c#中,String的存储方式很特殊,在c#的内存中,在常量区里会分配一块空间叫做String暂存池(常量池),在某些时候,我们的字符串数据是存储在这个常量池中的,而地址依然是存放在栈中。
例如用 String str = "xXXXX" 的方式来创建String变量的话,那么String的值便会存储在String常量池中,在我们以这种方式创建String变量时,编译器会先判断你这个内容有没有已经在常量池出现过了,如果已经出现过,那么不会再在常量池中使用空间来存放一个相同的内容,这个内容只会固定有一个引用,所以在创造相同内容的String的时候,他们的引用都是相同的。
string 的内部是一个 char 集合,他的长度 Length 就是字符char 数组的字符个数,一个字符两个字节。
String具有恒定性和驻留性,
恒定性:指字符串是不可变的,字符串一经创建,就不会改变,任何改变都会产生新的字符串,因此调用ToUpper或ToLower方法都会导致创建一个新的字符串
因此在比较字符串时:
if(str1.ToLower()==str2.ToLower()) //这种方式会产生新的字符串,不推荐
if(string.Compare(str1,str2,true)) //这种方式性能更好
因此如果是用作bool.Parse,方法本身已经是忽略大小写的,调用时不需要调用ToLower方法。由于字符串的不变性,在大量使用字符串操作时,会导致创建大量的字符串对象,带来极大的性能损失,因此要尽量避免使用ToUpper等字符串修改方法。
bool.Parse(string value):将逻辑值的指定字符串表示形式转换为其等效的 System.Boolean 值;如果该字符串不等于 System.Boolean.TrueString 或System.Boolean.FalseString 的值,则会引发异常,说人话就是value是否是true或false的大小写形式。
驻留性:相同的字符串在内存(堆)中只分配一次,第二次申请字符串时,发现已经有该字符串是,直接返回已有字符串的地址,这就是驻留的基本过程。
关于这个特性可以通过几道面试题来理解:
参考自:C#知识点之到底创建了几个string对象?_string a=“1” 会创建几个对象-CSDN博客
String str = new String(“abc“)究竟创建了几个对象?_string str=new string(“abc“);到底创建了几个string对象?-CSDN博客
①string[] str1 = new string[1]{“abc”};创建了几个对象?
答案:1个或2个。第一个,先查找常量池中是否有“abc”,如果没有,则在常量池中创建一个“abc”对象,如果有,则不创建。
第二个,new 本身就要在堆中开辟一块内存,创建一个“abc”对象实例。并返还地址给栈中的str。
②string str2 = “a” + “b” + “d”; 创建了几个对象?
答案:1个。首先没有new。堆中创建对象0个。
那么按照第一个问题的推理,“a”,“b”,"d"三个在常量池中都没有,应该至少创建3个啊?
这里有一个小知识点,这样的代码,在编译时就已经把abc这三个常量拼在一起了,所以只在常量池中创建了一个“abd”的对象。
③在上面的基础上 str2 = “abc”; 创建了几个对象?
答案:0个。因为常量池中已有,直接返回内存地址。str2修改引用
④String str1 = "abc"; String str2 = "ab" + "c"; str1==str2是true吗?
答案:是。因为String str2 = "ab" + "c"会查找常量池中时候存在内容为"abc"字符串对象,如存在则直接让str2引用该对象,显然String str1 = "abc"的时候,上面说了,会在常量池中创建"abc"对象,所以str1引用该对象,str2也引用该对象,所以str1==str2
⑤String str1 = "abc"; String str2 = "ab"; String str3 = str2 + "c"; str1==str3是false吗?
答案:是。因为String str3 = str2 + "c"涉及到变量(不全是常量)的相加,所以会生成新的对象,其内部实现是先new一个StringBuilder,然后 append(str2),append("c");然后让str3引用toString()返回的对象
字符串拼接方式:C# String 字符拼接测试(“+”、string.Format、StringBuilder 比较) - 漠里 - 博客园 (cnblogs.com)
StringBuilder:
当字符串需要频繁修改且长度较长时,常用StringBuilder,StringBuilder不会创建大量的新对象,StringBuilder 只在以下两种情况下会分配新对象:
- 追加字符串时,当字符总长度超过了当前设置的容量 Capacity,这个时候,会重新创建一个更大的字符数组,此时会涉及到分配新对象。
- 调用 StringBuilder.ToString(),创建新的字符串。
StringBuilder 内部同 string 一样,有一个 char[] 字符数组,负责维护字符串内容。
通过无参构造,append()添加元素时。数组的默认长度为16,每次扩容为数组原长度的2倍+2(value.length<<1+2)。
因此,与 char 数组相关,就有两个很重要的属性:
- public int Capacity:StringBuilder 的容量,其实就是字符数组的长度。
- public int Length:StringBuilder 中实际字符的长度,>=0,<= 容量 Capacity。
StringBuilder通过含参构造添加元素时。
①直接添加一个长度。数组的初始长度为该长度
②添加一个字符串。数组的初始长度为该字符串的长度+16(str.length+16)。每次扩容为数组原长度的2倍+2(和上述append()方法相同,因为也是通过append()添加元素的)
追加字符串的过程:
- StringBuilder 的默认初始容量为16;
- 使用 stringBuilder.Append() 追加一个字符串时,当字符数大于16,StringBuilder 会自动申请一个更大的字符数组,一般是倍增;
- 在新的字符数组分配完成后,将原字符数组中的字符复制到新字符数组中,原字符数组就被GC回收;
- 最后把需要追加的字符串追加到新字符数组中;
17.深拷贝与浅拷贝
参考自:深拷贝(deepcopy)与浅拷贝(copy)的区别_深copy-CSDN博客
深拷贝和浅拷贝是只针对Object和Array这样的引用数据类型的。
浅拷贝
被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。即对象的浅拷贝会对“主"对象进行拷贝,但不会复制主对象里面的对象。"里面的对象”会在原来的 对象和它的副本之间共享。浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存(分支)。
简而言之,浅拷贝仅仅复制所考虑的对象,而不复制它所引用的对象
深拷贝
深拷贝是一个整个独立的对象拷贝,深拷贝会递归拷贝所有层级的对象属性,并拷贝属性指向的动态分配的内存。 当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。
简而言之,深拷贝把要复制的对象所引用的对象都复制了一遍。
18.序列化与反序列化:
参考自:序列化理解起来很简单 - 知乎 (zhihu.com)
C#实现对象序列化的三种方式 - 知乎 (zhihu.com)
序列化:把对象转化为可传输的字节序列过程称为序列化。
反序列化:把字节序列还原为对象的过程称为反序列化。
序列化最终的目的是为了对象可以跨平台存储,和进行网络传输。而我们进行跨平台存储和网络传输的方式就是IO,而我们的IO支持的数据格式就是字节数组。
Unity会自动为Public变量做序列化,序列化的意思是说再次读取Unity时序列化的变量是有值的,因此在Inspector面板我们可以看到public的变量和加了[SerializeField]的私有变量
序列化的三种常见方式
①JSON:JSON序列化是目前使用很多的一种方式,实现JSON序列化的方式也有很多,一般会使用NewtonSoft插件来做,参考方法:Unity 使用 Newtonsoft json 库进行序列化和反序列化_unity json中的能将color反序列化成对象吗-CSDN博客
②XML:XML序列化可提高可读性,以及对象共享和使用的灵活性,XML序列化将对象的公共字段和属性或方法的参数和返回值序列化成符合特定XML格式的流。
③二进制:二进制序列化及反序列化的方式主要是使用BinaryFormatter这个类
19.MVC与MVVM
参考自:MVC与MVVM模式的区别 - 掘金 (juejin.cn)
MVC:
MVC思想 :Controller负责将Model的数据用View显示出来。
MVC | 解释 |
---|---|
① Model(模型) | 是应用程序中用于处理应用程序数据逻辑的部分。 通常模型对象负责在数据库中存取数据。处理数据的crud |
② View(视图) | 是应用程序中处理数据显示的部分。通常视图是依据模型数据创建的。视图层,前端 |
③ Controller(控制器) | 是应用程序中处理用户交互的部分。 控制器负责从视图读取数据,控制用户输入,并将数据发送给模型。 一般包括业务处理模块和router路由模块 |
优点:
- 耦合度低(运用MVC的应用程序的三个部件是相互独立的,改变其中一个不会影响其他两个);
- 重用性高(多个视图可以使用同一个模型)
- 生命周期成本低
- 部署快(业务分工明确)
- 可维护性高
缺点:
- 不适合小型项目开发
- 视图与控制器联系过于紧密,妨碍了它们的独立重用
MVVM :
MVVM由Model,View,ViewModel三部分构成。
M V VM | 解释 |
---|---|
Model | 代表数据模型(Vue的data),数据和业务逻辑都在Model层中定义; |
View | 代表UI视图,负责数据的展示(Vue的el); |
ViewModel | 是一个对象,负责监听 Model 中数据的改变并且控制View视图的更新,处理用户交互操作; |
- Model 和 View 并无直接关联,而是通过 ViewModel 来进行交互的(即双向数据绑定),
- Model 和 ViewModel之间有着双向数据绑定的联系。
View的变化可以引起Model的变化,Model的变化也可以引起View变化(类似于浅拷贝)。ViewModel
是View
和Model
层的桥梁,数据会绑定到viewModel
层并自动将数据渲染到页面中,视图变化的时候会通知viewModel
层更新数据。
优点:
- 低耦合:
- 视图(View)可以独立于Model变化和修改,一个Model可以绑定到不同的View上,
- 当View变化的时候Model可以不变化,当Model变化的时候View也可以不变;
- 可重用性:你可以把一些视图逻辑放在一个Model里面,让很多View重用这段视图逻辑。
- 独立开发:双向数据绑定的模式,实现了View和Model的自动同步,因此开发者只需要专注对数据的维护操作即可,而不需要一直操作 dom。
可以实现双向绑定的标签:Input,textarea,select标签等(可以输入或改变标签内容的标签)
MVC与MVVM的区别
- MVVM实现了View和Model的自动同步
- 当Model属性改变时,不用手动操作Dom元素去改变View的显示。
- 而改变属性后,该属性对应View的显示会自动改变
·
20.协程
参考链接:聊一聊Unity协程背后的实现原理 - iwiniwin - 博客园 (cnblogs.com)
Unity 协程(Coroutine)原理与用法详解_unity coroutine-CSDN博客
Unity协程的定义、使用及原理,与线程的区别、缺点全方面解析_unity ienumerator和java线程的区别-CSDN博客
Unity中,只能在主线程中获取物体的组件、方法、对象,如果脱离主线程,Unity的很多功能无法实现,因此多线程的存在在Unity开发中意义不大。
协程与线程的区别:
- 对于协程而言,同一时间只能执行一个协程,而线程则是并发的,可以同时有多个线程在运行
- 两者在内存的使用上是相同的,共享堆,不共享栈
- Unity的协程在主线程上执行。它们允许你将任务分解成多个步骤或等待,但这些步骤依然是在主线程上顺序执行的。
- 协程通常比线程更轻量级,因为它们不需要分配额外的操作系统资源。线程需要分配内存和操作系统线程资源,而协程只需要Unity的调度器来管理。
- 协程在Unity中的执行是协作的,可以安全地访问和修改Unity的游戏对象和组件。线程在多线程环境中需要额外的同步措施来确保数据的安全性,容易引入竞态条件和死锁。
微观上线程是并行(对于多核CPU)的,而协程是串行的
原理:
协程实际上是通过迭代器来实现的
具体有两篇博客写的比较详细:
Unity中协程与迭代器的关系_unity迭代器协程-CSDN博客
Unity——协程(Coroutine)_unity 协程-CSDN博客
协程的实际使用:
①将一个复杂程序分帧执行:
如果一个复杂的函数对于一帧的性能需求很大,我们就可以通过yield return null将步骤拆除,从而将性能压力分摊开来,最终获取一个流畅的过程,这就是一个简单的应用
举例如果需要使用Update中一个循环去遍历列表,每帧的代码执行量比较大,就可以将这样的执行放置到协程中来处理,通过协程将计算量分配多帧。
②进行计时器工作
③异步加载
-
AB
包资源的异步加载 -
Reaources
资源的异步加载 -
场景的异步加载
-
WWW模块的异步请求
yield return 的一些常见用法:
yield是C#的关键字,其实就是快速定义迭代器的语法糖。只要是yield
出现在其中的方法就会被编译器自动编译成一个迭代器,对于这样的函数可以称之为迭代器函数。迭代器函数的返回值就是自动生成的迭代器类的一个对象
yield return null; 暂停协程等待下一帧继续执行(Update执行完之后才开始执行,但是会在LateUpdate之前执行)
yield return 0或其他数字; 暂停协程等待下一帧继续执行
yield return new WairForSeconds(时间); 等待规定时间后继续执行
yield return StartCoroutine("协程方法名");开启一个协程(嵌套协程)
21.UI的打开与隐藏
这一块属于UGUI的优化策略了,目前呆过的两家公司都没有用其他的方法去抵消SetActive对性能的开销,我在尝试优化这一块的时候也是纠结了很久最终放弃了对SetActive的替换方案。
曾探寻过的改良办法:
方法:Scale改为0,0,0,再改为1,1,1;
问题:改回后draw call加倍;大量垃圾回收;
方法:将界面移除Canvas这个父物体;
问题:改回后draw call加倍;大量垃圾回收;而且新增父物体增加额外引用耦合;
方法:放在Camera的某个culling层上;
问题:改回后draw call加倍;大量垃圾回收;只对screen space-camera有效;
方法:Canvas.enable = false;
问题:改回后延迟严重;而且不方便使用;
方法:移动到摄像头外
问题:其实也会耗费性能的,与setactive无异
其中比较现实的是使用CanvasGroup的方案
GetComponent<CanvasGroup>().alpha = 1;
GetComponent<CanvasGroup>().interactable = true;
GetComponent<CanvasGroup>().blocksRaycasts = true;
对于实际开发的项目来说,SetActive其实就是最佳的选择(GF底层也是用的SetActive(Visible)),这方面的优化主要应该在预加载和缓存关闭的UIPanel。
21.委托与事件的作用
①有助于Unity里编写高效的、干净的代码,相对而言,
②委托和事件帮助我们编写模块化和可重用的代码,通过对事件进行分发,以达到解耦的目的
btw,记得不要忘记解除监听,否则,它会导致内存泄漏,且通常会导致double甚至多次回调事件对应的函数。
22.Delegate, Event, UnityEvent, Action, UnityAction, Func 的区别
参考自:帮你理清C#委托、事件、Action、Func - 技术专栏 - Unity官方开发者社区
简单来说,Delegate, Event, Action, Func属于C#的内容,UnityEvent、UnityAction只是Unity封装的更适合编辑器开发的版本。
Delegate是委托,不多解释
Event就是特殊的委托,直观的区别就是在外部类中无法直接使用=赋值。当你在委托声明的时候加了event字段之后,直接赋值的操作会变成private权限,那么自然在其他类中就无法直接赋值了,为什么+=和-=就可以呢?那是因为+=和-=重写变成了add和remove的方法,是public的,所以可以在外部调用。
Action是带泛型参数的委托
Func是带返回值的委托。
UnityEvent可以用于Unity内的.AddListener(),也就是Button里的点击事件,在unity编辑器中可以可以拖动脚本,属于是更适合Unity内部开发的封装手段。
23.值类型与引用类型
在C#中值类型的变量直接存储数据,而引用类型的变量持有的是数据的引用,数据存储在数据堆中。
值类型(value type):byte,short,int,long,float,double,decimal,char,bool 和 struct 统称为值类型,均隐式派生自System.ValueType。值类型变量声明后,不管是否已经赋值,编译器为其分配内存。
引用类型(reference type):string 和 class统称为引用类型。当声明一个类时,只在栈中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的内存空间。当使用 new 创建一个类的实例时,分配堆上的空间,并把堆上空间的地址保存到栈上分配的小片空间中。
简单地说“值类型存储在栈上,引用类型存储在托管堆上”是不对的。必须具体情况具体分析。
内存分配方面:
数组的元素不管是引用类型还是值类型,都存储在托管堆上。
引用类型在栈中存储一个引用,其实际的存储位置位于托管堆。而值类型总是分配在它声明的地方:作为字段时,跟随其所属的变量(实 例)存储;作为局部变量时,存储在栈上。(栈的内存是自动释放的,堆内存是.NET中会由GC来自动释放)
值类型作为方法中的局部变量时,在栈中分配,而作为类的成员变量时,在堆中分配。
24.拆箱与装箱
如果将一个值类型转换为一个它实现的某个接口或object会发生什么?结果必然是对一个存储位置的引用,且这个存储位置表面上存储的是这个引用类型的实例,实际上则是存储的值类型的值。这个转换称为装箱。相反的过程称为拆箱。
装箱(从值类型转换到引用类型)的步骤:
首先在堆上分配内存。这些内存主要用于存储值类型的数据。
接着发生一次内存拷贝动作,将当前存储位置的值类型数据拷贝到堆上分配好的位置。
最后返回对堆上的新存储位置的引用。
拆箱(从引用类型转换为值类型)的步骤则相反:
首先检查已装箱的值的类型兼容目标类型。
接着发生一次内存拷贝动作,将堆中存储的值拷贝到栈上的值类型实例中。
最后返回这个新的值。
频繁拆装箱会导致性能问题,由于拆箱和装箱都会涉及到一次内存拷贝动作,因此频繁地进行拆装箱会大幅影响性能。