Fusing Two APKs via Tinker: Compile-Time Skeleton Grafting and Runtime Takeover Produce One App
Using Tinker Hot Update in Reverse to Implement Pluginization: The Principle of Fusing Two Independent APKs into One
Author: 碳基Agent
Tags: 架构
This is a technical article about architectural thinking and engineering principles. Class names, task names, and paths in the text are desensitized and abstracted, and do not represent any specific commercial project.
This article answers a core question: Can we, without introducing any traditional pluginization framework (no Hook on system AMS/PMS), rely solely on Tinker's hot-update capability to merge two independently developed APKs (a lightweight host + a huge plugin) into the same application, dynamically fusing them at runtime?
The answer is yes. The following fully dissects this "reverse pluginization" scheme of compile-time static skeleton grafting + runtime Tinker injection of flesh and blood.
Preface: A One-Sentence Breakdown of the Core Idea
Traditional Android pluginization (such as VirtualApp, RePlugin, Shadow) follows the container-clone approach:
The host provides a runtime container, and by Hook-ing system-level APIs or constructing complex delegation mechanisms, it lets an unregistered plugin APK "borrow a shell to go public" inside the container.
The scheme described here reverses that thinking completely: it borrows hot update to accomplish package fusion:
- Compile-time skeleton grafting: Decompile the target plugin APK, extract its
AndroidManifest.xml, empty shell classes (Stub) for the four major components, and minimal referenced resources, merge them into the host project, and compile a baseline package that carries the skeleton of both packages but only the flesh of the host (tens of MB) (oldApk);- Build-time full diff of flesh: Inject a
TINKER_IDinto the complete target plugin APK to disguise it as a "new version" (newApk), then perform a Tinker diff against the baseline package, producing a patch package (Patch) containing all of the plugin's DEX, Res, Assets, and SO;- Runtime fusion: The user first installs a tens-of-MB host to run basic business; after the patch finishes downloading in the background, it is handed to Tinker for merging. At restart, reflection is used to promote the plugin's real
Applicationand switch the entry Activity. The host instantly transforms into the complete target app!
This is the essence of "reverse pluginization with Tinker": no container isolation, no AMS Hook, but using hot update to genuinely fuse two independent APKs into one package across the lifecycle.
1. Why Abandon Traditional Pluginization in Favor of "Reverse Using Tinker"?
1.1 The Typical Business Pain Point
When distributing large, heavy applications or resource-intensive apps (e.g., multi-GB 3D games, large interactive apps), there is a classic conversion-funnel pain point: the larger the first package, the higher the user download abandonment rate.
The ideal product form is:
- First install a tiny package (e.g., 30–40 MB): it contains a lightweight experience or a custom downloader, letting the user install and enter the app within 3 seconds;
- Silently upgrade in the background to the full package (e.g., 2 GB): while the user is using the lightweight features, the background downloads the complete native app and "fuses" it in, so the next launch runs the native full version directly.
But the engineering hard constraint is often: the complete version is an APK produced by an independent team (or even an external engine team), and the host team does not have access to its source code, nor can they require the other side to refactor according to pluginization specifications.
1.2 Traditional Pluginization vs. Tinker Reverse Fusion
Faced with the need to "stuff one finished APK into another APK," the contrast between the two technical routes is stark:
flowchart TB
subgraph TRAD[Traditional pluginization route - Runtime deception]
direction LR
T_H[Host APK]
T_P[Plugin APK<br/>components not registered in host]
T_HOOK[Hook AMS / Instrumentation<br/>placeholder Activity to deceive system]
T_ISO[Independent ClassLoader / Resources<br/>dual-package isolated run]
T_H --> T_HOOK
T_P --> T_HOOK
T_HOOK --> T_ISO
end
subgraph TINKER_WAY[Tinker reverse pluginization - compile-time grafting + runtime assimilation]
direction LR
K_H[Host source]
K_P[Plugin finished APK]
K_MERGE[Compile-time extract plugin skeleton<br/>generate mixed baseline oldApk]
K_DIFF[Tinker diff<br/>oldApk vs plugin newApk]
K_RUN[Runtime patch injection<br/>two packages truly fuse into one]
K_H --> K_MERGE
K_P --> K_MERGE
K_P --> K_DIFF
K_MERGE --> K_DIFF
K_DIFF --> K_RUN
end
| Comparison dimension | Traditional pluginization (VirtualApp / RePlugin) | Tinker reverse pluginization (two-package merge) |
|---|---|---|
| Core philosophy | Clone and isolate: simulate a system environment inside the host to run another app | Graft and assimilate: treat the plugin as the host's "next version" and merge it into the body |
| Four component registration | Plugin components unregistered, rely on runtime Hook of AMS / placeholder deception | All plugin components are merged into the host Manifest at compile time, legitimately registered |
| Android 9–15 compatibility | Very poor; Hidden API restrictions cause Hooks to fail frequently and crash with each system upgrade | Very high; zero AMS/PMS Hook, conforms to the system's component-management mechanism |
| Refactoring cost of target package | Needs to integrate a plugin framework SDK or be constrained by a specific host container | Zero source refactoring; directly decompile the finished APK and inject TINKER_ID |
| Class and resource space | Independent DexClassLoader, independent Resources |
Merged into host's main ClassLoader and Resources, naturally interoperable |
| Applicable scenario | One host runs multiple small business plugins | One lightweight host dynamically fuses into one giant complete app |
In one sentence: Traditional pluginization fights the Android system at runtime; Tinker reverse pluginization gets the legal paperwork (Manifest components, resource IDs) done at compile time, and at runtime only lets Tinker move the code and resource bytes into place.
2. Overall Architecture: How Two Independent APKs Fuse into One
2.1 System Panorama Architecture Diagram
The entire system is divided into three stages: pre-decompilation skeleton grafting (Stub Merge), baseline build and diff (Build & Patch), and runtime dynamic fusion (Runtime Assimilation):
flowchart TB
subgraph P1[Phase 1 - Compile-time decompile and skeleton graft Stub Merge]
A0[Target plugin APK finished package] --> A1[Apktool decompile]
A1 --> A2[Manifest parse and fusion<br/>components / permissions / version / LAUNCHER hijack]
A1 --> A3[Generate Java empty-shell classes]
A1 --> A4[BFS minimal resource closure extraction]
A1 --> A5[public.xml to plugin_R.txt<br/>resource ID mapping]
end
subgraph P2[Phase 2 - Build mixed baseline and full diff Build & Patch]
B0[Host source + Stub resources] --> B1[AAPT2 --stable-ids<br/>compile mixed baseline oldApk]
B2[Complete plugin APK + temporary TINKER_ID<br/>produce newApk] --> B3[Tinker diff<br/>oldApk vs newApk]
B1 --> B3
B3 --> B4[Full diff patch Patch APK<br/>DEX / Res / Assets / SO]
end
subgraph P3[Phase 3 - Runtime dual-mode switching and takeover]
C0[User installs oldApk] --> C1{Tinker.isTinkerLoaded?}
C1 -->|false| C2[Host mode - lightweight UI + background Patch download]
C1 -->|true| C3[Plugin mode - load Patch<br/>path redirection + replace Application<br/>jump to plugin MainActivity]
end
P1 -->|Stub list / empty-shell classes / resource mapping| P2
P2 -->|Patch uploaded to CDN and distributed| P3
2.2 Process Form Evolution Before and After Fusion
To intuitively understand "two packages become one," look at this package structure evolution diagram:
flowchart LR
subgraph PKG_HOST[1. Mixed baseline oldApk ~40MB]
H_M[Merged Manifest<br/>host components + all plugin component declarations]
H_D[DEX - host real code<br/>+ empty-shell Stub classes for plugin components]
H_R[Res - host resources + minimal resource closure<br/>referenced by plugin Manifest, IDs aligned]
H_S[SO / Assets - only host's own]
end
subgraph PKG_PATCH[2. Diff patch Patch ~2GB]
P_D[DEX - plugin real business code<br/>overrides empty-shell Stub classes]
P_R[Res - plugin complete resource package<br/>resources.arsc + res/*]
P_A[Assets - plugin full game resources]
P_S[SO - plugin full Native libraries]
end
subgraph PKG_MERGED[3. Host process after runtime fusion]
M_APP[Application - plugin real Application<br/>replaced ActivityThread global reference]
M_CL[ClassLoader - plugin real DEX at front<br/>+ host DEX at back]
M_RES[AssetManager - Patch complete resources<br/>+ addAssetPath mounts host original APK]
M_SO[NativeLibraryDir - points to Patch SO directory]
end
PKG_HOST -->|installed on phone| PKG_MERGED
PKG_PATCH -->|Tinker merged and injected| PKG_MERGED
The cleverest part of this design:
- From the perspective of the Android system (AMS/PMS): from the moment the user installs
oldApk, all the plugin's Activities, Services, Providers, and Permissions are legitimately registered with the system; - From the perspective of the compiler (javac/AAPT2): when compiling
oldApk, the plugin class names referenced in the Manifest are backed by empty-shell.javafiles, and referenced@drawable/@styleare backed by a minimal resource closure, so compilation passes smoothly; - From the runtime perspective: before the Patch is installed, empty-shell classes and plugin components are never invoked; after the Patch is installed, Tinker's DEX-array-front-loading mechanism and resource-replacement mechanism exactly use the plugin's real implementations to override the compile-time empty shells!
3. Build-Time Core Principles: How to Fold Two APKs into a Single Build Flow
To make the host APK the plugin APK's "legal past life (oldApk)", directly diffing the host source against the plugin APK with Tinker is absolutely impossible. Four major grafting operations must first be completed via a build-time plugin-merge task.
3.1 Operation One: Manifest Deep Fusion and "Launcher Hijacking"
After decompiling the target plugin APK, the builder parses its AndroidManifest.xml and generates a Stub Manifest specifically for the host flavor to merge. There are four easily-pitfall details:
flowchart LR
RAW[Plugin original AndroidManifest.xml] --> STEP1[1. Extract all components<br/>activity / service / receiver / provider / activity-alias]
STEP1 --> STEP2[2. Force version assimilation<br/>extract plugin versionName/Code from apktool.yml<br/>use tools:replace to override host version]
STEP2 --> STEP3[3. Android 12+ exported completion<br/>auto add exported=true if intent-filter present<br/>else false, to avoid install failure on higher versions]
STEP3 --> STEP4[4. Hijack LAUNCHER entry<br/>transfer the MAIN/LAUNCHER launch authority<br/>to host's routing RouterActivity]
STEP4 --> OUT[Output Stub AndroidManifest.xml<br/>all nodes with tools:node='replace']
(1) Why force versionName and versionCode assimilation?
During apktool decompilation, version numbers are often stripped into apktool.yml. The build script must re-parse them and write them on the root node of the generated Stub Manifest:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.targetapp"
android:versionName="1.0.0"
android:versionCode="100"
tools:replace="android:versionName,android:versionCode">
Reason: The fused package essentially represents the plugin version. Using tools:replace at compile time to force the host's versionName/versionCode to the plugin's version not only ensures a consistent version baseline for diffing, but also lets the runtime PackageManager read exactly the plugin version, perfectly matching the server-side patch URL template (patch_{versionName}_{versionCode}_{tinkerId}.apk).
(2) Why hijack the LAUNCHER entry?
The plugin APK originally has its own desktop launcher Activity (or activity-alias). If it were merged as-is, when a user installs the 40MB host and taps the desktop icon before the patch is downloaded, the system would directly launch the plugin's main Activity. But only empty-shell classes exist at that point, with no plugin business code or resources, causing a black screen or crash.
Therefore, the merger scans components and:
- Sets the host's routing page
RouterActivityas the real desktop entry; - If the plugin uses
<activity-alias>as theMAIN/LAUNCHER, it automatically redirectsandroid:targetActivityto the host'sRouterActivity; - Records the plugin's original entry via
<meta-data android:name="targetActivity" android:value="plugin real main Activity"/>for routing after fusion.
3.2 Operation Two: Auto-Generate Java Empty-Shell Classes (Stub Classes)
When hundreds of the plugin's Activities, Services, and Providers are merged into the host AndroidManifest.xml, compiling the host immediately hits a problem:
- AAPT2 / Lint / ProGuard will verify that classes declared in the Manifest exist;
- If a class does not exist, or the package name starts with
.(relative path), compilation or runtime resolution fails.
The solution is to scan all components at compile time and dynamically generate corresponding .java source files:
flowchart LR
A[Parse plugin Manifest component list] --> B{Belongs to common dependency library?<br/> e.g., AndroidX / shared SDK}
B -->|yes| C[Skip generation<br/>avoid Duplicate class conflict with host existing classes]
B -->|no| D{Is inner class?<br/> class name contains $}
D -->|yes| E[Aggregate into outer class AST node<br/>generate public static class Inner extends Activity]
D -->|no| F[Generate independent Java source<br/>public class Xxx extends Activity/Service/Provider]
The generated empty-shell classes are clean, implementing only the most basic abstract method stubs (e.g., Service.onBind returns null, ContentProvider.onCreate returns false).
- When the host has no patch: they satisfy Manifest validation and minimize package size (hundreds of empty classes compile to a few dozen KB);
- After a Tinker patch is applied: Tinker inserts the patch's complete DEX at the front of the
ClassLoader'sdexElementsarray, so when the system loads a same-named class it directly hits the patch's real plugin class, and the empty-shell class automatically withdraws.
3.3 Operation Three: BFS Minimal Resource Closure Extraction (Why the Host Doesn't Bloat)
This is one of the highlights of build-time design.
The plugin's AndroidManifest.xml necessarily references a large number of resources, e.g.:
android:icon="@mipmap/ic_launcher"android:theme="@style/AppTheme"- And
@style/AppThemeinvalues/styles.xmlreferences@color/primary,@drawable/bg_splash...
The conflict:
- If the plugin's
res/is fully copied into the host, the host instantly swells by hundreds of MB, defeating the lightweight purpose; - If these resources are not copied, AAPT2 fails during host compilation with
resource not found.
Solution: a minimal resource dependency closure extractor based on BFS (breadth-first search).
flowchart TB
START[Read plugin AndroidManifest.xml] --> EXTRACT[Regex extract all @type/name resource references<br/>put into BFS queue Queue]
EXTRACT --> LOOP{Queue empty?}
LOOP -->|no| POP[Take ref Ref type, name]
POP --> FILTER{System/common library prefix?<br/> abc_ / material_ / design_ / mtrl_}
FILTER -->|yes| LOOP
FILTER -->|no| TYPE_CHK{Resource type judgment}
TYPE_CHK -->|file-type resource<br/>drawable / layout / xml / anim| COPY_FILE[Copy file to stub/res/<br/>if XML, continue scanning inner @ references and enqueue]
TYPE_CHK -->|value-type resource<br/>string / color / style / attr| COPY_VAL[Extract XML node from values/<br/>scan parent and child nodes' @ references and enqueue]
COPY_FILE --> LOOP
COPY_VAL --> LOOP
LOOP -->|yes| CLEAN_ATTR[Clean high-version-only attributes & lower-case file names<br/>output minimal compilable res directory]
With this BFS algorithm, even if the plugin originally has 20,000 resources, the ones extracted into the host are usually only a few dozen icons, launch themes, and basic strings strongly related to the Manifest, keeping the size within a few hundred KB.
It also handles a detail: attrs.xml is kept on demand. If the plugin's custom theme uses custom attributes, the extractor also extracts the corresponding <attr> definitions to prevent AAPT2 from reporting undefined attributes.
3.4 Operation Four: Lock Resource IDs with plugin_R.txt (The Lifeline)
Why is resource ID fixation the "lifeline" of the entire scheme?
Root cause: AAPT2 ID drift
During Android compilation, AAPT2 assigns each resource a 32-bit integer ID (0x7fPPTTEEEE).
- When the plugin APK was originally compiled with 20,000 resources,
@drawable/icongot ID0x7f080001; - When the host APK is compiled with only the few dozen minimal-resource-closure resources, AAPT2 renumbers by default, so
@drawable/iconbecomes0x7f020001.
If the resource IDs on the two sides don't match:
- Tinker's resource diff fails to match;
- At runtime, plugin code using the compile-time hardcoded constant
0x7f080001to look up resources directly throwsResources$NotFoundExceptionand crashes!
Solution: public.xml → plugin_R.txt → AAPT2 --stable-ids
flowchart LR
PUB[1. Plugin decompile output<br/>res/values/public.xml<br/>contains full resource original hex IDs]
RTXT[2. Convert to R.txt format<br/>output plugin_R.txt]
TINKER_CFG[3. Tinker build config<br/>applyResourceMapping = plugin_R.txt]
AAPT2[4. AAPT2 link phase<br/>inject --stable-ids parameter]
RESULT[5. Same-named resource IDs in host APK<br/>100% match plugin APK]
PUB --> RTXT --> TINKER_CFG --> AAPT2 --> RESULT
Engineering iron rule: When building the host baseline package, you must enable Tinker's applyResourceMapping (via build parameters such as -PenableTinker=true to trigger the tinkerProcessResourceId task). If this switch is missed, compilation won't error, but the baseline package's resource IDs will drift, and online patch loading will crash.
3.5 Dual TinkerId Mechanism and Full-Diff Direction
After preparing the mixed baseline (oldApk), the next step is to use Tinker to compute the diff against the complete plugin (newApk). There are two counter-intuitive designs:
(1) Diff direction: host is oldApk, plugin is newApk
In conventional hot update, oldApk is the old app version, newApk is the new app version.
In this scheme:
oldApk(old package) = the just-compiled host mixed baseline (~40MB);newApk(new package) = the complete plugin APK given by the external team (~2GB).
Tinker's diff engine compares the two and finds that newApk has all the plugin business DEX, all game-level Assets, all engine SO libraries, and the complete resources.arsc that oldApk lacks. So Tinker packages all this "extra content" into a patch.apk.
(2) Why design two different TinkerIds?
Tinker's patch validation mechanism requires:
- Both
oldApkandnewApkcontain<meta-data android:name="TINKER_ID" .../>inAndroidManifest.xml; - The
TINKER_IDofoldApkandnewApkmust differ (otherwise Tinker considers them identical and refuses to generate a patch); - The generated
Patchpackage internally records the baseline package's TINKER_ID (i.e.,oldApk's ID). At runtime, the host checks its ownTINKER_IDagainst the patch; only a match allows installation.
But here's the problem: the plugin APK is a finished product compiled by an external team, and it doesn't contain a TINKER_ID!
Therefore the build pipeline introduces a dual-ID lifecycle:
flowchart LR
S1[1. Gradle config phase<br/>generate core TinkerId<br/>bind to tinkerPatch.buildConfig.tinkerId]
S2[2. Temporary ID injection task<br/>independently generate temporary TinkerId<br/>inject into plugin APK to get newApk]
S3[3. Host packaging task<br/>Tinker writes core ID into host manifest<br/>compile mixed baseline oldApk]
S4[4. tinkerPatch task<br/>diff oldApk and newApk<br/>Patch internally binds core ID]
S5[5. Rename and upload artifacts<br/>uniformly named with core ID<br/>baseline and Patch uploaded to CDN]
S1 --> S2 --> S3 --> S4 --> S5
| ID type | Purpose | Written to | Used for artifact naming and online matching? |
|---|---|---|---|
| Temporary ID (plugin injected ID) | Only as a placeholder to fool the Tinker diff engine, giving the plugin APK a legitimate newApk identity |
intermediate build artifact | No (use and discard) |
| Core ID (host baseline ID) | Truly identifies this build batch; written to host Manifest and patch metadata package_meta.txt |
host baseline & Patch validation header | Yes (determines which patch the client downloads) |
4. Runtime Core Principles: How the Patch "Takes Over" the Host Process
After the user downloads that 2GB Patch inside the host app and installing it via TinkerInstaller.onReceiveUpgradePatch(), the real "magic" begins at the next process start.
Why call it "taking over"? Because after the host process starts, it must hand over its code space, resource space, file paths, Application instance, and main entry Activity entirely to the plugin.
4.1 Runtime Five-Step "Takeover" Sequence Diagram
sequenceDiagram
autonumber
participant AMS as Android System (ActivityThread)
participant PROXY as Host Proxy Application<br/>(extends TinkerApplication)
participant TINKER as Tinker Loader
participant REDIR as PatchPathRedirector<br/>(path redirector)
participant REAL as Plugin Real Application<br/>(or plugin hardened shell StubApp)
participant ROUTER as Entry Router RouterActivity
AMS->>PROXY: 1. Create proxy Application & call attachBaseContext(base)
PROXY->>TINKER: super.attachBaseContext(base) triggers Tinker loading
TINKER-->>PROXY: Mount Patch DEX to ClassLoader, mount Patch Resources
PROXY->>PROXY: 2. MultiDex.install & install Patch SO library path (per ABI)
PROXY->>REDIR: 3. redirectIfNeeded(this)
REDIR->>REDIR: Save host original sourceDir<br/>change ApplicationInfo.sourceDir / nativeLibraryDir to Patch APK path
PROXY->>PROXY: 4. Reflect AssetManager.addAssetPath(host original sourceDir)<br/>ensure host's own assets aren't lost
PROXY->>REAL: 5. Read target Application class name config<br/>reflect newInstance() to create plugin real Application and call attachBaseContext
AMS->>PROXY: 6. Call proxy Application.onCreate()
PROXY->>AMS: 7. Reflect into ActivityThread / LoadedApk / ContentProvider<br/>replace all system-held Application references with REAL
PROXY->>REAL: 8. Call plugin real Application.onCreate()
AMS->>ROUTER: 9. Launch desktop entry RouterActivity
ROUTER->>ROUTER: Check Tinker.isTinkerLoaded() == true
ROUTER->>REAL: 10. Launch plugin's true MainActivity, completing the fusion!
Next we dissect the four most critical technical difficulties one by one.
4.2 Difficulty One: Proxy Application and System Global Reference Tampering
Every large app or game has its own Application subclass (if the plugin has a hardening shell, its entry Application is often the shell's StubApp). A large amount of plugin SDK initialization, global Context holding, and decryption logic happens in the real Application's attachBaseContext and onCreate.
But when the Android system starts a process, it only instantiates the ProxyApplication declared in the Manifest's <application android:name="...">, which inherits from TinkerApplication.
To make the plugin feel it's not being "hosted," when ProxyApplication detects isTinkerLoaded() == true, it performs a bait-and-switch:
flowchart TB
START[ProxyApplication.attachBaseContext] --> READ_META[Read plugin real Application class name<br/>from Manifest meta-data]
READ_META --> NEW_INST[Reflect instantiate using merged ClassLoader<br/>realApplication = Class.forName(name).newInstance()]
NEW_INST --> ATTACH[Reflect call ContextWrapper.attachBaseContext(base)<br/>complete plugin Application context binding]
ATTACH --> ONCREATE[Enter ProxyApplication.onCreate]
ONCREATE --> HOOK1[Tamper point 1 - ActivityThread.mInitialApplication = realApplication]
ONCREATE --> HOOK2[Tamper point 2 - ActivityThread.mAllApplications<br/>remove ProxyApplication, add realApplication]
ONCREATE --> HOOK3[Tamper point 3 - Iterate mPackages / mResourcePackages<br/>replace LoadedApk.mApplication with realApplication]
ONCREATE --> HOOK4[Tamper point 4 - Iterate ActivityThread.mProviderMap<br/>replace initialized ContentProvider.mContext with realApplication]
HOOK1 & HOOK2 & HOOK3 & HOOK4 --> CALL_REAL[Call realApplication.onCreate()]
CALL_REAL -.->|if reflection fails| FALLBACK[Fallback protection - useProxyFallback = true<br/>override getResources / getAssets / getClassLoader / registerActivityLifecycleCallbacks etc., delegate 14 methods fully]
Why tamper these four places?
ActivityThread.mInitialApplication: many third-party SDKs (and hardening shells) obtain the global Context by reflectingActivityThread.currentApplication(). If not replaced, they get the host'sProxyApplication, and a forced cast like((GameApp) context)throwsClassCastException;LoadedApk.mApplication: when the system later launches any pluginActivityorService, it takesmApplicationfromLoadedApkand assigns it toActivity.getApplication(). After replacement, all plugin Activities callinggetApplication()get the genuine pluginApplication;ContentProvider.mContext: in the Android system,ContentProvider.onCreate()executes beforeApplication.onCreate()(it is initialized withProxyApplicationduring theinstallContentProvidersphase). So inonCreate()you must iteratemProviderMapand correct themContextinside already-created Providers torealApplication.
4.3 Difficulty Two: Hardening Shell Conflict and PatchPathRedirector Path Redirection
This is the most hidden and engineering-heavy pitfall in real deployment.
Why does a plugin with a hardening shell crash after Tinker loads?
Commercial games or heavy apps almost always use a hardening shell (e.g., common commercial hardening solutions). The shell's StubApp at startup does one thing:
It reads
context.getApplicationInfo().sourceDir(i.e.,/data/app/~~xxx/base.apk), opens this ZIP file, searches for the encrypted main DEX payload or signature-validation data, decrypts it, and loads it dynamically.
But in this "Tinker reverse fusion" scheme:
/data/app/~~xxx/base.apkis the 40MB host baseline package, which does NOT contain the plugin's hardening payload!- The plugin's real payload and complete files live in
/data/user/0/<pkg>/tinker/patch-xxxxxxx/patch-xxxxxxx.apk!
If the hardening shell reads the default sourceDir, it crashes because it can't find the encrypted data.
Solution: Two-way path surgery (PatchPathRedirector + injectHostAssetPath)
flowchart TB
subgraph STEP_A[Step 1 - Derive and redirect sourceDir to patch package]
A1[Read application.getPackageResourcePath<br/>get .../tinker/patch-xxx/res/resources.apk]
A2[Parse real patch package path<br/>.../tinker/patch-xxx/patch-xxx.apk]
A3[Save original host path -<br/>sOriginalSourceDir = appInfo.sourceDir]
A4[Modify ApplicationInfo -<br/>appInfo.sourceDir = patchApkPath<br/>appInfo.nativeLibraryDir = patchApkPath]
A1 --> A2 --> A3 --> A4
end
subgraph STEP_B[Step 2 - Mount host original APK back to AssetManager]
B1[Problem - after sourceDir points to Patch<br/>accessing host's own assets would fail]
B2[Reflect AssetManager.addAssetPath sOriginalSourceDir]
B3[Result - hardening shell reads complete plugin package from sourceDir<br/>while AssetManager has both plugin and host resources]
B1 --> B2 --> B3
end
STEP_A --> STEP_B
Through this combination punch of "first redirect sourceDir to fool the hardening shell, then addAssetPath to restore the host resource path," even third-party finished APKs with commercial hardening shells run seamlessly under this scheme.
4.4 Difficulty Three: ABI Mismatch on Emulators and Real Devices
When the SO library in the patch needs to load, the code calls TinkerLoadLibrary.installNavitveLibraryABI(context, currentABI).
But how is currentABI determined?
If you directly use Build.SUPPORTED_ABIS[0], on PC Android emulators (e.g., MuMu, LDPlayer) you immediately hit a trap:
- The emulator's
Build.SUPPORTED_ABISreturns["x86_64", "x86", "arm64-v8a", "armeabi-v7a"](via Houdini / libndk_translation to translate ARM); - The plugin APK, to control size, often packages only
lib/arm64-v8a/*.soorlib/armeabi-v7a/*.so, with nox86_64directory; - If you blindly take
SUPPORTED_ABIS[0](i.e.,x86_64) and pass it to Tinker, Tinker looks for SO underlib/x86_64/in the patch, and naturally throwsUnsatisfiedLinkError!
Therefore, the correct ABI decision must go through a three-layer practical detection fallback:
flowchart LR
START[Get ABI to load] --> L1[Layer 1 - check installed nativeLibraryDir<br/>e.g., /data/app/.../lib/arm64 maps to arm64-v8a]
L1 -->|directory missing or unmappable| L2[Layer 2 - iterate Build.SUPPORTED_ABIS<br/>open APK ZipFile and check if lib/ABI dir/*.so actually exists]
L2 -->|find first existing ABI| OK[Return matching ABI<br/>inject Tinker Native path]
L1 -->|successfully mapped| OK
L2 -->|none found| L3[Layer 3 - final fallback<br/>return SUPPORTED_ABIS[0]]
L3 --> OK
4.5 Difficulty Four: Large Whole-Package Patch Download, Installation, Failure Self-Healing, and Emergency Mode
Because the "patch" here is not a few-hundred-KB hotfix but a 1–2 GB complete game package, runtime stability is severely tested:
(1) Main-thread I/O and ANR protection
Tinker's native TinkerInstaller.onReceiveUpgradePatch() and tinker.cleanPatchByPatchApk() internally compute a full MD5 of the patch file. MD5 of a 1KB file takes 1ms; MD5 of a 2GB file takes seconds to over ten seconds!
- Installation must be async + concurrency lock: wrap
tryInstallPatchAsyncto trigger on an independent worker thread, and use asynchronizedlock to block repeated user taps; - Cleanup must be async: cleaning an invalid patch (
cleanPatchAsync) must also run on a background thread; on the main thread it triggers a 5-second ANR.
(2) Emergency Mode (the tradeoff between install speed and first-launch performance)
When facing a 2GB patch and multiple DEXs with tens of thousands of methods, if dex2oat compiles machine code synchronously during installation, the user might stare at the "Synthesizing game..." progress bar for several minutes, leading to high mid-way forced-kill rates.
- Enabling
Emergency Mode(withdexMode = "raw") skips the time-consumingdex2oatduring installation, compressing the synthesis time from minutes to tens of seconds; - The cost is that the first restart into the game relies on ART's JIT / interpreted execution, so loading is slightly slower, but you can dynamically adjust via server-side switch (
useEmergencyMode) per device high/low end.
(3) Fine-grained exception classification and self-healing state machine
A 2GB patch installed across a wide variety of user devices typically fails in three ways: insufficient disk space (decompression + merging needs double space), network corruption during download, and intermittent I/O interruption. Blind retries only create an infinite loop.
Therefore the scheme builds a self-healing state machine on top of DefaultTinkerResultService, based on exception stack characteristics:
stateDiagram-v2
[*] --> Download complete, pending install
Download complete, pending install --> Installing on child thread: tryInstallPatchAsync()
Installing on child thread --> Install success: PatchResult.isSuccess == true
Install success --> Delete original patch: free 2GB temporary space
Delete original patch --> Broadcast notice and guide restart: next launch goes straight to plugin main UI
Installing on child thread --> Exception classifier: PatchResult.isSuccess == false
Exception classifier --> Insufficient space: IOException (no space / enospc) or OOM
Insufficient space --> [*]: clean residual patch + popup to tell user to free space (no auto retry)
Exception classifier --> Intermittent IO retry: normal IOException / Unknown
Intermittent IO retry --> Installing on child thread: retries < 3 (reinstall original patch)
Intermittent IO retry --> Corrupted-download handling: if retries still fail after 3
Exception classifier --> Corrupted-download handling: SecurityException / md5 / signature / tinker_id mismatch
Corrupted-download handling --> Re-download from CDN: re-downloads < 2 (thoroughly delete original patch + cleanPatch)
Re-download from CDN --> Download complete, pending install
Corrupted-download handling --> [*]: if re-downloads still fail after 2, give up and prompt user to retry manually later
5. Deep Retrospective: Advantages, Costs, and Boundaries of This "Reverse Pluginization" Scheme
Any architecture is an art of compromise. Using Tinker in reverse for two-package fusion has distinct benefits and costs:
5.1 Core Advantages (Why Is It a Dimensional Reduction Blow in Certain Scenarios?)
- Zero intrusion into the target package: the plugin team doesn't change a line of code, doesn't integrate any plugin SDK; just hand over a finished APK and it can be fused.
- 100% native compatibility for the four components: because components, permissions, and the minimal resource closure are merged into the host Manifest at compile time, there is no "unregistered component deceiving AMS" black magic at runtime; upgrades to Android 14 / 15 / 16 stay rock solid.
- Smooth cross-package transition: before fusion it's a complete lightweight app; after fusion it transforms into a complete heavy native app, sharing the same desktop icon, same package name, and same user login data directory (
/data/data/<pkg>/).
5.2 Objective Costs and Limitations (When Can't This Be Used?)
- Only supports 1+1=1, not one-to-many (1+N):
Because all classes, resource IDs (
0x7f), andApplicationinstances are globally assimilated, one host flavor can only bind and fuse with one target plugin; it can't simultaneously load three unrelated plugins A, B, C like RePlugin. - Patch size is close to the whole package; essentially it delays full-package download rather than saving total traffic:
When the target package's bulk is in
assets/(e.g., game engine assets), since the host baseline has none of those assets, the Tinker-computed Patch is almost the same size as the target package (e.g., a 2GB game yields a Patch around 2GB). It solves the first-install speed and conversion rate problem, not total traffic savings. - Peak disk-space requirement is high:
Downloading a 2GB patch plus Tinker decompressing and merging into
/data/user/0/<pkg>/tinker/means peak disk usage around 4GB during install (only after success doesMicroResultServicedelete the original patch to drop back to 2GB). So for low-storage devices, space checks (INSUFFICIENT_STORAGE) are critical. Applicationreflection tampering carries system-evolution maintenance costs: Although it avoids AMS/PMS Hooks, to replaceProxyApplicationwith the plugin's realApplication, it still reflects intoActivityThread.mInitialApplication,LoadedApk.mApplication, andContentProvider.mContext. There is auseProxyFallbackfull-method-forwarding fallback, but each major system upgrade still needs regression verification.
6. Code-Level Pitfalls and Evolution Suggestions (Practical Avoidance Checklist)
If you're going to maintain this architecture in production for the long term, these four engineering details deserve attention:
- Avoid duplicate
TinkerInstaller.install()calls inApplicationLikeIn theDefaultApplicationLikesubclass'sonBaseContextAttached(), initializeTinkerInstaller.install(...)only once, and keep it in sync with theisInstalledflag inTinkerManagerto avoid duplicate registration ofLoadReporter/PatchReporter. - Unify
tinkerIdas a single source of truth If build-time tasks separately callSystem.currentTimeMillis()to generatetinkerId, inconsistent values between configuration and execution phases will cause the produced Patch and baseline to not match. Always read fromproject.tinkerPatch.buildConfig.tinkerIdand output abuild-manifest.jsonat build end for CI/CD verification. - Conflict governance for common dependencies (
EXCLUDED_PACKAGE_PREFIXES) Because the host and plugin DEXs end up in the sameClassLoader, if both integrate the same third-party SDK with different versions, you get class shadowing orNoSuchMethodError. The build-timeStubGeneratorexclusion list and Gradle'spackagingOptions.exclude(e.g., excluding duplicate coroutine debug classes or shared.so) must be covered by automated conflict scanning. - Add a "continuous crash on launch auto-rollback" mechanism
If after full fusion the plugin's
Application.onCreate()crashes repeatedly on specific device models, use Tinker'sUncaughtExceptionHandlercounter: if the process crashes within 5 seconds on three consecutive launches, automatically callTinker.with(context).cleanPatch()to roll back to lightweight host mode and show the user a repair message, rather than letting the app deadlock completely.
Conclusion
Looking back at the whole scheme, the most exciting part is thinking outside the framework:
While everyone studies "how to keep Hook-ing system APIs to achieve pluginization on high Android versions," this scheme goes against the grain:
- At compile time, using
apktool+ AST empty-shell generation + BFS resource extraction +stable-ids, it legally registers the plugin's "household registration" into the host; - At runtime, it treats the plugin as the host's "hot-update patch," letting the mature Tinker framework do the dirtiest and most tiring DEX, Res, Assets, and SO merging, and only makes a light push on
Applicationand entry routing to complete the fusion of two independent APKs.
Without touching a line of the target package's source, without Hook-ing a single system service, borrow the hot-update ship to cross the pluginization river. This is the most charming "skillful leverage" in engineering architecture.