Activity Lifecycle Internals: How the Framework Drives onCreate/onResume/onDestroy

skydovesJaewoong Eum (skydoves)||23 min read

Activity Lifecycle Internals: How the Framework Drives onCreate/onResume/onDestroy

Every Android developer has overridden onCreate(), onResume(), and onDestroy(). You write your initialization logic, register listeners, and clean up resources, trusting that the framework will call these methods at the right time, in the right order. But what actually invokes these callbacks? The lifecycle does not run itself. Somewhere deep in the Android framework, a sophisticated transaction system serializes commands on the system server side, sends them across a Binder IPC boundary, and then a state machine in your app's process figures out the exact sequence of intermediate transitions needed to reach the target state. The simplicity of Activity.onResume() belies an entire internal architecture devoted to making that call happen reliably.

In this article, you'll dive deep into the internal machinery that drives every Activity lifecycle callback. You'll trace the path from the system server's ClientTransaction through the TransactionExecutor's state machine, into ActivityThread's perform methods, through Instrumentation's dispatch layer, and all the way to the window management code that makes your Activity visible. Along the way, you'll see how the framework calculates intermediate lifecycle states, how it protects against invalid transitions, and why this layered architecture exists in the first place.

This isn't a guide on using Activity lifecycle callbacks. It's an exploration of the internal transaction and state machine architecture that makes them possible.

The fundamental problem: Coordinating lifecycle across process boundaries

When you think about lifecycle callbacks, you might imagine something simple. The system server decides an Activity should resume, and it calls onResume(). If only it were that straightforward. Consider the naive mental model:

// conceptual - what you might imagine happens
activityInstance.onResume();

