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.
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.
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.
Image loading is one of the most critical yet complex aspects of Android development. While libraries like Glide and Picasso have served developers for years, Coil emerged as a modern, Kotlin-first solution built from the ground up with coroutines. But the power of Coil goes far beyond its clean API, it's in the solid internal machinery that makes it both performant and memory-efficient. In this article, you'll dive deep into the internal mechanisms of Coil, exploring how image requests flow through an interceptor chain, how the two-tier memory cache achieves high hit rates while preventing memory leaks, how bitmap sampling uses bit manipulation for optimal memory usage, and the subtle optimizations that make it production-ready. Understanding the core abstraction At its heart, Coil is an image loading library that transforms data sources URLs, files, resources into decoded images displayed in views. What distinguishes Coil from other image loaders is its adherence to two fundamental principles: coroutine-native design and composable interceptor architecture. The coroutine-native design means everything in Coil is built around suspend functions. Image loading naturally fits the structured concurrency model, requests have lifecycles, can be cancelled, and should respect scopes. Traditional image loaders use callback chains, but Coil embraces coroutines: kotlin // Traditional callback approach imageLoader.loadurl { bitmap - imageView.setImageBitmapbitmap } // Coil's coroutine approach val result = imageLoader.execute ImageRequest.Buildercontext .dataurl .targetimageView .build The composable interceptor architecture means the entire request pipeline is a chain of interceptors, similar to OkHttp. Each interceptor can observe, transform, or short-circuit the request. This makes the library extensible without modifying core code. These properties aren't just conveniences, they're architectural decisions that enable better resource management, cleaner cancellation semantics, and powerful customization. Let's explore how these principles manifest in the implementation. The ImageLoader interface and RealImageLoader implementation If you examine the ImageLoader interface, it defines two primary entry points: kotlin interface ImageLoader { fun enqueuerequest: ImageRequest: Disposable suspend fun executerequest: ImageRequest: ImageResult } Two methods for the same operation? This reflects Android's dual nature, some callers need fire-and-forget loading enqueue for views, while others need structured concurrency execute for repositories or composables. The RealImageLoader implementation handles both cases with a unified internal pipeline: kotlin internal class RealImageLoader val options: Options, : ImageLoader { private val scope = CoroutineScopeoptions.logger private val systemCallbacks = SystemCallbacksthis private val requestService = RequestServicethis, systemCallbacks, options.logger override fun enqueuerequest: ImageRequest: Disposable { // Start executing the request on the main thread. val job = scope.asyncoptions.mainCoroutineContextLazy.value { executerequest, REQUESTTYPEENQUEUE } // Update the current request attached to the view and return a new disposable. return getDisposablerequest, job } override suspend fun executerequest: ImageRequest: ImageResult { if !needsExecuteOnMainDispatcherrequest { // Fast path: skip dispatching. return executerequest, REQUESTTYPEEXECUTE } else { // Slow path: dispatch to the main thread. return coroutineScope { val job = asyncoptions.mainCoroutineContextLazy.value { executerequest, REQUESTTYPEEXECUTE } getDisposablerequest, job.job.await } } } } Notice the fast path optimization in execute: if the request doesn't need main thread dispatch no target view, it executes immediately without the overhead of launching a coroutine. This is important for background image loading in repositories where you're just fetching the bitmap. The scope is a SupervisorJob scope, meaning one failed request doesn't cancel other in-flight requests: kotlin private fun CoroutineScopelogger: Logger?: CoroutineScope { val context = SupervisorJob + CoroutineExceptionHandler { , throwable - logger?.logTAG, throwable } return CoroutineScopecontext } This isolation ensures that a network error loading one image doesn't affect other images currently loading. The CoroutineExceptionHandler logs uncaught exceptions rather than crashing, making the library resilient to unexpected errors. The request execution pipeline: Interceptors all the way down The core of Coil's architecture is the interceptor chain. When you execute a request, it flows through a series of interceptors before reaching the EngineInterceptor, which performs the actual fetch and decode: kotlin private suspend fun executeinitialRequest: ImageRequest, type: Int: ImageResult { val requestDelegate = requestService.requestDelegate request = initialRequest, job = coroutineContext.job, findLifecycle = type == REQUESTTYPEENQUEUE, .apply { assertActive } val request = requestService.updateRequestinitialRequest val eventListener = options.eventListenerFactory.createrequest
Like what you see?
Subscribe to Dove Letter to get weekly insights about Android and Kotlin development, plus access to exclusive content and discussions.