Android 与 iOS
Android 和 iOS 把你的项目打包成真正的原生应用:引擎的 C++ 核心编译到 arm64, 经内嵌的 Dawn 渲染(WebGPU → iOS 上是 Metal、 Android 上是 Vulkan),游戏脚本跑在内嵌的 JS 引擎 (QuickJS-ng)上。它不是 WebView, 也不是网页构建的壳——这个进程里没有浏览器。
创作模型和其他任何目标完全一致。同样的场景、同样的组件、同样的 TypeScript。 项目里没有任何东西是“移动端专用”的。
两个平台,一套运行时
Section titled “两个平台,一套运行时”Android 和 iOS 是两个目标而不是一个“Mobile”,因为它们被组装成的东西完全不同——
这边是已签名的 .apk,那边是 Xcode 工程——而且只有其中一个能在任意系统上收尾。
一行说不清这台机器能不能把活干完,也说不清出来的是什么。
它们确实共享同一套运行时和同一份导出载荷。引擎、SDK 和游戏运行时都住在应用二进制里,
所以导出写出的是应用内容——你的场景、脚本和烘焙后的资产——而不是像 dist-web/
那样可直接运行的包。
| Android | iOS | |
|---|---|---|
| 图形 | Vulkan(经 Dawn) | Metal(经 Dawn) |
| 导出产物 | dist-android/ |
dist-ios/ |
| 组装方 | 编辑器(不需要 Android SDK) | 编辑器,然后 Xcode |
| 组装所需系统 | 任意桌面系统 | 仅 macOS |
| 结果 | 一个已签名的 .apk,或一个 Android Studio 工程 |
一个可打开、签名并运行的 Xcode 工程 |
Android 两者都给,是因为它两者都做得到:编辑器既能直接装配出安装包,也能写出这个安装包 本该由之构建的那个 Gradle 工程。游戏需要接自己的 SDK、加权限或加 Activity 时,在 Android 分区的输出里选「Android Studio 工程」——这正是 iOS 一直以来的形状,那边由 Xcode 收尾。
打出来的游戏跑在什么设备上
Section titled “打出来的游戏跑在什么设备上”| Android | iOS | |
|---|---|---|
| 最低系统 | Android 10(API 29) | iOS 17.0 |
| 声明的 target | API 33 | — |
| CPU | arm64-v8a(所有真机)+ x86_64(模拟器) |
arm64 真机 + 模拟器切片 |
| GPU | 要求 Vulkan 1.0.3——声明为 required="true",不具备的设备在 Play 上根本看不到这个应用 |
Metal |
Android 的下限是 29,因为引擎的字体路径调用 AFontMatcher_create,那是 API 29——
再往下没有可退的路。直到 v0.37.0 它声明的都还是 26,而那是个说法而非能力:
那些构建能装到 Android 10 和 11 上,然后起不来——因为 NDK 被要求按更高的 API 构建,
于是把每个受保护的符号都变成了加载期的硬依赖。v0.37.0 上能跑的东西在 v0.38.0 上
一样能跑,这个数字只是终于说了实话。
打包对话框区分两类缺失的前置条件,二者严重程度并不相同:
- 缺引擎运行时——对移动目标来说就是缺它的运行时模板——意味着根本产不出包。
- 缺原生工具链则不然。导出照样把应用内容写出来;只有最后的组装步骤需要工具链, 而那一步可以在另一台机器上跑——编辑器在 Windows 上时,iOS 构建本来就得如此。
-
拿到该目标的运行时。 安装运行时模板:随每个版本发布的、已为 arm64 预编译好的引擎。目标那一行提供 下载——如果你已经有归档文件(离线、镜像、公司共享盘),也可以从文件安装…。 它与编辑器版本精确匹配,因为 SDK 是编译进应用二进制的,所以每次升级编辑器 下载一次即可——引擎自己那一堆构建依赖一样都不用装。
Android 此外别无所需:APK 的编译、对齐与签名都由编辑器在它所运行的任何操作系统上完成。
-
装好平台工具链——只有 iOS 需要。
- Android —— 不需要。这条路径上没有 Android SDK、NDK 或 JDK。
- iOS —— 装有 Xcode 的 macOS,仅此而已:不需要 CMake、Ninja、xcodegen, 也不需要 Dawn 源码。Apple 不为其他操作系统提供工具链,所以对话框直说,而不是 假装可以——但导出照样会写出一份完整的工程,你可以拷到 Mac 上打开。
-
看对话框。 探测通过之前,该目标那一行会一直带着警告三角,面板里会点名缺什么。
- 文件 → Build…,在移动分组下选 Android 或 iOS。
- 选构建配置和输出目录(默认
dist-android/dist-ios)。这里不提供 source map—— 没有浏览器 devtools 来消费它。 - 和其他目标一样检查构建场景列表。
- Package。导出会烘焙资产、打包脚本,写出应用内容,外加一份携带应用身份的
app.config.json。
导出到这一步就完事了。 装好运行时模板后,输出目录里放的就是成品应用:
-
Android —— 一个已签名的
.apk,可直接adb install -r或拷到设备上。它由编辑器 首次使用时生成的开发密钥签名(与 Android Studio 的 debug key 同等地位:装设备够用, 任何应用商店都不收)。要用你自己的密钥,给node build-tools/cli.js native --package --content dist-android传--key/--cert。要发 Google Play,在 Android 分区勾上 Google Play App Bundle(.aab):导出会在 APK 旁边再写一份。Play 自 2021 年起对新应用强制要求 bundle 而非 APK——它是上传格式, 无法直接装到设备上,所以你测试用的仍然是那个 APK。
-
Android,且输出选了「Android Studio 工程」 —— 给的不是安装包,而是一个普通的 Gradle 工程:内容在
app/src/main/assets,引擎在app/src/main/jniLibs,宿主的 Java shim 以源码形式在app/src/main/java,应用身份写在app/build.gradle.kts里。用 Android Studio 打开该文件夹直接 Run;要接 SDK 就像在任何 Android 应用里那样加进build.gradle.kts。再次导出只会重写游戏内容,不动这个构建脚本——已经长出 SDK 的工程 不会因为重新打包游戏而被抹掉。 -
iOS —— 一个 Xcode 工程:
.xcframework、应用外壳和生成好的.pbxproj都已围绕你的 内容写出。打开它,在 Signing & Capabilities 下选好签名 Team,Run。
原生应用有一些引擎永远不读的系统级属性——运行时转不动手机,而应用商店会永久沿用 bundle id ——所以它们是写给“组装应用的人”看的,位置在 项目设置 → 打包:
| 设置 | 它会变成什么 |
|---|---|
| 应用图标 | 两个平台的启动图标——项目内的一张方形 PNG(建议 1024×1024)。Android 用作 launcher mipmap,iOS 用作 Xcode 据以派生各尺寸的 asset catalog,所以不做任何缩放,只需保留这一张。留空则使用 Estella 的标识,而不是平台的占位图。 |
| 应用 ID | Android 的 manifest package 和 iOS 的 bundle id(反向域名)。留空则按项目名推导;正式发布的应用应当自己指定。 |
| Android 版本号 | Google Play 用来排序构建的整数,每次上传都必须递增。用户看到的版本号来自项目版本。 |
| 朝向 | 项目设置 → 显示 → 朝向,一个项目级设置,所有目标都遵守。不设则跟随设计分辨率的宽高比。 |
导出把这些写进内容旁边的 app.config.json——刻意不放进 game.config.json,
后者才是运行时读的那份。
真机上跑得起来的东西
Section titled “真机上跑得起来的东西”全部。 移动目标编译整份引擎源码清单;在网页端以独立 WebAssembly side module 发布的
三个子系统(Box2D、Spine 运行时、MPEG-1 视频解码器)在这里是编进应用二进制的——
设备没有动态链接这套故事,也没有理由要有。app.sideModules 仍然如实作答,
所以运行时自己的特性门控和浏览器里表现一致。
| 子系统 | 真机上 |
|---|---|
| 精灵、瓦片地图、粒子、后处理 | 原生(Dawn) |
| 文本、位图字体、富文本 | 与网页端相同的图集与排版;字形从系统字体栅格化 |
| 物理(Box2D) | 编译进包;跨工作线程求解 |
| Spine | 编译进包——每个二进制一个运行时版本(-DESTELLA_SPINE_VERSION) |
| DragonBones | 编译进包——只有一个运行时,因为格式是固定的 |
| 视频 | 编译进包(构建时 cook,真机解码) |
| 音频 | 原生混音器(miniaudio)—— CoreAudio / AAudio |
| 文本输入 | 平台软键盘就是输入框的编辑面 |
| 网络 | 系统网络栈(NSURLSession / HttpURLConnection) |
| 压缩纹理 | KTX2 转码到设备支持的格式(ASTC → ETC2 → BC),含 mip 链 |
| 生命周期 | onShow / onHide 与系统内存警告都能到达应用 |
万一将来某个构建真的砍掉了某个子系统,导出会点名它——连同用到它的场景—— 而不是悄悄发出一个缺了半个场景的包。
只在真机上才咬人的那些事
Section titled “只在真机上才咬人的那些事”这些是你第一次做真机构建前值得知道的差异。每一条都至少发过一次坏包, 因为编辑器直接从磁盘伺服项目,根本不会察觉。
只有代码点名的资产会被剔除
Section titled “只有代码点名的资产会被剔除”构建发布的是它能从入口场景可达的东西。对场景点名的一切来说这是对的—— 但对只在代码里点名的东西它是瞎的:富文本标记里的纹理、按 url 播放的音频、 按路径 spawn 的预制体。这些会被剔除,而你第一次听说这件事, 是手机上一个没声的按钮或一张缺失的图。
在内容浏览器里标记这个文件夹:右键 → 交付方式 → 总是打进构建。
它会把 alwaysInclude 写进那份已经在决定本地 / 分包 / 远端交付的
.esengine/asset-groups.json:
{ "version": "1.0", "groups": { "markup-images": { "folder": "assets/textures", "mode": "local", "alwaysInclude": true } }}默认关闭,这是刻意的——正是可达性分析拦住了“什么都往包里塞”。 见资源。
为内存最紧张的平台单独调纹理
Section titled “为内存最紧张的平台单独调纹理”逐资产的导入设置每个平台一个页签,移动目标也在其中——所以一张纹理可以为手机压得比 网页端更狠,而不用动源 PNG。见资源。
你的游戏脚本是解释执行的
Section titled “你的游戏脚本是解释执行的”引擎以原生全速运行;只有你的游戏脚本跑在 QuickJS 上,它没有 JIT (Apple 的规则不留别的选项,Android 这边保持一致而不是分叉)。 用 TypeScript 写的逐实体循环,是这里比浏览器里明显更贵的那一样东西—— 热路径上优先用引擎自己的系统和批量 API,而不是手写逐实体迭代。
安装后的第一次启动
Section titled “安装后的第一次启动”在设备上解析 SDK bundle 要约 14 秒,而编译缓存要等一次启动付过账才存在—— 那次启动恰好就是刚装完的那一次。所以字节码是随应用一起构建的,跟着资产一起走: 全新安装到第一帧从约 10 秒变成约 0.1 秒。
构建机上没有 C 编译器就产不出它,应用会退回到首次运行时现编译:能用,
但那一次启动会有十秒停在一块被玩家读成「卡死」的画面上。启动记录会说明是哪种情况——
SDK bundle: loaded from bytecode shipped with the app,或者 bundle: NO bytecode
——所以首次启动慢是一个有答案的问题,而不是一桩悬案。
真机上出问题时
Section titled “真机上出问题时”每次启动都会在游戏旁边写一份记录,所以只在别人手机上出现的故障也能留下东西:
Android Android/data/<你的包名>/files/estella-boot.logiOS 应用的 Documents 目录上一次运行保留为 estella-boot.prev.log——否则应用崩掉后再打开一次,
死亡的记录就被重试的记录覆盖了。
estella boot record — 2026-07-29 19:07:38device: 24129PN74C, Android 16, arm64-v8aphase: gpu devicegpu: qualcomm Adreno (TM) 830 (adreno-8xx) — Adreno Vulkan Driver 512.800.71phase: surfacephase: engine contextphase: js runtime SDK bundle: loaded from bytecode shipped with the appphase: game scriptready in 105 ms有用的是那些 phase:文件里最后一个 phase,就是这次没能跑完的启动停在哪儿。
跑完了的会写 ready in。原生崩溃会追加信号、崩在哪个阶段、以及返回地址:
FATAL SIGSEGV (bad memory access) during phase: js runtimebacktrace (symbolize against this version's unstripped libestella_js_host.so): 0x7b41e2632c /data/app/.../libestella_js_host.so+0x1e32c这些地址要拿对应引擎版本、没有 strip 过的 libestella_js_host.so 来符号化
(llvm-addr2line -e libestella_js_host.so 0x1e32c);APK 里那份是 strip 过的。
从 Android 设备上取回:
adb shell cat /sdcard/Android/data/<你的包名>/files/estella-boot.logadb pull /sdcard/Android/data/<你的包名>/files/estella-boot.log游戏崩溃后,下一次启动会把记录发布到玩家能拿到的地方——
Android/media/<你的包名>/(文件管理器能列出),取不到时退回 Download/。
文件名是 estella-crash-<日期>.log,新那次运行自己的记录里也会写明它去了哪儿:
previous run CRASHED; its record was copied to /storage/emulated/0/Android/media/com.example.game/estella-crash-20260729-190738.log所以给报障的人的说法是:再打开一次游戏,然后把那个目录里最新的 estella-crash-*.log 发过来。
只有崩溃才会被发布——正常跑完的启动不会在那儿留下任何东西。
设备连着的时候,同样的内容也在 logcat 里:
adb logcat --pid=$(adb shell pidof <你的包名>)- 构建与导出 —— 所有目标、cook 选项与打包对话框。
- 资源 —— 可达性剔除、交付分组与导入设置。
- 屏幕与设计分辨率 —— 为手机的宽高比做设计。
native/README.md—— 宿主架构与完整构建配方。