Skip to content

fix(native): 存档与缓存分离,Android 下限降到 7.0 - #55

Merged
esengine merged 5 commits into
masterfrom
fix/native-durable-data-dir
Aug 3, 2026
Merged

fix(native): 存档与缓存分离,Android 下限降到 7.0#55
esengine merged 5 commits into
masterfrom
fix/native-durable-data-dir

Conversation

@esengine

@esengine esengine commented Aug 3, 2026

Copy link
Copy Markdown
Owner

两件事,都在 Android 原生宿主上,都需要这条流水线来编译验证。

1. 存档和缓存不是同一个目录

Platform 只有两个可写目录:一个应用私有、一个玩家可达。存储进了前者——而那个目录自己的文档写的是 SDK 字节码缓存,可再生、平台随时可回收。

iOS 真的会回收它。那里的 cacheDir()NSCachesDirectory,系统想要回空间时随时清空,且不进备份——玩家的存档和设置可能在两次启动之间消失,换新手机恢复时再消失一次。Android 上同一个调用落在 internalDataPath(files/),什么都不会清。一个 API,两个相反的承诺,而失信的那个是悄悄失信的。

所以补上缺的那个目录语义:

之前 之后
iOS cacheDir() NSCachesDirectory(存档在这) 不变——只放字节码和热更内容
iOS dataDir() 不存在 NSApplicationSupportDirectory,按需创建
Android cacheDir() internalDataPath(永不回收) 真正的 getCacheDir()
Android dataDir() 不存在 internalDataPath

Android 的 cache 目录走 JNI 问 Context.getCacheDir(),不是从 internalDataPath 拼字符串——那个布局已经变过一次(/data/data/data/user/0),猜错的失败形式是「一个永远没被写过的目录」,不报错。副作用:Android 的热更缓存终于落在系统能清理的地方,之前在 files/ 里只会一直涨。

SDK 侧加 es_readDataFile/es_writeDataFile,存档文件走它们。只绑了 cache 那对的旧宿主仍然能持久化(总好过每次退出就丢),但会警告一次。两处写入都改成先写临时文件再 rename。

2. 下限降到 Android 7.0 (API 24)

minSdk 是 29 只有一个原因:字体路径调用 AFontMatcher_create,那是 API 29,再往下没有可问的。host 里其余每个 NDK 符号都是 24 或更早,或者已经在守卫下解析——AChoreographer 正好在 24 到齐,postFrameCallback64 已守在 29,ADPF 走 dlsym,miniaudio 自己 dlopen AAudio/OpenSL。

所以给字体路径补上它缺的那条回退。24–28 上读 /system/fonts,逐个问候选字体有没有这个字的字形——那是决定文字能不能显示的唯一问题。family 名字只用来排序,因为名字要靠系统匹配器背后的别名来解析,而那些别名在这一层看不见。

代价说清楚:拿不到平台自己的回退顺序,两种字体都覆盖的字可能落到另一种上,两种都画得出来。

目录匹配不是 Android 知识,所以放在 host/media/ 里、栅格化器旁边(它本来就持有 stb_truetype),而不是塞进某一个平台的胶水——覆盖探测用的就是 stbtt_FindGlyphIndex,后面决定字形能否栅格化的同一个调用。

Vulkan 那道门要和这个数字一起读

它没变,仍然是 required="true",而且挡掉的设备比 API 级别挡掉的多得多:Vulkan 在硬件上一直是可选的,直到远晚于 Android 7 才普及,而 host 没有 GLES 路径。API 24 真正带来的是 Android 7、8 时代有 Vulkan 驱动的机器,比版本号本身暗示的范围小,而把这一点说出来,是「下限」和「承诺」的区别。manifest 注释、mobile 指南、CHANGELOG 三处都写了。

CI

矩阵跟着 manifest 走——两处必须是同一个数字,否则其中一个是摆设。从 6 个版本变成 11 个。

API 24–27 是我们发布的所有档位里最难验证的一档:那么老的托管模拟器可能完全没有 Vulkan,那几行可能返回「无数据」而不是通过或失败。仍然保留——一个用来回答「哪些版本能跑」的矩阵,应该有能力说「测不出来」。

验证情况

跑过的:

  • SDK tsc 干净;341 文件 / 3952 用例全绿(新增 3 条存储测试)
  • 新测试做过敏感度验证:把实现改回写 cache,那 2 条确实红
  • api-surface --check--check-dts 干净;pnpm run verify 全绿
  • iOS 的目录逻辑逐字抽出来在 macOS 上编译并真跑(Foundation API 相同):Application Support 被创建、与 Caches 不同、可写
  • font_scan.cpp 本体在本机编译并真跑:给它 macOS 字体目录,latin / latin-bold / CJK U+4E2D / emoji U+1F600 / 具名 family 五个用例全部命中,且每个命中都用栅格化器同一个调用复验确实能画出该码点;-Wall -Wextra 零警告

没跑过的:原生 C++ 本机编不了(无 NDK,Xcode 只有 CLT)。android.cpp__builtin_available + availability(introduced=29) 的拆分、以及整体在 android-24 下的编译,要靠这条流水线。这也是开 PR 而不是直推 master 的原因。

Platform had two writable directories — one private to the app, one a player
can reach — and storage went into the private one because that was the only
place for it. But that directory's contract, in its own doc comment, is the
SDK bytecode cache: regenerable, and the platform's to reclaim.

iOS reclaims it. cacheDir() there is NSCachesDirectory, which the system
empties whenever it wants the space back and which no backup includes, so a
player's saves and settings could vanish between launches and again when they
restored a new phone. On Android the same call lands in internalDataPath —
files/, which nothing reclaims. One API, two opposite promises, and the one
that broke its promise did it silently.

So the missing directory is now there. cacheDir() keeps the reclaimable half
(bytecode, hot-update content) and dataDir() holds what a player would notice
gone. iOS points it at Application Support, created on demand since unlike the
other two it does not exist until an app makes it. Android points it at
internalDataPath and moves cacheDir() to the real getCacheDir() — asked over
JNI rather than assembled from internalDataPath, because that layout has
already moved once (/data/data to /data/user/0) and a guess about it fails as
a directory that is simply never written. As a side effect the hot-update
cache on Android is finally in a directory the system can clean; it had been
growing in files/ forever.

es_readDataFile/es_writeDataFile are the JS-side half, and the storage
fallback writes the save file through them. A host that binds only the cache
pair still persists — losing the save at every exit is worse — but it says so
now, once, instead of leaving it to be discovered. Both file writes also go
through a temp file and a rename, so a kill mid-write can no longer leave a
truncated file where a save used to be.

The save guide never said where any of this lands; the sentence stopped at
"browser localStorage, WeChat storage, …". It now names all five, says why
iOS is Application Support and not Caches, and says that there is no directory
to open and why: two of the five platforms have no path namespace to hand out,
so a getSaveDirectory() would grow a per-platform branch in every game that
called it.

Node's storage is memory only, which the guide now states rather than implies.
minSdk was 29 for one reason: the font path called AFontMatcher_create, which
is API 29, and below it there was nothing to ask. Every other NDK symbol in the
host is API 24 or older, or already resolved under a guard — AChoreographer
arrives at exactly 24, postFrameCallback64 is guarded at 29, ADPF goes through
dlsym, and miniaudio dlopens AAudio or OpenSL by itself.

So the font path grew the fallback it was missing. On 24 through 28 the engine
reads /system/fonts and asks each candidate whether it has a glyph for the
character, which is the only question that decides whether text appears. A
family name orders the candidates and nothing more, because a name is what the
system matcher resolves through aliases we cannot see from here. What is lost
is the platform's own fallback ordering: a character two fonts both cover may
resolve to the other one, and it draws either way.

