跪拜 Guibai
← Back to the summary

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:

  1. 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);
  2. Build-time full diff of flesh: Inject a TINKER_ID into 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;
  3. 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 Application and 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:

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:


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:


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:

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).


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.:

The conflict:

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).

If the resource IDs on the two sides don't match:

  1. Tinker's resource diff fails to match;
  2. At runtime, plugin code using the compile-time hardcoded constant 0x7f080001 to look up resources directly throws Resources$NotFoundException and 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:

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:

  1. Both oldApk and newApk contain <meta-data android:name="TINKER_ID" .../> in AndroidManifest.xml;
  2. The TINKER_ID of oldApk and newApk must differ (otherwise Tinker considers them identical and refuses to generate a patch);
  3. The generated Patch package internally records the baseline package's TINKER_ID (i.e., oldApk's ID). At runtime, the host checks its own TINKER_ID against 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?

  1. ActivityThread.mInitialApplication: many third-party SDKs (and hardening shells) obtain the global Context by reflecting ActivityThread.currentApplication(). If not replaced, they get the host's ProxyApplication, and a forced cast like ((GameApp) context) throws ClassCastException;
  2. LoadedApk.mApplication: when the system later launches any plugin Activity or Service, it takes mApplication from LoadedApk and assigns it to Activity.getApplication(). After replacement, all plugin Activities calling getApplication() get the genuine plugin Application;
  3. ContentProvider.mContext: in the Android system, ContentProvider.onCreate() executes before Application.onCreate() (it is initialized with ProxyApplication during the installContentProviders phase). So in onCreate() you must iterate mProviderMap and correct the mContext inside already-created Providers to realApplication.

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:

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:

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!

(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.

(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?)

  1. 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.
  2. 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.
  3. 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?)

  1. Only supports 1+1=1, not one-to-many (1+N): Because all classes, resource IDs (0x7f), and Application instances 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.
  2. 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.
  3. 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 does MicroResultService delete the original patch to drop back to 2GB). So for low-storage devices, space checks (INSUFFICIENT_STORAGE) are critical.
  4. Application reflection tampering carries system-evolution maintenance costs: Although it avoids AMS/PMS Hooks, to replace ProxyApplication with the plugin's real Application, it still reflects into ActivityThread.mInitialApplication, LoadedApk.mApplication, and ContentProvider.mContext. There is a useProxyFallback full-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:

  1. Avoid duplicate TinkerInstaller.install() calls in ApplicationLike In the DefaultApplicationLike subclass's onBaseContextAttached(), initialize TinkerInstaller.install(...) only once, and keep it in sync with the isInstalled flag in TinkerManager to avoid duplicate registration of LoadReporter / PatchReporter.
  2. Unify tinkerId as a single source of truth If build-time tasks separately call System.currentTimeMillis() to generate tinkerId, inconsistent values between configuration and execution phases will cause the produced Patch and baseline to not match. Always read from project.tinkerPatch.buildConfig.tinkerId and output a build-manifest.json at build end for CI/CD verification.
  3. Conflict governance for common dependencies (EXCLUDED_PACKAGE_PREFIXES) Because the host and plugin DEXs end up in the same ClassLoader, if both integrate the same third-party SDK with different versions, you get class shadowing or NoSuchMethodError. The build-time StubGenerator exclusion list and Gradle's packagingOptions.exclude (e.g., excluding duplicate coroutine debug classes or shared .so) must be covered by automated conflict scanning.
  4. 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's UncaughtExceptionHandler counter: if the process crashes within 5 seconds on three consecutive launches, automatically call Tinker.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:

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.