跪拜 Guibai
← All articles
Architecture

Fusing Two APKs via Tinker: Compile-Time Skeleton Grafting and Runtime Takeover Produce One App

By 碳基Agent ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Teams that cannot modify a finished plugin APK can now deliver a tiny first install that later becomes the full native app, without fighting AMS/PMS or requiring the plugin team to adopt a plugin framework. The cost is a patch nearly as large as the plugin, high peak disk usage, and reflective Application swaps that need regression testing on every Android version.

Summary

Traditional Android pluginization hooks the system AMS/PMS and runs a plugin inside a container, which breaks on recent Android versions. This scheme inverts the idea: it treats the plugin APK as the host's "next version" and uses Tinker's diff and patch machinery to merge them.

At build time, the plugin is decompiled with apktool. Its manifest is assimilated into the host, all its components are replaced with empty stub Java classes, a BFS picker extracts only the resources the manifest directly references, and AAPT2 stable-ids locks resource IDs between the two packages. The result is a ~40MB baseline host that already carries the plugin's legal component declarations. A full diff against the original 2GB plugin produces a patch containing every DEX, resource, asset, and SO the plugin needs.

At runtime, Tinker loads that patch, then a proxy Application reflects the plugin's real Application into ActivityThread, swaps paths so hardened shells read the patch's sourceDir, and routes the launcher to the plugin's main Activity. The scheme works without touching plugin source, but the patch is nearly the size of the plugin, disk peaks reach about 4GB, and reflective substitution into ActivityThread must be re-verified each Android release.

Takeaways
— The scheme sets the plugin as Tinker's newApk and a host baseline (oldApk) that contains only empty stub classes and a minimal resource closure; Tinker's diff produces a patch with all plugin DEX, res, assets, and SOs.
— Plugin components are merged into the host manifest at compile time, so no AMS/PMS hooks are needed and Android 9–15 compatibility is high, but the host can only fuse with one plugin (1+1=1).
— AAPT2 with --stable-ids and an applyResourceMapping file lock resource IDs between the two packages; without it, plugin bytecode looking up a hardcoded ID will crash with Resources$NotFoundException.
— Two Tinker IDs are used: a temporary ID injected into the plugin to fool the diff engine, and a core ID written into the host and patch metadata for client-side patch matching.
— At runtime the proxy Application reflects the plugin's real Application into ActivityThread.mInitialApplication, LoadedApk.mApplication, and ContentProvider.mContext; a useProxyFallback delegates 14 methods if reflection fails.
— For hardened plugins, PatchPathRedirector changes ApplicationInfo.sourceDir to the patch path so the shell finds its payload, then addAssetPath restores the host's own assets.
— ABI selection must avoid Build.SUPPORTED_ABIS[0] on emulators; the code probes the installed nativeLibraryDir, then the APK's actual lib directories, before falling back to the first entry.
— A 2GB patch makes synchronous MD5 and dex2oat dangerous; installation and cleanup run on background threads with locks, and Emergency Mode skips dex2oat to cut install time to tens of seconds.
— A self-healing state machine classifies failures (no space, intermittent IO, corruption) and retries with bounded counters; three consecutive crashes inside 5 seconds trigger cleanPatch rollback.
— The author advises a single source of truth for tinkerId to avoid mismatches, and automated scanning to exclude duplicate common dependency classes that would collide in the merged ClassLoader.
Conclusions

The approach effectively treats the plugin as a legitimate new version of the host, so all the hard DEX/resource/asset merging is offloaded to Tinker's battle-tested patch pipeline; the original engineering risk shifts to resource-ID stability and global reference substitution.

Zero source modification does not translate to zero maintenance: reflecting into ActivityThread internals will still break across Android versions, and the fallback only reduces the blast radius, not the need for per-release regression checks.

The 1+1=1 restriction means this is a specialized solution, not a general plugin framework; it trades flexibility for a frictionless path to fuse a single large game or app into a lightweight host.

By patenting the plugin as a hot-update patch, the scheme makes the patch size nearly equal to the plugin, so it optimizes time-to-first-frame and conversion rate, not bandwidth or storage efficiency.

Concepts & terms
Stub classes
Empty Java classes generated at build time for every plugin component declared in the merged manifest. They satisfy the compiler and manifest validation, and are later overridden by the plugin's real classes when the Tinker patch loads its DEX at the front of the ClassLoader.
Resource ID mapping (stable-ids)
A mechanism that forces AAPT2 to assign the same resource IDs in the host baseline package as the plugin's original build. It uses the plugin's public.xml, converted to an R.txt mapping, and passes --stable-ids to AAPT2 so that at runtime bytecode references hardcoded IDs that actually exist.
PatchPathRedirector
A runtime component that changes ApplicationInfo.sourceDir and nativeLibraryDir to point to the Tinker patch file instead of the original host APK. This lets hardened plugin shells find their encrypted payload, and a subsequent addAssetPath call restores the host's own assets.
Emergency Mode
A Tinker installation mode that skips synchronous dex2oat compilation, reducing patch-install time from minutes to tens of seconds. The first launch after restart relies on ART JIT/interpretation, so it trades a slightly slower first entry for a much faster installation.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