Model Context Protocol

Dove Letter MCP Server

1,740+ curated Android, Jetpack Compose & Kotlin resources, from fundamentals to deep internals, handpicked by skydoves. Connect your AI coding assistant and get expert-level guidance directly in your IDE or terminal.

85+
Weekly Issues
1,740+
Curated Resources
42
Deep-Dive Articles
30K+
Lines of Content

What is the Dove Letter MCP Server?

The Model Context Protocol (MCP) lets AI coding assistants access external knowledge bases in real time. The Dove Letter MCP Server gives your AI assistant direct access to the entire Dove Letter knowledge base, 85+ weekly newsletter issues published since September 2024, containing 1,740+ handpicked resources and 42 exclusive deep-dive articles covering Android, Kotlin, and Jetpack Compose.

Instead of relying on generic web searches, your AI assistant can search, reference, and synthesize answers from a professionally curated, continuously updated knowledge base, giving you accurate architecture guidance, framework internals explanations, code patterns, and best practices.

Why Use It?

Expert-Level Code Reviews

Get architecture guidance grounded in community-vetted best practices and 65+ real-world developer discussions, not hallucinated patterns.

Understand Framework Internals

Ask how Compose recomposition, ViewModel survival, coroutine state machines, or R8 tree shaking actually work under the hood, backed by 42 exclusive deep-dive articles.

Stay Current

Check the latest Jetpack, Compose, Kotlin, and KSP releases with 68+ tracked library updates and 29+ AOSP framework changes.

Prepare for Interviews

Access 50+ curated interview Q&A covering data classes, coroutines, Compose performance, delegated properties, lifecycle, and DI.

11 Specialized Tools

Your AI assistant automatically selects the right tool for each query.

search_knowledge_base

Full-text search across all 42 articles and 500+ curated resources

get_architecture_guide

Architecture design guidance with articles, discussions, and code tips

explain_internals

Deep-dive into how Compose, Coroutines, Retrofit, ViewModel, and more work under the hood

get_code_examples

Ready-to-use code patterns from 50+ curated tips-with-code issues

get_best_practices

Community-vetted recommendations from 65+ real-world developer discussions

prepare_for_interview

Technical interview Q&A covering data classes, coroutines, Compose, DI, and more

check_library_updates

Latest releases and changes across Jetpack, Compose, Kotlin, KSP, and more

get_article / list_articles

Read full deep-dive articles and browse all 42 exclusive articles by topic

browse_category / list_categories

Browse 13 resource categories including tips, interviews, conference talks, AOSP changes, and more

Setup

1. Subscribe to Dove Letter via GitHub Sponsors. Already a subscriber? Sign in to doveletter.dev.

2. Once subscribed, the MCP API Keys section will appear on your Preferences page. Generate an API key there.

3. Configure your client below with the generated key.

Run this command in your terminal:

claude mcp add doveletter --transport streamable-http https://doveletter-mcp.vercel.app/api/mcp --header "Authorization: Bearer YOUR_API_KEY"

Add this to your .cursor/mcp.json:

