跳转到内容

Android 与 iOS

AndroidiOS 把你的项目打包成真正的原生应用:引擎的 C++ 核心编译到 arm64, 经内嵌的 Dawn 渲染(WebGPU → iOS 上是 Metal、 Android 上是 Vulkan),游戏脚本跑在内嵌的 JS 引擎 (QuickJS-ng)上。它不是 WebView, 也不是网页构建的壳——这个进程里没有浏览器。

创作模型和其他任何目标完全一致。同样的场景、同样的组件、同样的 TypeScript。 项目里没有任何东西是“移动端专用”的。

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 收尾。

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 构建本来就得如此。
  1. 拿到该目标的运行时。 安装运行时模板:随每个版本发布的、已为 arm64 预编译好的引擎。目标那一行提供 下载——如果你已经有归档文件(离线、镜像、公司共享盘),也可以从文件安装…。 它与编辑器版本精确匹配,因为 SDK 是编译进应用二进制的,所以每次升级编辑器 下载一次即可——引擎自己那一堆构建依赖一样都不用装。

    Android 此外别无所需:APK 的编译、对齐与签名都由编辑器在它所运行的任何操作系统上完成。

  2. 装好平台工具链——只有 iOS 需要。

    • Android —— 不需要。这条路径上没有 Android SDK、NDK 或 JDK。
    • iOS —— 装有 Xcode 的 macOS,仅此而已:不需要 CMake、Ninja、xcodegen, 也不需要 Dawn 源码。Apple 不为其他操作系统提供工具链,所以对话框直说,而不是 假装可以——但导出照样会写出一份完整的工程,你可以拷到 Mac 上打开。
  3. 看对话框。 探测通过之前,该目标那一行会一直带着警告三角,面板里会点名缺什么。

  1. 文件 → Build…,在移动分组下选 AndroidiOS
  2. 选构建配置和输出目录(默认 dist-android / dist-ios)。这里不提供 source map—— 没有浏览器 devtools 来消费它。
  3. 和其他目标一样检查构建场景列表。
  4. 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, 后者才是运行时读的那份。

全部。 移动目标编译整份引擎源码清单;在网页端以独立 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 与系统内存警告都能到达应用

万一将来某个构建真的砍掉了某个子系统,导出会点名它——连同用到它的场景—— 而不是悄悄发出一个缺了半个场景的包。

这些是你第一次做真机构建前值得知道的差异。每一条都至少发过一次坏包, 因为编辑器直接从磁盘伺服项目,根本不会察觉。

构建发布的是它能从入口场景可达的东西。对场景点名的一切来说这是对的—— 但对只在代码里点名的东西它是瞎的:富文本标记里的纹理、按 url 播放的音频、 按路径 spawn 的预制体。这些会被剔除,而你第一次听说这件事, 是手机上一个没声的按钮或一张缺失的图。

内容浏览器里标记这个文件夹:右键 → 交付方式 → 总是打进构建。 它会把 alwaysInclude 写进那份已经在决定本地 / 分包 / 远端交付的 .esengine/asset-groups.json:

{
"version": "1.0",
"groups": {
"markup-images": {
"folder": "assets/textures",
"mode": "local",
"alwaysInclude": true
}
}
}

默认关闭,这是刻意的——正是可达性分析拦住了“什么都往包里塞”。 见资源

为内存最紧张的平台单独调纹理

Section titled “为内存最紧张的平台单独调纹理”

逐资产的导入设置每个平台一个页签,移动目标也在其中——所以一张纹理可以为手机压得比 网页端更狠,而不用动源 PNG。见资源

引擎以原生全速运行;只有你的游戏脚本跑在 QuickJS 上,它没有 JIT (Apple 的规则不留别的选项,Android 这边保持一致而不是分叉)。 用 TypeScript 写的逐实体循环,是这里比浏览器里明显更贵的那一样东西—— 热路径上优先用引擎自己的系统和批量 API,而不是手写逐实体迭代。

在设备上解析 SDK bundle 要约 14 秒,而编译缓存要等一次启动付过账才存在—— 那次启动恰好就是刚装完的那一次。所以字节码是随应用一起构建的,跟着资产一起走: 全新安装到第一帧从约 10 秒变成约 0.1 秒。

构建机上没有 C 编译器就产不出它,应用会退回到首次运行时现编译:能用, 但那一次启动会有十秒停在一块被玩家读成「卡死」的画面上。启动记录会说明是哪种情况—— SDK bundle: loaded from bytecode shipped with the app,或者 bundle: NO bytecode ——所以首次启动慢是一个有答案的问题,而不是一桩悬案。

每次启动都会在游戏旁边写一份记录,所以只在别人手机上出现的故障也能留下东西:

Android Android/data/<你的包名>/files/estella-boot.log
iOS 应用的 Documents 目录

上一次运行保留为 estella-boot.prev.log——否则应用崩掉后再打开一次, 死亡的记录就被重试的记录覆盖了。

estella boot record — 2026-07-29 19:07:38
device: 24129PN74C, Android 16, arm64-v8a
phase: gpu device
gpu: qualcomm Adreno (TM) 830 (adreno-8xx) — Adreno Vulkan Driver 512.800.71
phase: surface
phase: engine context
phase: js runtime
SDK bundle: loaded from bytecode shipped with the app
phase: game script
ready in 105 ms

有用的是那些 phase:文件里最后一个 phase,就是这次没能跑完的启动停在哪儿。 跑完了的会写 ready in。原生崩溃会追加信号、崩在哪个阶段、以及返回地址:

FATAL SIGSEGV (bad memory access) during phase: js runtime
backtrace (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 设备上取回:

Terminal window
adb shell cat /sdcard/Android/data/<你的包名>/files/estella-boot.log
adb 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 里:

Terminal window
adb logcat --pid=$(adb shell pidof <你的包名>)