Unity 项目挂到 GitHub 上托管,听上去就是把文件夹往仓库里一推的事,真动手的人都知道这活儿坑有多深。我自己第一次把工程推上去,git status刷出来两万多个待提交文件,仓库体积十分钟内涨到 3 个 G,队友 clone 下来打开工程,贴图全变成粉色,预制体引用集体丢失。后来折腾了几年,从个人小 Demo 到七八个人同时开工的中型项目都踩过一遍,才摸清一套相对稳妥的做法。这篇东西讲的就是 Unity 工程交给 Git 托管这件事本身:哪些文件该进版本库、.meta文件为什么不能丢、二进制资源怎么处理、场景冲突怎么救、自动构建怎么配。内容偏实操,适合刚把工程从本地搬到远程仓库的独立开发者,也适合团队里负责搭基础设施的那位。不管你用的是哪种 Git 托管服务,规则是通用的,下面统一用 GitHub 举例。
1. Unity 仓库失控的根源:源码与产物的边界在哪
Unity 的工程目录结构和普通代码项目差别很大。一个纯代码项目里,几乎所有文件都是人手写的,改一行 diff 就一行。Unity 不是这样,它的目录里混着三类完全不同的东西:你手写的 C# 脚本和配置、引擎自动生成的各种缓存、以及美术同学丢进来的二进制资源。这三类东西的版本控制策略完全相反,混在一起提交,仓库必然失控。搞清楚这条边界,后面所有的忽略规则、LFS 规则、合并策略才有落脚点。
1.1 第一次git status就冒出两万个文件是怎么回事
新建的 Unity 工程,打开一次编辑器之后,根目录下会多出Library、Temp、Logs、obj、UserSettings这几个文件夹。Library是重点,它存放的是引擎对资源的导入结果:贴图压缩后的格式、模型的网格数据、脚本编译出来的程序集、着色器变体、资源依赖索引,全在里面。它的体积通常是Assets目录的好几倍,一个中等项目轻松几十个 G。
关键在于,Library是可再生的。它由Assets里的原始资源和ProjectSettings里的导入设置共同推导出来,删掉之后重新打开工程,Unity 会花几分钟到几十分钟重新生成一份。这份东西对版本控制毫无价值,因为它是产物不是输入。更麻烦的是它由引擎版本、平台设置、机器环境共同决定,A 同学电脑上的Library和 B 同学的根本不该一样,强行同步反而会引发一堆莫名其妙的导入错误。
同理,Temp是编译和构建的临时目录,obj是 C# 编译中间产物,Logs是日志,UserSettings存的是你这台机器上的编辑器布局、上次打开的场景、面板位置。这些全都不该进仓库。UserSettings尤其容易被误提交,因为它里面有个Layouts文件夹,看起来像是团队共享的界面配置,实际上每个人习惯不同,提交上去只会天天冲突。
判断一个目录该不该提交,问自己一句:删掉它,重新打开工程能不能自动恢复?能恢复的,一律忽略。
1.2.meta文件不是垃圾,它是整个工程的引用索引
这是我见过最多人栽跟头的地方。Assets目录下每个资源旁边都有一个同名的.meta文件,比如Player.prefab旁边有Player.prefab.meta。很多人第一眼看到觉得是冗余垃圾,顺手加进忽略列表,结果就是灾难开始。
.meta里存的是一串 GUID 和这个资源的导入设置。Unity 内部所有的引用,都不是按路径记的,而是按 GUID 记的。场景里一个物体引用了一个材质,存的是那个材质的 GUID;预制体里挂了一个脚本,存的是脚本的 GUID。只要.meta在,你把资源从Assets/A挪到Assets/B/C,所有引用照样有效,因为 GUID 没变。删掉.meta重新导入,Unity 会生成一个新的 GUID,全工程所有指向旧 GUID 的引用瞬间变成Missing,界面上就是粉色材质、空脚本槽。
所以规则很硬:.meta必须和资源一起提交,一个都不能少。.gitignore里那条!/[Aa]ssets/**/*.meta的感叹号是反选,作用是在整体忽略某些资源的同时,保证Assets下的.meta不被误伤。这个细节很多人抄模板时根本没注意。
还有一种情况更隐蔽:多人协作时,A 同学在本地新建了一个文件夹并放了资源,git add的时候只加了资源文件没加.meta,因为.meta在文件夹里默认被折叠了没看见。推上去之后,B 同学拉下来,Unity 给这个资源生成新 GUID,A 同学本地是老 GUID,两边一合并,引用全断。表现就是"我这里好好的,他那里全是粉色"。
1.3 二进制资源撞上 Git 的增量存储模型
Git 最初是为纯文本设计的。它存文件的方式是存整个文件快照,但压缩时对文本极其高效——两个版本的 C# 脚本只差一行,压缩后几乎不占额外空间。二进制文件没这个待遇,一张 4K 的 PSD 改了一个图层,在 Git 眼里就是两个完全不同的 800MB 对象,各存一份。改十次,就是 8 个 G。
Unity 项目里这种文件遍地都是:.psd、.png、.fbx、.wav、.mp3、.tga、.exr、.blend。一个美术同学正常迭代一周,仓库能涨几个 G,然后就到了推送超时、clone 半小时的地步。
解法是 Git LFS(Large File Storage)。它的原理不复杂:大文件本身不进 Git 的对象库,而是上传到单独的存储区,仓库里只留一个几百字节的文本指针文件,内容大概是版本号、文件哈希、大小。检出的时候 Git 根据指针自动把真文件拉下来。这样仓库主体保持轻量,克隆速度不受影响。
LFS 有几个必须提前知道的限制。第一,它不是免费的午餐,各家托管服务对 LFS 流量和存储都有配额,超了要么限速要么付费。第二,LFS 只对配置之后新增的提交生效,如果大文件已经进了历史,光加.gitattributes没用,得重写历史。第三,LFS 指针文件本质是文本,冲突时 Git 不能自动合并,只能选一边。这三点后面都会展开讲。
搞清楚这三层——产物该忽略、.meta必须留、二进制要走 LFS——Unity 仓库的地基才算打对。
2. 开工前先定规矩:忽略表与属性表的落地写法
很多人的做法是先把工程推上去,出问题了再补.gitignore。这个顺序是错的。.gitignore只对尚未被跟踪的文件生效,一旦Library已经被提交过,后面再加忽略规则,Git 依然会继续跟踪它们,你得手动git rm -r --cached清一遍。所以建仓库的第一件事不是git init,而是把规则文件写好。
2.1 用白名单思维反推忽略表
写.gitignore的时候,新手习惯一条条列"不要什么",写着写着总有漏网之鱼。更稳的思路是反过来:先明确要提交的东西只有四类,其余全部忽略。
| 提交内容 | 作用 | 说明 |
|---|---|---|
Assets/ | 全部游戏资源和脚本 | 含每个资源的.meta,一个都不能少 |
ProjectSettings/ | 工程级配置 | 标签层、物理、图形、输入、包管理设置 |
Packages/manifest.json | 依赖包清单 | 声明用了哪些包及版本 |
Packages/packages-lock.json | 依赖锁定 | 保证所有人装到完全相同的版本 |
四类之外的东西,除非有明确理由,一律不进仓库。这套白名单思路的好处是,就算以后 Unity 新增了某个临时目录,默认它也是被忽略的,不会因为规则没更新而误提交。
Packages/packages-lock.json这条值得单独说。它记录了每个包解析后的精确版本和来源,类似package-lock.json。没有它,manifest.json里写的"com.unity.textmeshpro": "3.0.6"在不同机器上可能解析出不同的间接依赖,导致"我这儿能编译他那儿报错"。这个文件一定要提交,而且尽量别手动改。
2.2 逐行拆解忽略表里那些容易被误删的条目
下面这份是实践中最常用的一份,我按块拆开讲为什么:
# ---------- 引擎生成的目录 ---------- [Ll]ibrary/ [Tt]emp/ [Oo]bj/ [Bb]uild/ [Bb]uilds/ [Ll]ogs/ [Uu]ser[Ss]ettings/ [Mm]emoryCaptures/ [Rr]ecordings/ # ---------- 自动生成的工程文件 ---------- *.csproj *.unityproj *.sln *.suo *.user *.userprefs *.pidb *.booproj *.svd *.pdb *.mdb *.opendb *.VC.db # ---------- 崩溃与构建产物 ---------- sysinfo.txt *.apk *.aab *.unitypackage *.app # ---------- 关键反选 ---------- !/[Aa]ssets/**/*.meta[Ll]ibrary/这个写法是大小写兼容。Windows 上 Unity 生成的是Library,某些 Linux 环境下可能是library,方括号匹配能同时覆盖。Build/和Builds/都要写,因为不同人导出时习惯不同。
*.csproj和*.sln这一组,很多人不理解为什么要忽略,明明是工程文件。原因是 Unity 会根据Assets下的脚本自动重新生成它们,而且里面包含大量绝对路径和包引用。两个人如果 IDE 不同、装的插件不同,这些文件天天冲突,且冲突了也没法手工合并。忽略掉,让 Unity 每次自己生成,反而干净。
*.apk、*.aab、*.unitypackage、*.app这些是构建产物,体积巨大且可以随时重新打出来。特别是.unitypackage,有人喜欢把第三方资源包整个提交,一个包几百兆,非常划不来——导进来之后的东西本来就进Assets了,原始包没有保留价值。
那条反选!/[Aa]ssets/**/*.meta是保命的。它的含义是:即使上面的规则命中了某个文件,只要它在Assets下且是.meta结尾,就不要忽略。安全网性质的一条。
还有一类需要视情况处理:如果你用了 Addressables,Assets/AddressableAssetsData下会生成.bin缓存文件,这些是可重建的,可以忽略;但AddressableAssetsData里的.asset配置必须提交。类似地,Assets/StreamingAssets下的内容如果是构建时拷贝进去的,注意别把中间产物一起提交了。
2.3.gitattributes:LFS 追踪和合并驱动都挂在这
.gitignore决定什么不进仓库,.gitattributes决定进了仓库的文件怎么被对待。Unity 项目里它承担两件事。
第一件是 LFS 追踪。写法有两种风格,按扩展名和按目录。按扩展名更直观:
# ---------- 文本规范化 ---------- * text=auto # ---------- 二进制资源走 LFS ---------- *.psd filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text *.tga filter=lfs diff=lfs merge=lfs -text *.exr filter=lfs diff=lfs merge=lfs -text *.fbx filter=lfs diff=lfs merge=lfs -text *.blend 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 *.dll filter=lfs diff=lfs merge=lfs -text # ---------- Unity YAML 走智能合并 ---------- *.unity merge=unityyamlmerge eol=lf *.prefab merge=unityyamlmerge eol=lf *.asset merge=unityyamlmerge eol=lf *.mat merge=unityyamlmerge eol=lf *.anim merge=unityyamlmerge eol=lf *.controller merge=unityyamlmerge eol=lf *.meta merge=unityyamlmerge eol=lf-text这个标记容易被忽略但很重要,它告诉 Git 不要对这个文件做换行符转换和文本 diff,避免把二进制内容当文本处理导致损坏。.dll走 LFS 是个取舍,插件 dll 通常几百 K 到几 M,如果插件很少可以不追,如果项目依赖大量第三方 SDK 建议追。
第二件是给 Unity 的 YAML 文件绑定合并驱动unityyamlmerge,这就是第 4 节要讲的智能合并,先在这里埋好伏笔。
规则文件写完之后,务必用git check-ignore -v <路径>验一下某个具体文件到底被哪条规则命中。这个命令排查规则冲突特别有效,比肉眼读几十行规则快得多。
2.4 序列化模式:Force Text 必须在第一次提交之前打开
这一步经常被跳过,但它决定了整个团队以后处理冲突的难度上限。
打开Edit > Project Settings > Editor,找到Asset Serialization,把Mode设成Force Text。新版 Unity 默认就是这个,但老工程升级上来的不一定。这个设置的效果是,场景、预制体、材质、动画控制器这些资源,存成可读的 YAML 文本而不是二进制。
差别有多大?二进制模式下,两个人同时改一个场景,冲突了只能选"我的"或"他的",另一边的改动全丢,没有第三条路。文本模式下,Unity 提供了UnityYAMLMerge工具,能按对象和字段粒度做三方合并,冲突范围能从"整个文件"缩小到"某个 GameObject 的某个属性"。
同一个面板里还有个Version Control选项,设置成Visible Meta Files(新版可能叫Visible Meta Files或已经是默认)。它的作用是让.meta文件在文件系统里可见,否则 Unity 会把它藏在内部,Git 根本拿不到。
这两项设置改完要提交
ProjectSettings/EditorSettings.asset,让整个团队统一。如果有人本地是二进制模式,他提交的场景会把文本格式覆盖回去,导致其他人下次打开发现格式变了,一串无意义的 diff。
配置好这两项,地基就算彻底完工,可以动手建仓库了。
3. 半年老工程怎么搬进仓库:一次完整的迁移实录
新项目从空文件夹开始是最省事的。但现实中更常见的情况是:工程已经做了大半年,本地文件夹里堆着Library、Builds、各种备份文件,现在才想起来要做版本控制。这种情况坑更多,得按步骤来。
3.1 备份、清空、断网验证三件套
动手之前第一件事:整个工程文件夹复制一份到别的盘。这不是客套话,迁移过程中一不小心git clean -fdx就能把没提交的资源全删了,我在一个项目上亲眼见过美术一下午的工作量蒸发。
备份完,按顺序做这几步:
- 关闭 Unity 编辑器。开着的时候
Library里的文件被占用,Git 操作可能失败,而且编辑器在后台持续写入,你根本不知道它什么时候改了东西。 - 删掉
Library、Temp、obj、Logs、UserSettings、Builds。删完确认工程目录下只剩Assets、ProjectSettings、Packages三个核心文件夹,加上可能存在的.vs、.idea之类的 IDE 目录。 - 把第 2 节写好的
.gitignore和.gitattributes放到工程根目录。 git init,然后git add .,先不要 commit。- 执行
git status,看暂存区里到底有多少文件、总体积多大。
第 5 步是关键检查点。正常情况下,一个中小项目的首次提交应该在几百 M 到 1 G 之间。如果显示好几千个文件、体积几个 G,说明有东西没被忽略掉,回头检查规则。可以用这条命令看暂存区各目录的体积分布:
git ls-files -s | awk '{print $4}' | xargs -I{} du -k {} 2>/dev/null | sort -rn | head -30它会列出暂存区里体积最大的 30 个文件,一眼就能看出是哪类资源在撑体积。如果全是大图,说明 LFS 没配好或者配置了但没生效。
最后一步验证:git commit之后,把整个工程文件夹改名(不是删除),然后从远程 clone 一份下来,打开 Unity,看是否报错、资源是否完整。这个"断网验证"能提前暴露所有.meta缺失、LFS 指针没拉全的问题,比等到队友拉下来才发现要好得多。
3.2 历史里已经塞进Library的补救手段
更麻烦的情况是:仓库已经用了一段时间,Library早就被提交过好几版,仓库体积已经失控。这时候加.gitignore是没用的,因为 Git 依然跟踪着这些文件。
第一步是让 Git 停止跟踪,但保留本地文件:
git rm -r --cached Library Temp obj Logs UserSettings git commit -m "chore: 停止跟踪引擎生成目录"这样以后的提交干净了,但历史里的那些大对象还在。.git文件夹依然臃肿,clone 依然要拉几个 G。要彻底清理得重写历史。
工具上,官方推荐的git filter-repo比老的filter-branch快几十倍也安全得多:
# 安装(需要 Python 环境) pip install git-filter-repo # 从全部历史中移除指定目录 git filter-repo --path Library --path Temp --path obj --invert-paths--invert-paths的含义是"删除匹配到的路径",配合多个--path就能一次性清掉多个目录。执行完之后本地历史被重写,所有 commit hash 都会变,必须用git push --force覆盖远程,并且通知所有协作者重新 clone,不能直接pull。这一步在多人项目里要挑个大家都不干活的时间段做,不然有人推了旧历史上去,一切白费。
重写历史前建议先打个标签备份:git tag backup-before-rewrite,万一搞砸了还能从 reflog 或者备份标签找回来。
3.3 把已经进历史的大文件迁到 LFS
LFS 的坑在于它只对配置之后的提交生效。.gitattributes写好、git lfs track执行完,之前提交的.psd依然是普通 Git 对象,依然占着仓库体积。
git filter-repo也能处理这个,它有一个专门的--to-vcs或者配合git lfs migrate使用更直接:
# 把历史中所有匹配的文件转成 LFS 对象 git lfs migrate import --include="*.psd,*.png,*.fbx,*.wav" --everything--everything表示遍历所有分支和标签。执行完会重写这些文件的存储方式,仓库里的普通对象变成指针,真文件迁到 LFS 存储。之后再git lfs push --all origin把 LFS 对象推上去。
这一步的风险点是:如果团队里有人的本地仓库没同步这次重写,他推上来的还是老格式,会造成指针和实体混杂,检出时报错smudge filter lfs failed。所以迁移前后一定要在群里明确通知,所有人删掉本地仓库重新 clone 是最省心的做法。
3.4 换台机器拉下来打不开,通常是这几处没对上
迁移完成后,新同事 clone 下来打不开工程,报错信息往往很含糊。按这个顺序排查,命中率很高:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 贴图全粉、脚本槽为空 | .meta没提交或被误忽略 | `git ls-files |
| 拉下来的大文件只有几百字节 | LFS 没装或没拉取 | git lfs install后git lfs pull |
| 打开就提示版本不一致 | 团队 Unity 版本不统一 | ProjectSettings/ProjectVersion.txt提交,约定统一版本 |
缺包、报package not found | packages-lock.json没提交 | 补提交并统一manifest.json |
| 输入系统/渲染管线报错 | ProjectSettings某个资产没提交 | 检查ProjectSettings目录是否完整 |
第一行那条命令特别实用,能快速比对。正常情况Assets下的.meta数量和需要它的资源数量应当匹配(每个文件夹也各有一个.meta)。差得越多,说明漏提交越严重。
4. 场景与预制体冲突:从满地狼藉到可控
多人协作里最让人头疼的不是代码冲突,代码冲突至少还能一行行看。场景冲突是真的会让人崩溃——两个人在同一个.unity文件里各改各的,合并之后场景里冒出一堆重复物体、组件丢失、光照参数错乱,排查起来比重做还慢。
4.1 一个.unity文件的冲突为什么能废掉半天
根源在于场景文件的组织方式。一个场景是单个 YAML 文件,里面按--- !u!<类型ID> &<对象ID>分段,每段描述一个对象。文件里的对象 ID 是本地生成的,两个人各自新增物体,ID 极可能撞上。Git 的三方合并只看文本行,不懂 YAML 语义,它会把两边的改动硬拼在一起,拼出来的结果语法可能是合法的,但逻辑完全错乱。
更隐蔽的是"删除 + 新增"的交叉情况。A 删了一个物体,B 在同位置改了这个物体的参数。文本合并的结果是物体既被删又被改,最终表现可能是引用指向一个不存在的对象,Unity 打开时报Missing Reference,但你根本定位不到是哪个。
所以对场景文件的处理原则很简单:尽量避免冲突,而不是解决了冲突。这听起来像废话,但确实是最有效的手段。
4.2UnityYAMLMerge的配置和它不生效的常见原因
.gitattributes里那一行*.unity merge=unityyamlmerge只是声明"这个类型的文件用名为unityyamlmerge的驱动来合并",驱动本身得单独配置。
打开全局或仓库级 Git 配置,加上:
git config merge.unityyamlmerge.name "Unity SmartMerge" git config merge.unityyamlmerge.driver "\"Unity安装路径/Editor/Data/Tools/UnityYAMLMerge.exe\" merge -p %O %B %A %A"路径按操作系统不同:
- Windows:
C:/Program Files/Unity/Hub/Editor/2022.3.xx/Editor/Data/Tools/UnityYAMLMerge.exe - macOS:
/Applications/Unity/Hub/Editor/2022.3.xx/Unity.app/Contents/Tools/UnityYAMLMerge - Linux:
<Unity安装目录>/Editor/Data/Tools/UnityYAMLMerge
四个参数的顺序%O %B %A %A分别代表 base(共同祖先)、ours(当前分支)、theirs(被合并分支)。注意最后写的是%A %A,这是官方推荐写法,表示合并结果输出到我们的文件里。
配置完用一次真实冲突验证:随便开两个分支各改一个场景里的不同物体,然后 merge。如果弹出Automatic merge failed并且场景文件里出现了<<<<<<<标记,说明驱动没生效。常见原因有这么几个:
.gitattributes里写的是merge=unityyamlmerge,但git config里的名字拼错,或者大小写不一致- 路径里有空格却没加引号,Git 解析时把路径截断了
- Unity 版本升级后旧路径失效,
2021.3.x换成了2022.3.x但配置没改 - 用了 Git GUI 客户端,客户端自己的合并工具覆盖了 Git 配置
最后一条特别容易忽略。某些图形客户端有自己的"外部合并工具"设置,优先级高于.gitattributes,配了半天不生效就是因为客户端在中间拦截了。排查时先用命令行git merge试,命令行生效了再调客户端。
即便 SmartMerge 正常工作,它也不是万能的。它擅长处理"两边改了不同物体的不同属性",对于"两边改了同一个物体的同一个字段"依然会冲突,对于数组顺序变化、List 增删的处理也不总是正确。所以配好工具只是降低冲突频率,不能指望它兜底。
4.3 LFS 文件锁:把覆盖别人的改动掐死在提交前
美术资源有个特点:PSD 文件没法做有意义的 diff。两个人都改同一张原画,只能选一边。但比起"选哪边",更常见的问题是"根本不知道对方也在改",等推上去才发现覆盖了别人两小时的工作。
LFS 提供了文件锁机制。开启方式是在.gitattributes里给需要保护的扩展名加上lockable:
*.psd filter=lfs diff=lfs merge=lfs -text lockable *.blend filter=lfs diff=lfs merge=lfs -text lockable然后git lfs lock <文件路径>就能锁住这个文件。锁定状态会同步到远程,其他人推同一文件时会被拒绝,提示文件被锁。改完git lfs unlock释放。
实际操作中要注意两点。一是锁是基于路径的,改了文件名锁就失效了,所以文件名尽量定了就别动。二是团队所有人必须都安装 LFS 并且git lfs install过,否则锁机制形同虚设——没装 LFS 的客户端根本不认识这些指令。
这套机制配合git lfs locks命令可以列出当前所有锁,谁锁了什么、什么时候锁的一目了然。我们后来的做法是每天早上开工前跑一次,确认自己要动的原画没被别人占着。
4.4 分支模型和提交粒度,比工具更影响体验
工具再好,也架不住所有人天天在同一个主分支上推。Unity 项目的特点是"改一个功能经常要动场景、动预制体、动配置",天然容易冲突。把分支切得合理一些,冲突量能降一大截。
小团队(五到八人)我推荐的做法是:
main分支保持随时可出包的状态,只接受通过 PR 合并的改动- 每个人按功能开短分支,命名带人名或功能名,比如
feature/inventory-system - 分支存活时间控制在三天以内,超过就强制拆小
- 场景文件默认只有一个人负责,其他人需要调整就在预制体上改,运行时再拼进场景
最后一条是经验之谈。把一个关卡场景拆成多个预制体,每个人只改自己负责的那块预制体,冲突概率能降一个数量级。场景本身变成纯粹的"装配清单",改动极少。
提交粒度上,最忌讳的是"攒一天然后一次提交"。一个大提交里混着脚本改动、场景改动、配置改动,出了问题根本没法回滚,只能整体回退。合理的粒度是每完成一个小功能点提交一次,提交信息写清楚动了哪块。Unity 有个小技巧:提交前在编辑器里跑一次File > Save Project,确保所有改动已经落盘,不然可能出现编辑器内存里有改动但没写到文件的情况。
5. 自动出包:把手动构建那二十分钟省掉
工程托管好之后,接下来最自然的一步就是自动构建。手动出包的问题是:打开 Unity 等编译、切平台、点构建、等半小时、发现报错、重来。这套流程一天走三遍,时间全耗在等待上。配好 CI 之后,推代码就自动出包,报错也能第一时间看到。
5.1 许可证和凭据的准备
GitHub Actions 的 runner 是干净的虚拟环境,里面没装 Unity,也没有你的许可证。所以第一步是把构建用的环境准备好。
社区里有一套成熟的 Unity 构建 Action(常被称为 GameCI),它支持从加密的许可证文件激活。流程大致是这样:
- 本地激活一次 Unity 许可证,拿到许可证文件(
.ulf) - 把文件内容转成 base64:
base64 -w 0 Unity_v2022.x.ulf > license.txt - 把这一长串字符存进仓库的
Settings > Secrets and variables > Actions,命名比如UNITY_LICENSE
如果有 Personal 版许可证,另外还需要UNITY_EMAIL、UNITY_PASSWORD两个 secret。这套凭据属于高度敏感信息,务必确认仓库是私有的,公开仓库里放这种东西等于把账号交出去。
5.2 工作流文件逐段读
在仓库根目录建.github/workflows/build.yml,一份可用的最小配置大概长这样:
name: Build Unity Project on: push: branches: [ main ] workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: 拉取代码(含 LFS 文件) uses: actions/checkout@v4 with: lfs: true fetch-depth: 0 - name: 恢复 Library 缓存 uses: actions/cache@v4 with: path: Library key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }} restore-keys: | Library- - name: 执行构建 uses: game-ci/unity-builder@v4 env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: targetPlatform: WebGL unityVersion: auto - name: 上传构建产物 uses: actions/upload-artifact@v4 with: name: build-webgl path: build几个参数值得展开。lfs: true必须有,否则检出的是 LFS 指针文件,Unity 打开时所有贴图都是坏的。fetch-depth: 0拉取完整历史,某些版本号生成逻辑会用到 git 提交次数。
targetPlatform支持StandaloneWindows64、Android、iOS、WebGL等,一次配多个平台就是多开几个 job,注意并行 job 会同时消耗构建配额。
unityVersion: auto让 Action 自动读取ProjectSettings/ProjectVersion.txt里的版本号去拉对应编辑器镜像。这个镜像有几个 G,第一次跑会慢,之后有缓存就快了。
5.3 缓存Library的两面性
缓存Library是提速的关键。没缓存的话,每次构建都要全量导入资源,一个中等项目二十分钟起步。有缓存能压到三五分钟。
但缓存也有坑。key用的是Assets、Packages、ProjectSettings三处内容的哈希,只要这三处有任何变化,key 就变了,缓存失效重新全量导入。这是保守策略,好处是不会用到过期缓存出错。
更激进的策略是用固定 key 加restore-keys兜底,能命中更久之前的缓存。代价是可能拿到跟当前代码不匹配的Library,偶尔出现"引用了一个已经被删掉的东西"这类诡异报错。我的经验是:项目初期用保守策略,等构建稳定了再考虑放宽。
还有一个细节,Library缓存体积可能到几个 G,GitHub 的缓存空间有上限,超了会淘汰最旧的缓存。所以别把Builds之类的东西也塞进缓存路径。
5.4 构建红了之后先查这几样
CI 挂了的时候,日志往往几百行,直接看容易懵。按这个顺序查,八成的失败都能定位:
许可证报错,日志里出现No valid Unity license或License activation failed。通常是 secret 名字拼错、许可证过期、或者 base64 转换时多了换行。重新生成一遍 secret 基本能解决。
LFS 文件拉取失败,日志里出现smudge filter lfs failed或者This repository is over its data quota。前者检查 checkout 步骤有没有lfs: true;后者是 LFS 流量用完了,得清理历史或者升级配额。
包解析失败,报Cannot resolve package。检查packages-lock.json是否提交完整,以及 CI 环境能不能访问包源。有些包依赖私有源,需要额外配凭据。
平台模块缺失,报Build target not supported。构建 Linux 上出 Android 包,需要预装 Android 模块。GameCI 的镜像默认带了一些,但不是全都有,需要时用with: androidTargetSdkVersion之类的参数指定,或者换用带完整模块的镜像。
构建超时,一般是因为没缓上,全量导入加上下载编辑器镜像超过默认时限。加大 timeout 或者优化缓存策略。
构建产物那一步用upload-artifact存起来,可以直接从 Actions 页面下载。如果要做发布,可以在后面接一个自动创建 release 并附上产物的步骤,这部分按各自需要扩展就行。
6. 最后聊几条不太好写进文档的经验
工程托管这件事,规则层面其实不复杂,难的是让整个团队保持一致。我遇到过的绝大多数事故,都不是技术方案有问题,而是有人绕过了方案:临时手动改了一下.gitignore、图省事git add -A把所有东西都推上去、编辑器里忘了存盘就提交。
有个做法我觉得挺管用:在仓库根目录放一份CONTRIBUTING.md,写清楚三条硬规矩——提交前跑一次git status确认没有Library;新加资源确认.meta一起进暂存区;动ProjectSettings的改动单独提一个 commit。文字不用多,够明确就行。后来发现新人入职读三分钟,能省下后面无数次救火。
还有个检测手段,可以在 CI 里加一个前置 job,检查本次提交是否包含了不该出现的路径,比如Library/、Temp/、*.csproj。命中就直接 fail,比事后清理历史省事得多。规则写起来也就几行:
if git diff --name-only HEAD~1 HEAD | grep -E '^(Library|Temp|obj|Logs)/'; then echo "提交中包含引擎生成目录,请检查 .gitignore" exit 1 fi这东西的价值在于,它把"事后补救"变成了"当场拦截"。人工 review 靠不住,机器检查才是真的靠得住。
至于仓库规模的长期维护,我自己的习惯是每个月看一眼仓库体积和 LFS 用量。如果 LFS 用量涨得异常快,通常意味着有人在提交一些本该忽略的中间文件,比如渲染出来的序列帧、录屏素材、或者从商店下的原始资源包。早点发现,清理成本就低。
最后补一句关于 Unity 版本的事。ProjectSettings/ProjectVersion.txt一定要提交,并且团队约定统一版本,别出现"你用 2021 我用 2022 大家互相覆盖"的局面。版本不一致导致的资源重新序列化,是那种看起来默默无闻、实际天天在制造无意义 diff 的隐形杀手,等你发现的时候,历史里已经堆了几百个只改了一行版本号的提交了。