Android & Kotlin Technical Articles

Detailed articles on Android development, Jetpack Compose internals, Kotlin coroutines, and open source library design by skydoves, Google Developer Expert and maintainer of Android libraries with 40M+ annual downloads. Read practical guides on Retrofit, Compose Preview, BottomSheet UI, coroutine compilation, and more.

Exclusive Articles
RSS

This is a collection of private or subscriber-first articles written by the Dove Letter, skydoves (Jaewoong). These articles can be released somewhere like Medium in the future, but always they will be revealed for Dove Letter members first.

Optimize App Performance by Mastering Stability in Jetpack Compose

Compose's performance model centers on one idea: skip work that does not need to happen. When the runtime can prove that a composable's inputs have not changed, it skips re-execution entirely. This optimization, called skipping, is what makes Compose fast by default. But small coding patterns can silently disable skipping across large sections of a UI tree, and without the right tools, these regressions are invisible until users notice dropped frames. In this article, you'll explore the stability system that powers skipping, how the compiler infers stability for your types, common patterns that break it mutable collections, var properties, lambda captures, wrong phase state reads, practical fixes with before and after code, how to detect instability using the Compose Stability Analyzer, and how to enforce stability baselines in CI/CD to prevent regressions from reaching production. The fundamental problem: Invisible recomposition waste Every composable function can be re-executed whenever the state it reads changes. When a state value changes, Compose walks the tree and re-executes every composable that depends on that state. The mechanism that makes this efficient is skipping: if a composable's parameters have not changed since the last execution, Compose skips it and reuses the previous output. For skipping to work, two conditions must be true. First, the parameter's type must be stable, meaning the compiler can guarantee that the value's observable state will not change without Compose being notified. Second, the current value must be equal to the previous value via equals. When both conditions hold, the composable is marked skippable, and the compiler generates a comparison check before each reexecution. The problem arises when a parameter type is unstable. If the compiler cannot guarantee stability, it has no choice but to re-execute the composable every time, regardless of whether the actual value changed. One unstable parameter is enough to disable skipping for that composable. Worse, the effect cascades: if a parent composable re-executes, it passes new parameter instances to all its children, triggering reexecution down the entire subtree. Consider a list screen where the data is passed as a List<Item: kotlin @Composable fun ItemListitems: List<Item { LazyColumn { itemsitems { item - ItemCarditem } } } List is a Kotlin interface. The compiler cannot prove that the underlying implementation is immutable because MutableList also implements List. This means items is unstable, so without Strong Skipping Mode the compiler cannot generate a skip check for ItemList. Every state change in the parent recomposes the entire list, even if the list content has not changed. How the compiler decides stability The Compose compiler analyzes every type used as a composable parameter and assigns it a stability classification. Understanding these classifications explains why certain patterns cause performance problems and how to fix them.

ComposePerformanceAndroid
Friday, April 10, 2026
Build Your Own Landscapist Image Plugin in Jetpack Compose

Landscapisthttps://github.com/skydoves/landscapist provides a composable image loading library for Jetpack Compose and Kotlin Multiplatform. Among its image composables, LandscapistImage stands out as the recommended choice: it uses Landscapist's own standalone loading engine built from scratch for Jetpack Compose and Kotlin Multiplatform, with no dependency on platform specific loaders like Glide or Coil. It handles fetching, caching, decoding, and display internally, and it works identically across Android, iOS, Desktop, and Web. On top of that, LandscapistImage exposes a plugin system through the ImagePlugin sealed interface, giving you five distinct hook points into the image loading lifecycle where you can inject custom behavior without modifying the loader itself. In this article, you'll explore the ImagePlugin architecture, examining each of the five plugin types and why they exist, how ImagePluginComponent collects and dispatches plugins through a DSL, and how built in plugins like PlaceholderPluginhttps://skydoves.github.io/landscapist/placeholder/placeholderplugin, ShimmerPluginhttps://skydoves.github.io/landscapist/placeholder/shimmerplugin, CircularRevealPluginhttps://skydoves.github.io/landscapist/animation/circular-reveal-animation, PalettePluginhttps://skydoves.github.io/landscapist/palette/, and ZoomablePluginhttps://skydoves.github.io/landscapist/zoomable/zoomableplugin implement these interfaces in practice. Why LandscapistImage for plugins Before diving into the plugin system, it is worth understanding why LandscapistImage is the best foundation for plugin based image loading. LandscapistImage uses its own standalone engine landscapist-core rather than delegating to Glide, Coil, or Fresco. This means every stage of the image loading pipeline, from network fetching through memory caching to bitmap decoding, is controlled by a single Kotlin Multiplatform implementation. The benefit for plugins is direct: when LandscapistImage transitions from loading to success, it knows the exact moment the bitmap becomes available. It passes that bitmap directly to PainterPlugin and SuccessStatePlugin without any adapter layer or platform specific conversion. The plugin receives a real ImageBitmap, not a wrapped platform object. This also means LandscapistImage works on every Compose Multiplatform target. A ShimmerPlugin you write for Android runs identically on iOS and Desktop. There is no "this plugin only works with Glide" problem, because there is no Glide in the pipeline. If you look at the LandscapistImage composable signature, you can see where plugins fit in: kotlin @Composable public fun LandscapistImage imageModel: - Any?, modifier: Modifier = Modifier, component: ImageComponent = rememberImageComponent {}, imageOptions: ImageOptions = ImageOptions, loading: @Composable BoxScope.LandscapistImageState.Loading - Unit? = null, success: @Composable BoxScope.LandscapistImageState.Success, Painter - Unit? = null, failure: @Composable BoxScope.LandscapistImageState.Failure - Unit? = null, The component parameter is the entry point for the plugin system. When you pass a rememberImageComponent { ... } block, every plugin you add inside that block gets dispatched at the correct lifecycle stage automatically. You can still use loading, success, and failure lambdas for one off customization, but plugins are the reusable, composable alternative. The fundamental problem: Extending image loading without modifying it