The reality is far more complex. The system server (running in its own process) cannot directly invoke methods on your Activity (running in your app's process). The call must cross a Binder IPC boundary. But that is just the start of the problem. What if the Activity is currently in the ON_STOP state and needs to reach ON_RESUME? The framework cannot jump directly. It must first transition through ON_RESTART, then ON_START, and only then ON_RESUME. Each of these intermediate callbacks must fire in order, because your code might depend on onStart() having run before onResume().

Furthermore, the system server may need to batch multiple commands (deliver a result, then resume) into a single transaction. It must handle edge cases like an Activity being destroyed while a resume command is in flight. And once the Activity is resumed, the framework must add its DecorView to the WindowManager so it actually becomes visible.

This is the fundamental problem: lifecycle callbacks are not simple method calls. They are the output of a distributed state machine that spans two processes, handles arbitrary state jumps, manages window visibility, and must always produce a deterministic callback order.

ActivityClientRecord: Tracking lifecycle state on the client side

The framework needs a way to track each Activity's current lifecycle state within the app process. This is the job of ActivityClientRecord, a static inner class of ActivityThread that serves as the client side bookkeeping record for each Activity instance.

If you examine the ActivityClientRecord:

// android.app.ActivityThread.ActivityClientRecord
public static final class ActivityClientRecord {
    public IBinder token;
    Activity activity;
    Window window;

    @LifecycleState
    private int mLifecycleState = PRE_ON_CREATE;

    boolean paused;
    boolean stopped;
    // ...
}

Notice the structure:

  1. token is the Binder token that uniquely identifies this Activity across the system server and app process boundary. Every lifecycle command references an Activity by this token.
  2. mLifecycleState tracks the current lifecycle state as an integer constant. It starts at PRE_ON_CREATE, meaning the Activity has not yet been created.
  3. paused and stopped are legacy boolean flags maintained for backward compatibility with older APIs, but mLifecycleState is the authoritative state tracker.

The setState() method keeps everything in sync:

// android.app.ActivityThread.ActivityClientRecord
public void setState(@LifecycleState int newLifecycleState) {
    mLifecycleState = newLifecycleState;
    switch (mLifecycleState) {
        case ON_CREATE:
            paused = true;
            stopped = true;
            break;
        case ON_RESUME:
            paused = false;
            stopped = false;
            break;
        case ON_PAUSE:
            paused = true;
            stopped = false;
            break;
        case ON_STOP:
            paused = true;
            stopped = true;
            break;
    }
}

This is important: every time the Activity advances through a lifecycle state, setState() is called immediately after the callback completes. The TransactionExecutor (which you'll see next) reads getLifecycleState() to determine where the Activity currently is before calculating the path to the next target state. If this bookkeeping were ever out of sync, the state machine would produce incorrect transition sequences.

ClientTransaction: Bundling lifecycle commands for IPC

The system server cannot call methods on your Activity directly. Instead, it constructs a ClientTransaction, a Parcelable container that bundles one or more lifecycle commands for delivery to the app process.

If you examine the ClientTransaction:

// android.app.servertransaction.ClientTransaction
public class ClientTransaction implements Parcelable {

    @NonNull
    private final List<ClientTransactionItem> mTransactionItems = new ArrayList<>();

    @Nullable
    private final IApplicationThread mClient;

    public void addTransactionItem(@NonNull ClientTransactionItem item) {
        mTransactionItems.add(item);
    }

    public void schedule() throws RemoteException {
        mClient.scheduleTransaction(this);
    }
}

The key elements:

  1. mTransactionItems is an ordered list of ClientTransactionItem instances. Each item represents either a lifecycle state request (like "move to RESUMED") or a non lifecycle callback (like "deliver activity result").
  2. mClient is an IApplicationThread reference, the Binder proxy to the target app's main thread. This field exists only on the server side and becomes null after parceling.
  3. schedule() sends the entire transaction across the IPC boundary by calling mClient.scheduleTransaction(this).

The critical observation: a single ClientTransaction can contain multiple items for different purposes. For example, the system server might bundle a NewIntentItem followed by a ResumeActivityItem into one transaction. This batching reduces the number of IPC round trips, because each Binder call has overhead regardless of payload size.

When the transaction arrives on the app side, ClientTransactionHandler.scheduleTransaction() receives it:

// android.app.ClientTransactionHandler
void scheduleTransaction(ClientTransaction transaction) {
    transaction.preExecute(this);
    sendMessage(ActivityThread.H.EXECUTE_TRANSACTION, transaction);
}

The preExecute() call allows each item to perform pre scheduling work (like updating process state), and then the transaction is posted as a message to the main thread's Handler. This ensures all lifecycle transitions execute on the main thread, which is a fundamental guarantee of the Android framework.

TransactionExecutor: The execution engine

When the EXECUTE_TRANSACTION message is handled on the main thread, TransactionExecutor.execute() takes over. This class is the central engine that interprets transaction items and drives the Activity through the correct sequence of lifecycle states.

If you examine the execute() method:

// android.app.servertransaction.TransactionExecutor
public void execute(@NonNull ClientTransaction transaction) {
    Trace.traceBegin(Trace.TRACE_TAG_WINDOW_MANAGER, "clientTransactionExecuted");
    try {
        executeTransactionItems(transaction);
    } catch (Exception e) {
        Slog.e(TAG, "Failed to execute the transaction: "
                + transactionToString(transaction, mTransactionHandler));
        throw e;
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_WINDOW_MANAGER);
    }
    mPendingActions.clear();
}

The executeTransactionItems() method iterates over every item in the transaction and dispatches based on type:

// android.app.servertransaction.TransactionExecutor
public void executeTransactionItems(@NonNull ClientTransaction transaction) {
    final List<ClientTransactionItem> items = transaction.getTransactionItems();
    final int size = items.size();
    for (int i = 0; i < size; i++) {
        final ClientTransactionItem item = items.get(i);
        if (item.isActivityLifecycleItem()) {
            executeLifecycleItem(transaction, (ActivityLifecycleItem) item);
        } else {
            executeNonLifecycleItem(transaction, item,
                    shouldExcludeLastLifecycleState(items, i));
        }
    }
}

The key difference? Lifecycle items and non lifecycle items follow different execution paths. For lifecycle items, executeLifecycleItem() first cycles the Activity to one state before the target state, then executes the final transition with the lifecycle item's specific parameters:

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor