接手别人的 Unity 工程,我第一件事往往不是打开场景,而是先看它的版本库。这个习惯是被坑出来的:凡是仓库里直接躺着 Library 目录的项目,几乎没有一个是干净的——有人 clone 下来二十几个 G,有人一拉代码就冲突,有人只改了一个材质参数,结果整个场景文件的 diff 全红。使用 GitHub 托管 Unity 项目,难点从来不是git init和git push这两条命令,而是要先想明白一件事:一个 Unity 工程里,到底哪些是"资产",哪些只是"这台机器上的临时缓存"。
这篇内容讲的就是这一整套东西:从编辑器里那几个必须改的开关,到.gitignore与.gitattributes怎么逐行取舍;从 Git LFS 该圈哪些文件、不该圈哪些文件,到场景与预制体冲突为什么几乎没法手工解决、Smart Merge 又该怎么配;再到分支模型、提交规范、自动化构建和一堆踩过的坑。适合两类人看:刚把项目从"压缩包传来传去"迁到版本库的新手,以及团队已经用了 Git 但每次合并都想砸键盘的老手。基础命令我默认你会,不会的地方我也顺手提一句。
1. 先搞清楚:Unity 工程目录里哪些东西真的该进版本库
1.1 从 Library 目录说起,为什么它是"编译缓存"而不是"代码"
Unity 的 Library 目录里存的是资源导入后的中间产物:贴图被压成平台格式、模型被拆成网格与骨架、场景被序列化成内部结构,还有一整套 GUID 到实际资源的索引表。这些东西全部可以从 Assets 和 ProjectSettings 重新生成,属于"派生物",就像 C# 项目里的 bin 和 obj。把它提交上去的代价极其直接:一是仓库体积按周膨胀,二是每次不同机器上的导入结果都可能不同,三是团队里两个人同时改一个场景时,Library 里的缓存文件会先打起来,而你根本不知道冲突的那几百行二进制是什么。
我在一个外包项目里见过一个极端案例:仓库 3.2G,其中 2.9G 是 Library,实际资产不到 200M。后来清理完重新推上去,clone 时间从四十多分钟降到两分钟。这不是"优化体验"级别的收益,而是"能不能正常协作"级别的差异。
同样该被排除的还有几个常被忽略的目录:
| 目录 / 文件 | 性质 | 处理方式 |
|---|---|---|
Library/ | 导入缓存、GUID 索引 | 忽略,可随时重建 |
Temp/、Obj/ | 编译中间产物 | 忽略 |
Logs/ | 编辑器日志 | 忽略 |
UserSettings/ | 每个开发者本机的窗口布局、偏好 | 忽略 |
Build/、Builds/ | 构建输出 | 忽略,产物走发布页 |
MemoryCaptures/、Recordings/ | 性能抓取与录制 | 忽略 |
Packages/ | 包清单(manifest) | 必须提交 |
ProjectSettings/ | 项目级设置 | 必须提交 |
Assets/ | 全部资产与脚本 | 必须提交,含 .meta |
表格最后一行加粗的部分是新手最容易搞错的地方:Unity 工程真正的"源码",其实只有 Assets、ProjectSettings、Packages 三块。其余的都可以在一次 Open Project 里原地复活。
1.2 .meta 文件:Unity 用 GUID 认人,不是用路径
.meta文件这件事必须单独说,因为它是 Unity 接管 Git 之后最反直觉的一点。Unity 给每一个资产分配一个 GUID,场景、预制体、材质里引用别的资产时,记录的是 GUID 而不是路径。而 GUID 存在哪儿?就存在同名的.meta文件里。
这意味着两件事。第一,.meta必须和资产一起提交,缺一个就等于告诉 Unity"这个资产是新来的",于是所有引用它的地方一起变成 Missing。第二,重命名或移动资产必须用编辑器里做,或者至少保证.meta跟着一起移动;在资源管理器里直接拖拽、用命令行mv却没带上 meta,后果就是引用全断——而且这种断法有时候当时看不出来,等到打包才报一堆空引用。
我见过最隐蔽的一次事故:一位同事在系统文件管理器里把一个贴图文件夹改了名字,.meta没跟着走。编辑器里看着一切正常,因为项目里还残留着缓存;结果第二天 CI 拉干净环境构建,UI 里三十多个图标全变白块。查了两个小时才定位到是两个字母的目录名差异。
提示:在 Unity 的 Preferences 里把版本控制模式设为"可见 Meta 文件",让
.meta在工程面板里显式显示出来。眼不见心不烦的结果往往是出事时找不到原因。
1.3 用文本序列化换取可读 diff,这是整套方案的地基
Unity 默认允许把场景和预制体序列化成二进制。二进制对 Git 来说是黑盒:改一个数字,整个文件都变,diff 没有任何信息量,合并更不可能。所以只要涉及多人协作,这一步必须先改:
在 Edit → Project Settings → Editor 里,把Asset Serialization改成Force Text,把Version Control Mode改成Visible Meta Files。改完 Unity 会提示重新序列化,让它跑完,然后提交一次全量变更——这次提交的 diff 会非常大,但它是一次性的,之后所有的场景与预制体都变成 YAML 文本,至少能看出"谁把哪个组件的哪个字段改了"。
这一步没有商量的余地。我试过在二进制模式下强行协作,最后的解决办法是"谁要改场景先群里喊一声,其他人不准开",效率低到不如用 U 盘拷。文本化之后才有了后面所有讨论的基础:Smart Merge 能工作、代码评审能看清、出问题能git diff定位。
1.4 整体方案长什么样:一个可以照抄的决策框架
把这套东西摊开,思路其实是一条主线:把"能自动重建的"全部排除,把"人手工做的"全部纳入,把"体积大且难合并的"交给专门的大文件通道,把"容易冲突的"用配置和流程提前化解。
| 文件类型 | 举例 | 交给谁管 | 理由 |
|---|---|---|---|
| 脚本、配置、Shader | .cs.json.shader | 普通 Git | 纯文本,需要逐行 diff 与合并 |
| 场景、预制体、材质 | .unity.prefab.mat | 普通 Git + Smart Merge | 文本化后可合并,但需专用工具 |
| 美术源文件、音视频 | .psd.fbx.wav.mp4 | Git LFS | 体积大、二进制、几乎不需要合并 |
| 导入缓存、IDE 配置 | Library/.csproj | 忽略 | 本机生成,无共享价值 |
| 构建产物 | .apk.exe | 发布页 / 制品库 | 与源码版本解耦 |
按照这张表落地,一个中型 Unity 项目(几十个场景、几千个资产)的仓库可以稳定控制在几百兆这个量级,日常提交的 diff 保持可读,合并冲突从"每天几次"降到"一周一两次且能解决"。
2. 开库第一步:编辑器设置与 .gitignore 的逐行取舍
2.1 仓库初始化的顺序,别搞反
我建议的顺序是:先在 Unity 里改好序列化模式和 Meta 可见,然后关掉编辑器,再git init,接着把.gitignore和.gitattributes写好,最后才第一次git add。顺序反过来也能救,但要多敲几条git rm -r --cached,而且容易漏。
# 在 Unity 工程根目录(有 Assets 和 ProjectSettings 的那一层) git init # 先把忽略规则和属性规则写好,再添加文件 curl -o .gitignore https://raw.githubusercontent.com/github/gitignore/main/Unity.gitignore # 上面这条只是拉官方模板,后面我们还要手改 git add .gitignore .gitattributes git commit -m "chore: 初始化版本库与忽略规则" git add . git status最后那句git status是关键的自检动作。正常的结果应该是几百个.cs、.meta、.unity、.asset文件,加上ProjectSettings/、Packages/下的 json。如果列表里出现了Library/xxx或者.csproj,说明忽略规则还没生效,先别 commit。
2.2 .gitignore 里每一段在防什么
官方模板已经能覆盖八成场景,但它是给所有 Unity 项目通用的,实际用起来我一般会做几处调整。下面是我常用的版本,附上每段的意图:
# ---------- Unity 自动生成 ---------- /[Ll]ibrary/ /[Tt]emp/ /[Oo]bj/ /[Bb]uild/ /[Bb]uilds/ /[Ll]ogs/ /[Uu]ser[Ss]ettings/ /[Mm]emoryCaptures/ /[Rr]ecordings/ # ---------- IDE 与工程文件 ---------- .vs/ .idea/ .vscode/ *.csproj *.unityproj *.sln *.suo *.user *.userprefs *.pidb *.booproj *.svd *.pdb *.mdb *.opendb *.VC.db # ---------- 构建与导出产物 ---------- *.apk *.aab *.ipa *.unitypackage *.app /[Ee]xported[Oo]bj/ crashlytics-build.properties sysinfo.txt # ---------- 系统与临时文件 ---------- .DS_Store Thumbs.db *~ *.tmp几点值得解释。"[Ll]ibrary" 这种大小写双写是历史遗留:早期不同平台上 Unity 生成的目录名大小写不一致,虽然现在基本统一成小写,但写上不亏,成本是零。.csproj和.sln必须忽略,因为它们由 Unity 按当前机器上的包和插件动态生成,A 机器提交上去,B 机器拉下来会出现一堆幽灵引用错误——这类报错特别迷惑人,看着像代码问题,其实是工程文件不匹配。*.unitypackage也建议忽略,那是一次性导入包,不该长期躺在源码库。
至于.vscode/,如果团队里确实共享调试配置(比如 launch.json 里定义了附加到编辑器的配置),可以改成忽略.vscode/*但保留!.vscode/launch.json,这个取舍看团队习惯,我倾向于共享一份,省得每个人自己配。
2.3 已经提交了垃圾目录,怎么干净地救回来
这是最高频的问题:项目跑了半年才发现 Library 被提交了。很多人第一反应是删掉 Library 再提交,结果只是新增了一次"删除"记录,历史里的那几个 G 依然在,clone 依旧慢。
正确的做法分两步。第一步让文件脱离跟踪但保留在磁盘上:
git rm -r --cached Library Temp Obj Logs UserSettings git rm --cached -r "*.csproj" git commit -m "chore: 移除误提交的生成目录"第二步是重写历史,把大文件从所有提交里彻底抹掉。这一步是破坏性的,必须全组同步、所有人重新 clone,不能只是 pull。工具上社区有成熟的仓库重写方案,具体选哪个不重要,重要的是流程:
注意:重写历史前先在群里通知,并让所有人把手上的分支推上去。重写完成后,每个人必须重新 clone 一份,不要试图在旧仓库上 pull,否则会把旧的大对象再合并回来,等于白干。
做完之后可以用git count-objects -vH看体积,正常会看到 size-pack 掉到原来的几分之一。我一般还会顺手跑一次git gc --aggressive --prune=now,虽然慢,但效果实在。
3. Git LFS:大文件走专用通道,但别什么都往里塞
3.1 判断一个文件该不该进 LFS 的三个问题
LFS 的原理是把大文件的实体存到单独的存储区,仓库里只留一个几百字节的指针文件。它的好处很明显:clone 变快、普通 diff 不受干扰、仓库体积可控。但它不是万能的,判断标准我总结成三个问题:
第一,这个文件是二进制吗?如果是纯文本,进 LFS 反而有害,因为你失去了逐行 diff 和自动合并的能力。
第二,它的体积会显著增长吗?一张 200K 的 png 图标,进不进 LFS 都无所谓;一张 80M 的 psd 分层图,必须进。
第三,它需要被合并吗?场景、预制体、材质虽然是文本,但它们是"协作热点",必须留在普通 Git 里,配合 Smart Merge 处理冲突。把.unity塞进 LFS 是新手常见的反向操作,后果是所有场景冲突从"能救"变成"完全没救"。
3.2 .gitattributes 的实际写法与几处反直觉
# 让 Git 正确识别文本文件,统一换行处理 * text=auto # 明确指定文本类型,diff 才好看 *.cs text diff=csharp *.shader text *.cginc text *.hlsl text *.json text *.xml text *.md text *.txt text # Unity 的协作热点:保留在普通 Git,指定专用合并驱动 *.unity merge=unityyamlmerge *.prefab merge=unityyamlmerge *.asset merge=unityyamlmerge # 大体积二进制资产交给 LFS *.psd filter=lfs diff=lfs merge=lfs -text *.tga filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text *.fbx filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text *.mp3 filter=lfs diff=lfs merge=lfs -text *.mp4 filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text几个细节值得说。*.png进 LFS 这件事在小项目里其实可以省,因为 UI 图集动辄几百张小图,全部走 LFS 会让git lfs ls-files长到没法看,而且每张图都要走一次网络请求,clone 时反而更慢。我的经验线是:单文件超过 2M 的贴图走 LFS,小图标走普通 Git。但这条线在团队里必须统一,不能各写各的,否则同一张图在不同分支上一个是指针一个是实体,合并时必然出乱子。
-text这个标记容易被忽略。它的作用是告诉 Git"别把这个文件当文本处理,不要做换行转换"。对二进制文件来说这很重要,少了它,Windows 上的自动换行转换可能悄悄改坏文件——症状是图片打不开、模型导入报错,而 diff 上什么都看不出来。
3.3 配额、迁移与几个真实的坑
LFS 的存储和流量通常是分开计费的,免费额度对个人项目够用,团队项目很容易顶到上限。这不是配置问题,而是策略问题,我一般从三个方向控制:一是把构建产物彻底排除在外(它们体积最大且完全不需要版本管理);二是对美术源文件做约定,比如 psd 只提交定稿版本,中间稿放共享盘;三是定期清理已删除但仍被历史引用的对象。
已经提交过的大文件要转 LFS,用迁移命令:
# 把历史中的 psd 全部转成 LFS 指针 git lfs migrate import --include="*.psd,*.fbx,*.wav" --everything # 查看当前被 LFS 跟踪的文件 git lfs ls-files # 检查哪些大文件还在普通 Git 里(按体积排序) git rev-list --objects --all \ | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ | awk '/^blob/ {print $3, $4}' | sort -rn | head -20最后那条命令我强烈建议每个接手老项目的人都跑一遍,它会把仓库里最占地方的二十个对象按体积列出来,谁提交的、是什么文件一目了然。我第一次跑的时候发现一个 400M 的测试视频躺在三年前的一次提交里,从来没人用过。
还有两个高频坑。一是 clone 时忘了装 LFS 客户端,拉下来的全是几十字节的指针文件,Unity 打开一看所有贴图都是空的,报错信息还完全不提 LFS。二是 CI 环境里 checkout 默认不拉 LFS 内容,需要在 checkout 步骤显式打开lfs: true。这两点后面还会提到。
4. 场景与预制体的冲突:为什么手工合并基本没戏
4.1 一次典型的场景冲突长什么样
文本化之后,一个.unity文件是一份 YAML,里面有成千上万个对象,每个对象带一个fileID,还有一串m_LocalPosition、m_Color、m_Enabled之类的字段。两个人同时改同一个场景,冲突的表现是:几十处冲突标记散落在文件各处,每一处都夹在几百行的对象定义中间,你完全不知道这两坨 YAML 对应的是场景里哪个物体。
硬着头皮手工解决的结果通常有两种:要么把某个组件删多了,打开场景发现一个灯不见了;要么 fileID 引用对不上,Unity 直接拒绝打开并提示文件损坏。所以结论很明确——场景冲突不靠手工,靠工具加流程。
4.2 配好 UnityYAMLMerge,让合并有个基本盘
Unity 安装目录里带了一个命令行合并工具,专门理解 YAML 结构,能按对象粒度合并,遇到真正冲突的字段才留标记。配置方式是在 git 配置里加一段:
# 放在 ~/.gitconfig(全局)或项目 .git/config(仅本项目) [merge] tool = unityyamlmerge [mergetool "unityyamlmerge"] trustExitCode = false cmd = 'C:/Program Files/Unity/Hub/Editor/2021.3.20f1/Editor/Data/Tools/UnityYAMLMerge.exe' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"macOS 下路径类似/Applications/Unity/Hub/Editor/2021.3.20f1/Unity.app/Contents/Tools/UnityYAMLMerge。注意版本号要换成团队实际用的编辑器版本,而且这个路径在换机器、升级编辑器后都会失效——这是它最烦人的地方,建议写进仓库的 README 或者用一个初始化脚本统一下发。
配好之后,冲突时执行git mergetool,工具会尝试三方合并,把能自动解决的解决掉,剩下真正冲突的字段写成带标记的文本,你用编辑器打开再看。它解决不了"两个人同时改同一个物体的同一个属性"这种本质冲突,但能把 90% 的"你改 A 物体、我改 B 物体"的假冲突消化掉。
提示:
.gitattributes里那三行merge=unityyamlmerge是让这个工具自动生效的关键。很多人只配了 gitconfig 却没写 attributes,结果git merge时根本不会调用它,以为工具没用。
4.3 从源头减少冲突:三条被验证过的团队约定
工具只能兜底,真正有效的是拆分和约定。以下三条我在几个团队里都推行过,效果最好。
第一条,场景按功能拆小。一个几千物体的大场景,谁都可能改到它。正确的做法是按玩法或关卡区域拆成多个场景,用 Additive 方式加载,主场景只保留全局的灯光、相机和环境。这样"改 UI 的人"和"调关卡的人"压根不在同一个文件上工作。拆分时要注意公共资源的引用方式,别为了拆而拆出一堆重复预制体。
第二条,预制体优先于场景编辑。场景里应该尽量是预制体实例,调整细节时进入预制体编辑模式改,而不是在场景里对实例做覆盖。这样做的好处是修改集中在预制体文件上,而且覆盖属性以 override 的形式存在场景里,冲突面积小得多。我见过一个项目,场景里全是散装物体,改一次布局冲突三天,后来强制预制体化,同样的工作量冲突降到几乎没有。
第三条,约定"场景锁"。这不是技术手段,是流程手段:谁要在共享场景上做大改,先在协作频道里说一声,改完立刻提交并推送,不要攒着。听起来原始,但成本最低、见效最快。规模再大一点可以上文件级的锁机制,不过对十几人的团队来说,一条群消息足够。
4.4 一个实操场景:冲突已经发生了,按这个顺序处理
假设你现在git merge完,git status里一堆both modified。我的处理顺序是:
- 先别慌着开 Unity。关掉编辑器,Unity 在打开状态下会持续写文件,边合并边被写会出各种诡异问题。
- 对代码文件(
.cs)手工合并,这类冲突最好处理,直接看逻辑。 - 对
.unity/.prefab/.asset,逐个git mergetool,让 Smart Merge 先跑一遍。 - 跑完检查剩余冲突标记:
grep -rn "<<<<<<<" Assets/。有残留就说明工具也没辙,这时候判断哪边的改动更重要,直接取一边(git checkout --ours或--theirs)通常比硬凑更安全。 - 全部解决后打开 Unity,让它重新导入,逐个场景点开看一眼,确认没有物体丢失、引用没断。
- 确认无误再
git add+git commit,提交信息写清楚"合并 X 分支,场景 A 采用远端版本"。
第 5 步不能省。我吃过一次亏,合并完直接提交,两天后美术反馈某个特效不见了,翻回去看是合并时把一个粒子系统的对象块整段吞掉了,而当时的 diff 看起来"很干净"。
5. 分支模型、提交规范与自动化卡口
5.1 中小团队够用的分支模型
Unity 项目的分支不需要搞太复杂,但也不能只有一条 main。我推荐的结构是三层:
| 分支 | 用途 | 生命周期 | 谁可以推 |
|---|---|---|---|
main | 随时可出包 | 长期 | 只接受 PR 合并 |
dev | 日常集成分支 | 长期 | 组内成员 |
feat/xxx、fix/xxx | 单个功能或修复 | 几天到两周 | 个人 |
规则就三条:功能分支从dev切出,完成后 PR 回dev;dev稳定后合并到main并打 tag;main上不接受直接推送。这样做的核心目的是让"可能坏的东西"隔离在功能分支里——Unity 项目最怕的就是有人把一个半成品场景推上主干,别人一拉就编译不过。
有个细节要提醒:Unity 项目合并分支后必须重新打开编辑器让它导入,不能指望命令行git merge完就万事大吉。所以合并到dev的动作最好由一个人统一做,做完在群里说一句"dev 已更新,大家拉一下",避免几个人同时合并互相打架。
5.2 提交粒度与提交信息:别让三个月后的自己骂人
Unity 项目有一种特有的"连坐"现象:你只改了一个脚本,git status里却出现十几个.meta的变更;你只挪了一个文件夹,结果几十个文件的路径全变了。这些是正常的,但提交时必须拆开说清楚。
我的习惯是三类提交严格分开:纯资产变更(美术资源、贴图)、纯代码变更、场景/预制体变更。理由很实际:出问题时可以快速git revert某一类而不影响其他。全混在一个提交里,回滚就是灾难。
提交信息我要求包含三样:做了什么、为什么、影响面。看两个例子对比就很清楚。
# 不推荐 update # 推荐 fix: 修复关卡2传送点重复触发 - 触发判定从 OnTriggerEnter 改为带冷却的一次性触发 - 影响场景: Level_02(已本地验证) - 关联问题: 反复进出传送门会叠加位移第二种写法半年后再看依然能读懂。而且它在做代码评审时特别有用——评审的人一眼就知道该重点看哪个文件。
5.3 用钩子和分支保护卡住低级错误
有一类错误反复出现,靠人自觉很难杜绝:忘了保存场景就提交、提交了没带 meta 的新文件、把Library又加回来、提交信息写"1"。这类问题适合用自动化卡口拦住。
一个简单的提交前钩子,检查暂存区里有没有不该出现的路径:
#!/bin/sh # .git/hooks/pre-commit blocked="Library/ Temp/ Obj/ Logs/ UserSettings/" for p in $blocked; do if git diff --cached --name-only | grep -q "^$p"; then echo "拒绝提交:暂存区包含生成目录 $p" echo "请执行 git rm -r --cached $p 后重试" exit 1 fi done # 检查新增的资产是否带了 meta git diff --cached --name-only --diff-filter=A | grep -E '^Assets/.*[^.]$' | while read f; do [ -f "$f.meta" ] || { echo "缺少 meta 文件:$f"; exit 1; } done exit 0这个脚本不完美(比如目录名匹配、子模块场景没覆盖),但它拦住的问题足够多。仓库端的卡口则是分支保护:main要求 PR + 至少一人评审 + 状态检查通过才能合并。评审对 Unity 项目尤其有价值,因为场景文件的 diff 虽然丑,但"谁改了哪个组件的哪个字段"是能看出来的,很多低级错误在评审阶段就能发现。
注意:钩子脚本放在
.git/hooks里不会随仓库分发。要团队共享,就把脚本放进仓库目录(比如Tools/hooks/),再让每个人执行一次git config core.hooksPath Tools/hooks。这一步写进项目 README 的新人引导清单里。
6. 自动化构建:在 GitHub 上跑一条能用的流水线
6.1 流水线该做什么,不该做什么
先说定位。自动化构建要解决的是"每次合并后代码能不能过、能不能出包",而不是替代本地调试。所以它不需要跑得飞快,但必须稳定、日志可查。我一般配三个触发:PR 到dev时跑编译与单元测试,合并到main时出包并归档,手动触发用来出特定分支的测试包。
有一个地方要提前想清楚:Unity 的授权。命令行构建需要激活,这是流水线里最容易卡住的一步,通常的做法是把授权文件作为加密密钥存在仓库设置里,构建时还原。这一步的细节各团队情况不同,配置一次之后基本不用再动,但第一次配的时候务必留足时间。
6.2 一份结构清晰的构建配置
下面这份配置省略了授权细节,重点在流程骨架,特别标注了几个 Unity 项目特有的关键点:
name: build on: push: branches: [main] pull_request: branches: [dev] workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: 拉取代码(必须开启 LFS) uses: actions/checkout@v4 with: lfs: true fetch-depth: 0 - name: 校验 LFS 内容完整性 run: git lfs pull && git lfs fsck - name: 缓存 Library 目录 uses: actions/cache@v4 with: path: Library key: Library-${{ hashFiles('Packages/packages-lock.json', 'ProjectSettings/ProjectVersion.txt') }} restore-keys: | Library- - name: 运行编辑模式测试 run: | unity-editor -batchmode -nographics -quit \ -projectPath . \ -runTests -testPlatform EditMode \ -testResults ./test-results.xml \ -logFile ./logs/editmode.log - name: 构建播放器 run: | unity-editor -batchmode -nographics -quit \ -projectPath . \ -executeMethod BuildScript.PerformBuild \ -logFile ./logs/build.log - name: 上传日志与产物 if: always() uses: actions/upload-artifact@v4 with: name: build-output path: | logs/ test-results.xml Build/几处值得单独说明。
fetch-depth: 0是为了让构建脚本能读到完整的提交历史,方便把提交号写进版本号。如果只用默认的浅克隆,取版本信息会失败。
缓存 Library 是收益最大也最容易出错的一步。缓存键必须绑定packages-lock.json和ProjectVersion.txt,否则依赖变了但缓存没失效,会出现"本地能编译、流水线报找不到类型"的鬼故事。我一开始图省事只用了固定 key,结果升级了一个包之后流水线连续失败七次,第八次才想起来清缓存。用restore-keys做前缀回退是个稳妥的折中:完全匹配没有时退回最近的旧缓存,虽然会重新导入一部分,但比从零开始快得多。
-nographics这个参数在无头环境里很关键,不加会因为缺图形设备直接崩。但同时要注意,某些依赖 GPU 的功能(比如烘焙光照、部分后处理)在无头环境里跑不了,这类步骤要么单独拆一个带显卡的构建机,要么挪到本地做。
失败也要上传日志,if: always()就是干这个的。Unity 的批处理日志会把错误藏在几千行输出里,没有日志文件根本查不动。我还会在构建脚本里主动判断EditorApplication.Exit(1),让失败真的变成失败——默认情况下批处理模式的异常不一定会让退出码非零,流水线会显示绿色的成功,这种"假绿"最坑人。
6.3 构建脚本里该写什么
-executeMethod指向的静态方法要注意两件事:一是必须在Editor目录下(或者被#if UNITY_EDITOR包起来),否则打包时会报找不到;二是要自己处理版本号和环境变量。
// Assets/Editor/BuildScript.cs using UnityEditor; using UnityEngine; public static class BuildScript { public static void PerformBuild() { // 版本号从环境变量拼装,没有就用默认值 string buildNumber = System.Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER") ?? "0"; string commit = System.Environment.GetEnvironmentVariable("GITHUB_SHA") ?? "local"; PlayerSettings.bundleVersion = $"1.0.{buildNumber}"; PlayerSettings.SetApplicationIdentifier( BuildTargetGroup.Android, "com.example.prototype"); var options = new BuildPlayerOptions { scenes = new[] { "Assets/Scenes/Boot.unity" }, locationPathName = "Build/android/app.aab", target = BuildTarget.Android, options = BuildOptions.None }; var report = BuildPipeline.BuildPlayer(options); if (report.summary.result != UnityEditor.Build.Reporting.BuildResult.Succeeded) { Debug.LogError($"构建失败,commit={commit},错误数={report.summary.totalErrors}"); EditorApplication.Exit(1); // 让流水线正确失败 } } }这段的重点不在 API,而在最后那个EditorApplication.Exit(1)。少了它,构建失败在流水线上可能显示为成功,等到发版时才发现包是空的。
7. 排查速查表与几条踩出来的经验
7.1 高频问题速查
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| clone 后贴图全是空白 | 没装 LFS 或没拉取内容 | 装 LFS 后git lfs pull |
| 打开场景提示文件损坏 | 场景合并时结构被破坏 | 从上一个正常提交取回.unity,重做改动 |
| 引用大面积显示 Missing | .meta丢失或被改动 | 从历史恢复对应.meta,不要新建 |
| 流水线报找不到某类型 | Library 缓存过期 | 换packages-lock.json参与缓存键,清缓存重跑 |
| 提交后 diff 里全是换行变更 | 缺.gitattributes的text=auto | 补上后重新规范化一次提交 |
| 仓库突然涨了几百兆 | 误提交构建产物或大文件 | 用体积排序命令定位,重写历史清理 |
| 两人改同一场景,冲突几十处 | 场景未拆分 | 上 Smart Merge 应急,同时推动场景拆小 |
| CI 拉取后构建失败但本地正常 | 本地有未提交的资产 | 养成提交前git status清零的习惯 |
7.2 几条只有踩过才知道的细节
第一条,改文件夹名一定要在编辑器里改。这句话值得写在文档第一行。命令行mv不会带着.meta走,而元数据一旦对不上,症状可能是几小时后才出现的——某个材质变粉、某个预制体引用空。粉红色在 Unity 里基本等于"Shader 找不到",而 Shader 丢了往往就是路径或 GUID 的事。
第二条,合并前先让所有人清空工作区。Unity 在后台会不停地写文件:导入、刷新、自动保存。如果合并时编辑器开着,很容易出现"合并完成了但文件又被覆盖回去"的情况。我们后来的做法是合并前群里喊一声,大家关编辑器、git stash或提交,然后由一个人统一做合并。
第三条,别在.gitignore里和.csproj较劲。有人为了让 IDE 索引更好用,硬要把 csproj 提交上去,结果每次拉代码都有一堆冲突。正解是让 Unity 自己生成,在 IDE 里重新打开一次工程文件即可。真觉得每次重新生成麻烦,就写个小脚本,一键调用 Unity 的-executeMethod重新生成工程。
第四条,资产命名尽量别用中文和空格。这不是 Git 的问题,而是跨平台和工具链的兼容问题。空格在命令行里要转义、中文在某些 CI 环境的编码下会变乱码,最后表现为"某个文件在别人机器上路径不同"。我一般要求资产目录全部用英文加下划线,纯本地调试用的临时资源单独放一个被忽略的目录里。
第五条,小步提交比什么都重要。Unity 项目的回滚成本很高,因为一次提交里往往同时包含代码、资产、场景。如果这个提交有三百个文件变更,出问题时你根本没法判断该回滚哪一部分。我给自己定的线是单个提交不超过三十个文件,超了就拆。拆不动说明这次改动本身就该再切一次。
7.3 我个人的收尾习惯
最后分享一个我坚持了很久的小动作:每次从主干拉下新代码,打开 Unity 之后不要立刻开始干活,先做三件事——看一眼 Console 有没有红色的资源警告、随便点开一个主要场景确认物体齐全、跑一次空构建。这三件事加起来不到五分钟,但能拦住绝大多数"拉下来就是坏的"的情况。等我开始改代码才发现环境有问题,成本就完全不一样了,因为那时候已经分不清是自己的改动引起的,还是拉下来的代码本来就有问题。
还有一条关于纪律的体会。这套东西配起来其实不难,.gitignore、.gitattributes、Smart Merge、流水线,半天能弄完。真正难的是让所有人持续遵守:改文件夹要在编辑器里改、场景要拆小、提交要分类。我见过太多项目,配置文档写得漂漂亮亮,三个月后又回到 Library 被提交、场景冲突靠喊话的状态。所以我的建议是,把关键规则做成自动化卡口——钩子能拦的绝不靠提醒,能在 CI 里检查的绝不靠评审记忆。剩下的那部分靠人,才靠得住。