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.
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_baseFull-text search across all 42 articles and 500+ curated resources
get_architecture_guideArchitecture design guidance with articles, discussions, and code tips
explain_internalsDeep-dive into how Compose, Coroutines, Retrofit, ViewModel, and more work under the hood
get_code_examplesReady-to-use code patterns from 50+ curated tips-with-code issues
get_best_practicesCommunity-vetted recommendations from 65+ real-world developer discussions
prepare_for_interviewTechnical interview Q&A covering data classes, coroutines, Compose, DI, and more
check_library_updatesLatest releases and changes across Jetpack, Compose, Kotlin, KSP, and more
get_article / list_articlesRead full deep-dive articles and browse all 42 exclusive articles by topic
browse_category / list_categoriesBrowse 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:
- All primitive types (
Int,String,Float, etc.) - Function types (lambdas)
- Enum types
- Types annotated with
@Stableor@Immutable - Classes where all properties are
valwith stable types MutableState<T>(the container itself notifies Compose of changes)
A type is unstable if:
- It has any
varproperty - 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
- The beauty of Kotlin Type System: Nullable types, sealed classes, type inference, and more
- Kotlin Generics: The Ultimate Guide with Practical Examples: Deep dive into generics with extensive practical examples
- The val Property != Immutable in Kotlin: Why
valis read-only, not truly immutable - Kotlin Under the Hood: Exploring Constructors and Init Blocks: Execution sequence of constructors and init blocks
- Kotlin Under the Hood: Objects, Companion Objects, and Annotations:
@JvmStatic,@JvmField,@JvmOverloadsexplained - Kotlin Design Patterns: Proxy Explained: The proxy pattern in Kotlin with practical examples
Kotlin Multiplatform
- Build Your First Android and iOS Mobile App With Kotlin Multiplatform: Set up KMP and build your first cross-platform app
- Exploring Kotlin Multiplatform: A Visual Guide: KMP/CMP architecture illustrated with visual diagrams
- Writing a Kotlin Multiplatform App from Start to Store: Notes from building and shipping a KMP app by Zac Sweers
- Android Developers: Kotlin Multiplatform Overview: Official overview of KMP and Jetpack library support
- Convert Your Native Project to Kotlin Multiplatform: Step-by-step migration guide
Dove Letter Deep-Dive Articles (Exclusive)
| Article Slug | Topic |
|---|---|
kotlin-lazy-internals | Internal Mechanisms of Lazy |
kotlin-error-handling | A Proposed Evolution for Kotlin's Error Handling |
kotlin-shared-internals-keep | Shared Internals: Kotlin's New Proposal for Cross-Module Visibility |
deep-dive-compatibility | A 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
- Exploring the Declarative Nature of Jetpack Compose: Understand how declarative UI differs from traditional XML-based views
- Understanding and Handling 11 Side-Effects in Jetpack Compose: Master
LaunchedEffect,DisposableEffect,SideEffect, and more - The simplest way to understand side effects in Compose: A simplified breakdown of Compose side-effect functions
- Command Your User Inputs with Jetpack Compose: Text Field Features Hidden in Plain Sight: From basic text fields to advanced features
Navigation
- Type safe navigation for Compose: The stable type-safe Navigation 2.8.0 APIs, migration guide, and testing tips
- Implementing Soft Navigation Requests in Jetpack Compose Navigation: Proper navigation handling patterns
Performance & Internals
- New ways of optimizing stability in Jetpack Compose: Strategies for reducing unnecessary recompositions
- Practical performance problem-solving in Jetpack Compose: Official codelab: Lazy components, recomposition optimization, Layout Inspector
- Enhancing Performance in Jetpack Compose with derivedStateOf: Practical examples of
derivedStateOfusage and benefits - Compose Performance - Finding Regressions: Detecting performance regressions using Perfetto
- Compose Crashes: IllegalStateException Size is out of range: Debugging weird Compose crashes
Advanced Topics
- Loading Initial Data in LaunchedEffect vs. ViewModel: Where to load initial data in composable functions vs ViewModels
- Design Server-Driven UI with Jetpack Compose and Firebase: Implementing Server-Driven UI with Compose
- Designing Effective UIs For Enhancing Compose Previews: Maximizing the potential of Compose Previews
- Jetpack Compose Previews: Delving Deep Into Their Inner Workings: How Compose Previews work internally
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
| Option | Shrink | Obfuscate | Description |
|---|---|---|---|
-keep | No | No | Full protection |
-keepclassmembers | Class: Yes | Class: Yes | Only protect members if class survives |
-keepclasseswithmembers | No | No | Conditional on members existing |
-keepnames | Yes | No | Allow removal, preserve name if kept |
-keepclassmembernames | Yes | Yes | Member name preservation only |
-keepclasseswithmembernames | Yes | No | Conditional name preservation |
Worklist-Based Reachability (Enqueuer)
The Enqueuer maintains liveTypes, liveMethods, liveFields, and a worklist:
- Seed worklist with root set items
- For each method: parse bytecode, enqueue referenced types/methods/fields
- Handle virtual dispatch: when subclass
B extends Abecomes live and overridesfoo(), enqueueB.foo()too - Repeat until fixpoint (worklist empty)
Everything not in the live sets is removed by TreePruner.
Full Mode vs Compatibility Mode
| Behavior | Compat Mode | Full Mode |
|---|---|---|
| No-arg constructors | Implicitly kept | Removed unless explicit |
| Generic signatures | Retained | Stripped (breaks Gson!) |
| Reflection heuristics | Kept | Removed |
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
| Result | Meaning |
|---|---|
IGNORED | Scope no longer valid or removed |
SCHEDULED | Not composing; recomposition queued |
DEFERRED | Composing, but scope already passed |
IMMINENT | Composing, 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
- Jetpack Compose Mechanism: Compose structures, declarative UI, composable functions, stability, and KMP
- Jetpack Compose Structure and Stability: Overall structure and understanding stability for performance
- Advanced layout animations in Compose (Shared elements): Shared element transitions and Lookahead system
- Compose animations - Android Developers Backstage: Animation primitives to Shared Element Transitions
- DroidKaigi 2024: Creative and complex user interfaces with Compose UI: Recreating complex UIs from video games to showcase Compose APIs
- Droidcon NYC: Composing an API the right way: Official guidelines for designing Compose APIs
- Android Developers: Fragments in Compose: Migration from fragments to Compose
- Compose beyond the UI: Molecule at Swedish Railways: Harnessing Compose for non-UI parts using Molecule
Compose Tips Series (Google)
- Are you making delightful UI? | Compose Tips: Edge-to-edge, insets, nested scrolling, pagers, brushes, and shaders
- Pager | Compose Tips: Pager composable, fling distance, and PagerState animations
- Nested scrolling | Compose Tips: Coordinating multiple scrollable components
- Brush | Compose Tips: Solid colors, gradient brushes, and gradient animations
- Shaders | Compose Tips: Pixel painting and graphics rendering with shaders
- Flow layouts | Compose Tips: Flow layouts for dynamic, adaptive designs
- Lazy grids | Compose Tips: Dynamic grid layouts for different window sizes
- Navigation Compose meet Type Safety: Type-safe Navigation Compose APIs
Kotlin & Coroutines
- 5 Kotlin Coroutine Secrets I Wish I Knew Earlier: Common coroutine mistakes you haven't heard before
- Exploring Kotlin Performance with Romain Guy: Kotlin performance nuances and optimizations by Romain Guy (Google)
- Kotlin After 2.0 | Talking Kotlin: Future of Kotlin after 2.0
- Closing Panel | KotlinConf'24: Jake Wharton, Hadi, and community Q&A
Multiplatform
- Lifecycle path to Multiplatform: Google's journey converting AndroidX Lifecycle libraries to KMP
- Kotlin Multiplatform in Google Workspace: Google Workspace's migration from Java to KMP
- Room Renovations, KMP: Room's journey entering the KMP world
Performance & Platform
- Micro optimizations - Android Developers Backstage: Micro optimizations and custom tools discussion
- Droidcon Berlin: Actionable App Performance Measurement: Where to start with app performance
- Android Developers: Building for the future of Android: Edge-to-edge UI, predictive back, widgets, and performance
- The only Correct Way to Load Initial Data In Your Android App?: Three approaches to loading initial screen data
Conference Collections
- Droidcon San Francisco 2024: All recorded videos
- Droidcon Berlin 2024 - 103 Videos: Full presentation collection
- TheAndroidShow Playlists: Google's Android Show video playlists
Clean Architecture & Design Patterns
- How to architect Android apps: a deep dive into principles, not rules: SOLID and Clean Architecture principles adapted flexibly to Android
- The "Real" Modularization in Android: Clean Architecture with SOLID principles for modularization
- A Use Case for UseCases in Kotlin: Why UseCase classes matter for encapsulating business rules
- Android Clean Architecture: Implementing Use Cases, Managers, and Multi-Provider Systems: Single-responsibility Use Cases, Manager classes, and multiple providers
ViewModel & State
- Loading Initial Data in LaunchedEffect vs. ViewModel: Where to load initial data in Compose
- Loading Initial Data on Android Part 2: Clear All Your Doubts: Arguments, flow refresh, side effects in
ViewModel.init, andWhileSubscribed(5_000) - Composable-scoped ViewModel: An interesting experiment: Scoping ViewModel lifecycles with AndroidX internals
- Exploring ViewModel Internals: ViewModel's inner workings under the hood
- Understanding ViewModel Persistence Across Configuration Changes: Internal mechanics of ViewModel persistence
Modularization & Navigation
- Approaches for Multi-Module Feature Architecture on Android: Structuring multi-module architecture
- Automating Modularization And Wiring Them: A Gradle-based Approach: Streamlining module creation with Gradle
- How we improved our Android navigation performance by ~30%: Yelp's single-activity architecture migration
Dove Letter Deep-Dive Articles (Exclusive)
| Article Slug | Topic |
|---|---|
viewmodel | ViewModel: How Configuration Change Survival Actually Works |
viewmodel-internals | Breaking down the ViewModel's Internal Mechanisms and Multiplatform |
activity-lifecycle-internals | Activity Lifecycle Internals: How the Framework Drives onCreate/onResume/onDestroy |
dependency-injection-container | Building a Simple Dependency Injection Container in Kotlin |
sandwich | Scalable 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:
-
GreedyScheduler: For immediate, in-process execution when constraints are already met. Runs work directly in the current process usingWorkerWrapper. -
SystemJobScheduler: Delegates to Android'sJobScheduler(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
- Object methods (
equals,hashCode,toString): Delegated directly: not HTTP endpoints - Default interface methods: Executed via
MethodHandle: not parsed as endpoints - 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:
| State | Value | Meaning |
|---|---|---|
| Absent | null | No thread has started parsing |
| Lock object | Object | A thread is currently parsing |
ServiceMethod | Parsed model | Ready 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
RequestFactory: Parses@GET,@POST,@Path,@Query,@Bodyinto parameter handlersHttpServiceMethod: Handles return type adaptation (Call<T>,suspend fun, RxJavaObservable<T>)OkHttpCall: Lazy wrapper around OkHttp: the actual HTTP request isn't created untilexecute()/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:
rememberfor 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:
- Android calls
onRetainNonConfigurationInstance()before destroying the Activity ComponentActivitypackages theViewModelStoreintoNonConfigurationInstances- The object stays in memory, held by
ActivityThread - The new Activity retrieves it via
lastNonConfigurationInstanceinensureViewModelStore()
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:
- Close keyed resources (including
viewModelScope) - Close unkeyed resources (constructor-provided closeables)
- Clear only the unkeyed set (keyed map is intentionally NOT cleared to prevent accidental recreation of
viewModelScope) - 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
- Advanced Kotlin Coroutine Cheat sheet (for Android Engineer): A handy reference for tackling intricate coroutine scenarios
- Advanced Kotlin Flow Cheat sheet (for Android Engineer): Hot vs cold flows, Channel, Flow, SharedFlow, and StateFlow
- Kotlin Coroutine Mechanisms part 1: runBlocking vs. launch: Differences between
runBlockingandlaunch - Kotlin Coroutine Mechanisms part 2: launch v. async: Fire-and-forget vs concurrent tasks returning results
- Kotlin Coroutine Mechanisms part 3: swapping CoroutineContext: Transitioning between Dispatchers.IO and Dispatchers.Main
Cancellation & Lifecycle
- Coroutine Cancellation and Timeouts: Managing resources and terminating operations
- Cancellation in Kotlin Coroutines - Internal working: How coroutines handle cancellation internally
- Understanding the Coroutine Lifecycle in Kotlin: Job states, transitions, and nested coroutines
- Android Process Lifecycle + Coroutines: Combining lifecycle-process with coroutines
Dispatchers & Context
- Exploring CoroutineContext: Deep dive into CoroutineScope and CoroutineContext
- Exploring the Secrets of Dispatchers Default and IO: CPU cores, threads, and why dispatchers matter
- Dispatchers.Unconfined and why you actually want EmptyCoroutineContext: Avoiding unpredictable behavior with Unconfined
- Dispatchers - IO and Default Under the Hood: How dispatchers decide thread allocation
Structured Concurrency & Patterns
- Understanding supervisorScope, SupervisorJob, coroutineScope, and Job: Why
SupervisorJobbehaves differently fromsupervisorScope - Coroutine patterns in Android, and why they work: Effective patterns for Android with structured concurrency
- Top 10 Coroutine Mistakes We All Have Made as Android Developers: Common mistakes and how to avoid them
- Mastering Kotlin Coroutine Channels: Producer-consumer workflows, fan-in, fan-out, and buffering
- ShareIn vs StateIn in Kotlin Flows: When to use each for converting cold flows to hot flows
Internals
- Coroutine Suspension Mechanics: The Finite State Machine within: How suspend functions become state machines
- RxJava to Kotlin Coroutines: The Ultimate Migration Guide: Comprehensive migration guide from RxJava
Dove Letter Deep-Dive Articles (Exclusive)
| Article Slug | Topic |
|---|---|
coroutines-compiler-machinery | How Kotlin Turns suspend into State Machines |
cancellation-coroutines | CancellationException 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:
- Walk the linked list from head to tail
- For each record, check if it's valid for this snapshot (not from the future, not from a concurrent snapshot)
- 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:
- Checks policy equivalence (skips if the value hasn't changed via
StructuralEqualityPolicy) - Tries to reuse an existing
StateRecordthat no active snapshot can see - If no reusable record exists, creates a new one and prepends it to the linked list
- Temporarily sets the record's
snapshotIdtoSnapshotIdMax(invisible to all) while populating - 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 safeStateRecordreuse 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:
$completionis not our continuation class: allocate new - Resume: Sign bit is set: this is
invokeSuspendre-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.