From c261f88c3fe8345f3947f37f375447b31f9a5809 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 13 Oct 2026 16:51:16 +0200 Subject: [PATCH] perf(android): decode posters at tile size, and keep disk reads off the main thread - RemoteArtwork takes the edge it is drawn at; library tiles decode their posters at that size rather than at 1024 pixels, sampled down and then scaled so a poster never lands under its tile. The decoded cache's ceiling rises from 32 to 64 MiB, so a grid scrolled back over is still decoded. - The title page decodes its download records on the IO dispatcher. WorkManager reports progress several times a second while a season downloads, and each report re-read every record on the main thread. - The settings file is loaded on a background thread at startup, so the playback service's start callback no longer reads it from disk on the main thread (B41). --- .../protonstream/ProtonStreamApplication.kt | 5 ++ .../protonstream/settings/SettingsStore.kt | 5 ++ .../io/narl/protonstream/ui/LibraryScreen.kt | 3 ++ .../io/narl/protonstream/ui/RemoteArtwork.kt | 48 +++++++++++++------ .../io/narl/protonstream/ui/TitleScreen.kt | 7 ++- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/ProtonStreamApplication.kt b/android/app/src/main/java/io/narl/protonstream/ProtonStreamApplication.kt index ca063e4..4aa5e20 100644 --- a/android/app/src/main/java/io/narl/protonstream/ProtonStreamApplication.kt +++ b/android/app/src/main/java/io/narl/protonstream/ProtonStreamApplication.kt @@ -2,10 +2,15 @@ package io.narl.protonstream import android.app.Application import io.narl.protonstream.native.NativeRuntime +import io.narl.protonstream.settings.SettingsStore class ProtonStreamApplication : Application() { override fun onCreate() { super.onCreate() NativeRuntime.initialize(this) + // SharedPreferences reads its file on first use, and the first use was + // on the main thread — in the playback service's start callback among + // others. Read here, off it, and every later read is from memory. + Thread({ SettingsStore.preload(this) }, "settings-preload").start() } } diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt index d405eec..64a832f 100644 --- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt @@ -65,6 +65,11 @@ class SettingsStore(context: Context) { fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL + /** Load the file into memory, so no later read waits on the disk. */ + fun preload(context: Context) { + context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all + } + private const val NAME = "proton_stream_settings" private const val KEY_WIFI_ONLY = "wifi_only" private const val KEY_BACKGROUND_AUDIO = "background_audio" diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 85d156d..3c42ef1 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -693,6 +693,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () entry.title.displayName, Modifier.fillMaxSize().clearAndSetSemantics {}, fallback = entry.episode.thumbnailSource, + maxEdge = 220.dp, ) entry.episode.progress?.let { progress -> AccentProgress( @@ -754,6 +755,8 @@ private fun PosterTile( .clip(MaterialTheme.shapes.medium) .clearAndSetSemantics {}, fallback = title.thumbnailSource, + // The tile's height, at the widest a grid column gets. + maxEdge = 280.dp, ) if (folded > 0) { Text( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index 1b545c0..d230d6c 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import io.narl.protonstream.native.NativeRuntime import kotlinx.coroutines.Dispatchers @@ -87,16 +88,20 @@ internal fun RemoteArtwork( modifier: Modifier = Modifier, fallback: ThumbnailSource? = null, labelled: Boolean = true, + maxEdge: Dp? = null, ) { val context = LocalContext.current val enabled = LocalArtworkLoading.current + // Decoded at the size it is drawn: a grid tile is a few hundred pixels, + // and decoding its poster at 1024 holds four times the memory for nothing. + val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE var image by remember(url, fallback) { mutableStateOf(ArtworkLoad.Pending) } LaunchedEffect(url, fallback, enabled) { if (!enabled) { image = ArtworkLoad.Missing return@LaunchedEffect } - image = (loadArtwork(context, url) ?: loadThumbnail(fallback)) + image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)) ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing } @@ -186,8 +191,8 @@ internal fun initials(name: String): String { * loader's protocol checks and exactly this disk cache, so the artwork the shade * shows costs no second download. */ -internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested -> - decoded.get(requested) ?: withContext(Dispatchers.IO) { +internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested -> + decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) { runCatching { val uri = URI(requested) require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" } @@ -197,7 +202,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url? val cached = directory.resolve("$digest.img") val fresh = !cached.isFile val bytes = if (fresh) download(uri) else cached.readBytes() - val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" } + val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" } if (fresh) { // Written only once it has decoded. Caching the bytes first is // what turns one hostile poster into a crash on every launch @@ -210,7 +215,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url? cached.setLastModified(System.currentTimeMillis()) } bitmap - }.getOrNull()?.also { decoded.put(requested, it) } + }.getOrNull()?.also { decoded.put(sized(requested, edge), it) } } } @@ -224,21 +229,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url? * share or a session that has to be reopened is transient, and the next * composition should try again. */ -internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? { +internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? { val file = source ?: return null val key = file.key - decoded.get(key)?.let { return it } + decoded.get(sized(key, edge))?.let { return it } if (key in missing) return null val fetched = withContext(Dispatchers.IO) { runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) } } val bytes = fetched.getOrElse { return null } - val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } } + val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } } if (bitmap == null) { missing.add(key) return null } - decoded.put(key, bitmap) + decoded.put(sized(key, edge), bitmap) return bitmap } @@ -257,28 +262,43 @@ private fun download(uri: URI): ByteArray { } /** - * Decode within a bound the display can actually use. + * Decode with its longest edge at most [edge] pixels. * * [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what * it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB * `ARGB_8888` allocation on a device that will not give it. The bounds pass * costs no allocation and settles a sample size before anything is committed. + * + * Sampling halves, so it stops while the image is still at least [edge]; the + * decoder scales the rest of the way, which a poster sampled straight to under + * its tile would show as blur. */ -private fun decodeSampled(bytes: ByteArray): Bitmap? { +private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) val longest = maxOf(bounds.outWidth, bounds.outHeight) if (longest <= 0) return null var sample = 1 - while (longest / sample > MAX_ART_EDGE) sample *= 2 + while (longest / (sample * 2) >= edge) sample *= 2 + val sampled = longest / sample return BitmapFactory.decodeByteArray( bytes, 0, bytes.size, - BitmapFactory.Options().apply { inSampleSize = sample }, + BitmapFactory.Options().apply { + inSampleSize = sample + if (sampled > edge) { + inScaled = true + inDensity = sampled + inTargetDensity = edge + } + }, ) } +/** The decoded cache's key for one image at one size. */ +private fun sized(key: String, edge: Int) = "$key#$edge" + /** * Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first. * @@ -315,7 +335,7 @@ private val missing: MutableSet = Collections.synchronizedSet(mutableSet * is a full-resolution decode per item. */ private val decoded = object : LruCache( - (Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(), + (Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(), ) { override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index a6fc0b8..d91e0d6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -147,7 +148,11 @@ internal fun TitleScreen( // without a trip to the Downloads tab. val work by WorkManager.getInstance(context) .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) - val downloads = remember(work) { DownloadStateStore(context).records() } + // Decoded off the main thread: WorkManager reports progress several times + // a second while a season downloads, and each report re-reads every record. + val downloads by produceState(emptyList(), work) { + value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() } + } // On a tablet the page keeps a phone's reading width, centred: a Play // button and synopsis lines two thousand pixels wide are read by turning