个人简历准备
自我介绍
面试官您好!我叫Sarf,是武汉工程大学软件工程专业 2026 届应届毕业生,主攻 Unity 客户端开发方向。
在校期间,我系统掌握了 C# 语言与面向对象编程,深入熟悉 Unity 引擎的核心模块,包括对象生命周期管理、组件系统、资源加载与性能优化等关键技术。
在实践中,我独立主导过两个 Unity 项目:一个是 2D 类银河恶魔城动作 RPG,实现了角色控制、战斗系统和资源热更新;另一个是移动端休闲跳跃游戏,负责了无限关卡生成和性能调优。
专业技能方面
点乘:a⋅b=∣a∣∣b∣cosθ(判断2个向量是否同方向(大于0为同方向, = 0为垂直),Unity中可以判断物体是否在前方)
叉乘:a×b=∣a∣∣b∣sinθ⋅n,可以用来判断点是否在三角形内(分别3条边与顶点到该点的向量 进行判断,3个结果都相同在三角形内)
1.熟悉使用 C# ,理解协程、接口、委托/事件等核心特性 ,具备面向对象编程能力
事件和委托
对于C#委托和事件,委托是一种类型安全的方法引用,允许程序将方法当作数据进行存储和传递。通过委托程序可以在运行时决定调用那个方法,同时一个委托还可以引用多个方法,这些方法会按照顺序进行执行,但是,如果一个委托被公开给外部使用,外部代码就可以随意操作这个委托,例如重新赋值、清空委托、或者直接调用委托。这会导致类的内部行为可能被外部破坏,使程序的控制权变得混乱。为了解决这个问题,C# 引入了事件(event)。
对于事件,则是对委托的安全封装,他只允许在声明事件的类的内部进行调用,不允许外部调用,同时外部只允许订阅和取消订阅事件的操作,不能重新赋值和清空事件。
因此可以理解为:委托是一种保存和调用方法的机制,而事件是在委托基础上实现的一种更安全的通知机制,用于实现发布—订阅模式。 简单来说,事件就是“受限制的委托”,它限制了外部对委托的直接操作,使程序结构更加安全和清晰。
C#的内存管理(GC操作)
C#采用自动内存管理机制,分为栈内存和托管堆内存两部分。
栈内存存储值类型、方法参数和局部变量引用,其生命周期与作用域严格绑定——当方法调用结束时,对应的栈帧自动弹出,内存瞬时释放,这个过程是确定且即
时的。
托管堆内存存储所有引用类型对象,由垃圾回收器(GC)全权管理,采用分代回收策略将堆划分为三代:
新创建的小对象(≤85KB)首先进入第0代;当第0代堆空间耗尽时,GC触发一次第0代回收,从根对象出发标记所有可达对象,然后回收未标记的不可达对象,接
着将存活对象压缩并晋升至第1代;若第1代空间也满,则触发第1代回收,同时处理第0代和第1代;第2代存放长期存活对象,其回收(Full GC)通常只在系统内
存严重不足或显式调用时发生,开销较大。超过85KB的大对象直接进入大对象堆(属于第2代但单独管理),避免复制开销。整个GC过程完全自动,开发者无需
手动释放,但需注意非托管资源(文件流、数据库连接等)必须通过IDisposable接口和using语句显式清理。
协程
协程并不会创建新的线程,而是在同一个线程中通过任务切换来实现多个任务交替执行。程序在运行过程中会在不同任务之间进行切换,每个任务只执行一小段,然后让出执行权,再由调度器安排其他任务运行。从宏观上看,多个任务像是在同时进行,但实际上在某一时刻仍然只有一个任务在执行。
这种机制依赖于 CPU 的时间片调度或程序内部的任务调度。例如在一些游戏引擎(如 Unity)中,协程就是通过在主线程中分阶段执行任务来实现的,它不会开启新的线程,而是利用帧更新机制在不同时间点继续执行未完成的逻辑。
2.熟悉 Unity 引擎,掌握对象的生命周期,组件系统,熟悉常用的插件
生命周期:Awake - Enable - Start - Update - FixedUpdate - LatedUpdate - OnGUI - OnDisable - OnDestrory
Update / FixedUpdate / LateUpdate / OnGUI 会在游戏运行期间循环执行,而 Awake、Start、OnDestroy 等通常只执行一次。
常用的插件
TextMeshPro:TextMeshPro 是 Unity 官方的 高级文本插件,用于替代旧版 UI Text。它支持更清晰的文字渲染、更丰富的文本格式和更好的性能,现在已经成为 Unity UI 的标准文本组件。
CinemaMachine:Cinemachine 是 Unity 官方提供的 高级摄像机控制插件,可以轻松实现摄像机跟随、镜头切换、平滑移动等效果。常用于第三人称或电影式镜头控制。
Addressables:Addressables 是 Unity 官方的 资源管理系统,用于更高效地加载和管理游戏资源,支持异步加载、远程资源更新等功能。
DOTween:DOTween 是一个非常常用的动画插件,用于实现各种 补间动画(Tween Animation),例如物体移动、旋转、缩放、UI 动画等。相比 Unity 自带动画系统,DOTween 使用简单、性能较好,常用于 UI 动画和过渡效果。
3.熟悉 Unity 基础框架(单例模式 、对象池、事件管理、资源加载、空场景加载优化)
对于单例模式:提供一个全局访问点,在整个应用程序生命周期中,只存在一个该类的对象,只能通过一个静态方法去访问实例
对象池模块:通过对象复用减少Instantiate/Destroy的调用,特别适用于频繁创建销毁的对象,性能优化的好方法。
事件管理:事件中心模块是游戏开发中用于管理事件发布与订阅的核心系统,它解耦了事件的发送者和接收者,提供了一种高效、安全的通信机制。事件中心使用字典存储事件名和对应的委托(Dictionary<string, UnityAction<object>>),采用单例模式确保全局访问。通过AddListener添加监听,RemoveListener移除监听,EventTrigger触发事件。所有事件数据通过object类型传递,需要使用时进行类型转换
资源加载模块:资源加载模块提供资源加载的公共接口,使用单例模块进行管理资源。同样也使用2种方法进行资源加载的模块,同步和异步。在资源加载的过程中,同步资源加载是需要等待加载完成后主线程才移动,所有加载可以直接使用,而异步加载时不是立马得到的,需要等待n帧才能加载完成,待资源加载完成后,使用回调函数来操作资源(延迟返回)
空场景加载:Unity 空场景加载,通常是指先进入一个内容很少、只负责过渡和初始化的场景,在这个场景中完成资源预加载、配置初始化、进度条显示以及异步加载目标场景等工作;这样可以避免直接切换到大场景时出现卡顿、黑屏或加载不完整的问题,也便于统一处理启动逻辑、网络连接、数据读取和用户提示。
1 | using UnityEngine; |
4.熟悉 AssetBundle 资源管理 ,能够实现资源的同步与异步加载
AssetBundle是一种资源打包和多态加载的机制,可以把图片,预制体prefab,音效,材质,场景,配置文件单独打成包,在游戏运行的时候按照需要进行加载,可以减少内存消耗。AssetBundle 支持资源动态加载。实际使用时,不能只加载目标包本身,因为它可能依赖其他资源包。通常需要先获取主包或 Manifest,解析出依赖关系,先加载依赖包,再加载目标包,然后从 Bundle 中读取资源并实例化到场景中。
对于资源加载,体积较小且需要立即使用的资源通常采用同步加载;而体积较大或加载耗时较长的资源更适合采用异步加载,以避免阻塞主线程、造成界面卡顿。
5.熟悉 Lua 语言基础,了解如何结合 AB 包实现代码与资源的热替换
ipairs和pairs的区别:对于ipairs是从索引1开始遍历,步长为1,只能遍历数组部分, 中间不是数字的key忽略, 到第一个不连续的数字为止(不含),遍历时只能取key为整数值,遇到nil时终止遍历。对于pairs会遍历所有key,对于key的类型没有要求,遇到nil时可以跳过,不会影响后面的遍历,既可以遍历数组部分,又能遍历哈希部分,但是不保证遍历顺序,因为Lua的表是无序的。
点和冒号的区别:点(.)和冒号(:) 都可以用来访问对象的方法,但:
.调用时,不会自动传入自身:调用时,会自动把当前对象作为第一个参数传入,这个参数通常叫self
1 | obj.func(a, b) -> obj.func(obj, a, b) -- 不是,这里是错误的,就是普通函数调用,传什么参数就是什么参数,不会自动传 obj。 |
在定义函数的时候也有区别
1 | function obj.func(a, b) |
lua中元表是什么意思:元表是 Lua 中给 table 定义特殊行为的一种机制。通过为 table 设置元表,可以控制它在访问不存在字段、赋值、运算、比较、打印、调用等场景下的表现。元表常用于实现继承、面向对象和运算符重载。如常见的元方法,__index,当 table 中找不到某个字段时,可以通过元表中的 __index 去继续查找。或者 _ _add, table 支持运算符,让表可以进行相加操作。
lua中 _ _index 和 _ _newindex 的区别 :index是当在table中找不到元素是会去他的元表中进行寻找,如果元表中的index方法是一个表,则进行相同的操作(检测是否有元表,元表中是否存在等),如果index方法是一个函数,则返回函数的返回值。 newindex则是为当你给一个表中的不存在的元素赋值时,解释器会寻找newindex这个方法,如果存在则会调用这个函数。
lua中闭包是什么意思:子函数使用父函数中的局部变量。简单了解他是一个函数,不过它访问了另外一个函数的作用域中的变量
Upvalue是什么意思:在函数内部引用的函数外部的局部变量
lua中的hotfix是什么意思:就是游戏的热修复,就是在游戏上线的时候,某个函数出现了bug,通过总共hotfix可以运行时替换掉原有函数的逻辑,不需要重新发新版的逻辑,让游戏逻辑走新函数的逻辑(相对于由原先的逻辑指向新的逻辑)。如何实现的呢:就是游戏客户端重新开启的时候检查资源版本,发现服务器有新的补丁包,下载补丁包,将补丁的lua文件加载到lua虚拟机中,重新执行,这样就走了热补丁的路线(xLua 的 hotfix 本质上是先在编译阶段通过 IL 注入把目标 C# 方法改造成“调用时先检查是否有 Lua 补丁”的结构,然后在运行时用 xlua.hotfix 把对应的 Lua 函数挂到这个方法入口上,这样后续对该 C# 方法的调用就会先走 Lua 逻辑,没有补丁时再回退到原始 C# 实现,因此才能在不重启游戏、不重新发包的情况下动态修复代码。)
热更新的流程是什么:C# 程序内嵌运行一个 Lua 虚拟机(Lua VM),Lua 代码在这个虚拟机中执行。热更新时,新的 Lua 脚本(热更代码)通过 require 或 load 等方式被加载到 Lua VM 中。
当 C# 侧的对象(如 GameObject)需要暴露给 Lua 调用时,它会被注册到一个 ObjectTranslator(对象翻译器)的字典中。
这个字典会为每个 C# 对象生成一个唯一的 Object ID,并将其与对象实例关联起来。
Lua 热更新是游戏、服务端等场景中核心的动态更新技术,其核心优势在于 Lua 作为解释型语言,代码以字符串形式加载执行,无需编译链接,可在程序运行时动态替换逻辑
Lua 热更新的核心流程可概括为:客户端先向服务器请求最新版本清单,对比本地清单的版本号 / 文件 MD5,确定需更新的 Lua 文件后,从服务器 / CDN 下载打包好的 Lua 代码(或字节码)并解压到指定目录;接着通过 Lua 的load/require等 API 编译新代码为字节码,在保证原子性的时机(如游戏帧间隙),用新代码的函数 / 表引用替换全局表(_G)或模块表中的旧引用;最后校验更新后的逻辑是否正常执行,清理旧代码的无效引用并触发 Lua GC 回收内存,整个过程无需重启程序,实现运行时动态更新 Lua 逻辑
C#和lua相互调用:Lua 与 C# 的相互调用以 Lua 虚拟机(Lua VM)为核心载体,所有数据交互均通过 Lua 栈完成:C# 侧通过 P/Invoke 调用 Lua 官方 C API(如 lua_getglobal、lua_call)操作 Lua 栈,可获取 / 执行 Lua 的全局变量、函数,也能向 Lua 栈推送参数并接收返回值;Lua 侧则通过 XLua 等框架将 C# 对象 / 函数注册到 Lua 注册表,生成唯一 Object ID 并封装为 userdata(Lua 侧代理),调用时解析 userdata 中的 Object ID,回调到 C# 侧的 ObjectTranslator 字典找到原始对象,执行对应方法后将结果回传至 Lua 栈,整个过程实现了跨语言的函数调用、数据传递,且能兼容 Lua 热更新后的引用替换逻辑。
Lua 与 C# 的相互调用核心依托 Lua 虚拟机作为中间桥梁:C# 调用 Lua 时,通过封装好的 API(如 XLua)直接访问 Lua 的全局函数、变量,传递参数并接收返回值;而 Lua 调用 C# 时,先将 C# 的类、方法注册到 Lua 环境中,Lua 侧会拿到这些 C# 元素的 “代理引用”,调用该引用就会触发底层回调到 C# 执行对应逻辑,执行结果再返回给 Lua,整个过程无需关注底层栈操作,只需通过框架提供的语法完成跨语言调用,且能适配 Lua 热更新场景下的逻辑替换。
6.了解常见游戏设计模式(观察者、状态机、单例模式等)
单例模式:单例模式的核心作用是 保证一个类在程序运行期间只有一个实例,并提供一个全局访问入口。通常会通过私有化构造函数、在类内部保存唯一实例,并通过静态方法或静态属性对外获取该实例。它常用于管理全局唯一对象,比如游戏中的资源管理器、音频管理器、UI 管理器、配置管理器等。
状态机:状态机的作用是 把对象在不同状态下的行为和状态切换规则拆分开管理,从而避免大量 if-else 或 switch-case 判断,使逻辑更清晰、可维护性更高。比如角色可能有待机、移动、攻击、受击、死亡等状态,每个状态负责自己的进入、更新和退出逻辑,状态之间按照规则切换,这样在角色状态较多时会更容易扩展和维护。
观察者模式:观察者模式本质上是 一种事件通知机制,也可以理解为发布-订阅模式。一个对象状态发生变化时,会通知所有订阅了该事件的观察者对象,让它们自动执行各自对应的处理逻辑。它的优点是降低对象之间的耦合度。比如在游戏里,角色死亡后,可以通过事件通知 UI、任务系统、音效系统、成就系统分别做出响应,而不需要角色代码直接依赖这些模块。
工厂模式:将对象的创建过程封装起来,让调用方无需关心对象的具体创建细节(比如类的实例化、初始化参数等),只需通过 “工厂” 获取所需对象。也就你有一个工厂,你想要什么产品,工厂就根据你的需要生成对应的产品,你不需要知道具体的生产流程
抽象工厂模式:你只需要告诉工厂你想要哪一类产品,工厂就会把这一类对应的整套配套产品都创建出来。
实习方面
1.请介绍一下你的这段实习吧
在实习期间,我主要负责一款纸牌接龙游戏的移动端开发。主要工作包括三个部分
第一,负责基于 UGUI 的主界面、对局界面、结算界面的 UI 构建与交互逻辑。
第二,参与核心玩法的算法开发,包括基于 Fisher-Yates 的随机洗牌与接龙规则校验。
第三,负责资源管理优化以及对象池设计,减少实例化开销,显著提升发牌与对局切换的流畅度。
这段经历让我熟悉了从界面、逻辑到性能的完整开发流程。”
2.你在项目中最有技术含量的工作是什么?
我负责了对象池系统,通过预创建和复用纸牌与 UI 元素,减少 Instantiate/Destroy 带来的 GC 分配,使发牌和对局切换更流畅。
在算法层面,我用 Fisher-Yates 实现等概率洗牌,确保公平性。
UI 层则通过图集管理与 Canvas 分层减少渲染开销。
这三个部分在工程价值上都比较重要。”
3.你为什么要做对象池?效果是什么?
因为纸牌数量固定、批量创建频繁、生命周期短,用对象池复用能显著减少 Instantiate/Destroy 导致的 GC Alloc
纸牌在发牌、回收时会频繁生成和销毁,用 Instantiate/Destroy 会造成大量 GC 开销,带来掉帧。
对象池通过预创建 + 循环复用对象,几乎不产生 GC。
实际效果是在移动端发牌动画变得更流畅,对局切换无明显卡顿
4.Fisher-Yates 算法你能讲一下吗?
Fisher-Yates 是 O(n) 的等概率洗牌算法,它通过从尾到头逐个交换随机位置,保证每个排列出现的概率一致。
相比常见的随机多次抽取方式,它不会出现概率偏差,同时性能更好。
从牌堆数组末尾开始往前遍历,设置一个变量i用于维护已洗牌和未洗牌区的边界,从n - 1开始,再未洗牌区设置一个随机索引j,交换数组第 i个元素和第 j个元素,此时i - 1,循环往复,直到i为0,代表洗牌完毕
1 | public static void Shuffle<T>(T[] array) |
5.你们的接龙规则校验怎么做的?
工作区规则(Tableau):颜色交替、点数递减、空列只能放 K
基础堆规则(Foundation):同花色递增、空基础堆只能放 A
胜负规则:四个基础堆都从 A → K 堆满
| 规则类型 | 核心职责 | 依赖信息 | 典型场景 |
|---|---|---|---|
| 可移动规则 | 判断「某张 / 某组牌能否从 A 位置移到 B 位置」(移动的前提条件) | 源位置牌状态、目标位置状态 | tableau→tableau、waste→foundation |
| 堆叠规则 | 判断「牌与牌之间能否相邻堆叠」(移动后是否符合堆的结构要求) | 相邻两张牌的属性(花色 / 点数) | 牌组内堆叠、目标堆顶与移动牌堆叠 |
| 胜负规则 | 判断游戏是否获胜 / 失败(全局状态校验) | 所有基牌堆状态、剩余可移动操作 | 4 个基牌堆均从 A→K 完整堆叠 |
首先是牌面结构,基本的数据结构
1 | class Card { |
工作区“接龙规则”校验
移动到工作区规则:-
- 颜色必须交替(红/黑)
- 点数必须递减(例如 7 → 6)
- 空列只能放 K
1 | bool IsValidToTableau(Card card, Pile to) |
基础堆规则
移动到基础堆:
- 花色相同
- 点数递增(A → 2 → 3 → … → K)
- 空堆只能放 A
1 | bool IsValidToFoundation(Card card, Pile to) |
翻牌规则(Flip Rule)
当工作区最上层的牌被移动后,如果下一张是背面,则翻面:
1 | void TryFlip(Pile from) |
胜负规则(Win Rule)
所有基础堆都堆满 13 张牌则胜利。
1 | bool IsWin(List<Pile> foundations) |
6.你做资源优化具体做了什么?改善了什么?
我主要做了资源压缩、格式优化、图集整理,并分类管理音效与美术素材。
结果是包体变小、加载速度更快、UI 渲染批次减少。
7.为什么Fisher-Yates洗牌算法能够保证公平性?
原理:Fisher-Yates洗牌算法通过从数组末端开始,逐个位置确定元素的方式实现洗牌。对于长度为n的数组,算法从最后一个位置开始,在前面的所有元素中随机选择一个与当前位置交换,然后向前移动一个位置重复此过程,直到处理完所有元素。
算法的每一步都保证了严格的概率均等:
- 第一步:从n个元素中随机选择一个放在最后位置,每个元素被选中的概率严格为1/n
- 第二步:从剩余的n-1个元素中随机选择一个放在倒数第二位置,每个剩余元素被选中的概率为1/(n-1)
- 第i步:从剩余的n-i+1个元素中随机选择,概率为1/(n-i+1)
任何一个特定排列产生的概率是所有步骤概率的乘积:
1/n × 1/(n-1) × 1/(n-2) × … × 1/1 = 1/n!
由于每个排列的产生概率都严格等于1/n!,因此算法保证了绝对的公平性。
8.项目参与的完整业务流程(前期 → 中期 → 后期)
在 前期 我熟悉了项目结构,并搭建 UGUI 框架,参与制定主界面、对局界面、结算界面的交互流程。
中期负责了游戏UI场景的搭建,包括游戏主界面,部分对局UI等(以及按钮点击和卡牌的拖拽),核心逻辑 方面,我实现了基于 Fisher-Yates 的公平洗牌算法,并编写了接龙的规则校验,包括颜色、数字递减、牌堆合法性等。在 性能优化 上,我构建了一个轻量对象池管理卡牌和动态 UI,减少 GC 压力;同时参与整理 AssetBundle,提升了资源加载速度。
9.卡牌点击、拖拽、落点检测怎么实现的?
首先,每一张卡牌本身是一个 UI 组件,我在卡牌上加了输入事件逻辑,负责响应点击、拖拽开始、拖拽中、拖拽结束这几个阶段。拖拽中就是不断把当前指针位置转换成 UI 坐标,让卡牌跟着手指移动,并且提到最上层,避免被挡住。
关键是拖拽结束这一刻,我会做一次 UI 射线检测:从当前的指针位置往下射线,拿到所有被命中的 UI,然后在这些命中的 UI 里去找有没有我们的“牌堆区域”。这样就能知道这次拖拽是落到了哪个牌堆上。
找到牌堆之后,我不会直接把牌扔上去,而是调用牌堆那边封装的规则判断逻辑,比如红黑交替、数字递减、空堆只能放 K 等。如果规则通过,就把卡牌的父节点设成这个牌堆,并做一次位置吸附;如果规则不通过或者没落到任何牌堆,就把卡牌位置恢复成拖拽前的状态,相当于自动回弹。
实习项目深挖
1.对象池是怎么实现的?
我使用 Dictionary<string, List<GameObject>> 作为对象池结构,Key 表示对象类型。
取对象时会先检查字典中是否存在可用对象:
- 若有剩余对象:直接取出并激活。
- 若列表为空或不存在:先创建新对象并加入池中,再返回使用。
使用完成后不执行 Destroy,而是将对象失活后重新放回对应的 List 中,以便下次复用。
这样避免了频繁的 Instantiate/Destroy,减少 GC 开销并提升运行效率。”
2.对象池会不会无限膨胀?
不会。我会根据卡牌项目的特点预设合理的初始容量,比如纸牌数量是确定的(52 张或带辅助 UI),池子的上限是可控的。
即使某些情况需要扩容,也会有最大限制,不会让对象池无限增长。
3.你为什么不用 Addressables?
项目体量较小,资源数量不复杂,使用 AB 或 Addressables 反而会增加构建和管理成本。
因为资源更新频率不高,我选择了更轻量的方式,直接从 Resources 文件夹 加载即可,开发效率更高,也满足项目需求。
