Image Loading, Caching and Transformations with Coil and Glide

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

TrailMate shows a photo for almost every trail: a thumbnail in the row TrailAdapter/TrailViewHolder binds in RecyclerView and Lists, a larger hero image on the detail screen, and the trail photos a hiker captures on Local Storage: DataStore, Files, Scoped Storage and MediaStore's PhotoCaptureScreen. This page covers the two libraries TrailMate uses to load those images — Coil 3.6.3 (io.coil-kt.coil3:coil-compose, coil-network-okhttp) for the Compose UI, and Glide 5.0.9 (com.github.bumptech.glide:glide, KSP compiler) for the legacy Views UI’s TrailViewHolder — what problem an image-loading library solves in the first place, the request-to-display pipeline both libraries implement, Bitmap fundamentals that explain why that pipeline looks the way it does, and where Fresco and Picasso fit as legacy options. Networking concepts this page builds on (the shared OkHttpClient, interceptors, caching headers) are covered on Networking with Retrofit, OkHttp and Ktor; how TrailMateApplication wires a singleton ImageLoader alongside its other DI setup is expanded on Dependency Injection with Hilt, Dagger and Koin. Coroutines and Flow, used internally by Coil, are covered in Coroutines Basics and are not re-explained here.

Why an Image Library, Not BitmapFactory.decodeStream

Loading trail.photoUrl into an ImageView or an Image composable looks, at first glance, like it should be a one-line BitmapFactory.decodeStream(url.openStream()). In practice that line hides several problems that a naive implementation gets wrong, all of which an image-loading library solves once instead of once per screen:

  • Threading. Decoding a JPEG/PNG/WebP is CPU-bound work that must not run on the main thread — doing so janks scrolling and can trigger an ANR on a large image, the same main-thread rule networking libraries enforce (see Networking with Retrofit, OkHttp and Ktor).

  • Downsampling. A 12-megapixel camera photo decoded at full resolution into a 48dp thumbnail wastes memory by two orders of magnitude and can throw OutOfMemoryError outright; the decoder must be told to downsample before allocating the full bitmap, not after.

  • Caching. Re-fetching and re-decoding the same trail.photoUrl every time TrailListScreen recomposes, or every time a RecyclerView row scrolls back into view, is wasteful in both network and CPU terms. An in-memory cache of already-decoded bitmaps and a disk cache of the raw encoded bytes both matter, for different reasons.

  • View recycling and cancellation. In a RecyclerView, a ViewHolder gets reused for a different item while a previous image request for it is still in flight; whichever bitmap arrives last must not be shown on the wrong row. Compose has an equivalent problem when a composable leaves composition mid-request. A request tied to the target’s lifecycle must be cancelled, not merely ignored.

  • Placeholders, error states and transformations. Showing a placeholder while loading, an error drawable on failure, and applying a shape (circle, rounded corners) or a color transformation (grayscale) to the result are common enough to deserve first-class API support rather than being hand-rolled on every screen.

Coil and Glide both solve every one of these the same general way, which is why the two libraries look similar from the outside despite very different internal implementations. That shared shape is the pipeline below.

The Image Loading Pipeline

A seven-stage image loading pipeline diagram. Stage 1, Request (ImageRequest or RequestOptions), flows into stage 2, an in-memory cache check (Coil’s MemoryCache or Glide’s memory cache); a cache hit (green arrow) skips straight down to stage 7, Display, reusing the already-decoded bitmap. A cache miss (red dashed arrow) falls through to stage 3, a disk cache check (Coil’s DiskCache or Glide’s DiskLruCache) keyed by URL or a custom signature; a disk hit (green arrow) goes straight to stage 5, Decode. A disk miss (red dashed arrow) falls through to stage 4, Network, which fetches the image over a shared OkHttpClient and writes the response into the disk cache before continuing. Stage 5, Decode, uses ImageDecoder or BitmapFactory with inSampleSize downsampling to produce a bitmap sized for the target, feeding stage 6, Transform (circle crop, rounded corners, grayscale, or a custom transformation), whose result is written back into the memory cache and finally shown at stage 7, Display, on an ImageView or an Image composable.

Every stage after the first two is progressively more expensive, which is exactly why the pipeline checks them in this order — a request that only ever hits the memory cache never touches the disk, the network or the CPU decoder at all:

Stage What happens

1. Request

An ImageRequest (Coil) or a chain of RequestOptions (Glide) is built: the source (URL, Uri, resource id, file), the target size, placeholder/error drawables, and any transformations.

2. Memory cache

An in-memory LRU cache of already-decoded, already-transformed bitmaps, keyed by the request’s parameters. A hit returns instantly on the calling thread — no coroutine dispatch, no disk I/O.

3. Disk cache

A disk-backed cache of the raw encoded bytes (the JPEG/WebP as downloaded), keyed by URL by default or by a custom cache key/signature. A hit still needs decoding (stage 5) but skips the network entirely.

4. Network

A shared HTTP client (OkHttpClient for both libraries on Android) fetches the bytes and writes them into the disk cache for next time.

5. Decode

The encoded bytes become a Bitmap, downsampled to roughly the target size (inSampleSize or an ImageDecoder target size) so memory use is proportional to what will actually be displayed, not to the source image’s native resolution.

6. Transform

Optional pixel-level transformations (crop to a circle, round the corners, convert to grayscale) run on the decoded bitmap; the result — not the untransformed bitmap — is what gets cached in stage 2 and shown.

7. Display

The final bitmap is bound to an ImageView, or held by an Image/AsyncImagePainter state so a composable can draw it.

Bitmap fundamentals (inSampleSize, ImageDecoder, hardware bitmaps) are expanded later on this page; they explain the machinery stage 5 relies on. The rest of this page covers Coil, then Glide, each following this same shape with different APIs and defaults.

Coil 3.6: Image Loading for Compose (and Views)

Coil ("Coroutine Image Loader") is built on Kotlin coroutines from the ground up; Coil 3 additionally made the library Kotlin-Multiplatform-capable and split networking into a separate artifact so an app chooses its own HTTP engine. TrailMate depends on:

// gradle/libs.versions.toml
[versions]
coil = "3.6.3"

[libraries]
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil" }
coil-test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }

coil-compose pulls in Coil’s core plus its Compose integration; coil-network-okhttp is a separate artifact in Coil 3 precisely so that a Kotlin Multiplatform target that cannot use OkHttp (a pure iOS/JS target) is not forced to depend on it — an Android-only app like TrailMate always adds it. See Coil — Upgrading to Coil 3 for what changed from Coil 2.

See also Essential Multiplatform Libraries in the Kotlin Multiplatform section for Coil 3 in shared code and Compose Multiplatform.

AsyncImage, SubcomposeAsyncImage and rememberAsyncImagePainter

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItemRow.kt
package com.example.trailmate.feature.trails

import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.example.trailmate.core.model.Trail

@Composable
fun TrailThumbnail(trail: Trail, modifier: Modifier = Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
            .data(trail.photoUrl)
            .crossfade(true)
            .build(),
        contentDescription = trail.name,
        contentScale = ContentScale.Crop,
        placeholder = painterResource(com.example.trailmate.feature.trails.R.drawable.trail_placeholder),
        error = painterResource(com.example.trailmate.feature.trails.R.drawable.trail_error),
        modifier = modifier.size(96.dp),
    )
}

@Composable
fun TrailHeroImage(trail: Trail, modifier: Modifier = Modifier) {
    SubcomposeAsyncImage(
        model = trail.photoUrl,
        contentDescription = trail.name,
        modifier = modifier,
    ) {
        val state = painter.state.collectAsState().value
        if (state is coil3.compose.AsyncImagePainter.State.Loading) {
            androidx.compose.material3.CircularProgressIndicator()
        } else {
            SubcomposeAsyncImageContent()
        }
    }
}

@Composable
fun trailPainter(trail: Trail): Painter =
    rememberAsyncImagePainter(model = trail.photoUrl)

AsyncImage is the common case: it composes Image internally and swaps between placeholder, content and error states automatically — there is no way to observe the intermediate loading state from outside it, which is exactly why it is the right default when a screen has nothing extra to show while loading. SubcomposeAsyncImage exists for the opposite case: TrailHeroImage needs a CircularProgressIndicator (a composable, not a drawable) while loading, and subcomposition is what lets it inspect painter.state and choose what to compose next. rememberAsyncImagePainter is the lowest-level of the three — a bare Painter for call sites that need to pass an image into an API expecting a Painter directly (a Modifier.paint(…​), or a custom layout that is not built around Image itself) rather than a ready-made composable. A common mistake is reaching for SubcomposeAsyncImage by default: subcomposition has a real performance cost in a list, so AsyncImage (with drawable placeholder/error) should be preferred unless a composable loading/error state is genuinely needed.

ImageRequest and the Singleton ImageLoader

An ImageRequest describes one load (the data, target size, placeholders, transformations, cache policies); an ImageLoader is the reusable engine that executes requests, owns the memory and disk caches, and should be built once and shared app-wide, the same reasoning that applies to OkHttpClient on Networking with Retrofit, OkHttp and Ktor. Coil 3 replaced a global mutable Coil.setImageLoader(…​) with SingletonImageLoader.Factory, an interface TrailMateApplication implements so the singleton is built lazily, on first use, from application context rather than eagerly in onCreate():

// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
package com.example.trailmate

import android.app.Application
import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import coil3.video.VideoFrameDecoder
import dagger.hilt.android.HiltAndroidApp
import okio.Path.Companion.toOkioPath
import javax.inject.Inject

@HiltAndroidApp
class TrailMateApplication : Application(), SingletonImageLoader.Factory {

    @Inject lateinit var sharedOkHttpClient: okhttp3.OkHttpClient

    override fun newImageLoader(context: PlatformContext): ImageLoader =
        ImageLoader.Builder(context)
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(context, percent = 0.25)
                    .build()
            }
            .diskCache {
                DiskCache.Builder()
                    .directory(cacheDir.resolve("image_cache").toOkioPath())
                    .maxSizeBytes(50L * 1024 * 1024)
                    .build()
            }
            .components {
                add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttpClient }))
                add(SvgDecoder.Factory())
                // AnimatedImageDecoder uses ImageDecoder (API 28+); minSdk is 26, so fall back to GifDecoder.
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    add(AnimatedImageDecoder.Factory())
                } else {
                    add(GifDecoder.Factory())
                }
                add(VideoFrameDecoder.Factory())
            }
            .crossfade(true)
            .build()
}

SingletonImageLoader.Factory is invoked at most once, the first time any Coil API needs the singleton, and its result is cached for the process lifetime — this is what lets AsyncImage and ImageView.load() calls throughout the app share one ImageLoader (and therefore one memory cache, one disk cache and one network client) without every call site threading an ImageLoader instance through manually. Passing OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttpClient }) reuses the exact same OkHttpClient that NetworkModule builds for TrailApi, so authentication interceptors, connection pooling and the network security config are consistent across REST calls and image fetches; without this, Coil falls back to its own default OkHttp client, which duplicates a connection pool and skips any interceptor an app already configured for its API calls. See Coil — Image loaders, Coil — Network and Android Developers — Images in Compose (animated image support).

MemoryCache and DiskCache: Sizing, Policies and Keys

maxSizePercent(context, percent = 0.25) sizes the memory cache as a fraction of the app’s available memory class rather than a fixed byte count, so it scales sensibly across low-end and high-end devices; maxSizeBytes on the disk cache is a hard cap in bytes because disk space does not vary the same way memory does. Both caches can be bypassed per request through CachePolicy:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RefreshTrailPhoto.kt
import coil3.request.CachePolicy
import coil3.request.ImageRequest

fun refreshedPhotoRequest(context: android.content.Context, trail: com.example.trailmate.core.model.Trail) =
    ImageRequest.Builder(context)
        .data(trail.photoUrl)
        .memoryCachePolicy(CachePolicy.WRITE_ONLY) // never read a stale cached bitmap
        .diskCachePolicy(CachePolicy.ENABLED)
        .memoryCacheKey("trail_${trail.id}_v${trail.photoVersion}")
        .build()

CachePolicy.WRITE_ONLY on the memory cache is the pattern for "the trail photo may have changed" — for example after a hiker uploads a new one — forcing a re-fetch/re-decode while still writing the fresh result back for next time; CachePolicy.READ_ONLY is the inverse, useful for an offline mode that must not attempt a network write. A custom memoryCacheKey/diskCacheKey (Coil derives a default key from the data and transformations otherwise) is how trail.photoVersion invalidates a specific trail’s cached image without clearing the whole cache — the same problem Glide’s signatures solve later on this page. See Coil — Image loaders, Caching and Coil — CachePolicy.

Transformations: Circle Crop, Rounded Corners and a Custom Grayscale Transform

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailAvatar.kt
package com.example.trailmate.feature.trails

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import coil3.size.Size
import coil3.transform.CircleCropTransformation
import coil3.transform.RoundedCornersTransformation
import coil3.transform.Transformation

/** Converts the decoded bitmap to grayscale, e.g. for an unavailable/closed trail. */
object GrayscaleTransformation : Transformation() {
    override val cacheKey: String = "com.example.trailmate.GrayscaleTransformation"

    override suspend fun transform(input: Bitmap, size: Size): Bitmap {
        val output = Bitmap.createBitmap(input.width, input.height, input.config ?: Bitmap.Config.ARGB_8888)
        val canvas = Canvas(output)
        val paint = Paint().apply {
            colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
        }
        canvas.drawBitmap(input, 0f, 0f, paint)
        return output
    }
}

// Usage inside an ImageRequest.Builder -- pass every transformation in ONE call; they run in order.
// A second .transformations(...) call replaces the list set by the first one.
// .transformations(GrayscaleTransformation, RoundedCornersTransformation(radius = 16f))

CircleCropTransformation and RoundedCornersTransformation ship in Coil itself; a custom Transformation like GrayscaleTransformation only needs cacheKey (used to distinguish cached results of the same source image with different transformations applied — getting this wrong means either cache collisions across different transformations or a cache that never hits) and a suspend transform function, run off the main thread as part of pipeline stage 6. Multiple transformations are applied in sequence by passing them all to a single .transformations(…​) call (it takes a vararg or a List), each one receiving the previous one’s output; calling .transformations(…​) again does not chain — it replaces the list set by the earlier call. See Coil — Transformations.

ImageView.load(): Coil in the Legacy Views UI

Coil is not Compose-only — coil-compose’s companion extension functions target `ImageView directly, which matters for any screen not yet migrated (see Migrating from Views to Compose):

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/ProfileHeaderBinder.kt
import coil3.load
import coil3.request.crossfade
import coil3.transform.CircleCropTransformation

fun bindProfilePhoto(imageView: android.widget.ImageView, photoUrl: String) {
    imageView.load(photoUrl) {
        crossfade(true)
        transformations(CircleCropTransformation())
        placeholder(com.example.trailmate.feature.settings.R.drawable.avatar_placeholder)
    }
}

ImageView.load(…​) is a thin wrapper that builds an ImageRequest targeting that specific ImageView and executes it against the singleton ImageLoader — it automatically cancels a previous, still-in-flight request for the same view when a new one starts (and when the view is detached), which is what makes it safe to call from onBindViewHolder() without manually tracking cancellation the way a hand-rolled loader would need to. See Coil — Getting started and Coil — View extensions.

SVG, GIF and Video Frame Decoders

The coil-svg, coil-gif and coil-video artifacts, registered above in TrailMateApplication’s `components block, extend the decode stage (pipeline stage 5) to formats BitmapFactory cannot handle on its own: an SVG region icon, an animated GIF celebration on completing a hike, or a representative frame extracted from a short video clip a hiker recorded. Once registered, they are transparent to call sites — AsyncImage/load() picks the right decoder from the data’s content type automatically; nothing at the call site changes to opt into coil-svg versus the default raster decoder. See Coil — GIFs, Coil — SVGs and Coil — Videos.

Preloading

A trail list often benefits from warming the cache for images that are about to scroll into view:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailPhotoPreloader.kt
import coil3.ImageLoader
import coil3.request.ImageRequest

fun preloadUpcomingTrailPhotos(
    context: android.content.Context,
    imageLoader: ImageLoader,
    trails: List<com.example.trailmate.core.model.Trail>,
) {
    trails.forEach { trail ->
        val request = ImageRequest.Builder(context)
            .data(trail.photoUrl)
            .build()
        imageLoader.enqueue(request)
    }
}

imageLoader.enqueue(…​) runs the same pipeline (memory cache, then disk cache, then network, then decode) but with no target to display into — its only effect is populating the memory and disk caches, so that when AsyncImage/TrailViewHolder actually asks for the same URL moments later it resolves from stage 2 or 3 instead of stage 4. Preloading too aggressively wastes bandwidth and battery on images the user may never scroll to, so TrailMate limits it to a small lookahead window rather than the whole list. See Coil — Image loaders, Preloading.

Testing with coil-test

// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/TrailThumbnailTest.kt
package com.example.trailmate.feature.trails

import android.graphics.Color
import coil3.ColorImage
import coil3.ImageLoader
import coil3.test.FakeImageLoaderEngine
import coil3.test.intercept
import org.junit.Test

class TrailThumbnailTest {
    @Test
    fun `fake engine returns a fixed image for any request`() {
        val engine = FakeImageLoaderEngine.Builder()
            // The predicate receives the request's data itself (here the URL String), not the request.
            .intercept({ it == "https://api.example.com/photos/t1.jpg" }, ColorImage(Color.GREEN))
            .default(ColorImage(Color.GRAY))
            .build()
        val imageLoader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext())
            .components { add(engine) }
            .build()
        // Inject `imageLoader` in place of the singleton for the composable/view under test.
    }
}