Directory matching is not Android knowledge, so it sits in host/media beside
the rasterizer that already owns stb_truetype rather than inside one platform's
glue — the coverage probe IS stbtt_FindGlyphIndex, the same call that later
decides whether the glyph rasterizes.

Read the Vulkan requirement alongside the number. It is unchanged, still
required="true", and it filters far more devices than the API level does:
Vulkan stayed optional for hardware long after Android 7, and the host has no
GLES path. API 24 adds the Android 7 and 8 era phones that do have a Vulkan
driver, which is a smaller set than the version alone suggests, and saying so
is the difference between a floor and a promise.

The compatibility matrix follows the manifest — the same number in both places,
or one of them is decoration. That takes it from six versions to eleven. API 24
through 27 is the least verifiable tier we ship: a hosted emulator that old may
have no Vulkan at all, so those rows can come back "no data" rather than pass
or fail. Left in anyway, because a matrix whose job is to say which versions
work should be able to say it cannot tell.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Android 兼容性

同一个包(space-shooter),在 12 个 Android 版本上各跑一台模拟器,每个版本装的是同一个构建产物。

运行时模板从这个分支构建 —— 测的是这里改的代码,不是已发布的版本。

Android API ready 启动到首帧 PSS Graphics CPU 帧间隔 中位/p95 结果
? 24 ✗ 没测到 — 这个 job 没跑完(模拟器没启动起来,或 job 超时)
? 25 ✗ 没测到 — 这个 job 没跑完(模拟器没启动起来,或 job 超时)
8.0.0 26 262 ms 16 MB 0 MB 0% ✗ never reported ready
9 28 759 ms 17 MB 0 MB 0% ✗ never reported ready
10 29 345 ms 1354 ms 78 MB 0 MB 91% 134.35 / 173.54 ms
11 30 554 ms 717 ms 67 MB 0 MB 87% 117.46 / 149.87 ms
12 31 534 ms 667 ms 67 MB 0 MB 53% 146.93 / 254.26 ms
12 32 579 ms 644 ms 67 MB 0 MB 62.5% 142.58 / 181.21 ms
13 33 795 ms 539 ms 67 MB 0 MB 32% 128.51 / 174.89 ms
14 34 398 ms 932 ms 72 MB 0 MB 26.5% 99.53 / 140.54 ms
15 35 743 ms 1164 ms 68 MB 0 MB 24%
16 36 928 ms 1418 ms 70 MB 0 MB 34.5%

2/12 个 Android 版本装上了但没跑起来。

  • Android 8.0.0 (API 26) — never reported ready
    • ERROR [gpu device] no adapter
  • Android 9 (API 28) — never reported ready
    • ERROR [gpu device] no adapter

2 个版本没有数据 —— 这是关于这条流水线的结论,不是关于 Android 的。
这些版本既没被证明可用,也没被证明有问题。

  • API 24 — 没测到 — 这个 job 没跑完(模拟器没启动起来,或 job 超时)
  • API 25 — 没测到 — 这个 job 没跑完(模拟器没启动起来,或 job 超时)

截图 — 需要人工看

这里没有任何像素判据。上面的表只回答了「装上了、起来了、没报错」;
画面对不对只有人能判断。

Android ? (API 24) Android ? (API 25)
(没有截图) (没有截图)
Android 8.0.0 (API 26) Android 9 (API 28)
Android 10 (API 29) Android 11 (API 30)
Android 12 (API 31) Android 12 (API 32)
Android 13 (API 33) Android 14 (API 34)
Android 15 (API 35) Android 16 (API 36)

这些数字的边界

渲染器:Google (Google Inc.), Android Emulator OpenGL ES Translator (Google SwiftShader), OpenGL ES 3.0 (OpenGL ES 3.0 SwiftShader 4.0.0.1)

是软件渲染。 runner 没有 GPU,Dawn 跑在模拟器自带的 SwiftShader 上,
所以上面的帧间隔是 CPU 光栅化的耗时,GPU 占用和功耗在这里根本不存在。
跨版本互相比较仍然有意义,把任何一个数当成真机性能则没有。

Graphics 是 0 也是同一个原因:那一列统计的是 gralloc/GPU 归属的内存,
软件渲染下不存在这种分配,Dawn 的缓冲全落在 PSS 的其他分类里。
这一列只有在真机上才有意义。

没测到的项,以及原因
  • API 26 — SurfaceFlinger had too few presented frames to time
  • API 28 — SurfaceFlinger had too few presented frames to time
  • API 35 — SurfaceFlinger had too few presented frames to time
  • API 36 — SurfaceFlinger had too few presented frames to time

…ms of

GLYPH_BOLD and GLYPH_ITALIC live in glyph_raster.hpp, not Host.hpp, so the
header that takes them as its `style` argument has to say so — a caller had to
know to include a second header, and the .cpp did not compile at all.

The local probe missed it by being more generous than the real headers: its
stand-in Host.hpp declared the flags itself, so the include graph it validated
was not the one that ships. It now mirrors the real split.
…loor

Lowering minSdk to 24 compiled nothing: every AFont*/AFontMatcher_* call and
even AChoreographer_postFrameCallback64 — which was already inside
`__builtin_available(android 29, *)` — failed with "unavailable: introduced in
Android 29". The guard was never the problem. Without ANDROID_WEAK_API_DEFS the
NDK marks a symbol newer than the build target `unavailable` outright, which
means "this build cannot see it", and a guard has nothing to test.

That flag is now on. The comment beside the ADPF block argued against it, on
the grounds that a toolchain flag's absence would silently restore the failure
it prevents — which was true while the floor equalled the newest API this file
called, because dropping it changed nothing anyone would notice. Below that
floor it is a compile error that names the symbol, which is exactly how it came
to be turned on. The comment now says that instead of the opposite.

Dawn's build directory carries the API level too. It did not, so the cached
Dawn from an android-29 build was about to be linked into a host targeted at
24 — the same mixing of levels this floor exists to prevent, arriving through
the cache instead of through a flag. The level is part of the path for the same
reason the ABI is.

And the floor is read from the manifest now rather than written down beside it.
Lowering it meant editing the same number in four defaults across four files,
with the rule that it MUST equal minSdkVersion living only in a comment. It is
the manifest's number; the build asks for it.
…on broken

API 24 and 25 never reached sys.boot_completed and the run reported "no data
after two attempts" — after one. The poll ran from 01:11 to 01:30 and the job's
own 20-minute cap cancelled it mid-attempt, so the retry this file is written
around never executed, and neither did the step that decides what happened.

Three things kept those two versions from booting, and the fourth kept anyone
from seeing which:

  · `emulator-boot-timeout` was inherited. The default (600) bounded nothing
    here — nineteen minutes of polling says so — so it is set explicitly, and
    short enough that two attempts and a system-image download fit the cap.
  · The cap was 20 minutes, which two bounded attempts never could fit. 40.
  · Every level got `--device pixel_6`, a 2021 profile. Below API 26 that is a
    2016 system image being told it is hardware that did not exist yet; those
    levels get `pixel`, which shipped with Android 7.1.
  · And the emulator booted from a snapshot (`Loading snapshot 'default_boot'`),
    which an old image that cannot load it answers by hanging rather than by
    failing. `-no-snapshot-load`.

The failure message now separates "two attempts, no data" from "the second
attempt never ran" — one says the Android version is broken, the other says
this job ran out of time before it could find out, and they were being printed
as the same sentence.

The discovery job is renamed to what it does: it reads sdkmanager's image list.
Whether a version can BOOT is what the matrix below finds out, and calling the
list a boot check is how "the runner supports 24" got believed in the first
place.

Not touched: the declared floor. Whether Android 7.0 stays the promise is a
decision about what we support, and this only restores CI's ability to answer.
@esengine
esengine merged commit 46662eb into master Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant