From 811e31599cebf9e15064c49a20be7b255871afd8 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Fri, 8 May 2026 02:18:40 -0700 Subject: [PATCH] Improve image loading performance 1. We'd configured Coil to load images at their original size, but Coil refuses to use its memory cache when you do that. Now, we're passing it an arbitrary size, and it's magically happy with that. 2. We're now remembering the `ImageRequest` model, ensuring its stability for recomposition --- .../SkipUI/SkipUI/Components/AsyncImage.swift | 33 ++++++++++++------- Sources/SkipUI/SkipUI/Components/Image.swift | 32 ++++++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Components/AsyncImage.swift b/Sources/SkipUI/SkipUI/Components/AsyncImage.swift index 1dcc7a0e..9aeb05c0 100644 --- a/Sources/SkipUI/SkipUI/Components/AsyncImage.swift +++ b/Sources/SkipUI/SkipUI/Components/AsyncImage.swift @@ -4,11 +4,11 @@ import Foundation #if SKIP import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import android.webkit.MimeTypeMap import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest -import coil3.size.Size import coil3.fetch.Fetcher import coil3.fetch.FetchResult import coil3.ImageLoader @@ -114,16 +114,27 @@ public struct AsyncImage : View, Renderable { // we add a custom fetchers that will handle loading the URL. // Otherwise use Coil's default URL string handling let requestSource: Any = AssetURLFetcher.handlesURL(url) ? url : urlString - let model = ImageRequest.Builder(LocalContext.current) - .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs - .decoderFactory(coil3.svg.SvgDecoder.Factory()) - //.decoderFactory(coil3.gif.GifDecoder.Factory()) - .decoderFactory(PdfDecoder.Factory()) - .data(requestSource) - .size(Size.ORIGINAL) - .memoryCacheKey(urlString) - .diskCacheKey(urlString) - .build() + + let androidContext = LocalContext.current + let dm = androidContext.resources.displayMetrics + let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) + let cacheKey = "\(urlString)#\(maxPx)x\(maxPx)" + let model = remember(urlString, maxPx) { + // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! + // We're using maxPx as an arbitrary bound to force it to cache properly + // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: + // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + return ImageRequest.Builder(androidContext) + .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs + .decoderFactory(coil3.svg.SvgDecoder.Factory()) + //.decoderFactory(coil3.gif.GifDecoder.Factory()) + .decoderFactory(PdfDecoder.Factory()) + .data(requestSource) + .size(coil3.size.Size(width: maxPx, height: maxPx)) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + .build() + } SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in let placeholderView = content(AsyncImagePhase.empty) diff --git a/Sources/SkipUI/SkipUI/Components/Image.swift b/Sources/SkipUI/SkipUI/Components/Image.swift index b8c96230..d31c7960 100644 --- a/Sources/SkipUI/SkipUI/Components/Image.swift +++ b/Sources/SkipUI/SkipUI/Components/Image.swift @@ -17,6 +17,7 @@ import androidx.compose.material.icons.twotone.__ import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint import androidx.compose.ui.geometry.Rect @@ -142,16 +143,26 @@ public struct Image : View, Renderable, Equatable { @Composable private func RenderAssetImage(asset: AssetImageInfo, label: Text?, aspectRatio: Double?, contentMode: ContentMode?, context: ComposeContext) { let url = asset.url - let model = ImageRequest.Builder(LocalContext.current) - .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs - .decoderFactory(coil3.svg.SvgDecoder.Factory()) - //.decoderFactory(coil3.gif.GifDecoder.Factory()) - .decoderFactory(PdfDecoder.Factory()) - .data(url) - .size(coil3.size.Size.ORIGINAL) - .memoryCacheKey(url.description) - .diskCacheKey(url.description) - .build() + let androidContext = LocalContext.current + let dm = androidContext.resources.displayMetrics + let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) + let cacheKey = "\(url.description)#\(maxPx)x\(maxPx)" + let model = remember(asset.url, maxPx) { + // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! + // We're using maxPx as an arbitrary bound to force it to cache properly + // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: + // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + return ImageRequest.Builder(androidContext) + .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs + .decoderFactory(coil3.svg.SvgDecoder.Factory()) + //.decoderFactory(coil3.gif.GifDecoder.Factory()) + .decoderFactory(PdfDecoder.Factory()) + .data(asset.url) + .size(coil3.size.Size(width: maxPx, height: maxPx)) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + .build() + } let shouldTint = (templateRenderingMode == .template) || (templateRenderingMode == nil && asset.isTemplateImage) let tintColor = shouldTint ? EnvironmentValues.shared._foregroundStyle?.asColor(opacity: 1.0, animationContext: context) ?? Color.primary.colorImpl() : nil @@ -161,6 +172,7 @@ public struct Image : View, Renderable, Equatable { }, success: { state in RenderPainter(painter: self.painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: context) }, error: { state in + }) }