coil-test’s `FakeImageLoaderEngine intercepts requests before they reach the real pipeline, returning a fixed Coil Image (such as a ColorImage) per matcher instead of decoding a real image — this is what makes a Compose UI test or a Robolectric test of TrailThumbnail deterministic and independent of network access. See Coil — Testing and Unit Testing with JUnit, Mockito, MockK and Robolectric.

Glide 5: Image Loading for the Legacy Views UI

Glide predates Coil and remains the standard choice for RecyclerView-heavy, Views-based screens; TrailMate’s legacy TrailAdapter.TrailViewHolder (introduced on RecyclerView and Lists) uses it. The plain Glide.with(…​) API needs no code generation at all; an annotation processor (historically kapt, now KSP) is only needed to process an AppGlideModule (below). Glide’s KSP processor does not generate the deprecated GlideApp "generated API" — code migrating from kapt must replace GlideApp/GlideRequests references with Glide/RequestManager first. The recyclerview-integration artifact provides RecyclerViewPreloader:

// gradle/libs.versions.toml
[versions]
glide = "5.0.9"

[libraries]
glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
glide-recyclerview = { module = "com.github.bumptech.glide:recyclerview-integration", version.ref = "glide" }
glide-compose = "com.github.bumptech.glide:compose:1.0.0-beta10"
// feature/trails/build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation(libs.glide)
    implementation(libs.glide.recyclerview)
    ksp(libs.glide.ksp)
}

Glide.with(…​).load(…​).into(…​) in TrailViewHolder

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailAdapter.kt (excerpt)
package com.example.trailmate.feature.trails

import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey

class TrailViewHolder(private val binding: ItemTrailBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(trail: com.example.trailmate.core.model.Trail) {
        binding.trailName.text = trail.name

        val options = RequestOptions()
            .placeholder(com.example.trailmate.feature.trails.R.drawable.trail_placeholder)
            .error(com.example.trailmate.feature.trails.R.drawable.trail_error)
            .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
            .signature(ObjectKey(trail.photoVersion))
            .centerCrop()

        Glide.with(binding.root)
            .load(trail.photoUrl)
            .apply(options)
            .into(binding.trailPhoto)
    }
}

Glide.with(binding.root) (or Glide.with(fragment)/Glide.with(activity)) is what ties the request’s lifecycle to whichever Android component was passed — Glide registers a headless Fragment against that lifecycle owner and automatically pauses/resumes/clears the request as it starts, stops and is destroyed, so scrolling TrailViewHolder off-screen and being recycled does not leak the previous request. Glide.with(view), used above, resolves the nearest Activity/Fragment lifecycle automatically from the view’s context, which is the common pattern inside a RecyclerView.ViewHolder.bind() where no Fragment reference is otherwise held. A common mistake is calling Glide.with(applicationContext) from inside a ViewHolder: it compiles, but it ties the request to the application’s lifecycle instead of the screen’s, so a request never gets cancelled when the screen is destroyed.

RequestOptions, DiskCacheStrategy and skipMemoryCache

DiskCacheStrategy What it caches

NONE

Nothing is written to Glide’s disk cache. Appropriate for a one-off image that will never be requested again (e.g. a fullscreen ephemeral preview).

DATA

The original, unmodified bytes as fetched from the source, before any resizing/transformation. Useful when the same source is likely to be requested again at a different size or with different transformations.

RESOURCE

The final, transformed and resized bitmap. Fastest to reuse when the same request (same size, same transformations) recurs, as it does for `TrailViewHolder’s fixed-size thumbnails — this is what TrailMate sets explicitly for row images.

ALL

Both DATA and RESOURCE.

AUTOMATIC (default when unset)

Lets Glide choose per load based on the source: only the original DATA for remote data such as a URL (re-downloading is expensive), only the transformed RESOURCE for local data (re-reading the original is cheap).

skipMemoryCache(true) (a separate flag from diskCacheStrategy) opts a request out of the in-memory cache entirely — appropriate for a very large, rarely-repeated image (a fullscreen photo viewer) where holding it in the shared memory cache would evict smaller, more frequently reused thumbnails. See Glide — Caching and Glide — DiskCacheStrategy.

Signatures: Invalidating a Cached Image

Glide, like Coil, keys its caches on the request’s URL by default — if trail.photoUrl never changes even after a hiker uploads a new photo for the same trail (a common backend pattern: a stable URL whose content changes), a naive Glide request keeps serving the stale cached bitmap forever. ObjectKey(trail.photoVersion), used above, folds an app-controlled version value into the cache key so that bumping photoVersion after an upload produces a different key and therefore a fresh fetch, without needing to change the URL itself or clear the whole cache. See Glide — Caching, Cache invalidation.