ComposeAndroidKotlin
Saturday, March 28, 2026
How Compose Preview Works Under the Hood

Every Android developer using Compose has written @Preview above a composable and watched it appear in the Studio design panel. But what actually happens between that annotation and the rendered pixels? The answer involves annotation metadata, XML layout inflation, fake Android lifecycle objects, reflection based composable invocation, and a JVM based rendering engine, all collaborating to make a composable believe it is running inside a real Activity. In this article, you'll explore the full pipeline that transforms a @Preview annotation into a rendered image, tracing the journey from the annotation definition itself, through ComposeViewAdapter the FrameLayout that orchestrates the render, ComposableInvoker which calls your composable via reflection while respecting the Compose compiler's ABI, Inspectable which enables inspection mode and records composition data, and the ViewInfo tree that maps rendered pixels back to source code lines. The fundamental problem: Rendering the uncallable A @Composable function is not a regular function. The Compose compiler transforms every @Composable function to accept a Composer parameter and synthetic $changed and $default integers. Beyond the function signature, composables expect to run inside an environment that provides lifecycle owners, a ViewModelStore, a SavedStateRegistry, and other Android framework objects. These dependencies come for free inside a running Activity, but Studio needs to render your composable without a running emulator or device. The tooling must reconstruct enough of the Android runtime for the composable to believe it is inside a real Activity, call the composable through reflection while matching the compiler's transformed signature exactly, and then extract the rendered layout information so Studio can map pixels to source code. This is the challenge the ui-tooling library solves. The @Preview annotation: Metadata, not behavior The @Preview annotation itself does nothing at runtime. It is purely metadata that Studio reads to configure the rendering environment. Looking at the annotation definition: kotlin @MustBeDocumented @RetentionAnnotationRetention.BINARY @TargetAnnotationTarget.ANNOTATIONCLASS, AnnotationTarget.FUNCTION @Repeatable annotation class Preview val name: String = "", val group: String = "", @IntRangefrom = 1 val apiLevel: Int = -1, val widthDp: Int = -1, val heightDp: Int = -1, val locale: String = "", @FloatRangefrom = 0.01 val fontScale: Float = 1f, val showSystemUi: Boolean = false, val showBackground: Boolean = false, val backgroundColor: Long = 0, @AndroidUiMode val uiMode: Int = 0, @Device val device: String = Devices.DEFAULT, @Wallpaper val wallpaper: Int = Wallpapers.NONE, Three meta annotations define how this annotation behaves:

ComposeAndroidKotlin
Sunday, March 15, 2026
Five Algorithms and Data Structures Hidden Inside Jetpack Compose

