独立开发近一年游戏没什么成果,最近开始找工作,今天面试了第一家公司,结果很糟糕,在这里记录反省。
公司位置在浦东世纪大道附近,约的 10 点面试,提早了一个小时到,就在楼下等到快 10 点再上去的。
面试过程没有笔试,面试官拿着一张面试题直接问我,很多题目没答好,有些是因为紧张,有些是真的不太了解。面试官最后直接说 “今天就这样吧,你 C# 基础太差”(不是原话,但是差不多)。以下为回顾的面试题
一、说说 lua 的一个元表
现在的理解是内置一个数组和一个字典的容器,可以用于实现继承。
二、手写一个栈
第一份工作有用过 lua,所以面试官上来就叫我用 lua 手写一个栈,我直接说大部分语法忘记了,面试官有点不耐烦地说那用 C# 写一个。这时候我还是没太反应过来是要我实现一个栈,我居然还以为他只是要我新建一个 Stack,还问他要什么类型的,还是不耐烦地回答我说 “int吧”。我觉得不对劲,再问了一遍才明白是要我实现一个栈功能的类,这个时候他应该以为我不知道栈,还强调了一遍后进先出。现在才刚刚想起来我当时的确弄混了栈和队列,写的是个队列。
出于紧张,一时间没有思路,问了一句能不能用其他数据结构,答不能,我懵了。面试官这个时候已经不想继续了,直接说 “不然今天就这样吧”。我再问一遍之后跟我说可以用数组,实际上这里是给我挖了一个坑。然后我就开始动手写,写的是个队列(狗头,现在回想真的太蠢了。不过面试官可能也没有看得很仔细,没有指出我实现的不是栈而是队列,但是指出了其他问题。
代码就不贴了,说说面试官讲的问题
1.用一个成员变量作为最后入队的下标,面试官说意义不明,而且没有初始化。意义不明是因为栈不需要队尾标记,初始化怪自己没认真检查。
2.出队的时候下标超出数组范围没有给出异常抛出。这个一方面是不应该用数组,另一方面我得好好看看这种异常情况除了打印以外,抛出要怎么写。
查阅后知道可以用链表实现链栈,用数组实现顺序栈,自己再撸一遍 C# 贴出来,先是链栈:
public class MyStack<T>
{
public int Count
{
get { return count; }
}
private int count;
private MyNode<T> _myNode;
public void Clear()
{
_myNode = null;
count = 0;
}
public bool Contains(T val)
{
MyNode<T> checkNode = _myNode;
while (checkNode != null)
{
if (checkNode.val.Equals(val)) return true;
checkNode = checkNode.lastNode;
}
return false;
}
public T Peek()
{
if (_myNode != null) return _myNode.val;
throw new InvalidOperationException("Stack is empty.");
}
public T Pop()
{
if (_myNode != null)
{
T val = _myNode.val;
_myNode = _myNode.lastNode;
count--;
return val;
}
throw new InvalidOperationException("Stack is empty.");
}
public void Push(T val)
{
MyNode<T> newNode = new MyNode<T>(val);
newNode.lastNode = _myNode;
_myNode = newNode;
count++;
}
public T[] ToArray()
{
T[] arr = new T[count];
MyNode<T> checkNode = _myNode;
for (int i = 0; i < count; i++)
{
arr[i] = checkNode.val;
checkNode = checkNode.lastNode;
}
return arr;
}
private class MyNode<T>
{
public T val;
public MyNode<T> lastNode;
public MyNode(T val)
{
this.val = val;
}
}
}
顺序栈:
class MyStack<T>
{
private T[] _stackArr;
private int _index;
public int Count
{
get { return _index + 1; }
}
public MyStack2()
{
_stackArr = new T[8];
_index = -1;
}
public void Clear()
{
_index = -1;
}
public bool Contains(T val)
{
for (int i = 0; i < _index; i++)
{
if (_stackArr[i].Equals(val)) return true;
}
return false;
}
public T Peek()
{
if (_index == -1) throw new InvalidOperationException("Stack is empty.");
return _stackArr[_index];
}
public T Pop()
{
if (_index == -1) throw new InvalidOperationException("Stack is empty.");
return _stackArr[_index--];
}
public void Push(T val)
{
if (Count >= _stackArr.Length)
{
T[] temp = new T[_stackArr.Length * 2];
for (int i = 0; i < _stackArr.Length; i++)
{
temp[i] = _stackArr[i];
}
_stackArr = temp;
}
_index++;
_stackArr[_index] = val;
}
public T[] ToArray()
{
T[] ret = new T[Count];
for (int i = 0; i < Count; i++)
{
ret[i] = _stackArr[i];
}
return ret;
}
}
晚些再用 lua 撸一遍。
三、DrawCall 是什么?如何减少 DrawCall?如何减少在 ui 中的 DrawCall 使用?顶点的坐标空间变换过程?
这个算是面试官随口问的,因为我简历上没写渲染相关。
第一个问题我随便回答的绘制过程。回头翻看了冯乐乐的《Unity Shader入门精要》,总结一下应该回答: CPU 对 GPU 发起的对一个图元列表的命令。
第二个问题上网搜了一圈,感觉 陈嘉栋(慕容小匹夫)大佬的文章 深入浅出聊优化:从Draw Calls到GC讲的比较详细。这里要注意一下,DrawCall 的优化实际是对 CPU 的优化。稍微总结一下方法以防以后再遇到类似问题:
1、静态批处理:使用相同材质,并且不需要移动的物体,都在 Inspector 右上角勾选 Static 的下拉菜单勾选 Batching Static 之后完成
静态批处理前:
静态批处理后:
2.动态批处理:材质、网格相同的两个物体 Unity 会自动进行批处理,在 Unity5 之前有对 Scale 限制,Unity5 之后没有,但是还有如下限制:
- 网格物体的顶点需要小于 900;
- 如果着色器使用了顶点位置、法线和 UV 值(纹理坐标)三种属性,只能批处理 300 顶点一下的物体,如果使用了更多的属性,最大顶点数量只能是 900 / n(n 为使用的属性数量);
- 多通道(多 pass)的 Shader 会妨碍批处理操作;
- 带有 lightmap(光照纹理)的物体需要指向 lightmap 的同一位置。
另外还需要在 Edit —> Project Settings —> Player —> Other Settings 中勾选 Dynamic Batching(Unity 2019.4.2f1)。
开启动态批处理前,左:相同的两个物体;右:不同的两个物体:
开启动态批处理后,左:相同的两个物体;右:不同的两个物体:
3.通过把纹理打包成图集来尽量减少材质的使用,具体操作看 weixin_44819220 大佬的文章 Unity3d 打包图集。
4.尽量少用反光、阴影一类效果,因为会使物体多次渲染。
第三个问题在 怣*痛 大佬的文章 UI优化 drawcall的优化 感觉整理较好,还是自己列举一下以防再次碰到:
1.减少 mask 的使用,每个 mask 会单独计算 DrawCall,可以考虑用带通道的图片代替 mask 的遮罩功能。
2.合并图集,DrawCall 会按照图集逐个进行批处理。
3.在一串层级关系中,带有来自相同图集图片的组件最好是父子层级关系,即两个组件之间没有其他层级插入,否则还是会进行多次批处理。
4.减少 UI 层级的深度。
5.常用的部分和不常用的部分分在两个节点下,动静分部。
7.尽可能去除组件的 Raycast Target(射线检测)属性。(效果不明显)
第四个问题我好像是答对了,具体还是参照 冯乐乐的《Unity Shader入门精要》,顺序是 模型空间—>世界空间—>观察空间—>裁剪空间(齐次裁剪空间)。
四、如何优化内存
在这个问题之前,面试官先问了对象池能用在什么地方,我就回答反复使用的物体,之后问怎么优化内存我就先答了刚刚说的对象池,然后含糊地说了减少模型顶点,string 改用 stringbuilder,答得很不完整。
看了 UWA 创始人张鑫巨佬的文章 性能优化,进无止境-内存篇(上)和 性能优化,进无止境---内存篇(下)。以下总结一下要点:
1.资源内存占用:
(1) 纹理:
A.纹理格式:根据硬件选择合适的纹理格式;
B.纹理尺寸:尽可能降低纹理尺寸;
C.UI资源的Mipmap功能:在UI纹理中开启Mipmp功能不会提升渲染效率,反而增加无用的内存占用,建议关闭;
D.Read & Write:开启纹理的 “Read & Write” 会使纹理内存增大一倍。
(2)网格:如果没有 Color、Normal、Tangent 等属性的 Mesh 和有这些属性的 Mesh 进行合并,没有这些属性的 Mesh 也将会被添加上这些属性,造成很大的内存开销。
2.引擎模块自身占用:较多开销在 WebStream 和 SerializeFile,绝大部分内存分配是由 AssetBundle 加载资源导致,要及时释放 AssetBundle 或者将解压后的 AssetBundle 数据存储于本地。
3.托管堆内存占用:Mono 不会把堆内存返还给系统。
4.内存泄漏:资源泄露,切换场景没有释放资源。
5.资源冗余:
(1)同样的资源被打入两个 AB 包;
(2)直接预制不同的 Material,而不是修改同一个 Material 的属性,因为修改属性会实例化一个新的 Material。
另外把网上到处都在转的面试题中优化内存部分也写在这里:
1.压缩自带类库;
2.将暂时不用的以后还需要使用的物体隐藏起来而不是直接 Destroy 掉(对象池);
3.降低模型的片面数、降低模型的骨骼数量、降低贴图的大小;
4.使用光照贴图,使用多层次细节(LOD),使用着色器(Shader),使用预设(Prefab)。
五、GC的底层原理
这个我完全没有答上来。
看了 carsonche 大佬的文章 Unity GC垃圾回收 和 zblade 大佬的文章 Unity优化之GC——合理优化Unity的GC,两位似乎都是翻译自官方文档,描述得比较简单一些。因为想强化记忆,所以在这里手打一遍大致内容。
简介:回收不再使用的内存空间。
原理:Unity 采用 Boehm GC(2019 之后加入 增量式垃圾回收),其使用 Mark-Sweep(标记-清除),即通过将使用中的内存标记后遍历清除未标记内存。具体标记过程依赖一个根对象遍历所有内存,标记结束后遍历整个堆内存清除未标记内存。GC 在堆(heap)中操作,堆栈栈(stack)中存储较小、生命周期较短的数据,堆与之相反,所以如果没有 GC 的话堆会一直增长。
堆栈的运行方式类似数据结构的 Stack,操作十分快捷简单,就不多做阐述。
堆的内存分配过程:
- 检测内存空间是否足够,如果有则直接分配,否则进入 2;
- 触发 GC 回收废弃的内存空间,再检测内存空间是否足够,如果有则分配,否则进入 3;
- 扩展堆内存的大小,分配内存空间。
GC 的具体操作(操作耗费大,堆上使用中的空间越多耗费越大):
- 通过根对象检查堆内存上所有存储变量;
- 检测变量的引用是否在激活状态;
- 把引用不在激活状态的变量,标记为可回收;
- 移除可回收的变量,回收对应的内存空间;
GC 触发时机:
- 上文的内存分配过程中的触发(所以频繁分配堆内存的话 GC 会被反复触发);
- 根据不同平台,以一定的频率自动触发;
- 手动强制执行回收。
优化:
- 尽量避免在 Update 或碰撞检测等函数中创建引用对象,可以用成员变量缓存反复使用;
- 数据结构的重置尽量使用清空,而不是重置;
- 使用对象池,避免反复创建和销毁需要反复使用的对象;
- string 每次操作都会分配内存,如果字符串有一部分经常改变,另一部分不会的话,把两个部分分开;
- 也可以用 StringBuilder 代替 String;
- 尽量介绍 Debug.Log() 等函数,它无论如何会产生一个字符串;
- 许多函数可以代替属性或者其他函数,可以避免这些属性或者函数返回时分配新的内存;
- 减少装箱操作,装箱操作会在堆内存分配一个 System.Object 类型的引用;
- 协程 yield return 返回参数时会产生不必要的内存垃圾,如果返回 null 则不会
- 协程如果要返回一个 new 对象的话,可以用缓存优化;
- 可以用其他方法代替协程,比如计时器可以写在 Update 中代替协程。
- Unity 5.5 之前的 foreach 需要在堆内存产生一个 System.Object ,尽量用 for 或者 while 代替;
- 减少函数引用(这个如果引用不是特别频繁的话,我觉得没有必要)。
六、协程和线程的区别
面试常见题了,凭着印象说:线程可以开起多条,协程任意指定时间只能有一个在运行(因为都在主线程当中),主线程以外的线程无法访问 Unity3D 的相关对象、方法、组件,线程安全需要加锁。面试官没说什么,我回答应该是不够的。
网上没有找到很满意的相关文章,之后又找到的话再放上来。
七、字典和列表的时间复杂度
面试官开始直接问我字典的时间复杂度是多少,我没反应过来,随口答了 O(nlogn),显然不对,只能说不知道,补了一句我只知道字典是用哈希表实现的。面试官又问了列表的时间复杂度是多少,我还是答不知道。之后面试官就叫我走了。
主要还是出于紧张,当时一直脑子一团乱没明白问的到底是啥,查阅后知道这个问题要答对数据结构进行添加、查找、Contains 等的时间复杂度,如果当时反应过来是回答这些的话,想一下就能想出来了。
字典 Dictionary:1.获取的时间复杂度为 O(1),因为使用哈希表索引。2.添加的时间复杂度再不需要扩容时为 O(1),需要扩容时为 O(n)。3.ContainsKey 的时间复杂度为 O(1),因为使用哈希表索引。4.ContainsValue 的时间复杂度为 O(n),因为需要遍历整个字典查找。
列表 List:1.获取的时间复杂度为 O(1),直接用整型下标索引获取得到。2.添加的时间复杂度同字典。3.Contains 的时间复杂度为 O(n),因为需要遍历列表查找。