Transformations: centerCrop, circleCrop, RoundedCorners, MultiTransformation and a Custom BitmapTransformation

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/GlideGrayscaleTransformation.kt
package com.example.trailmate.feature.trails

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest

class GrayscaleBitmapTransformation : BitmapTransformation() {
    companion object {
        private const val ID = "com.example.trailmate.GrayscaleBitmapTransformation"
        private val ID_BYTES = ID.toByteArray(Charsets.UTF_8)
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
        val result = pool.get(toTransform.width, toTransform.height, toTransform.config ?: Bitmap.Config.ARGB_8888)
        val canvas = Canvas(result)
        val paint = Paint().apply {
            colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
        }
        canvas.drawBitmap(toTransform, 0f, 0f, paint)
        return result
    }

    override fun equals(other: Any?): Boolean = other is GrayscaleBitmapTransformation
    override fun hashCode(): Int = ID.hashCode()

    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
        messageDigest.update(ID_BYTES)
    }
}
// Usage: combine centerCrop and rounded corners, or apply the custom grayscale transform.
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.load.resource.bitmap.MultiTransformation

RequestOptions().centerCrop()
RequestOptions().transform(CircleCrop())
RequestOptions().transform(MultiTransformation(CenterCrop(), RoundedCorners(16)))
RequestOptions().transform(GrayscaleBitmapTransformation())

centerCrop()/circleCrop() are convenience methods on RequestOptions for the two most common built-in transformations; MultiTransformation composes several Transformation<Bitmap> instances in sequence, the same role a multi-argument .transformations(…​) call plays for Coil. A custom BitmapTransformation must implement updateDiskCacheKey (writing bytes that uniquely identify the transformation into a MessageDigest) and equals/hashCode — both are required for Glide’s caches to distinguish "trail 1’s photo, grayscale" from "trail 1’s photo, untransformed" correctly; a transformation that skips updateDiskCacheKey silently corrupts the disk cache by writing multiple transformed variants under the same key. pool.get(…​) reuses a BitmapPool-managed bitmap instead of allocating a fresh one, reducing garbage-collector pressure — allocating with Bitmap.createBitmap(…​) instead works but defeats Glide’s bitmap pooling. See Glide — Transformations and Glide — BitmapTransformation.

AppGlideModule with KSP

// app/src/main/kotlin/com/example/trailmate/TrailMateGlideModule.kt
package com.example.trailmate

import android.content.Context
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
import com.bumptech.glide.module.AppGlideModule

@GlideModule
class TrailMateGlideModule : AppGlideModule() {
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        val calculator = MemorySizeCalculator.Builder(context)
            .setMemoryCacheScreens(2f)
            .build()
        builder.setMemoryCache(LruResourceCache(calculator.memoryCacheSize.toLong()))
        builder.setDiskCache(InternalCacheDiskCacheFactory(context, 50L * 1024 * 1024))
    }

    // registerComponents(context, glide, registry) is overridden only to add custom
    // ModelLoaders/decoders; it can use glide.bitmapPool and glide.arrayPool when a
    // component needs Glide's shared pools.
}

@GlideModule is what the KSP processor scans for at compile time, so that Glide applies this module’s options and components when it initializes. With KSP there is no generated GlideApp class — Glide’s generated API is deprecated and only the old Java annotation processor produces it — so the app keeps calling Glide.with(…​) exactly as earlier on this page. applyOptions is where global defaults live (disk cache size and location, a default RequestOptions, a BitmapPool size); registerComponents is where custom decoders/encoders are added, mirroring the role Coil’s components { add(…​) } block plays in TrailMateApplication. See Glide — Download & Setup (Kotlin — KSP), Glide — Generated API and Glide — Configuration.

RecyclerViewPreloader

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailPreloadSetup.kt
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.util.ViewPreloadSizeProvider
import com.example.trailmate.core.model.Trail

fun attachTrailPreloader(
    recyclerView: RecyclerView,
    trailAdapter: TrailAdapter,
    requestManager: RequestManager,
    // TrailViewHolder calls sizeProvider.setView(binding.trailPhoto) once, so preloads match the real size.
    sizeProvider: ViewPreloadSizeProvider<Trail>,
) {
    val preloadModelProvider = object : ListPreloader.PreloadModelProvider<Trail> {
        override fun getPreloadItems(position: Int): List<Trail> = listOf(trailAdapter.currentList[position])
        override fun getPreloadRequestBuilder(item: Trail): RequestBuilder<*> =
            requestManager.load(item.photoUrl).centerCrop()
    }
    // RecyclerViewPreloader is the RecyclerView.OnScrollListener adapter; ListPreloader itself is an
    // AbsListView.OnScrollListener and cannot be passed to addOnScrollListener.
    val preloader = RecyclerViewPreloader(
        requestManager, preloadModelProvider, sizeProvider, /* maxPreload = */ 10,
    )
    recyclerView.addOnScrollListener(preloader)
}

