Skip to content

Reuse the opened APK in DirectApkSoSource - #152

Open
shubh73 wants to merge 1 commit into
meta:mainfrom
shubh73:directapk-reuse-zipfile
Open

shubh73 wants to merge 1 commit into
meta:mainfrom
shubh73:directapk-reuse-zipfile

Conversation

@shubh73

@shubh73 shubh73 commented Oct 19, 2026 •

Copy link
Copy Markdown

DirectApkSoSource opens a new ZipFile for every library whose dependencies it resolves, and once more for each ABI path in prepare(). Each open parses the APK's whole central directory, so the cost grows with the number of entries in the APK. In the React Native template that's 10 parses per cold start: 7 libraries plus 3 ABI paths. Related to #137.

This keeps one ZipFile open per APK that has native libraries, for the lifetime of the source. APKs without libraries, like language or density splits, are closed at the end of prepare(). recover() closes the rest; it runs under the sources write lock, so nothing is reading them at that point.

The cost is one file descriptor per APK plus its parsed central directory, about 37 KB for a 482-entry APK and 470 KB for a 5,482-entry one. One behavior change: if the APK moves while the app is running, dependency lookups keep reading the old file until recovery. The dlopen that follows still fails on the old path and triggers the same recovery as before.

Test plan

Cold start of the React Native template release app (react-native 27e77216ef) on a OnePlus CPH2667 running Android 16, installed with its baseline profile. 4×4 Latin-square crossover, 640 launches per APK. The large APK is the same app with 5,000 small files added under assets/.

Median time to full display, from one run with all four builds side by side (R8 off, the template default):

APK main #152 #153 Both
482 entries 366 ms 356 ms 346 ms 330 ms
5,482 entries 422 ms 357 ms 428 ms 350 ms

Against main, this PR is −2.4% (−5.9% to +1.0%) on the small APK and −14.0% (−16.2% to −11.6%) on the large one. Together with #153 it's −6.4% (−9.6% to −3.0%) and −14.8% (−17.2% to −12.3%). These are estimated over all launches with 95% confidence intervals.

Repeating the run with R8 on gave the same picture: both PRs together measured −5.3% (−8.3% to −2.5%) and −13.4% (−16.3% to −10.7%) against main.

MinElf returns the same dependencies as before for every library in both APKs, matching llvm-readelf -d. I couldn't run the internal SoLoaderDirectApkTest suite.

🤖 Generated with Claude Code

DirectApkSoSource opened a new ZipFile for every library whose dependencies it
resolved, and once more per ABI path in prepare(). Opening a ZipFile parses the
APK's whole central directory, so this cost grows with the number of entries in
the APK, and apps paid it again for each native library they load at startup.

Keep one ZipFile per APK that contains native libraries, for the lifetime of the
source. APKs without native libraries, such as language or density splits, are
closed at the end of prepare(), and the rest are closed when the source is
replaced during recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shubh73

shubh73 commented Oct 19, 2026 •

Copy link
Copy Markdown
Author

A note on the bigger picture, since these two PRs only make the current path cheaper.

I also measured the SOLOADER_ENABLE_SYSTEMLOAD_WRAPPER_SOSOURCE flag that @michalgr suggested in #137, using the same app with React Native's merged-library mapping. It worked, and it's faster than these PRs, because it skips the APK scan and dependency resolution entirely. Median time to full display, from a separate run with R8 on and all three builds side by side:

APK main #152 + #153 System loader flag
482 entries 390 ms 374 ms (−5.3%) 356 ms (−8.0%)
5,482 entries 458 ms 385 ms (−13.4%) 366 ms (−19.0%)

As far as I can tell, React Native only needs SoLoader for the merged-library name mapping, but it always takes the top path:

flowchart LR
    RN["React Native<br/>SoLoader.init(context, mapping)"] --> F{"flags"}
    F -->|"0, hard-coded today"| D["DirectApkSoSource"]
    D --> S["Parse the APK's central directory<br/>#152: once instead of per library"]
    S --> R["Read each library's ELF header<br/>for its dependencies<br/>#153: buffered reads"]
    R --> L["dlopen"]
    F -.->|"system loader flag"| W["System.loadLibrary<br/>with the mapped name"]
    W --> L2["dlopen<br/>linker resolves dependencies"]
Loading

Since 8d9ffb1, passing an ExternalSoMapping forces SoLoader on, and init(Context, ExternalSoMapping) hard-codes flags to 0, so React Native can't take the bottom path even though the system linker resolves dependencies on API 24+. This is also likely what @Szymon20000 hit in #141 and react/react-native#53670.

Fixing that properly would mean:

  • a way to pass flags together with the mapping (or making the system loader the default when a mapping is given);
  • React Native opting into it;
  • a new SoLoader release. React Native and Expo are on 0.12.1, the latest on Maven, so even these two PRs need a release to reach apps.

The trade-off is losing SoLoader's recovery. On this path the backup source is already disabled, so for React Native that mostly means no longer recovering when the APK moves while the app is running.

I kept these PRs to changes with no behavior change, so they're easy to land on their own and help everyone on the current path. If the direction above sounds right, I'm happy to put together a PR for it.

🤖 Generated with Claude Code

@shubh73

shubh73 commented Oct 19, 2026 •

Copy link
Copy Markdown
Author

Same comparison on a more realistic app: the default Expo SDK 58 template with MMKV, Skia and SQLite added, which loads 23 libraries through DirectApkSoSource. One run of 1,000 cold starts, R8 on:

Build Median time to full display Change
SoLoader 0.12.1, as shipped 1,124 ms
native_deps.txt 1,100 ms −1.7% (−2.6% to −0.9%)
0.12.1 + #152 + #153 1,071 ms −4.3% (−5.1% to −3.4%)
System loader flag 1,056 ms −5.6% (−6.4% to −4.8%)
Libraries extracted at install (#141) 1,049 ms −6.4% (−7.2% to −5.5%)

MMKV and SQLite both worked with the system loader flag: each wrote data at startup and read it back.

🤖 Generated with Claude Code

@adrianrdzv

Copy link
Copy Markdown

Another data point in favour of this, from an Android TV app (react-native-tvos 0.86.0-2, SoLoader 0.12.1, extractNativeLibs=false).

Device: Android 14 set-top box, armeabi-v7a. APK: 114 MB, 2,505 entries.

On Android 14 each uncached ZipFile open also runs zip path validation (SafeZipPathValidatorCallback) on every entry name, so on this hardware one open costs about 25 ms. DirectApkSoSource does about 10 of them on the main thread during loadReactNative.

We noticed it when enabling R8 optimization (proguard-android-optimize.txt) for Google Play's code optimization requirement. Before that, an early class loader lookup kept the APK open by accident, so every later open hit libcore's zip cache. Optimization removed that lookup, and the full cost showed up:

APK already open Not open
loadReactNative (simpleperf, main thread) 103–106 ms 331–348 ms
of which ZipFile.<init> ~1 ms 233–244 ms
Time to first frame, median of 7 670 ms 868 ms

Holding one ZipFile on the APK open from Application.onCreate brings it back to 673 ms. That's the effect this PR has, without an app-side workaround. So on slower devices with Android 14+, the gain from #152 could be much larger than the −14% measured above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants