NativeScript / NativeScript/android
Crash on startup when enabling R8 optimization
Dieses Issue hat noch niemand übernommen.
- Vorherrschende Sprache
- C++
- Sterne
- 563
- Forks
- 144
- Ø Merge
- 10 Std. 46 Min.
- Gemergte PRs (30 T.)
- 14
Beschreibung
Environment
Provide version numbers for the following components (information can be retrieved by running tns info in your project folder or by inspecting the package.json of the project):
- CLI: 9.1.0
- Cross-platform modules:
@nativescript/core9.1.0 - Android Runtime:
@nativescript/android9.1.0 (commit4e7f2076d0065d1bf157efe18f54dd7d7d73b0f4) - iOS Runtime (if applicable): n/a
- Plugin(s): none — reproduces on a bare
ns create --vueapp
Also: AGP 8.12.1, Gradle 8.14.3, JDK 21, compileSdk/targetSdk 35, minSdk 24,
build tools 35.0.0, NDK r29. Device: Android emulator, API 35, arm64, 16 KB
pages (sdk_gphone16k_arm64).
Describe the bug
Google will soon require apps to be better optimized (see https://android-developers.googleblog.com/2026/08/app-quality-memory-optimization-secure-onboarding.html). To enable optimization with R8 one can add this to app.gradle:
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
"${getAppResourcesPath()}/Android/proguard-rules.pro",
metadataKeepRules
}
}
However when doing this the app will crash on startup. Using the Opus 5 (1M) model, the following was found when investigating:
With minifyEnabled true, a NativeScript app crashes on cold
start with a fatal JNI abort that names nothing:
Abort message: 'JNI DETECTED ERROR IN APPLICATION: java_class == null
in call to GetStaticFieldID
from void com.tns.Runtime.runModule(int, java.lang.String)'
signal 6 (SIGABRT), code -1 (SI_QUEUE)
#05 pc 00000000006356bc /apex/com.android.art/lib64/libart.so (art::JNI<false>::GetStaticFieldID(...)+904)
#06 pc 00000000003225cc base.apk!libNativeScript.so
#07 pc 000000000031d8d0 base.apk!libNativeScript.so
#08 pc 0000000000335658 base.apk!libNativeScript.so
There are two separate problems here. The second is the reason the first is so
expensive.
1. The runtime passes an unchecked FindClass result to JNI
FieldAccessor::GetJavaField uses JEnv::FindClass's return value without
checking it:
// test-app/runtime/src/main/cpp/FieldAccessor.cpp
if (isStatic) {
fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType());
fieldData->fid = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig);
JEnv::FindClass returns nullptr — with a Java exception left pending
(JEnv.cpp, the m_env->Throw(...) branches) — whenever the class is not in the
dex. GetStaticFieldID(nullptr, ...) is then a fatal abort, and it happens
before the runtime constructs any exception, so neither the class nor the
member name is ever logged. Calling a further JNI function with an exception
already pending is itself illegal, so this is a JNI contract violation
independent of the missing class.
SetJavaField and MethodCache::ResolveMethodSignature guard the identical
pattern with NS_DCHECK, which compiles to nothing in release builds
(NativeScriptAssert.h) — so they are equally fatal in exactly the builds where
this occurs.
The consequence is that the crash destroys the information needed to fix it.
There is no -keep rule you can write for a class whose name you cannot learn.
2. Metadata is generated pre-R8 and nothing tells R8 what it covers
buildMetadata runs against the full compile classpath before R8. Every type in
that metadata is reachable from JavaScript and, typically, from nowhere else — no
bytecode references it — so R8 is free to shrink or rename it, and the runtime
then asks the VM for a name that is no longer there.
Two failure shapes follow, and the second is what makes the usual advice fail:
- Class removed. A bare
ns create --vueapp crashes with
ClassNotFoundException: androidx.activity.OnBackPressedCallback. - A type named in a signature renamed. After
-keep class androidx.activity.** { *; }
the dex contains
OnBackPressedDispatcher.addCallback(Landroidx/lifecycle/t;Landroidx/activity/OnBackPressedCallback;)V
while the metadata asks forLandroidx/lifecycle/LifecycleOwner;. The
declaring class was kept and the method was kept — a parameter type was
renamed, and JNI resolves by full signature.
So keeping a class protects nothing unless every type named in every signature
reachable from JavaScript is kept too. In practice that is the transitive
closure of the metadata, i.e. nearly the whole compile classpath, and
namespace-by-namespace -keep rules cannot converge on it.
To Reproduce
ns create repro --vue --appid com.example.repro
cd repro
npm pkg set dependencies.@nativescript/core=9.1.0
npm pkg set dependencies.@nativescript/android=9.1.0
npm install
App_Resources/Android/app.gradle, inside android { }:
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
}
}
Build a release APK, adb uninstall, install, launch. Crashes on first launch
with ClassNotFoundException: androidx.activity.OnBackPressedCallback.
To reach the unnamed abort — the one this report is really about — add
-keep class androidx.activity.** { *; }, then put a static field read on a
class R8 renames at the top of app.ts, so it runs inside runModule:
// androidx.lifecycle.Lifecycle$State is renamed: nothing in bytecode names it
const state = (androidx as any).lifecycle.Lifecycle.State.CREATED
That reproduces the abort quoted above with zero third-party dependencies. In our
testing the libNativeScript.so frame offsets (0x3225cc, 0x31d8d0,
0x335658) matched a production app's tombstone exactly, confirming the same
code path.
Expected behavior
- A class missing from the final dex must never be a silent native abort. The
runtime should checkFindClass, consume the pending exception, and raise a
NativeScriptExceptionnaming the class and the member — diagnosable from
JavaScript and actionable with a-keeprule. minifyEnabled trueshould either work out of the box, or fail with an error
that says what to keep. Today a defaultns createapp cannot be shipped with
R8 enabled and gives the developer nothing to act on.
Additional context
These were written by Claude (Opus 5) while diagnosing this against the runtime
source at 4e7f2076, built from source and swapped into the app. They work, and
they are offered as evidence of where the problem is — not as a finished
design. We would expect a maintainer to disagree with parts of the second and
third in particular.
(a) Never hand a null jclass to JNI. Adds
JEnv::FindClassOrThrow(className, usage), which clears the pending exception
and throws a NativeScriptException naming the class and member, and routes the
metadata-driven lookups through it (FieldAccessor ×4, MethodCache,
JsArgConverter, JsArgToArrayConverter). The abort becomes:
com.tns.NativeScriptException: Module evaluation promise rejected: .../bundle.mjs —
Cannot resolve class 'androidx.lifecycle.Lifecycle$State' while reading field 'CREATED'.
This one we would argue for regardless of how the rest is solved. It is small,
it has no downside, and it converts an undebuggable abort into an ordinary
error. The NS_DCHECK guards elsewhere should probably become real checks too,
since they protect nothing in release builds.
(b) Generate the keep rules from the metadata. The metadata already is the
set of types JavaScript can reach, so the generator can emit the rules for it:
-keep class X { *; } for declared types, -keep class X for types named only
in signatures. Wired in via proguardFiles plus
minify*WithR8.dependsOn(buildMetadata) — which is acyclic, since
buildMetadata reads compiled classes and dependency jars and never anything R8
produces. This is roughly what the commented-out line at app/build.gradle:1236
("ensure buildMetadata is done before R8 to allow custom proguard from
metadata") seems to have intended.
Two traps worth knowing, both silent:
buildMetadatadeclares only the three.datfiles as outputs, so Gradle
considers it up-to-date and never regenerates the rules; R8 then reads a stale
file. The task needs the generated rule file registered as an output.- Nested types are separate tree nodes, so a naive join emits
androidx.lifecycle.Lifecycle.State. ProGuard reads that as classStatein
package…Lifecycle, matches nothing, and R8 strips the members. A mistyped
keep rule is never an error — only an absent protection.
(c) Ship R8's mapping and translate at lookup time. (b) is correct but
pins every name, so R8 can no longer rename anything — Play's App optimisation
panel reads ~0 for shrinking and optimisation. Regenerating the metadata after
R8 is not the fix: those names are also the names the JS bundle writes, so
post-R8 names would break every call site.
What does work is translating on the way out. The build writes R8's class
mapping beside the metadata (assets/metadata/r8-mapping.dat), the runtime
loads it, and JEnv::FindClass plus the four member-lookup wrappers translate
class names and the Lsome/Type; inside signatures. The metadata and JavaScript
keep talking about androidx.lifecycle.LifecycleOwner while the VM is asked for
androidx.lifecycle.t, and the generated rules can then say
-keep,allowobfuscation.
Translation turned out to be needed in four places, and the last one is worth
raising on its own:
| path | where |
|---|---|
| JNI class lookup | JEnv::FindClass |
| JNI signatures | the four JEnv member-ID wrappers |
| runtime binding generation | DexFactory.generateDex → Class.forName |
| central class lookup | ClassStorageServiceImpl.retrieveClass |
Every Java-side resolution funnels through ClassStorageServiceImpl, so one
translation there covers the Java side — except that DexFactory's two
Class.forName calls bypass that service entirely. That looks like an
inconsistency in the runtime independent of this change.
This is the part we are least confident in, and we would rather it prompted a
design discussion than be taken as a patch. Measured on the repro app, 5/5 cold
starts clean:
| names pinned | with remap | |
|---|---|---|
| classes renamed | 1 251 (30%) | 3 985 (94%) |
r8.json non-obfuscated |
81.89% | 76.45% |
| AAB bytes | 43 107 514 | 43 234 501 |
94% of classes renamed, but the obfuscation figure moved only ~5 points and the
bundle grew 127 KB, because the mapping asset is 271 KB. Known gaps:
- It pins member names (
-keepclassmembernames) so only a class map has to be
shipped. Members are the bulk of the identifier mass, so this is what caps the
gain. Allowing member renaming needs the member mapping and signature-keyed
lookups — a materially bigger change, and the obvious next step for anyone who
wants this to score well. com.tns.**is excluded from renaming, because the runtime resolves those by
hardcoded name from C++ before the mapping has even been read.- Getting the mapping into the APK at all is awkward: assets are merged and
compressed (compress<Variant>Assets) before R8 runs, so writing into the
merged-assets directory from R8'sdoLastputs the file on disk after the only
task that would have packaged it — silently, with no warning. It works as a
generated asset source whose task depends on R8, which then also has to be
declared to the lint tasks. A first-class place for post-R8 build output would
be better than either.
On the evidence above we would not claim this pays for itself as an
optimisation yet. It demonstrates that renaming can be supported without
breaking JavaScript; (b) alone ships a smaller bundle for far less machinery.
The honest summary: (a) is a bug fix we would ship as-is. (b) makes R8 usable and
is close to what the repository already gestured at. (c) demonstrates that
renaming can be supported without breaking JavaScript, but the version here
buys only part of the available benefit and deserves a better design than we
gave it.
Smaller notes
- Metadata filtering (
whitelist.mdg/blacklist.mdg) is read from the Gradle
root, whichns prepareregenerates. A file placed there by hand disappears on
the next build and the metadata is silently unfiltered. It would help if the
filters were sourced fromApp_Resources— or at least if an absent filter
were logged. - An absent
whitelist.mdgdefaults to*:*, but a present-and-empty one
replaces that default and allows almost nothing. That asymmetry is a sharp
edge; an empty whitelist is far more likely to be a mistake than an intent.
Beitragsleitfaden
Erste Schritte
- Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
- Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
- Forke das Repository und arbeite in einem Branch.
- Öffne einen Pull Request, der die Issue-Nummer nennt.
Rechercherichtung
Den Absturz mit den bereitgestellten Schritten ns create repro --vue reproduzieren und zuerst test-app/runtime/src/main/cpp/FieldAccessor.cpp, JEnv.cpp und MethodCache untersuchen. Die Metadaten und die R8-Verdrahtung rund um app/build.gradle:1236 überprüfen; nach Abschluss sollte der fatale JNI-Abbruch durch einen diagnostischen Laufzeitfehler ersetzt sein und ein unterstütztes, verifiziertes Verhalten für R8-aktivierte Builds etabliert werden.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Bewertung
- Tech-Stack
- android, cpp
- Bereich
- build-system, mobile-dev
- Issue-Typ
- Bug
- Schwierigkeit
- 5/5
- Geschätzter Aufwand
- Über eine Woche
- Aktivitätsstatus
- Aktiv
- Klarheit
- Größtenteils klar
- Anfängerfreundlichkeit
- 38/100