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:
+48
-39
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user