Kembali ke Dev Logs

Android Diary / 4 menit baca

The 4 Ways an Android App Can Die (and a 3-Layer Defense to Survive Them)

Most Android engineers only prepare for one failure mode: a crash caused by a bug. But there are actually four distinct ways an app can "die" — and three of them never even trigger onDestroy(). If you've ever had to keep an app alive on a managed device (think signage boxes or set-top boxes) without root access, understanding this distinction is the difference between a system that's actually reliable and one that just looks reliable in testing.

1. Four Types of App Death

Force Close (Exception Crash)
Caused by coding bugs — NullPointerException, IndexOutOfBoundsException, memory leaks. The symptoms are obvious: an "App has stopped" dialog, or the screen flickers back to the home screen. Critically, onDestroy() is not called here — the process gets torn down abruptly by the exception.

Graceful Exit (Normal Closure)
Happens when the user presses Back, Exit, or swipes the app away from Recent Apps. This is the only scenario where onDestroy() is actually called by the system — which is exactly why it's the easiest to handle, and also the one most engineers over-rely on.

Silent Kill (Low Memory Killer / OOM)
RAM fills up, and the Android system kills background or foreground processes to protect itself — or it happens via am kill through ADB. No error dialog, no warning — the app just vanishes. Just like Force Close, onDestroy() never runs because the process is cut instantly.

Hard Kill (Force Stop)
The user goes to Settings → Apps → Force Stop, or runs am force-stop. This is total shutdown — no code can run after this point until the user manually relaunches the app.

2. Why This Distinction Matters

If your recovery strategy leans entirely on onDestroy(), you're only covering one out of four possible death scenarios. For non-kiosk apps (no root/admin access), the realistic approach is defense in depth — multiple layers that each cover the gaps the others leave open.

3. The 3-Layer Defense Strategy

Layer 1: Global Crash Handler (handles bugs)

Catches errors before Android gets a chance to show the crash dialog:

  1. Catch the exception.
  2. Check crash frequency (circuit breaker) — this prevents bootloops.
  3. Schedule a restart via AlarmManager (e.g. a 2-second delay).
  4. Kill the process immediately.

Layer 2: Sticky Service (handles swipes & silent kills)

A background service running with the START_STICKY flag. If the system kills the app due to low memory, the OS itself will flag this service for automatic restart.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    // START_STICKY: request the OS restart this service if killed
    return START_STICKY
}

override fun onTaskRemoved(rootIntent: Intent?) {
// Called when the user swipes the app away from Recent Apps
restartApp()
super.onTaskRemoved(rootIntent)
}
```

Layer 3: Watchdog (WorkManager) — optional

If both layers above fail, WorkManager provides periodic checks (minimum interval of 15 minutes) as a last-resort safety net.

4. Best Practices in Implementation

  • On app start (onCreate)
  • Initialize the GlobalCrashHandler.
  • Start KeepAliveService (the sticky service).
  • Cancel any pending restart alarms (to avoid double starts).
  • While the app is running
  • Save a "last alive" timestamp to SharedPreferences every minute.
  • Reset the crash counter once the app has run smoothly for more than 2 minutes.
  • When a crash occurs
  • The handler catches the error.
  • Check the crash count: fewer than 3 crashes in a minute → schedule a restart (2 seconds) → kill the process. More than 3 → don't restart, and let it die to prevent the device from hanging or overheating.
  • When the user swipe-closes the app
  • KeepAliveService.onTaskRemoved() fires.
  • Schedule a restart (2 seconds).
  • Stop the service.

A key snippet: anti-NPE AlarmManager code

Always include a unique action when building the alarm's Intent — without it, the PendingIntent can silently fail to match, and the restart never fires:

fun scheduleRestart(context: Context) {
    val intent = Intent(context, StartupOnBootUpReceiver::class.java).apply {
        // A UNIQUE ACTION IS MANDATORY
        action = "${context.packageName}.ACTION_RESTART_APP"
        setPackage(context.packageName)
        addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
    }
    // ... proceed to PendingIntent and AlarmManager ...
}

The receiver needs to handle both boot and restart requests:

<receiver android:name=".StartupOnBootUpReceiver" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
        <action android:name="${applicationId}.ACTION_RESTART_APP" />
        <action android:name="${applicationId}.ACTION_CRASH_RESTART" />
    </intent-filter>
</receiver>

5. Conclusion

  1. Never rely solely on onDestroy() — Android frequently skips it (Silent Kill).
  2. Use a GlobalCrashHandler to handle coding errors gracefully.
  3. A sticky service with onTaskRemoved() is your primary defense for reviving the app after a swipe or a system kill.
  4. Accept the reality: Force Stop via Settings is an absolute Android feature that cannot be bypassed — and being upfront about that limitation is a sign of mature engineering, not a gap in the solution.

---
Written from hands-on experience building high-availability requirements for non-kiosk-mode applications.