Jetpack Compose is a UI toolkit on the surface, but its internals draw from decades of computer science research. The runtime uses a data structure borrowed from text editors to store composition state. The modifier system applies an algorithm from version control to diff node chains. The state management layer implements a concurrency model from database engines. These are not theoretical exercises. They are practical solutions to problems that Compose solves every frame. In this article, you'll explore five algorithms and data structures embedded in Compose's internals: the gap buffer that powers the slot table, Myers' diff algorithm applied to modifier chains, snapshot isolation borrowed from database MVCC, bit packing used to compress flags and parameters, and positional memoization that makes remember work without explicit keys. Gap Buffer: The text editor trick inside the slot table When you type in a text editor, characters are inserted at the cursor position. The naive approach, shifting every subsequent character one position to the right, costs On per keystroke. Text editors solved this decades ago with the gap buffer: an array that maintains a block of unused space the "gap" at the cursor position. Inserting a character fills one slot of the gap at O1 cost. Moving the cursor shifts the gap to a new location, but sequential edits at nearby positions are fast because the gap is already there. Compose's slot table faces the same problem. During composition, the runtime inserts groups and their associated data slots as composable functions execute. The SlotWriter maintains two parallel gap buffers: one for groups structural metadata and one for slots stored values like remembered state. Each buffer tracks a gapStart position and a gapLen count. When the writer needs to insert at a position away from the current gap, it moves the gap there first. The operation shifts array elements to reposition the empty space simplified: kotlin private fun moveGroupGapToindex: Int { if groupGapStart == index return

ComposeAndroidKotlin
Wednesday, March 11, 2026
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: java // 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 ONSTOP state and needs to reach ONRESUME? The framework cannot jump directly. It must first transition through ONRESTART, then ONSTART, and only then ONRESUME. 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: java // android.app.ActivityThread.ActivityClientRecord public static final class ActivityClientRecord { public IBinder token; Activity activity; Window window; @LifecycleState private int mLifecycleState = PREONCREATE; 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 PREONCREATE, 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: java // android.app.ActivityThread.ActivityClientRecord public void setState@LifecycleState int newLifecycleState { mLifecycleState = newLifecycleState; switch mLifecycleState { case ONCREATE: paused = true; stopped = true; break; case ONRESUME: paused = false; stopped = false; break; case ONPAUSE: paused = true; stopped = false; break; case ONSTOP: 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.

ArchitectureAndroid
Sunday, February 15, 2026
Building a Google Maps Style Bottom Sheet with Jetpack Compose

Google Maps popularized a bottom sheet pattern that most Android developers recognize immediately: a small panel peeking from the bottom of the screen, expandable to a mid height for quick details, and draggable to full screen for comprehensive information. The user interacts with the map behind the sheet at all times. This pattern looks simple on the surface, but implementing it correctly requires solving several problems: multi state anchoring, non modal interaction, dynamic content adaptation, and nested scroll coordination. Jetpack Compose's standard ModalBottomSheet only supports two states expanded and hidden and blocks background interaction with a scrim, making it unsuitable for this use case. In this article, you'll explore how to build a Google Maps style bottom sheet using FlexibleBottomSheethttps://github.com/skydoves/flexiblebottomsheet, covering how to configure three expansion states with custom height ratios, how to enable non modal mode so users can interact with the content behind the sheet, how to adapt your UI dynamically based on the sheet's current state, how to control state transitions programmatically, how to handle nested scrolling inside the sheet, and how to wrap content dynamically for variable height sheets. Why ModalBottomSheet falls short Consider the standard Material 3 bottom sheet: kotlin @Composable fun StandardBottomSheet { ModalBottomSheet onDismissRequest = { / dismiss / }, sheetState = rememberModalBottomSheetState, { Text"Content here" } } This gives you two states: expanded and hidden. The sheet covers the background with a scrim, blocking all interaction behind it. For a confirmation dialog or action menu, this is fine. But for a Google Maps style experience, you need: 1. Three visible states: A peek height showing a summary, a mid height for details, and a full height for comprehensive content. 2. No scrim: The map behind the sheet must remain fully interactive. 3. Dynamic content: The content should adapt based on the current expansion state. 4. Nested scrolling: Scrollable content inside the fully expanded sheet should scroll naturally, and dragging down from the top of the scroll should collapse the sheet. FlexibleBottomSheethttps://github.com/skydoves/flexiblebottomsheet addresses all of these. Setting up a three state bottom sheet

ComposeCoroutinesAndroid
Sunday, February 15, 2026
R8 Keep Rule Resolution: How the Android Compiler Decides What Lives and What Dies

Every Android release build passes through R8, the whole-program optimizing compiler that shrinks, obfuscates, and optimizes your code before it ships to users. At the center of R8's decision-making are keep rules, the declarative specifications that tell the compiler which classes and members must survive the optimization pipeline. When you write -keep class com.example.MyClass, you're interacting with a sophisticated rule resolution engine that matches patterns against the entire class graph, feeds matched items into a worklist-based reachability analyzer, and ultimately determines what lives and what dies in your final APK. In this article, you'll dive deep into how R8 resolves keep rules under the hood, exploring how the six keep options form a semantic matrix controlling shrinking, obfuscation, and optimization independently, how the RootSetBuilder matches rule patterns against every class in the application, how the Enqueuer performs worklist-based reachability analysis from matched roots, how the Minifier renames surviving classes and members, how conditional -if/-keep pairs enable annotation-driven rule activation, how full mode differs from compatibility mode at the behavioral level, how libraries bundle consumer rules through META-INF directories, and how DI frameworks like Dagger and Hilt interact with R8's tree shaking. This isn't a guide on writing keep rules, it's an exploration of the compiler machinery that resolves, matches, and enforces them. The fundamental problem: Static analysis meets dynamic access Consider a simple Android application using reflection: java public class PluginLoader { public Plugin loadPluginString className throws Exception { Class<? clazz = Class.forNameclassName; return Plugin clazz.getDeclaredConstructor.newInstance; } } R8 performs static analysis. It reads every instruction in every method and builds a graph of what references what. But Class.forNameclassName is a string argument known only at runtime. R8 cannot see this usage statically, so className's target class appears unreferenced. Without intervention, R8 removes it. The naive approach would keep everything: proguard -keep class { ; } This defeats the purpose of R8 entirely, no shrinking, no obfuscation, no size reduction. The challenge is surgical precision: keep exactly what dynamic code needs, nothing more. Keep rules solve this by providing a declarative specification language that tells R8: "These classes and members are entry points. Trace from here, and remove everything else." The keep rule system is the interface between your knowledge of dynamic access patterns and R8's static analysis engine. The R8 pipeline: Where keep rules fit Before examining keep rules in detail, understanding R8's position in the build pipeline provides essential context. Unlike ProGuard, which operated as a separate step producing optimized Java bytecode that was then dexed, R8 is a unified step that reads Java bytecode and directly outputs optimized DEX: ProGuard legacy: .java → javac → .class → ProGuard → optimized .class → dx → .dex R8 current: .java → javac/kotlinc → .class → R8 → optimized .dex Internally, R8 maintains three code representations. CfCode represents JVM classfile bytecode from input .class files. DexCode represents Dalvik DEX bytecode for the output. IRCode is R8's high-level intermediate representation, a register-based SSA Static Single Assignment form that both CfCode and DexCode are lifted into for optimization. The master pipeline in R8.java orchestrates the following phases: 1. Input Reading ApplicationReader 2. Type Hierarchy AppInfoWithSubtyping 3. Root Set Building RootSetBuilder ← Keep rules matched here 4. Enqueuing/Tracing Enqueuer ← Reachability analysis 5. Tree Pruning TreePruner ← Dead code removed 6. Annotation Removal AnnotationRemover 7. Member Rebinding MemberRebindingAnalysis 8. Class Merging SimpleClassMerger 9. IR Conversion IRConverter.optimize ← SSA-based optimizations 10. Second Enqueuer Pass Enqueuer ← Re-traces after optimization 11. Minification Minifier.run ← Name obfuscation 12. Output Writing ApplicationWriter.write Keep rules enter the pipeline at phase 3, where the RootSetBuilder matches them against the full class graph. The matched items become "roots" that seed the Enqueuer's reachability analysis in phase 4. The keep rule taxonomy: Six options, three axes

Android
Tuesday, February 3, 2026
ViewModel: How Configuration Change Survival Actually Works

Android's ViewModel is one of the most widely used architecture components, yet its core survival mechanism remains a mystery to most developers. You annotate a class, call viewModels in your Activity, and your state magically survives screen rotation. But what actually happens behind the scenes? The answer involves a retained in memory object that is never serialized, a simple HashMap keyed by strings, a carefully ordered resource cleanup sequence, and a factory system that separates creation from retrieval. In this article, you'll dive deep into the internal machinery that makes ViewModel survive configuration changes, exploring how ComponentActivity retains the ViewModelStore through Android's NonConfigurationInstances mechanism, how ViewModelProvider coordinates thread safe retrieval and creation through ViewModelProviderImpl, how ViewModelImpl manages resource lifecycle with a deliberate clearing order, how CreationExtras enables stateless factory injection, and how fragments piggyback on this entire system through FragmentManagerViewModel. This isn't a guide on using ViewModel. It's an exploration of the retention, creation, and destruction machinery that makes configuration change survival possible. The fundamental problem: State that outlives Activity instances Consider this common scenario: kotlin class CounterActivity : ComponentActivity { private var count = 0 override fun onCreatesavedInstanceState: Bundle? { super.onCreatesavedInstanceState setContent { ButtononClick = { count++ } { Text"Count: $count" } } } } Rotate the device, and count resets to zero. The Android framework destroys and recreates the Activity on configuration changes. Every field, every local variable, every reference is gone. The Bundle approach works for small, serializable data: kotlin override fun onSaveInstanceStateoutState: Bundle { super.onSaveInstanceStateoutState outState.putInt"count", count } But Bundles have a strict 1MB transaction limit, can only hold primitive and parcelable types, and require manual serialization and deserialization. What about a list of 10,000 items fetched from a network request? A database cursor? A WebSocket connection? These cannot be serialized into a Bundle. ViewModel solves this by retaining the object in memory across configuration changes. Not serialized. Not parceled. The exact same object instance, held in memory while the old Activity is destroyed and the new one is created. ViewModelStore: The retention container At the foundation of the system is ViewModelStore, a wrapper around a MutableMap: kotlin public open class ViewModelStore { private val map = mutableMapOf<String, ViewModel @RestrictToRestrictTo.Scope.LIBRARYGROUP public fun putkey: String, viewModel: ViewModel { val oldViewModel = map.putkey, viewModel oldViewModel?.clear } @RestrictToRestrictTo.Scope.LIBRARYGROUP public operator fun getkey: String: ViewModel? = mapkey public fun clear { for vm in map.values { vm.clear } map.clear } }

AndroidKotlin
Wednesday, January 28, 2026
Introducing the Experimental Styles API in Jetpack Compose

Jetpack Compose's Modifier system has been the primary way to apply visual properties to composables. You chain modifiers like background, padding, and border to build up the appearance and behavior of UI elements. While powerful, this approach has limitations when dealing with interactive states. When you want a button to change color when pressed, you need to manually track state, create animated values, and conditionally apply different modifiers. The new experimental Styles APIhttps://android-review.googlesource.com/c/platform/frameworks/support/+/3756487 aims to solve this by providing a declarative way to define state-dependent styling with automatic animations. In this article, you'll explore how the Styles API works, examining how Style objects encapsulate visual properties as composable lambdas, how StyleScope provides access to layout, drawing, and text properties, how StyleState exposes interaction states like pressed, hovered, and focused, how the system automatically animates between style states without manual Animatable management, and how the two-node modifier architecture efficiently applies styles while minimizing invalidation. This isn't a guide on basic Compose styling; it's an exploration of a new paradigm for defining interactive, stateful UI appearances. The problem with stateful styling Consider implementing a button that changes color when hovered and pressed. With the current Modifier approach, you need to manage this manually: kotlin @Composable fun InteractiveButtononClick: - Unit { val interactionSource = remember { MutableInteractionSource } val isPressed by interactionSource.collectIsPressedAsState val isHovered by interactionSource.collectIsHoveredAsState val backgroundColor by animateColorAsState targetValue = when { isPressed - Color.Red isHovered - Color.Yellow else - Color.Green } Box modifier = Modifier .clickableinteractionSource = interactionSource, indication = null { onClick } .backgroundbackgroundColor .size150.dp } This pattern requires several pieces: an InteractionSource to track interactions, state derivations for each interaction type, animated values for smooth transitions, and conditional logic to determine the current appearance. The code is verbose and the concerns are scattered across multiple declarations. The Styles API consolidates this into a single declarative definition: kotlin @Composable fun InteractiveButtononClick: - Unit { ClickableStyleableBox onClick = onClick, style = { backgroundColor.Green size150.dp hovered { animate { backgroundColor.Yellow } } pressed { animate { backgroundColor.Red } } } }

ComposeAndroidKotlin
Wednesday, January 21, 2026
Compose Compiler Stability Inference System

A comprehensive study of how the Compose compiler determines type stability for recomposition optimization. Table of Contents - Compose Compiler Stability Inference Systemcompose-compiler-stability-inference-system - Table of Contentstable-of-contents - Chapter 1: Foundationschapter-1-foundations - 1.1 Introduction11-introduction - 1.2 Core Concepts12-core-concepts - Stability Definitionstability-definition - Recomposition Mechanicsrecomposition-mechanics - 1.3 The Role of Stability13-the-role-of-stability - Performance Impactperformance-impact - Chapter 2: Stability Type Systemchapter-2-stability-type-system - 2.1 Type Hierarchy21-type-hierarchy - 2.2 Compile-Time Stability22-compile-time-stability - Stability.Certainstabilitycertain - 2.3 Runtime Stability23-runtime-stability - Stability.Runtimestabilityruntime - 2.4 Uncertain Stability24-uncertain-stability - Stability.Unknownstabilityunknown - 2.5 Parametric Stability25-parametric-stability - Stability.Parameterstabilityparameter - 2.6 Combined Stability26-combined-stability - Stability.Combinedstabilitycombined - 2.7 Stability Decision Tree27-stability-decision-tree - Complete Decision Treecomplete-decision-tree - Decision Tree for Generic Typesdecision-tree-for-generic-types - Expression Stability Decision Treeexpression-stability-decision-tree - Key Decision Points Explainedkey-decision-points-explained - Chapter 3: The Inference Algorithmchapter-3-the-inference-algorithm - 3.1 Algorithm Overview31-algorithm-overview - 3.2 Type-Level Analysis32-type-level-analysis - Phase 1: Fast Path Type Checksphase-1-fast-path-type-checks - Phase 2: Type Parameter Handlingphase-2-type-parameter-handling - Phase 3: Nullable Type Unwrappingphase-3-nullable-type-unwrapping - Phase 4: Inline Class Handlingphase-4-inline-class-handling - 3.3 Class-Level Analysis33-class-level-analysis - Phase 5: Cycle Detectionphase-5-cycle-detection - Phase 6: Annotation and Marker Checksphase-6-annotation-and-marker-checks - Phase 7: Known Constructsphase-7-known-constructs - Phase 8: External Configurationphase-8-external-configuration - Phase 9: External Module Handlingphase-9-external-module-handling - Phase 10: Java Type Handlingphase-10-java-type-handling - Phase 11: General Interface Handlingphase-11-general-interface-handling - Phase 12: Field-by-Field Analysisphase-12-field-by-field-analysis - 3.4 Expression-Level Analysis34-expression-level-analysis - Constant Expressionsconstant-expressions - Function Call Expressionsfunction-call-expressions - Variable Reference Expressionsvariable-reference-expressions - Chapter 4: Implementation Mechanismschapter-4-implementation-mechanisms - 4.1 Bitmask Encoding41-bitmask-encoding - Encoding Schemeencoding-scheme - Special Bit: Known Stablespecial-bit-known-stable - Bitmask Applicationbitmask-application - 4.2 Runtime Field Generation42-runtime-field-generation - JVM Platformjvm-platform - Non-JVM Platformsnon-jvm-platforms - 4.3 Annotation Processing43-annotation-processing - @StabilityInferred Annotationstabilityinferred-annotation - Annotation Generationannotation-generation - 4.4 Normalization Process44-normalization-process - Chapter 5: Case Studieschapter-5-case-studies - 5.1 Primitive and Built-in Types51-primitive-and-built-in-types - Integer Typesinteger-types - String Typestring-type - Function Typesfunction-types - 5.2 User-Defined Classes52-user-defined-classes - Simple Data Classsimple-data-class - Class with Mutable Propertyclass-with-mutable-property - Class with Mixed Propertiesclass-with-mixed-properties - 5.3 Generic Types53-generic-types - Simple Generic Containersimple-generic-container - Multiple Type Parametersmultiple-type-parameters - Nested Generic Typesnested-generic-types - 5.4 External Dependencies54-external-dependencies - External Class with Annotationexternal-class-with-annotation - External Class Without Annotationexternal-class-without-annotation - 5.5 Interface and Abstract Types55-interface-and-abstract-types - Interface Parameterinterface-parameter - Abstract Classabstract-class - Interface with @Stableinterface-with-stable - 5.6 Inheritance Hierarchies56-inheritance-hierarchies - Stable Inheritancestable-inheritance - Unstable Inheritanceunstable-inheritance - Chapter 6: Configuration and Toolingchapter-6-configuration-and-tooling - 6.1 Stability Annotations61-stability-annotations - @Stable Annotationstable-annotation - @Immutable Annotationimmutable-annotation - Compiler-Level Differences: @Stable vs @Immutablecompiler-level-differences-stable-vs-immutable - @StableMarker Meta-Annotationstablemarker-meta-annotation - 6.2 Configuration Files62-configuration-files - File Formatfile-format - Pattern Syntaxpattern-syntax - Gradle Configurationgradle-configuration - 6.3 Compiler Reports63-compiler-reports - Enabling Reportsenabling-reports - Generated Filesgenerated-files - 6.4 Common Issues and Solutions64-common-issues-and-solutions - Issue 1: Accidental var Usageissue-1-accidental-var-usage - Issue 2: Mutable Collectionsissue-2-mutable-collections - Issue 3: Interface Parametersissue-3-interface-parameters - Issue 4: External Library Typesissue-4-external-library-types - Issue 5: Inheritance from Unstable Baseissue-5-inheritance-from-unstable-base - Chapter 7: Advanced Topicschapter-7-advanced-topics - 7.1 Type Substitution71-type-substitution - Substitution Map Constructionsubstitution-map-construction - Substitution Applicationsubstitution-application - Nested Substitutionnested-substitution - 7.2 Cycle Detection72-cycle-detection - Detection Mechanismdetection-mechanism - Example: Self-Referential Typeexample-self-referential-type - Limitationlimitation - 7.3 Special Cases73-special-cases - Protobuf Typesprotobuf-types - Delegated Propertiesdelegated-properties - Inline Classes with Markersinline-classes-with-markers - Chapter 8: Compiler Analysis Systemchapter-8-compiler-analysis-system - 8.1 Analysis Infrastructure81-analysis-infrastructure - WritableSlices: Data Flow Storagewritableslices-data-flow-storage - BindingContext and BindingTracebindingcontext-and-bindingtrace - 8.2 Composable Call Validation82-composable-call-validation - Context Checking Algorithmcontext-checking-algorithm - Inline Lambda Restrictionsinline-lambda-restrictions - Type Compatibility Checkingtype-compatibility-checking - 8.3 Declaration Validation83-declaration-validation - Composable Function Rulescomposable-function-rules - Property Restrictionsproperty-restrictions - Override Consistencyoverride-consistency - 8.4 Applier Target System84-applier-target-system - Scheme Structurescheme-structure - Target Inference Algorithmtarget-inference-algorithm - Cross-Target Validationcross-target-validation - 8.5 Type Resolution and Inference85-type-resolution-and-inference - Automatic Composable Inferenceautomatic-composable-inference - Lambda Type Adaptationlambda-type-adaptation - 8.6 Analysis Pipeline86-analysis-pipeline - Compilation Phasescompilation-phases - Data Flow Through Phasesdata-flow-through-phases - 8.7 Practical Examples87-practical-examples - Example: Composable Context Validationexample-composable-context-validation - Example: Inline Lambda Analysisexample-inline-lambda-analysis - Example: Stability and Skippingexample-stability-and-skipping - Appendix: Source Code Referencesappendix-source-code-references - Primary Source Filesprimary-source-files - Conclusionconclusion Chapter 1: Foundations 1.1 Introduction The Compose compiler implements a stability inference system to enable recomposition optimization. This system analyzes types at compile time to determine whether their values can be safely compared for equality during recomposition. Source File: compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/Stability.kt The inference process involves analyzing type declarations, examining field properties, and tracking stability through generic type parameters. The results inform the runtime whether to skip recomposition when parameter values remain unchanged. 1.2 Core Concepts Stability Definition A type is considered stable when it satisfies three conditions: 1. Immutability: The observable state of an instance does not change after construction 2. Equality semantics: Two instances with equal observable state are equal via equals 3. Change notification: If the type contains observable mutable state, all state changes trigger composition invalidation These properties allow the runtime to make optimization decisions based on value comparison. Recomposition Mechanics When a composable function receives parameters, the runtime determines whether to execute the function body: kotlin @Composable fun UserProfileuser: User { // Function body } The decision process: 1. Compare the new user value with the previous value 2. If equal and the type is stable, skip recomposition 3. If different or unstable, execute the function body Without stability information, the runtime must conservatively recompose on every invocation, regardless of whether parameters changed. 1.3 The Role of Stability Performance Impact Stability inference affects recomposition in three ways: Smart Skipping: Composable functions with stable parameters can be skipped when parameter values remain unchanged. This reduces the number of function executions during recomposition. Comparison Propagation: The compiler passes stability information to child composable calls, enabling nested optimizations throughout the composition tree. Comparison Strategy: The runtime selects between structural equality equals for stable types and referential equality === for unstable types, affecting change detection behavior. Consider this example: kotlin // Unstable parameter type - interface with unknown stability @Composable fun ExpensiveListitems: List<String { // List is an interface - has Unknown stability // Falls back to instance comparison } // Stable parameter type - using immutable collection @Composable fun ExpensiveListitems: ImmutableList<String { // ImmutableList is in KnownStableConstructs // Can skip recomposition when unchanged } // Alternative: Using listOf result @Composable fun ExpensiveListitems: List<String { // If items comes from listOf, the expression is stable // But the List type itself is still an interface with Unknown stability } The key insight: List and MutableList are both interfaces with Unknown stability. To achieve stable parameters, use: 1. ImmutableList from kotlinx.collections.immutable in KnownStableConstructs 2. Add kotlin.collections.List to your stability configuration file 3. Use @Stable annotation on your data classes containing List Chapter 2: Stability Type System 2.1 Type Hierarchy The compiler represents stability through a sealed class hierarchy defined in :

ComposeAndroidKotlin
Friday, October 3, 2025
A Study of the Jetpack Compose SlotTable Internals

The SlotTable is the in-memory data structure that represents the UI tree of a Jetpack Compose application. Instead of a traditional tree of objects, it's a highly optimized, flat structure designed for extremely fast UI updates. Let's explore its internals by examining the code you provided. 1. The Core Data Model: groups and slots At the heart of the SlotTable are two parallel, flat arrays. This is the first and most critical concept to grasp. kotlin internal class SlotTable : CompositionData, Iterable<CompositionGroup { / An array to store group information... an array of an inline struct. / var groups = IntArray0 private set / An array that stores the slots for a group. / var slots = Array<Any?0 { null } private set //... } groups: IntArray: This is the blueprint of your UI. It stores the structure and metadata of your composables in a compact, primitive array. Think of it as a highly efficient, inlined list of instructions that describes the hierarchy, keys, and properties of each composable call. Because it's a flat IntArray, the CPU can scan it very rapidly without expensive memory jumps pointer chasing.

ComposeArchitectureAndroid
Sunday, September 28, 2025
A Study: Building a Simple Dependency Injection Container in Kotlin for Android

Dependency Injection DI is a core software design pattern that promotes loose coupling and enhances the testability and scalability of applications. While powerful libraries like Hilt and Koin are the standard for production Android apps, building a simple DI container from scratch is a valuable exercise. It demystifies the "magic" and solidifies the core concepts: providing dependencies to classes instead of having them create their own. In this study, we will design and implement a basic, lifecycle-aware DI container. Our goal is to create a tool that can: 1. Register dependencies like a UserRepository or AnalyticsService. 2. Provide instances of these dependencies on demand. 3. Manage the scope of these dependencies e.g., as singletons. 4. Integrate cleanly with the Android ViewModel architecture. Step 1: Designing the Core DIContainer The heart of our tool will be a container class responsible for holding and creating our dependencies. A simple way to store registered dependencies is in a Map, where the key is the class type KClass and the value is a factory lambda that knows how to create an instance of that class.

AndroidKotlin
Sunday, August 31, 2025
A Study of R8 Modes and Their Impact on Android Applications

R8 is the default code shrinker, optimizer, and obfuscator for Android applications. It plays a crucial role in reducing APK size and improving runtime performance. While R8 is designed to be a drop-in replacement for ProGuard, its more advanced optimizations can introduce subtle behavioral changes. To manage this, R8 operates in two distinct modes: compatibility mode and full mode. This study will explore the differences between these two modes, with a particular focus on the aggressive optimizations of "full mode" and the necessary configuration adjustments developers must make, especially when working with reflection-heavy libraries like Gson and Retrofit. R8 Compatibility Mode: The Safe Default

Android
Sunday, August 31, 2025
A Study of API Guidelines for Building Better Jetpack Compose Components

The Jetpack Compose ecosystem has grown exponentially in recent years, and it is now widely adopted for building production-level UIs in Android applications. We can now say that Jetpack Compose is the future of Android UI development. One of the biggest advantages of Compose is its declarative approach. It allows developers to describe what the UI should display, while the framework handles how the UI should update when the underlying state changes. This model shifts the focus from imperative UI logic to a more intuitive and reactive way of thinking. However, building reusable and scalable UI components requires more than just a grasp of declarative principles. It demands a thoughtful approach to API design. To guide developers, the Android team has published a comprehensive set of API guidelines. These best practices are not strict rules but are strongly recommended for creating components that are consistent, scalable, and intuitive for other developers to use.

ComposeAndroidKotlin
Sunday, August 24, 2025

Like what you see?

Subscribe to Dove Letter to get weekly insights about Android and Kotlin development, plus access to exclusive content and discussions.