{
  "mcpServers": {
    "doveletter": {
      "url": "https://doveletter-mcp.vercel.app/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}

Add this to your .vscode/mcp.json:

{
  "servers": {
    "doveletter": {
      "type": "http",
      "url": "https://doveletter-mcp.vercel.app/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}

Open Settings → MCP Servers → Add Server and enter:

Server URL: https://doveletter-mcp.vercel.app/api/mcp

Auth Header: Authorization: Bearer YOUR_API_KEY

Add this to your ~/.gemini/settings.json:

{
  "mcpServers": {
    "doveletter": {
      "url": "https://doveletter-mcp.vercel.app/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}

Add this to your ~/.codeium/windsurf/mcp_config.json:

{
  "mcpServers": {
    "doveletter": {
      "serverUrl": "https://doveletter-mcp.vercel.app/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_API_KEY"
      }
    }
  }
}

See It in Action

Real answers generated by Claude Code (Opus 4.6) using the Dove Letter MCP Server knowledge base.

The Compose compiler's Stability Inference System determines whether types are "stable": meaning Compose can safely skip recomposition when parameters haven't changed.

The Stability Type Hierarchy

Stability
├── Certain(stable: Boolean)     // Compile-time known
├── Runtime(declaration)         // Checked at runtime (@StableMarker)
├── Unknown(declaration)         // Cannot determine
├── Parameter(index)             // Depends on type parameter
└── Combined(elements)           // Combination of the above

What Makes a Type Stable?

A type is stable if:

  1. All primitive types (Int, String, Float, etc.)
  2. Function types (lambdas)
  3. Enum types
  4. Types annotated with @Stable or @Immutable
  5. Classes where all properties are val with stable types
  6. MutableState<T> (the container itself notifies Compose of changes)

A type is unstable if:

  • It has any var property
  • It has a property of an unstable type (e.g., List, Map: interfaces with unknown implementations)
  • It comes from a non-Compose module without stability metadata

The Inference Algorithm (Simplified)

Phase 1: Fast Path Checks
  → Primitives, String, Function types → Stable
  → Annotated @Stable/@Immutable → Stable

Phase 2: Type-Level Analysis
  → Check all properties recursively
  → All val + all stable types → Stable
  → Any var or unstable type → Unstable

Phase 3: Generic Type Handling
  → List<T> → Unstable (interface, unknown impl)
  → ImmutableList<T> → Stable (kotlinx.collections.immutable)

Strong Skipping Mode (Default since Compose 1.7)

With Strong Skipping, even composables with unstable parameters can be skipped if parameters are referentially equal (===). This relaxes the stability requirement significantly:

  • Stable params: compared by equals()
  • Unstable params: compared by reference identity (===)
  • Lambdas: automatically remembered by the compiler

Practical Tips

// Unstable: List is an interface
data class UiState(val items: List<Item>)

// Stable: ImmutableList from kotlinx.collections.immutable
data class UiState(val items: ImmutableList<Item>)

// Or annotate to promise stability
@Immutable
data class UiState(val items: List<Item>)

Core Language Features

Kotlin Multiplatform

Dove Letter Deep-Dive Articles (Exclusive)

Article SlugTopic
kotlin-lazy-internalsInternal Mechanisms of Lazy
kotlin-error-handlingA Proposed Evolution for Kotlin's Error Handling
kotlin-shared-internals-keepShared Internals: Kotlin's New Proposal for Cross-Module Visibility
deep-dive-compatibilityA Deeper Dive into Compatibility Concepts

The Dove Letter knowledge base curates a rich collection of Compose articles spanning fundamentals, internals, performance, and advanced patterns. Here is a recommended reading path:

Fundamentals & UI Patterns

Navigation

Performance & Internals

Advanced Topics

R8 operates through a layered pipeline: pattern matching -> reachability analysis -> tree pruning -> obfuscation.

The Pipeline

1. Input Reading       →  Read all .class files
2. Root Set Building   →  Match keep rules against every class
3. Enqueuer/Tracing    →  Worklist-based reachability from roots
4. Tree Pruning        →  Remove everything unreachable
5. Minification        →  Rename survivors to short names
6. Output Writing      →  Emit optimized .dex

The Six Keep Options Matrix

OptionShrinkObfuscateDescription
-keepNoNoFull protection
-keepclassmembersClass: YesClass: YesOnly protect members if class survives
-keepclasseswithmembersNoNoConditional on members existing
-keepnamesYesNoAllow removal, preserve name if kept
-keepclassmembernamesYesYesMember name preservation only
-keepclasseswithmembernamesYesNoConditional name preservation

Worklist-Based Reachability (Enqueuer)

The Enqueuer maintains liveTypes, liveMethods, liveFields, and a worklist:

  1. Seed worklist with root set items
  2. For each method: parse bytecode, enqueue referenced types/methods/fields
  3. Handle virtual dispatch: when subclass B extends A becomes live and overrides foo(), enqueue B.foo() too
  4. Repeat until fixpoint (worklist empty)

Everything not in the live sets is removed by TreePruner.

Full Mode vs Compatibility Mode

BehaviorCompat ModeFull Mode
No-arg constructorsImplicitly keptRemoved unless explicit
Generic signaturesRetainedStripped (breaks Gson!)
Reflection heuristicsKeptRemoved

Conditional Rules for DI

-if class * { @dagger.hilt.android.lifecycle.HiltViewModel *; }
-keep class <1>_HiltModules { *; }

Back-references (<1>) let a single rule cover all generated classes automatically.

Compose uses Recompose Scopes: a runtime tracking mechanism that connects state reads to composable functions and orchestrates minimal UI updates.

The Compiler-Generated Pattern

Every composable is transformed by the compiler:

// After compiler transformation
fun Greeting(person: Person, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(67890)

    if ($composer.changed(person) || !$composer.skipping) {
        Text("Hello, ${person.name}!", $composer, 0)
    } else {
        $composer.skipToGroupEnd()  // Skip entirely!
    }

    $composer.endRestartGroup()?.updateScope { $composer ->
        Greeting(person, $composer, $changed or 1)  // Restart lambda
    }
}

Bidirectional Dependency Tracking

When a composable reads state, the readObserver creates a bidirectional mapping:

  • observations: state object -> scopes that read it
  • trackedInstances: (within each scope) state object -> composition token

Token-based duplicate detection provides O(1) deduplication without per-pass allocations.

Four Invalidation Outcomes

ResultMeaning
IGNOREDScope no longer valid or removed
SCHEDULEDNot composing; recomposition queued
DEFERREDComposing, but scope already passed
IMMINENTComposing, scope not yet reached: will recompose in this pass!

The DerivedState Optimization

Scopes remember previous values of derivedStateOf. When invalidated, they compare current vs. remembered values. If derivedStateOf { "$firstName $lastName" } produces the same result after firstName changes, no recomposition occurs.

Compact Flag Storage

RecomposeScopeImpl packs 11 boolean flags into a single Int using bit masks (saving ~10 bytes per scope across thousands of scopes):

private const val UsedFlag = 0x001
private const val RequiresRecomposeFlag = 0x008
private const val SkippedFlag = 0x010
// ... 8 more flags

The Dove Letter knowledge base curates conference talks and video content from Droidcon, KotlinConf, Google I/O, and more.

Compose & UI

Compose Tips Series (Google)

Kotlin & Coroutines

Multiplatform

Performance & Platform

Conference Collections

Clean Architecture & Design Patterns

ViewModel & State

Modularization & Navigation

Dove Letter Deep-Dive Articles (Exclusive)

Article SlugTopic
viewmodelViewModel: How Configuration Change Survival Actually Works
viewmodel-internalsBreaking down the ViewModel's Internal Mechanisms and Multiplatform
activity-lifecycle-internalsActivity Lifecycle Internals: How the Framework Drives onCreate/onResume/onDestroy
dependency-injection-containerBuilding a Simple Dependency Injection Container in Kotlin
sandwichScalable API Response Handling Across Multi Layered Architectures

Coil is built on two architectural principles: coroutine-native design and a composable interceptor chain (similar to OkHttp).

Two-Tier Memory Cache

Tier 1 - Strong Cache (LRU): A LinkedHashMap with accessOrder = true and a size limit. Each get() moves the entry to the end; removeEldestEntry() evicts when over capacity.

Tier 2 - Weak Cache (Unlimited): Holds WeakReferences to images. When the GC hasn't reclaimed an evicted image, you get a free cache hit.

The bridge: When evicting from the strong cache, entries are moved to the weak cache (not discarded). Scrolling back up through a list often finds images still in the weak cache.

override fun get(key: Key): Value? = synchronized(lock) {
    strongMemoryCache.get(key) ?: weakMemoryCache.get(key)
}

Two-Pass Bitmap Decoding

Pass 1 (inJustDecodeBounds = true): Reads only image headers for dimensions: fast and cheap.

Pass 2: With dimensions known, calculates optimal inSampleSize and decodes:

fun calculateInSampleSize(srcWidth: Int, srcHeight: Int,
                          dstWidth: Int, dstHeight: Int, scale: Scale): Int {
    val widthSample = (srcWidth / dstWidth).takeHighestOneBit()
    val heightSample = (srcHeight / dstHeight).takeHighestOneBit()
    return when (scale) {
        Scale.FILL -> minOf(widthSample, heightSample)
        Scale.FIT -> maxOf(widthSample, heightSample)
    }.coerceAtLeast(1)
}

takeHighestOneBit() returns the highest power-of-two <= the input (BitmapFactory only supports power-of-two sampling). Then density scaling fine-tunes to the exact target size.

Cache Key Design

Transformations require size-specific keys. A blurred 100x100 and blurred 200x200 are different cache entries. Images without transformations can be reused across sizes (if Precision.INEXACT).

Hardware Bitmap Optimization

Hardware bitmaps (GPU memory) are used by default for faster rendering and lower RAM usage, with automatic fallback to software bitmaps when EXIF transformations or custom transformations are needed.

WorkManager is built on a persistence-first architecture with dual-scheduler coordination.

The Persistence Layer

Every work request is persisted to a Room database as a WorkSpec entity before any execution begins:

@Entity
data class WorkSpec(
    @PrimaryKey val id: String,
    val state: State,          // ENQUEUED, RUNNING, SUCCEEDED, FAILED, BLOCKED, CANCELLED
    val workerClassName: String,
    val inputMergerClassName: String,
    val input: Data,
    val output: Data,
    val constraints: Constraints,
    val runAttemptCount: Int,
    val backoffPolicy: BackoffPolicy,
    val periodStartTime: Long,
    val intervalDuration: Long,
    // ...
)

This means: even if the process is killed immediately after enqueue(), the work definition survives in SQLite.

Dual-Scheduler System

WorkManager uses two schedulers simultaneously:

  1. GreedyScheduler: For immediate, in-process execution when constraints are already met. Runs work directly in the current process using WorkerWrapper.

  2. SystemJobScheduler: Delegates to Android's JobScheduler (API 23+) for deferred/constrained work. JobScheduler survives process death and reboots, waking the app when constraints are satisfied.

Force-Stop Detection

On every app startup, ForceStopRunnable checks whether a previously scheduled AlarmManager alarm still exists. If the alarm is missing, the app was force-stopped, and WorkManager reschedules all pending work with the system scheduler.

Work Chaining via Dependency Graph

Work chains create entries in a Dependency table:

-- "uploadWork" can't run until both "compressWork" and "validateWork" succeed
INSERT INTO dependency VALUES ('uploadWork', 'compressWork');
INSERT INTO dependency VALUES ('uploadWork', 'validateWork');

When a prerequisite completes, WorkManager queries for dependents whose all prerequisites have succeeded, then enqueues them.

Constraint Tracking

Each constraint type (NetworkConstraintTracker, BatteryChargingTracker, StorageNotLowTracker) registers system broadcast receivers. When constraints change, WorkManager re-evaluates all pending WorkSpecs and starts/stops work accordingly.

Retrofit uses Java's dynamic proxy mechanism to create implementations from interfaces at runtime. When you call retrofit.create(GitHubApi::class.java), something remarkable happens.

The Proxy Creation

public <T> T create(final Class<T> service) {
    validateServiceInterface(service);
    return (T) Proxy.newProxyInstance(
        service.getClassLoader(),
        new Class<?>[] {service},
        new InvocationHandler() { ... }
    );
}

Proxy.newProxyInstance() generates a synthetic class at runtime that implements your interface. Every method call is routed to the InvocationHandler.

Three Dispatch Paths

  1. Object methods (equals, hashCode, toString): Delegated directly: not HTTP endpoints
  2. Default interface methods: Executed via MethodHandle: not parsed as endpoints
  3. HTTP endpoint methods: Forwarded to loadServiceMethod() for annotation parsing

The Three-State Concurrent Cache

Retrofit's serviceMethodCache is a ConcurrentHashMap<Method, Object> with three possible states per entry:

StateValueMeaning
AbsentnullNo thread has started parsing
Lock objectObjectA thread is currently parsing
ServiceMethodParsed modelReady to use

The fast path (repeated calls): Just ConcurrentHashMap.get() + instanceof check: no synchronization.

Concurrent first calls: Only one thread parses; others detect the lock object in the map and synchronized on it until parsing completes.

Annotation Parsing Pipeline

  1. RequestFactory: Parses @GET, @POST, @Path, @Query, @Body into parameter handlers
  2. HttpServiceMethod: Handles return type adaptation (Call<T>, suspend fun, RxJava Observable<T>)
  3. OkHttpCall: Lazy wrapper around OkHttp: the actual HTTP request isn't created until execute()/enqueue()

Each parameter gets a ParameterHandler implementing the Strategy pattern (e.g., Path, Query, Body), with Decorator pattern for collections (producing multiple query params with the same name).

Based on community discussions, expert articles, and Compose runtime internals, here are battle-tested practices for 2025+.

1. Understand the Three Phases

Compose runs through Composition (what UI to show), Layout (where to place it), and Drawing (how to render it). State reads in each phase only trigger that phase: read sizes in Layout, colors in Drawing.

// BAD: Triggers recomposition on every scroll offset change
Text(modifier = Modifier.offset(y = scrollState.value.dp))

// GOOD: Defers read to Layout phase
Text(modifier = Modifier.offset { IntOffset(0, scrollState.value) })

2. Use derivedStateOf for Computed Values

// BAD: Recomposes on every scroll pixel
val showButton = scrollState.value > 100

// GOOD: Only recomposes when the boolean result changes
val showButton by remember {
    derivedStateOf { scrollState.value > 100 }
}

The snapshot system uses hash-based invalidation: if the derived value hasn't changed, scopes reading it won't recompose.

3. Use Lambda-Based Modifiers for Frequently Changing State

// BAD: Recomposes parent on every frame during animation
Box(Modifier.background(animatedColor.value))

// GOOD: Only triggers draw phase
Box(Modifier.drawBehind { drawRect(animatedColor.value) })

4. Move State Reads Down the Tree

// BAD: Parent recomposes, all children re-execute
@Composable fun Parent() {
    val name by viewModel.name.collectAsState()
    Child1(name)
    Child2()  // Unnecessarily re-executed
}

// GOOD: Only NameDisplay recomposes
@Composable fun Parent() {
    NameDisplay(viewModel)
    Child2()  // Skipped
}
@Composable fun NameDisplay(vm: MyViewModel) {
    val name by vm.name.collectAsState()
    Text(name)
}

5. Use key() for Dynamic Lists

LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)  // Compose can move and reuse, not recreate
    }
}

6. Leverage Strong Skipping (Default in Compose 1.7+)

Strong Skipping means unstable params are compared by reference (===). This works well with:

  • remember for computed objects
  • Data classes (same instance = same reference = skip)
  • Lambdas are automatically remembered by the compiler

7. Profile Before Optimizing

Use Layout Inspector (recomposition counts), Compose Stability Analyzer (stability heatmaps), and Compose compiler reports (-P plugin:...compose:metricsDestination=...) to find actual bottlenecks instead of guessing.

ViewModel survival is not serialization. The exact same object instance is held in memory while the old Activity is destroyed and the new one is created.

The Retention Chain

Activity.onRetainNonConfigurationInstance()
    └── NonConfigurationInstances { viewModelStore }
        └── ViewModelStore (MutableMap<String, ViewModel>)
            └── Your ViewModel instances

When a configuration change occurs:

  1. Android calls onRetainNonConfigurationInstance() before destroying the Activity
  2. ComponentActivity packages the ViewModelStore into NonConfigurationInstances
  3. The object stays in memory, held by ActivityThread
  4. The new Activity retrieves it via lastNonConfigurationInstance in ensureViewModelStore()

This is why ViewModels survive rotation but NOT process death: NonConfigurationInstances is never serialized.

ViewModelProvider's Thread-Safe Retrieval

fun <T : ViewModel> getViewModel(modelClass: KClass<T>, key: String): T {
    return synchronized(lock) {
        val viewModel = store[key]
        if (modelClass.isInstance(viewModel)) {
            return viewModel as T  // Fast path: retained from config change
        }
        // Create new via factory
        return createViewModel(factory, modelClass, extras).also { store.put(key, it) }
    }
}

The Clearing Order Matters

ViewModelImpl.clear() follows a deliberate sequence:

  1. Close keyed resources (including viewModelScope)
  2. Close unkeyed resources (constructor-provided closeables)
  3. Clear only the unkeyed set (keyed map is intentionally NOT cleared to prevent accidental recreation of viewModelScope)
  4. Call onCleared(): user cleanup runs after all resources are released

Fragment ViewModel Survival

Fragments can't use NonConfigurationInstances directly. Instead, FragmentManagerViewModel (itself a ViewModel in the Activity's store) holds a HashMap<String, ViewModelStore>: one per fragment keyed by fragment.mWho. The entire hierarchy cascades: Activity store -> FragmentManagerViewModel -> Fragment stores -> Fragment ViewModels.

Fundamentals & Core Concepts

Cancellation & Lifecycle

Dispatchers & Context

Structured Concurrency & Patterns

Internals

Dove Letter Deep-Dive Articles (Exclusive)

Article SlugTopic
coroutines-compiler-machineryHow Kotlin Turns suspend into State Machines
cancellation-coroutinesCancellationException in Coroutines

When you write var count by mutableStateOf(0), you're interacting with one of the most sophisticated concurrent systems in modern Android development: the Snapshot System, a Multi-Version Concurrency Control (MVCC) implementation.

Core Mechanism

At its heart, a Snapshot is an isolated view of mutable state at a specific point in time. Every piece of mutable state is a StateObject with a linked list of StateRecord objects, each representing the state's value at a different snapshot ID.

public abstract class StateRecord(
    internal var snapshotId: SnapshotId
) {
    internal var next: StateRecord? = null
    public abstract fun assign(value: StateRecord)
    public abstract fun create(): StateRecord
}

How Reads Work (Finding the Right Version)

When you read state.value, the system walks the StateRecord linked list to find the correct version for the current snapshot:

  1. Walk the linked list from head to tail
  2. For each record, check if it's valid for this snapshot (not from the future, not from a concurrent snapshot)
  3. Among valid records, select the one with the highest snapshot ID

This is entirely lock-free, making reads extremely fast.

How Writes Work (Creating New Records)

When you write state.value = newValue, the system:

  1. Checks policy equivalence (skips if the value hasn't changed via StructuralEqualityPolicy)
  2. Tries to reuse an existing StateRecord that no active snapshot can see
  3. If no reusable record exists, creates a new one and prepends it to the linked list
  4. Temporarily sets the record's snapshotId to SnapshotIdMax (invisible to all) while populating
  5. Once the value is set, atomically makes it visible by updating to the current snapshot ID

Optimized Data Structures

  • SnapshotIdSet: Uses bit-packing for the most recent 128 snapshot IDs (O(1) lookups) and a sorted array for older IDs. Since active snapshots cluster near the current ID, the vast majority of validity checks hit the fast path.
  • SnapshotDoubleIndexHeap: A min-heap tracking the lowest "pinning" snapshot ID across all active snapshots, enabling safe StateRecord reuse with O(1) minimum lookups.

Connecting to Recomposition

During composition, a readObserver is installed. Every state read triggers snapshot.readObserver?.invoke(state), recording that "scope X read state Y." When snapshots are applied, the applyObserver checks which scopes read modified states and calls scope.invalidate(), triggering recomposition of only the affected composables.

The suspend keyword triggers a six-stage compiler transformation pipeline that converts your sequential code into a resumable state machine. The JVM has no native coroutine support: when a method returns, its stack frame is gone forever.

The CPS Transformation

Every suspend function receives a hidden parameter:

// Source
suspend fun fetchUser(): User { ... }

// After compilation
fun fetchUser($completion: Continuation<User>?): Any?

The return type becomes Any? because the function can return either the actual result or the sentinel COROUTINE_SUSPENDED.

The Continuation Class

For each suspend function, the compiler generates an inner class extending ContinuationImpl:

class FetchData$1(
    var I$0: Int,        // spilled local variable
    var result: Any?,    // resume value
    var label: Int,      // current state
    completion: Continuation<*>?
) : ContinuationImpl(completion) {
    override fun invokeSuspend(result: Result<Any?>): Any? {
        this.result = result
        this.label = this.label or 0x80000000  // set sign bit
        return fetchData(0, this)               // re-enter
    }
}

The Sign Bit Trick

The compiler uses a single bit to distinguish three scenarios:

  • Fresh call: $completion is not our continuation class: allocate new
  • Resume: Sign bit is set: this is invokeSuspend re-entering
  • Recursive call: Same class but no sign bit: treat as fresh

The TABLESWITCH Dispatch

The function body is split into segments between suspension points, with a TABLESWITCH at entry:

TABLESWITCH 0..N:
    0 -> state_0      // initial entry
    1 -> state_1      // resume after first suspend call
    2 -> state_2      // resume after second suspend call
    default -> throw IllegalStateException

Variable Spilling

Local variables needed after suspension are saved into continuation fields before each suspend point and restored after resumption. Reference variables are nulled out after restore to prevent memory leaks.

Tail Call Optimization

If every suspension point is a tail call, the compiler skips the entire state machine: no continuation class, no field spilling, no TABLESWITCH. Just a simple COROUTINE_SUSPENDED check per call.

Exclusive to Dove Letter Subscribers

The MCP Server is available to all active Dove Letter subscribers. Subscribe via GitHub Sponsors to unlock access to the full knowledge base, including 42 exclusive deep-dive articles, 500+ curated resources, and 11 specialized AI tools that make your development workflow smarter.

Topics Covered

Jetpack ComposeKotlinCoroutinesViewModelNavigationDependency InjectionArchitecturePerformanceR8 / ProGuardCompose CompilerSnapshot SystemNetworkingTestingBuild SystemStorageLifecycleKMPUI Patterns