perf: avoid byte array allocation on first profile picture load

On cache miss, instead of reading the entire downloaded image into a
byte array (which could be 50MB+ for a malicious profile picture),
the fetcher now passes the NetworkFetcher result straight through to
Coil's normal streaming decoder pipeline — zero overhead vs current.

Thumbnail generation runs in background from Coil's disk cache file
using file-based BitmapFactory.decodeFile(), which is seekable
(supports two-pass bounds+decode) and never buffers the full file.

https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
This commit is contained in:
Claude
2026-04-05 15:14:48 +00:00
parent 6c9f6531cf
commit 3f032710d2
@@ -28,10 +28,10 @@ import coil3.Uri
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.asImage import coil3.asImage
import coil3.decode.DataSource import coil3.decode.DataSource
import coil3.disk.DiskCache
import coil3.fetch.FetchResult import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.key.Keyer import coil3.key.Keyer
import coil3.network.CacheStrategy import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy import coil3.network.ConcurrentRequestStrategy
@@ -49,11 +49,12 @@ import kotlin.coroutines.cancellation.CancellationException
* Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache.
* *
* URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. * URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables.
* This fetcher: *
* 1. Checks the thumbnail disk cache for a pre-resized JPEG (~5-10KB) * Flow:
* 2. On hit: returns the tiny thumbnail directly (fast disk read) * - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode.
* 3. On miss: returns the network result immediately for display, and generates * - Thumbnail cache miss (first load): passes the result straight through to Coil's normal
* the thumbnail in the background for next time * pipeline (zero overhead). In the background, reads the file from Coil's disk cache
* after download and generates a thumbnail for next time.
* *
* The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), * The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix),
* which goes through the normal Coil pipeline for full-resolution display. * which goes through the normal Coil pipeline for full-resolution display.
@@ -64,6 +65,7 @@ class ProfilePictureFetcher(
private val options: Options, private val options: Options,
private val thumbnailCache: ThumbnailDiskCache, private val thumbnailCache: ThumbnailDiskCache,
private val networkFetcher: Fetcher, private val networkFetcher: Fetcher,
private val diskCacheLazy: Lazy<DiskCache?>,
private val backgroundScope: CoroutineScope, private val backgroundScope: CoroutineScope,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
@@ -80,7 +82,11 @@ class ProfilePictureFetcher(
} }
} }
// Cache miss: download via normal network fetcher // Cache miss: download via normal network fetcher.
// NetworkFetcher writes the original to Coil's disk cache and returns
// a source pointing to it. We pass this through unchanged — Coil's
// decoder pipeline handles streaming decode with inSampleSize, no
// large byte array allocation.
val result = val result =
try { try {
networkFetcher.fetch() ?: return null networkFetcher.fetch() ?: return null
@@ -89,62 +95,57 @@ class ProfilePictureFetcher(
return null return null
} }
// For source results, read bytes, decode for immediate display, // Fire-and-forget: generate thumbnail from Coil's disk cache in background.
// and generate thumbnail in background for next time // The file is already there because NetworkFetcher just wrote it.
if (result is SourceFetchResult) { backgroundScope.launch {
val bytes = result.source.source().use { it.readByteArray() } generateThumbnailFromDiskCache()
// Decode at thumbnail size for immediate display
val bitmap = decodeThumbnailFromBytes(bytes)
if (bitmap != null) {
// Fire-and-forget: save thumbnail to disk for next load
backgroundScope.launch {
thumbnailCache.save(originalUrl, bitmap)
}
return ImageFetchResult(
image = bitmap.asImage(true),
isSampled = true,
dataSource = DataSource.NETWORK,
)
}
} }
return result return result
} }
private fun decodeThumbnailFromBytes(bytes: ByteArray): Bitmap? = /**
try { * Reads the original image from Coil's disk cache and generates a small
* thumbnail for our dedicated thumbnail cache. Uses file-based BitmapFactory
* decode which is seekable (supports two-pass bounds+decode) and never loads
* the entire file into a byte array.
*/
private fun generateThumbnailFromDiskCache() {
val diskCache = diskCacheLazy.value ?: return
diskCache.openSnapshot(originalUrl)?.use { snapshot ->
val file = snapshot.data.toFile()
val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX val targetSize = ThumbnailDiskCache.THUMBNAIL_SIZE_PX
// First pass: decode bounds only // First pass: decode bounds only (no memory allocation for pixels)
val boundsOptions = val boundsOptions =
BitmapFactory.Options().apply { BitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true
} }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOptions) BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) return
// Calculate inSampleSize for efficient memory use during decode // Calculate inSampleSize for efficient memory use during decode
val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize) val sampleSize = calculateInSampleSize(boundsOptions, targetSize, targetSize)
// Second pass: decode at reduced size // Second pass: decode at reduced size (streams from file, not byte array)
val decodeOptions = val decodeOptions =
BitmapFactory.Options().apply { BitmapFactory.Options().apply {
inSampleSize = sampleSize inSampleSize = sampleSize
} }
val decoded = val decoded = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions)
?: return null
// Scale to exact target size // Scale to exact target size
val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true) val scaled = Bitmap.createScaledBitmap(decoded, targetSize, targetSize, true)
if (scaled !== decoded) { if (scaled !== decoded) {
decoded.recycle() decoded.recycle()
} }
scaled
} catch (e: Exception) { thumbnailCache.save(originalUrl, scaled)
null scaled.recycle()
} }
}
private fun calculateInSampleSize( private fun calculateInSampleSize(
options: BitmapFactory.Options, options: BitmapFactory.Options,
@@ -189,19 +190,27 @@ class ProfilePictureFetcher(
if (data.scheme != SCHEME) return null if (data.scheme != SCHEME) return null
val originalUrl = extractOriginalUrl(data) val originalUrl = extractOriginalUrl(data)
val diskCacheLazy = lazy { imageLoader.diskCache }
val netFetcher = val netFetcher =
NetworkFetcher( NetworkFetcher(
url = originalUrl, url = originalUrl,
options = options, options = options,
networkClient = lazy { networkClient(originalUrl).asNetworkClient() }, networkClient = lazy { networkClient(originalUrl).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache }, diskCache = diskCacheLazy,
cacheStrategy = lazy { CacheStrategy.DEFAULT }, cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
) )
return ProfilePictureFetcher(originalUrl, options, thumbnailCache, netFetcher, backgroundScope) return ProfilePictureFetcher(
originalUrl,
options,
thumbnailCache,
netFetcher,
diskCacheLazy,
backgroundScope,
)
} }
} }