独立开发近一年游戏没什么成果,最近开始找工作,今天面试了第一家公司,结果很糟糕,在这里记录反省。

       公司位置在浦东世纪大道附近,约的 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 之后完成

lua 类 面试题_lua 类 面试题

静态批处理前:

lua 类 面试题_面试_02

静态批处理后:

lua 类 面试题_C#_03

       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)。

lua 类 面试题_C#_04

开启动态批处理前,左:相同的两个物体;右:不同的两个物体:

        

lua 类 面试题_面试_05

                                

lua 类 面试题_C#_06

开启动态批处理后,左:相同的两个物体;右:不同的两个物体:

         

lua 类 面试题_批处理_07

                              

lua 类 面试题_批处理_08

       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,操作十分快捷简单,就不多做阐述。

       堆的内存分配过程:

  1. 检测内存空间是否足够,如果有则直接分配,否则进入 2;
  2. 触发 GC 回收废弃的内存空间,再检测内存空间是否足够,如果有则分配,否则进入 3;
  3. 扩展堆内存的大小,分配内存空间。

       GC 的具体操作(操作耗费大,堆上使用中的空间越多耗费越大):

  1. 通过根对象检查堆内存上所有存储变量;
  2. 检测变量的引用是否在激活状态;
  3. 把引用不在激活状态的变量,标记为可回收;
  4. 移除可回收的变量,回收对应的内存空间;

       GC 触发时机:

  1. 上文的内存分配过程中的触发(所以频繁分配堆内存的话 GC 会被反复触发);
  2. 根据不同平台,以一定的频率自动触发;
  3. 手动强制执行回收。

       优化:

  1. 尽量避免在 Update 或碰撞检测等函数中创建引用对象,可以用成员变量缓存反复使用;
  2. 数据结构的重置尽量使用清空,而不是重置;
  3. 使用对象池,避免反复创建和销毁需要反复使用的对象;
  4. string 每次操作都会分配内存,如果字符串有一部分经常改变,另一部分不会的话,把两个部分分开;
  5. 也可以用 StringBuilder 代替 String;
  6. 尽量介绍 Debug.Log() 等函数,它无论如何会产生一个字符串;
  7. 许多函数可以代替属性或者其他函数,可以避免这些属性或者函数返回时分配新的内存;
  8. 减少装箱操作,装箱操作会在堆内存分配一个 System.Object 类型的引用;
  9. 协程 yield return 返回参数时会产生不必要的内存垃圾,如果返回 null 则不会
  10. 协程如果要返回一个 new 对象的话,可以用缓存优化;
  11. 可以用其他方法代替协程,比如计时器可以写在 Update 中代替协程。
  12. Unity 5.5 之前的 foreach 需要在堆内存产生一个 System.Object ,尽量用 for 或者 while 代替;
  13. 减少函数引用(这个如果引用不是特别频繁的话,我觉得没有必要)。

六、协程和线程的区别

       面试常见题了,凭着印象说:线程可以开起多条,协程任意指定时间只能有一个在运行(因为都在主线程当中),主线程以外的线程无法访问 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),因为需要遍历列表查找。