RecyclerViewPreloader (from recyclerview-integration, wrapping the ListView-oriented ListPreloader) watches scroll direction and position to preload the next maxPreload items' images before their ViewHolder`s are bound, so that by the time `TrailAdapter.onBindViewHolder() calls Glide.with(…​).load(…​) the request typically resolves from Glide’s own memory/disk cache instead of the network — the same effect Coil’s manual imageLoader.enqueue(…​) preloading achieves, but driven automatically by scroll position rather than a fixed lookahead window. See Glide — ListPreloader.

Glide Compose (Beta)

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailThumbnailGlide.kt
import com.bumptech.glide.integration.compose.GlideImage
import com.bumptech.glide.integration.compose.placeholder

@androidx.compose.runtime.Composable
fun TrailThumbnailGlide(trail: com.example.trailmate.core.model.Trail) {
    GlideImage(
        model = trail.photoUrl,
        contentDescription = trail.name,
        loading = placeholder(com.example.trailmate.feature.trails.R.drawable.trail_placeholder),
    )
}

com.github.bumptech.glide:compose remains at a 1.0.0-beta10 release with no stable version yet — a detail worth checking against Glide — Compose before depending on it in production, since a beta API can still change. TrailMate’s Compose UI uses Coil (above) rather than Glide Compose, precisely because Coil already has a stable Compose integration; Glide Compose exists mainly for a codebase already standardized on Glide’s caching/transformation stack that is migrating its Views screens to Compose incrementally and wants to avoid mixing two image libraries during the transition.

Bitmap Fundamentals

Both libraries' decode stage (pipeline stage 5) sits on the same platform APIs, worth understanding directly since they explain several of the defaults and pitfalls above.

inSampleSize and Downsampling

BitmapFactory.Options.inSampleSize tells the decoder to produce a bitmap at 1/inSampleSize the source image’s dimensions during decoding, rather than decoding at full size and scaling down afterwards — the difference matters because full-size decoding briefly allocates the full-resolution bitmap in memory regardless of what happens next. The standard pattern reads the image’s dimensions first with inJustDecodeBounds = true (which decodes only the header, not the pixels), computes the largest power-of-two inSampleSize that still covers the target size, then decodes for real:

// core/model/src/main/kotlin/com/example/trailmate/core/model/BitmapDownsampling.kt
import android.graphics.BitmapFactory

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height, width) = options.outHeight to options.outWidth
    var inSampleSize = 1
    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

