游戏小U在 Unity 里长大,现在要去两个国家定居:
🤖安卓联邦:办事大厅叫Gradle,流程标准化,在哪个城市都能办。
🍎苹果王国:手续由Xcode 工坊办理,规矩严格,只能在王国本土(macOS)办理。
🎭 角色表
| 角色 | 故事身份 | 真实身份 |
|---|---|---|
| 小U | 准备出国的主人公 | 你的 Unity 游戏 |
| 行李箱 | 场景、贴图、音频 | level0、sharedassets、resources.assets、.resS |
| 随身器官 | 心脏和大脑 | 引擎原生库libunity.so/libiPhone-lib.a |
| 外语老师 | 把 C# 翻成当地语言 | IL2CPP(C# → IL → C++) |
| 联络员 | 和当地系统打交道 | Java 胶水层 / ObjC 胶水层 |
| 安卓办事大厅 | 一条龙窗口服务 | Gradle + AGP |
| 苹果工坊 | 手工精密办理 | Xcode |
| 护照与签证 | 入境凭证 | Keystore 签名 / 证书 + 描述文件 |
第一章:出发前的准备 🎒
1. 收拾行李:序列化
小U先把家当分装进箱子,这部分内容在上一篇已经讲过:
🧳 level0 第一个场景里的家具摆放(GameObject、组件) 🧳 sharedassets0 这个场景用到的物品(贴图、网格、材质) 🧳 resources.assets Resources 文件夹里的东西,不管用不用都带上 📦 .resS / .resource 大件托运:像素、顶点、音频数据这些行李在两国都一样,是平台无关的。
2. 学外语:IL2CPP 🗣️
小U平时说C#,两国本地的编译器(clang)只认C++。所以出发前要上一门速成课:
C# 源码 → IL 字节码 → IL2CPP 翻译 → C++ 源码 → 当地编译器 → 机器码 母语 速记笔记 外语老师 外语讲稿 当地翻译官 能直接执行- 🍎 苹果王国有条法律:禁止临场发挥。运行时不允许生成新的可执行代码(禁止 JIT),所以所有话都得提前写成讲稿,必须用 IL2CPP。
- 🤖 安卓联邦允许临场发挥(Mono),但 Google Play 要求 64 位(ARM64),而主流 Unity 版本的 Mono 后端只支持 32 位 ARMv7。所以上架时基本也只能用 IL2CPP。
翻译时还会附带一本小词典global-metadata.dat,里面是类名、方法名和字符串,跟行李一起走。
⚠️上课前还会删减讲稿(Managed Stripping):老师会把"看起来没人用"的句子删掉。如果某段代码只通过反射调用,老师看不出来它有用,就可能把它删掉。这时需要用link.xml或[Preserve]标记"这段必须保留"。
第二章:🤖 安卓联邦 · Gradle 办事大厅
2.1 两种办理方式
| 方式 | 故事 | 实际操作 |
|---|---|---|
| 全程代办 | Unity 拿着材料替你跑完所有窗口,你直接拿证 | 不勾选Export Project,直接输出 APK/AAB |
| 自己办理 | Unity 把整套材料交给你,你自己去大厅 | 勾选Export Project,用 Android Studio 打开 |
两种方式的材料完全相同,区别只在于谁去排队。
2.2 材料袋里有什么:一套"住宅 + 工作室"
ExportedProject/ ├── settings.gradle 📋 登记表:申请人有 launcher 和 unityLibrary 两部分 ├── build.gradle 🏛️ 大厅规章:用哪一版 AGP ├── gradle.properties ⚙️ 办事参数:内存、AndroidX 等 │ ├── launcher/ 🏠 住宅门牌(很薄) │ ├── build.gradle 姓名(applicationId)、证件号(versionCode)、签名 │ └── res/ 门牌照片(图标)、欢迎画面 │ └── unityLibrary/ 🏭 工作室(真正干活的地方) ├── libs/unity-classes.jar 联络员手册 ├── src/main/assets/bin/Data/ 🧳 行李全部放这里 ├── src/main/jniLibs/arm64-v8a/ 🫀 心脏和大脑:libunity.so、libil2cpp.so └── Il2CppOutputProject/ 📜 外语讲稿(较新版本在大厅里现场编译)为什么住宅和工作室要分开?
因为工作室unityLibrary是一个标准的 Android Library,可以整体搬进别人家。这就是Unity as a Library:一个普通的原生 App,可以在某个页面里嵌入 Unity 内容。
2.3 大厅里的八个窗口 🪟
执行./gradlew :launcher:bundleRelease,排队开始:
🪟1 证件合并(Manifest Merge) 住宅、工作室、每个第三方 SDK 都带着自己的 AndroidManifest, 窗口把它们合成一份。条款冲突时,用 tools:replace / tools:remove 说明以谁为准。 🪟2 制作名片(aapt2) 图标、字符串、布局 → resources.arsc,并发放编号表 R。 🪟3 联络员上岗(javac / kotlinc) Java/Kotlin 胶水代码 → .class 🪟4 精简档案(R8) 删除没人用的类,把长名字改短。 ⚠️ 这个窗口和外语老师一样会"误删":Unity 通过 JNI/反射调用的 Java 类 在它看来没人用 → 删掉 → 运行时 ClassNotFoundException。 解决:在 proguard-user.txt 里写 -keep。 🪟5 方言转换(D8) .class → classes.dex,安卓系统(ART)只认 dex。 🪟6 器官检查(原生库) 编译 IL2CPP 的 C++(如果有),合并 .so,剥离调试符号(strip)。 🪟7 装箱(打包) 行李 + 名片 + dex + .so → APK 或 AAB 🪟8 盖章(对齐 + 签名) 没有 Keystore 签名的包,系统拒绝安装。 ⚠️ Keystore 一旦丢失,同一个应用就无法再发布更新,务必备份。2.4 海关特别规定:行李不许真空压缩 🧊
APK 本质是个 ZIP。按常理,压缩能省空间,但 Unity 专门叮嘱大厅:
noCompress=['.unity3d','.ress','.resource','.obb','.bundle',...]原因在于大件托运的取件方式。.resS里的贴图数据,对象中只记录了{offset, size},也就是"第几排、第几格开始,拿多少"。
- 📦真空压缩:想拿一件衣服,得先拆开整个真空袋。
- 🧊透明收纳箱(STORED,不压缩):按位置直接拿出来。
而且安卓有个特点:装进手机后,APK 不会被拆开,原样放在/data/app/.../base.apk。小U每次取东西,都是libunity.so通过AAssetManager找到文件在 APK 里的起始偏移,直接 seek 过去读取。
🚨由此产生的经典坑:
StreamingAssets里的文件也在这个没拆封的箱子里,所以File.ReadAllBytes读不到。不是路径写错了,而是它根本不是磁盘上的独立文件。应该用UnityWebRequest,或者先复制到persistentDataPath。
2.5 入境方式:APK 还是 AAB?
| 方式 | 故事 | 说明 |
|---|---|---|
| APK | 全家一起搬,不管用不用得上 | 可以直接安装,适合测试和国内渠道 |
| AAB | 把全部家当交给 Google 物流中心,按每户的情况分装配送 | 只给 ARM64 手机送 ARM64 的 .so,按屏幕密度和语言分发;Google Play 新应用必须使用 |
| Play Asset Delivery | 必需品随人走,其他的稍后寄、按需寄 | install-time / fast-follow / on-demand,解决基础包体积上限问题 |
2.6 写委托书:定制办理流程 ✍️
方式一:自带表格模板
在 Player Settings → Publishing Settings 勾选后,Assets/Plugins/Android/下会出现:
| 模板 | 对应的材料 |
|---|---|
mainTemplate.gradle | 工作室的 build.gradle |
launcherTemplate.gradle | 住宅的 build.gradle |
baseProjectTemplate.gradle | 根 build.gradle |
gradleTemplate.properties | 办事参数 |
AndroidManifest.xml | 工作室证件 |
proguard-user.txt | "请勿删除"清单 |
模板里的**DEPS**、**APPLICATIONID**、**SIGN**是 Unity 要填写的空格,不要删除。
方式二:材料生成后、递交前再改一次
usingSystem.IO;usingUnityEditor.Android;publicclassGradleTweak:IPostGenerateGradleAndroidProject{publicintcallbackOrder=>999;// path 指向 unityLibrary 目录publicvoidOnPostGenerateGradleAndroidProject(stringpath){stringroot=Path.GetFullPath(Path.Combine(path,".."));File.AppendAllText(Path.Combine(root,"gradle.properties"),"\norg.gradle.jvmargs=-Xmx4096m\n");}}Unity 2023.1+ / Unity 6 提供了
AndroidProjectFilesModifier,可以按结构修改 build.gradle 和清单,比拼接字符串更可靠。
第三章:🍎 苹果王国 · Xcode 工坊
3.1 王国的三条规矩 📜
- 只能在本土办理:必须在macOS + Xcode上完成。Windows 上的 Unity 只能准备材料(导出工程),拿不到最终的 IPA。
- 不提供代办:Unity 对 iOS永远只输出 Xcode 工程,后续手续必须进工坊完成。
- 禁止临场发挥:只能用 IL2CPP(第一章讲过)。
3.2 第二次递交材料时:重办还是补办?
| 选项 | 故事 | 适用场景 |
|---|---|---|
| Replace | 作废旧档案,全部重新办 | CI 自动构建,保证每次结果一致 |
| Append | 只更新 Unity 负责的部分,保留你手写的批注 | 本地调试,节省时间 |
⚠️ 在 Xcode 里手动改的设置没有记录,换台电脑就没了。所有修改最终都应该写进后处理脚本。
3.3 工坊里的档案柜
XcodeProject/ ├── Unity-iPhone.xcodeproj 📐 档案总目录 ├── Unity-iPhone.xcworkspace 🗂️ 用了 CocoaPods 后生成,必须打开这个! ├── Podfile 📦 第三方 SDK 订货单 ├── Info.plist 🪪 身份信息:Bundle ID、版本、权限说明 ├── MainApp/main.mm 🚪 入口:启动后加载 UnityFramework ├── Classes/ 🧑💼 联络员(ObjC/C++ 胶水层) │ └── UnityAppController.mm 负责启动、切后台、恢复等生命周期 ├── Libraries/ │ ├── libiPhone-lib.a 🫀 引擎本体(静态库) │ └── Plugins/iOS/ 你带来的原生插件 ├── Il2CppOutputProject/ 📜 外语讲稿(成百上千个 .cpp) └── Data/ 🧳 行李 └── Raw/ StreamingAssets3.4 又见"住宅 + 工作室"
和安卓几乎一模一样:
| Target | 身份 | 内容 |
|---|---|---|
| Unity-iPhone | 🏠 住宅(App 本体) | main.mm、图标、启动画面、Data 行李 |
| UnityFramework | 🏭 工作室(Framework) | 引擎库、IL2CPP 代码、Classes、插件 |
🚨经典坑:插件的源码和库要加到UnityFramework,而不是 Unity-iPhone,否则链接时会报
Undefined symbol。后处理脚本里应该用GetUnityFrameworkTargetGuid()。
3.5 签证三件套 🛂
想进入苹果王国,必须同时持有:
| 证件 | 故事 | 作用 |
|---|---|---|
| 证书(Certificate) | 护照 | 证明"开发者是谁" |
| 描述文件(Provisioning Profile) | 签证 | 规定"这个 App 能装在哪些设备上、用于开发还是上架" |
| Entitlements | 工作许可 | 推送、内购、iCloud、Sign in with Apple 等特殊能力 |
三者之间必须一一对应:Bundle ID 对不上、能力没在开发者后台开通、证书过期,都会导致签名失败。
3.6 办理流程
① 🔥 编译 clang 把 IL2CPP 的 C++ 和胶水层 ObjC++ 编译成 .o 这是最慢的一步,大项目十几分钟很常见 ② 🔗 链接 所有 .o + libiPhone-lib.a → UnityFramework + App 可执行文件 同时剥离没用到的函数 ③ 🛂 签名 使用证书 + 描述文件 + Entitlements ④ 🗃️ 归档 Product → Archive → .xcarchive(包含 App 和 dSYM) ⑤ 📤 导出 选择 App Store / Ad Hoc / Development / Enterprise → IPA ⑥ 🛃 海关 上传 App Store Connect → 人工审核 → 上架两件不能忘的事:
- 🗃️dSYM 是行车记录仪:崩溃时手机只记得一串内存地址,有了 dSYM 才能还原成"哪个文件第几行"。每次归档都要保存并上传到崩溃分析平台。
- 📝使用隐私权限必须说明理由:相机、麦克风、相册、定位,都要在
Info.plist里写明用途(如NSCameraUsageDescription)。漏写的话,调用时直接崩溃,审核也会被拒。
3.7 和安卓的一个关键区别:入境后箱子会被打开 📂
iOS 安装时会解压 IPA,Game.app在手机上就是一个普通文件夹。所以在 iOS 上,StreamingAssets(即Game.app/Data/Raw)可以用FileAPI 直接读取(只读)。
同样的代码,iOS 能用,安卓不能用,原因就在这里。
3.8 请一位常驻代理人:PostProcessBuild 🤵
每次重新生成工程都手动改设置太麻烦,写个脚本自动处理:
#ifUNITY_IOSusingSystem.IO;usingUnityEditor;usingUnityEditor.Callbacks;usingUnityEditor.iOS.Xcode;publicstaticclassIOSPostBuild{[PostProcessBuild(999)]publicstaticvoidOnPostprocessBuild(BuildTargettarget,stringpath){if(target!=BuildTarget.iOS)return;// ① 修改工程设置stringprojPath=PBXProject.GetPBXProjectPath(path);varproj=newPBXProject();proj.ReadFromFile(projPath);stringappTarget=proj.GetUnityMainTargetGuid();// 住宅stringfwTarget=proj.GetUnityFrameworkTargetGuid();// 工作室proj.AddFrameworkToProject(fwTarget,"StoreKit.framework",false);proj.SetBuildProperty(appTarget,"ENABLE_BITCODE","NO");proj.SetBuildProperty(fwTarget,"ENABLE_BITCODE","NO");proj.WriteToFile(projPath);// ② 修改身份信息stringplistPath=Path.Combine(path,"Info.plist");varplist=newPlistDocument();plist.ReadFromFile(plistPath);plist.root.SetString("NSCameraUsageDescription","用于扫描二维码");plist.root.SetBoolean("ITSAppUsesNonExemptEncryption",false);plist.WriteToFile(plistPath);// ③ 申请工作许可varcap=newProjectCapabilityManager(projPath,"Unity-iPhone.entitlements",null,appTarget);cap.AddPushNotifications(false);cap.AddInAppPurchase();cap.WriteToFile();}}#endif3.9 命令行办理(CI 用)
# 1. Unity 准备材料Unity-batchmode-quit-projectPath.-buildTargetiOS\-executeMethodIOSBuilder.Export-logFilebuild.log# 2. 安装第三方依赖(如果使用 CocoaPods)cdXcodeProject&&podinstall&&cd..# 3. 归档xcodebuild-workspaceXcodeProject/Unity-iPhone.xcworkspace\-schemeUnity-iPhone-configurationRelease\-archivePathbuild/Game.xcarchive archive# 4. 导出 IPAxcodebuild-exportArchive-archivePathbuild/Game.xcarchive\-exportOptionsPlistExportOptions.plist-exportPathbuild/ipa第四章:旅途见闻(常见问题) 🚑
| 遇到的状况 | 故事里发生了什么 | 怎么解决 |
|---|---|---|
安卓调用 SDK 闪退,ClassNotFoundException | R8 窗口把 JNI 调用的类当成没用的删了 | proguard-user.txt加-keep |
Manifest merger failed | 两份证件条款冲突 | tools:replace/tools:remove |
Duplicate class | 两个 SDK 带了同一个库 | 统一版本或exclude |
| 安卓读不到 StreamingAssets | 想用 File 去读一个没拆封的箱子 | UnityWebRequest |
| 反射找不到类型 / AOT 泛型报错 | 外语老师删了讲稿,或没见过这个泛型组合 | link.xml、[Preserve]、显式调用一次 |
iOS 报Undefined symbol | 插件放进了住宅,没放进工作室 | 加到UnityFramework |
| Xcode 找不到 Pods 里的库 | 打开了.xcodeproj | 打开.xcworkspace |
| 一调用相机就崩 | 没写隐私权限说明 | Info.plist补NS...UsageDescription |
| 签名失败 | 护照、签证、工作许可对不上 | 核对 Bundle ID、描述文件和 Capability |
| 崩溃日志全是地址 | 记录仪没保存 | 上传 dSYM / symbols.zip |
| 商店拒收新版本 | 版本号没递增 | versionCode/CFBundleVersion由 CI 自动递增 |
尾声:两国对照 🆚
| 对比项 | 🤖 安卓联邦(Gradle) | 🍎 苹果王国(Xcode) |
|---|---|---|
| 在哪办理 | Win / Mac / Linux 都可以 | 只能在 macOS |
| Unity 能否代办到底 | ✅ 直接出 APK/AAB | ❌ 只给 Xcode 工程 |
| 住宅 / 工作室 | launcher/unityLibrary | Unity-iPhone/UnityFramework |
| 引擎本体 | libunity.so(动态库) | libiPhone-lib.a(静态库) |
| 语言 | IL2CPP(上架基本必选) | IL2CPP(强制) |
| 证件 | AndroidManifest.xml | Info.plist+ Entitlements |
| 签名 | Keystore | 证书 + 描述文件 |
| 安装后的箱子 | 不拆封,按偏移直接读取 | 拆开,变成普通文件夹 |
| 行车记录仪 | symbols.zip | dSYM |
| 定制方式 | Gradle 模板 /IPostGenerateGradleAndroidProject | [PostProcessBuild]+PBXProject |
| 最终证件 | APK / AAB → Google Play | IPA → App Store 审核 |
🎬 一句话总结
小U 在 Unity 里收拾好行李(序列化)、学好外语(IL2CPP), 然后把材料交给两国的办事机构: 🤖 Gradle 大厅:合并证件 → 制作名片 → 精简档案 → 转换方言 → 装箱 → 盖章 🍎 Xcode 工坊:编译 → 链接 → 签证三件套 → 归档 → 海关审核Unity 负责准备材料,Gradle 和 Xcode 负责办理入境。
遇到打包问题时,先判断它发生在哪一段:
是行李和外语出了问题(资源、序列化、IL2CPP,属于 Unity 侧),
还是证件和手续出了问题(清单、依赖、编译链接、签名,属于 Gradle / Xcode 侧)。
定位到哪一段,问题就已经解决了一半。