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).
This commit is contained in:
nvrl committed 2026-10-13 16:51:16 +02:00
1 parent bf3fa19c70
commit c261f88c3f
5 files changed
+53 -15

No files matched your search

@@ -2,10 +2,15 @@ package io.narl.protonstream
import android.app.Application import android.app.Application
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
class ProtonStreamApplication : Application() { class ProtonStreamApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NativeRuntime.initialize(this) 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()
} }
} }
@@ -65,6 +65,11 @@ class SettingsStore(context: Context) {
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL 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 NAME = "proton_stream_settings"
private const val KEY_WIFI_ONLY = "wifi_only" private const val KEY_WIFI_ONLY = "wifi_only"
private const val KEY_BACKGROUND_AUDIO = "background_audio" private const val KEY_BACKGROUND_AUDIO = "background_audio"
@@ -693,6 +693,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
entry.title.displayName, entry.title.displayName,
Modifier.fillMaxSize().clearAndSetSemantics {}, Modifier.fillMaxSize().clearAndSetSemantics {},
fallback = entry.episode.thumbnailSource, fallback = entry.episode.thumbnailSource,
maxEdge = 220.dp,
) )
entry.episode.progress?.let { progress -> entry.episode.progress?.let { progress ->
AccentProgress( AccentProgress(
@@ -754,6 +755,8 @@ private fun PosterTile(
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clearAndSetSemantics {}, .clearAndSetSemantics {},
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
// The tile's height, at the widest a grid column gets.
maxEdge = 280.dp,
) )
if (folded > 0) { if (folded > 0) {
Text( Text(
@@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -87,16 +88,20 @@ internal fun RemoteArtwork(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null, fallback: ThumbnailSource? = null,
labelled: Boolean = true, labelled: Boolean = true,
maxEdge: Dp? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val enabled = LocalArtworkLoading.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>(ArtworkLoad.Pending) } var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
LaunchedEffect(url, fallback, enabled) { LaunchedEffect(url, fallback, enabled) {
if (!enabled) { if (!enabled) {
image = ArtworkLoad.Missing image = ArtworkLoad.Missing
return@LaunchedEffect return@LaunchedEffect
} }
image = (loadArtwork(context, url) ?: loadThumbnail(fallback)) image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing ?: 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 * loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download. * shows costs no second download.
*/ */
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested -> internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) { decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
runCatching { runCatching {
val uri = URI(requested) val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" } 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 cached = directory.resolve("$digest.img")
val fresh = !cached.isFile val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes() 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) { if (fresh) {
// Written only once it has decoded. Caching the bytes first is // Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch // 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()) cached.setLastModified(System.currentTimeMillis())
} }
bitmap 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 * share or a session that has to be reopened is transient, and the next
* composition should try again. * 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 file = source ?: return null
val key = file.key val key = file.key
decoded.get(key)?.let { return it } decoded.get(sized(key, edge))?.let { return it }
if (key in missing) return null if (key in missing) return null
val fetched = withContext(Dispatchers.IO) { val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) } runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
} }
val bytes = fetched.getOrElse { return null } 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) { if (bitmap == null) {
missing.add(key) missing.add(key)
return null return null
} }
decoded.put(key, bitmap) decoded.put(sized(key, edge), bitmap)
return 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 * [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 * 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 * `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. * 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 } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight) val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null if (longest <= 0) return null
var sample = 1 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( return BitmapFactory.decodeByteArray(
bytes, bytes,
0, 0,
bytes.size, 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. * Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
* *
@@ -315,7 +335,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
* is a full-resolution decode per item. * is a full-resolution decode per item.
*/ */
private val decoded = object : LruCache<String, Bitmap>( private val decoded = object : LruCache<String, Bitmap>(
(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 override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
} }
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -147,7 +148,11 @@ internal fun TitleScreen(
// without a trip to the Downloads tab. // without a trip to the Downloads tab.
val work by WorkManager.getInstance(context) val work by WorkManager.getInstance(context)
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) .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<RetainedDownload>(), work) {
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
}
// On a tablet the page keeps a phone's reading width, centred: a Play // 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 // button and synopsis lines two thousand pixels wide are read by turning