Coil and Glide both apply this same idea internally, driven by the target ImageView’s/composable’s measured size, which is why neither library needs an app to call `calculateInSampleSize manually in ordinary use — it is shown here to explain what the libraries are doing under the hood, not as code TrailMate calls directly. See Android Developers — Load large bitmaps efficiently.

ImageDecoder and Hardware Bitmaps

Since API 28, android.graphics.ImageDecoder replaces the older BitmapFactory decode path with a builder-style API (ImageDecoder.decodeBitmap(ImageDecoder.createSource(…​)) { decoder, info, source → …​ }) that exposes the same downsampling control via setTargetSize/setTargetSampleSize, plus post-processing hooks. Its more consequential change is the default memory config: ImageDecoder decodes into Bitmap.Config.HARDWARE by default, storing pixel data in GPU-accessible graphics memory rather than the regular Java heap. A hardware bitmap draws faster and uses less regular heap memory, but it cannot be read pixel-by-pixel (Bitmap.getPixel()), passed to most Canvas drawing operations that need direct pixel access, or safely mutated — code that needs those must explicitly opt out with ALLOCATOR_SOFTWARE or copy the hardware bitmap to a software one first. Both Coil and Glide account for this: a Transformation that manipulates pixels directly (like the custom grayscale transformations above) runs against a software bitmap internally rather than a hardware one. See Android Developers — ImageDecoder and Android Developers — Manage your app’s memory, Hardware bitmaps.

ExifInterface and Bitmap.compress

A photo TrailMate’s PhotoCaptureScreen captures with the camera carries EXIF metadata, including an orientation tag recorded by the sensor rather than by rotating the pixel data itself — decoding the raw bytes without reading that tag can show a hiker’s photo sideways or upside down:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoOrientation.kt
import androidx.exifinterface.media.ExifInterface
import android.graphics.Bitmap
import android.graphics.Matrix
import java.io.File

fun correctedOrientation(photoFile: File, bitmap: Bitmap): Bitmap {
    val exif = ExifInterface(photoFile.absolutePath)
    val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
    val degrees = when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> 90f
        ExifInterface.ORIENTATION_ROTATE_180 -> 180f
        ExifInterface.ORIENTATION_ROTATE_270 -> 270f
        else -> return bitmap
    }
    val matrix = Matrix().apply { postRotate(degrees) }
    return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}

fun saveCompressedPhoto(bitmap: Bitmap, outFile: File) {
    outFile.outputStream().use { stream ->
        bitmap.compress(Bitmap.CompressFormat.JPEG, /* quality = */ 85, stream)
    }
}

androidx.exifinterface:exifinterface reads/writes EXIF tags across a wider range of formats and API levels than the platform’s own android.media.ExifInterface, which is why AndroidX’s is the one to depend on. Bitmap.compress(format, quality, stream) re-encodes a decoded (and possibly transformed/resized) bitmap back to bytes for storage — JPEG at a quality around 80-90 is the usual choice for a photo library, since PNG ignores the quality parameter and produces much larger files for photographic content, while WEBP_LOSSY offers a smaller file at comparable visual quality on API 30+. See Android Developers — ExifInterface and Android Developers — Bitmap.compress.

Legacy: Fresco and Picasso

  • Fresco (Meta, com.facebook.fresco:fresco, currently 3.9.0) predates both Coil and Glide’s current designs and was historically notable for decoding directly into native (ashmem) memory on older API levels to avoid Java heap pressure — a concern much less pressing since `ImageDecoder’s hardware bitmaps became available. It remains maintained and is still used in some large, long-lived codebases (originally built around it), but it is not the recommended starting point for a new app given Coil’s and Glide’s more active Kotlin/Compose-first development.

  • Picasso (Square) is the direct ancestor of Glide’s API style and, per its own README, is explicitly positioned as superseded by Coil for new Kotlin projects. It is mentioned here only as legacy-code context; code encountered using Picasso.get().load(url).into(imageView) maps almost directly onto the Glide/Coil patterns shown on this page.

Comparing Coil and Glide

Coil 3 Glide 5

Language / concurrency model

Kotlin-first, built on coroutines; Kotlin Multiplatform-capable since Coil 3.

Java-based internally with a Kotlin-friendly API; Android/JVM only.

Compose support

First-class and stable (coil-compose): AsyncImage, SubcomposeAsyncImage.

Beta only (com.github.bumptech.glide:compose, 1.0.0-beta10), no stable release yet.

Views support

Supported via ImageView.load() extension functions.

First-class and the library’s original design center; RecyclerViewPreloader is a mature, Glide-specific feature with no direct Coil equivalent.

Setup

No annotation processor; DI-friendly SingletonImageLoader.Factory.

Plain Glide.with(…​) needs no processor; a KSP-processed AppGlideModule is only needed for global options or custom components (KSP does not generate the deprecated GlideApp API).

Networking

Pluggable engine via a separate artifact (coil-network-okhttp, or others for other KMP targets).

OkHttp integrated via com.github.bumptech.glide:okhttp3-integration, or the built-in HTTP stack by default.

Cache invalidation

Custom memoryCacheKey/diskCacheKey on ImageRequest.

signature(ObjectKey(…​)) on RequestOptions.

Best fit

A Compose-first app, or one sharing code across Kotlin Multiplatform targets.

A Views-heavy, RecyclerView-centric app, or one that specifically needs RecyclerViewPreloader.

TrailMate’s own mixed codebase reflects the recommendation this table implies: its Compose screens use Coil, and its legacy Views-based TrailAdapter/TrailViewHolder uses Glide. For a brand-new, Compose-only app, Coil alone is the simpler starting point — there is little reason to add Glide’s KSP setup purely for its Compose integration while it remains in beta. A team standardizing on a single library across a codebase that still has significant Views-based screens, and that specifically relies on RecyclerViewPreloader, may reasonably choose Glide for everything instead. See Migrating from Views to Compose for how TrailMate approaches this kind of screen-by-screen migration more generally.

For choosing the right image variant, BlurHash placeholders, Media3 video and device-side compression, see Android: Coil, Glide and BlurHash Placeholders in the Media Optimization section.