跪拜 Guibai
← Back to the summary

Running H5 Games in an Isolated Android Process Without Breaking IPC


theme: condensed-night-purple

Foreword

Putting WebView into an independent subprocess might be the ultimate solution for the excessive memory consumption of H5 games. However, the subsequent problems like process freezing and IPC callback loss are the real technical deep waters.

In the mobile application ecosystem, H5 games are favored for their rapid iteration and cross-platform advantages, but the huge memory overhead of WebView behind them is a persistent problem. Especially in complex applications like social networking and live streaming, the main process already carries a large amount of business. If another memory hog is added, the risk of system OOM (Out of Memory) and cascading crashes will rise sharply.

To this end, we designed and implemented a multi-process isolation architecture for H5 games. Its core goal is to completely detach WebView and the H5 games it carries, running them in an independent :web subprocess. This is not only to avoid memory risks but also to build a solid fault isolation wall.

However, 'isolation' is easy, but 'communication' is hard. Inter-process communication, timing, state synchronization, and dealing with the 'process freezing' mechanism of higher Android versions became the core of our攻坚 (problem-solving effort). This article will deeply analyze the technical challenges and evolution of this architecture, and share our practical experience in IPC communication design, WebView reuse, and security isolation, hoping to inspire developers facing similar problems.

1. Motivations and Challenges of Multi-Process Architecture Design

From Single Process to Multi-Process: A Forced Evolution

The fatal bottleneck of the single-process architecture was the driving force for change.

Significant Increase in Memory Usage: A moderately complex H5 game, with its JS engine, DOM rendering tree, and graphics layer cache, typically causes the WebView process's memory usage to soar above 200MB. When it runs in the same process as the main application, WebView usually introduces a large amount of Native Memory, Graphics Memory, GPU Cache, JS Heap, and other resources, potentially reaching hundreds of MB, significantly increasing the total application memory. This easily triggers the Android system's Low Memory Killer (LMK) mechanism, causing the main process to be cleaned up first, leading to crashes.

Cascading Crash Reactions: Under the single-process model, a crash of the WebView kernel or a fatal error in a JS script is directly transmitted to the main process. This means a crash in a small H5 game could bring down core functions like an entire social room or audio/video call, making the 'short board' effect of user experience extremely obvious.

The core benefits of multi-process isolation are clear and direct:

Absolute Physical Memory Isolation and Reclamation: The game's memory consumption is entirely borne by the independent :web subprocess. When the game is closed, we can directly terminate this process. After closing the subprocess, the system reclaims the entire process address space, releasing WebView-occupied resources more thoroughly than a single-process solution, eliminating memory leaks and residency at the root.

System-Level Fault Isolation: Even if the subprocess dies due to a webpage crash, OOM, etc., its 'sacrifice' is limited to itself, while the main process continues to run robustly. This achieves true 'explosion-proof isolation,' essentially guaranteeing the availability of the main App.

2. Core Challenge: When the Process is 'Frozen'

The benefits of the architecture are obvious, but the devil is in the details. After running WebView in a subprocess, we encountered four core challenges brought by the Android system (especially for Target SDK 37 and above):

Challenge: Callback Loss Caused by 'Process Freezing' in Higher Versions

This is the most棘手 (tricky) problem. Scenario reproduction: A user clicks to recharge in a mini-game, and the main process pulls up a full-screen payment page. At this moment, the :web subprocess hosting the game loses foreground focus, and Android's App Freezer mechanism immediately suspends and freezes it to save power.

When the main process payment succeeds and tries to notify the subprocess to refresh the balance via AIDL, while the target process is frozen, synchronous Binder calls may block and time out. Some system versions may also experience Binder Transaction Failure, causing the business layer to receive a DeadObjectException or Transaction Failure. The result is that the user returns to the game and finds the balance hasn't updated, breaking the experience.

Our solution: Introduced a dual-insurance mechanism of oneway asynchronous communication and main-process temporary storage and retransmission.

Challenge: Unilateral 'Cancellation' by RemoteCallbackList

Android's native RemoteCallbackList, when managing cross-process callbacks, automatically removes a callback from its list once it determines the peer process has died due to a DeadObjectException. This means even if the subprocess later thaws and revives, the main process has 'forgotten' it and can no longer push messages.

Our solution: Designed a 'forced re-registration' handshake protocol during subprocess thaw/rebuild to counter the system's automatic cleanup logic.

Challenge: 'Timing Race' at the Moment of Cold Start

When the :web process cold-starts and loads an H5 page, the page might initiate a JS call (like getting a Token) within the first second. However, the AIDL service binding is asynchronous (about 50-200ms). If a null value is returned directly, the H5 business logic will error.

Our solution: Introduced a synchronous blocking lock (with a 2-second timeout) on a non-main thread, making the JS call 'wait' instead of 'fail' until the service is ready.

Challenge: WebView Data Directory Conflict on Android 9+

Starting from Android 9, the system prohibits multiple processes from sharing the same WebView data directory; otherwise, it directly throws a RuntimeException causing a crash.

Our solution: During subprocess initialization, specified an exclusive data directory suffix for it via WebView.setDataDirectorySuffix("web"), elegantly circumventing multi-process lock conflicts.

3. Deep Analysis of Solution Advantages

🌟 Highlight 1: oneway — The Underlying Magic of the Asynchronous Bus

oneway is a keyword in AIDL that makes cross-process calls purely asynchronous. This is our core weapon against process freezing.

We declared all notification-type callback interfaces from the main process to the subprocess as oneway:

oneway interface IWebIPCCallback {

void onRechargeSuccess(String gameId, long balance);

void onAudioStateChanged(String gameId, boolean isMuted);

void forceCloseGame(String gameId);

}
Two Core Advantages of oneway:

Complete Elimination of Synchronous Hangs and Crashes: Traditional synchronous calls block the main thread when the target process is frozen, even causing ANR. A oneway call returns immediately after writing to the Binder buffer (the client thread does not wait for the Server to finish execution; the caller does not need to wait for the remote execution to complete, and can return immediately in most business scenarios). The main process never blocks and never crashes because of this.

Kernel-Level Message Queue: When the target process is frozen, the Binder driver does not discard oneway transactions but caches them in the kernel buffer. Once the subprocess thaws, the driver automatically 'flushes' the backlog of messages over, achieving reliable temporary storage and delayed delivery of messages, perfectly fitting the 'freeze-thaw' scenario.

🌟 Highlight 2: Dual-Channel Wake-up and 'Memory Recovery' Mechanism

To compensate for the defect of RemoteCallbackList automatically cleaning up callbacks, we built a 'dual-channel' wake-up network:

Main Process Side: Pending Event Temporary Storage Pool. Any event that fails to be delivered due to process freezing or disconnection is temporarily stored.

Channel One: Active Check-in on Thaw. When the subprocess Activity's onResume() (i.e., thawing and returning to the foreground) is triggered, it actively sends an 'I'm back' notification to the main process and forcibly re-registers callbacks that may have been deregistered. The main process then pushes all temporarily stored events.

Channel Two: Self-Handshake on Death and Rebuild. If the subprocess is completely killed due to low memory and then rebuilt, in the callback for a successful AIDL service connection, we actively scan the current interface and 'check in' with the main process, triggering the push of temporarily stored events.

This mechanism ensures that whether the subprocess is 'briefly frozen' or 'completely dead,' the state synchronization link can ultimately achieve a closed loop.

🌟 Highlight 3: Dynamic Attachment of WebView and a Two-Level Cache Pool

Creating a WebView instance is costly and prone to causing Activity memory leaks. Our WebViewManager adopts an innovative design:

Dynamic Attachment with MutableContextWrapper: WebView is initialized using ApplicationContext. When it needs to be attached to an Activity container, its baseContext is dynamically replaced with the Activity instance to gain UI capabilities; upon recycling, it switches back to ApplicationContext. This completely severs the strong reference chain from WebView to Activity, eradicating memory leaks.

Two-Level Cache Reuse:

• Active Cache: When a game is minimized to a floating window, the WebView is detached but kept in memory. The next time it's expanded, a second-level hot start is achieved without reloading the page.

• Idle Reuse Pool: When a game is completely closed, the WebView is reset (loaded with a blank page, paused) and placed into a pool. The next time a new game is opened, it is directly reused, eliminating the cold start overhead (about 100-300ms) and main thread jank of creating a WebView each time.

🌟 Highlight 4: 'Zero-Intrusion' JSBridge Proxy Design

After moving to multi-process, how can data services originally in the main process (user info, Token, etc.) be transparently provided to the subprocess's JS? Our solution is an RPC Transparent Proxy.

When a JSBridge API (like getAppToken) in the subprocess is called, it automatically turns to request the local WebIPCClient proxy. This proxy initiates a synchronous query to the main process via AIDL, retrieves the data, and returns it to JS.

The biggest advantage is transparency to the frontend: H5 pages require no changes and still call the original JSBridge protocol, completely unaware that a cross-process communication refactoring has occurred behind the scenes.

🌟 Highlight 5: Separation of Duties and 'On-Demand RPC'

We strictly divided the responsibilities of the main and subprocesses:

• Subprocess (:web): Minimalist. Only responsible for WebView rendering and JS execution, loading no complex business modules of the main App.

• Main Process: Plays the role of 'Data Center' and 'Dispatch Center'.

All requests from the subprocess that need to jump pages or handle complex Schemes are forwarded to the main process via IPC, which dispatches them uniformly. This 'on-demand query' model ensures the subprocess remains lightweight and achieves security isolation—even if an H5 page has a security vulnerability, it is difficult for an attacker to touch the core memory and business state of the main process.

🌟 Highlight 6: Graceful Process Reclamation and 'Delayed Suicide'

To ensure thorough resource release, we designed a graceful exit process for the subprocess:

Logical Cleanup: When closing a game, destroy the WebView instance and disconnect all JSBridge references.

Physical Suicide after a 500ms Delay: After cleanup, the subprocess delays 500ms before calling Process.killProcess() to commit suicide.

Why the delay? Immediate suicide could cause stuttering in the Activity closing animation, transient Binder connection interruptions leading to uncaught errors on the main process side, or AMS state synchronization issues. A brief delay gives the system enough buffer time. An appropriate delay (like 500ms) to end the subprocess can reduce occasional anomalies in closing animations, Binder callbacks, etc. Therefore, the strategy of 'logical exit + delayed process termination' was ultimately adopted.

4. Architecture Diagram

Cross-Process Recharge and Freeze Wake-up Flow

image.png

2. WebView Lifecycle and Context Transition State Machine

image.png

  1. Test Verification Checklist

Before going live, it is recommended to conduct targeted tests on the following core links to ensure the stability of the architecture: