optimize picture show

This commit is contained in:
2026-08-30 22:43:24 +08:00
parent 560f3cbac3
commit ed37d0d570
7 changed files with 145 additions and 25 deletions
@@ -52,6 +52,7 @@ fun AlbumGridItem(
AlbumCoverImage( AlbumCoverImage(
album = album, album = album,
radius = 12.dp, radius = 12.dp,
decodeToDisplaySize = true,
modifier = Modifier.fillMaxSize().then(heroMod), modifier = Modifier.fillMaxSize().then(heroMod),
) )
} }
@@ -51,6 +51,7 @@ fun ArtistListItem(
ArtistPhotoImage( ArtistPhotoImage(
artist = artist, artist = artist,
size = ListLeadingCoverSize, size = ListLeadingCoverSize,
decodeToDisplaySize = true,
modifier = Modifier.fillMaxSize().then(heroMod), modifier = Modifier.fillMaxSize().then(heroMod),
) )
} }
@@ -255,7 +255,12 @@ private fun AddSheetMenuRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (coverMusic != null) { if (coverMusic != null) {
MusicCoverImage(music = coverMusic, size = 48.dp, radius = 12.dp) MusicCoverImage(
music = coverMusic,
size = 48.dp,
radius = 12.dp,
decodeToDisplaySize = true,
)
} else { } else {
CoverPlaceholder(label = menu.name.take(1), size = 48.dp, radius = 12.dp) CoverPlaceholder(label = menu.name.take(1), size = 48.dp, radius = 12.dp)
} }
@@ -2,6 +2,7 @@ package com.zhushenwudi.lovelivemusicplayer.ui.components
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -17,8 +18,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import coil3.compose.AsyncImagePainter.State import coil3.compose.AsyncImagePainter.State
import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.ExperimentalResourceApi
import com.zhushenwudi.lovelivemusicplayer.AppContainer import com.zhushenwudi.lovelivemusicplayer.AppContainer
@@ -29,7 +32,9 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res
import com.zhushenwudi.lovelivemusicplayer.ui.image.CoverImageHolder import com.zhushenwudi.lovelivemusicplayer.ui.image.CoverImageHolder
import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage import com.zhushenwudi.lovelivemusicplayer.ui.image.LlmpAsyncImage
import com.zhushenwudi.lovelivemusicplayer.ui.image.decodeSizeBucketPx
import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter
import com.zhushenwudi.lovelivemusicplayer.ui.image.sizedMemoryCacheKey
import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey
private const val FALLBACK_LOGO = "drawable/logo_svg_logo.svg" private const val FALLBACK_LOGO = "drawable/logo_svg_logo.svg"
@@ -49,7 +54,12 @@ fun rememberMusicCoverLocalPath(music: Music?): String? =
?.takeIf { path -> AppContainer.fileSystem.exists(path) } ?.takeIf { path -> AppContainer.fileSystem.exists(path) }
} }
/** 封面可解析且可加载时显示,否则显示默认 Logo。 */ /**
* 封面可解析且可加载时显示,否则显示默认 Logo。
*
* @param decodeToDisplaySize 仅列表缩略图开启:按 [size] 解码以省内存/CPU。
* 播放页/大图保持默认 false。
*/
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun MusicCoverImage( fun MusicCoverImage(
@@ -57,11 +67,13 @@ fun MusicCoverImage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 48.dp, size: Dp = 48.dp,
radius: Dp = 12.dp, radius: Dp = 12.dp,
decodeToDisplaySize: Boolean = false,
) { ) {
val uri = rememberMusicCoverUri(music) val uri = rememberMusicCoverUri(music)
CoverAsyncImage( CoverAsyncImage(
modelUri = uri, modelUri = uri,
stableCacheKey = music.musicId?.let { musicCoverCacheKey(it) }, stableCacheKey = music.musicId?.let { musicCoverCacheKey(it) },
decodeSize = size.takeIf { decodeToDisplaySize },
// 先设置尺寸,再应用调用方修饰符(如 sharedElement 在 size 之后)。 // 先设置尺寸,再应用调用方修饰符(如 sharedElement 在 size 之后)。
modifier = Modifier.size(size).then(modifier), modifier = Modifier.size(size).then(modifier),
shape = RoundedCornerShape(radius), shape = RoundedCornerShape(radius),
@@ -69,28 +81,51 @@ fun MusicCoverImage(
) )
} }
/**
* @param decodeToDisplaySize 仅网格/列表缩略开启。详情大图勿开。
*/
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
fun AlbumCoverImage( fun AlbumCoverImage(
album: Album, album: Album,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
radius: Dp = 12.dp, radius: Dp = 12.dp,
decodeToDisplaySize: Boolean = false,
) { ) {
val uri = remember(album.albumId, album.coverPath, album.existFile) { val uri = remember(album.albumId, album.coverPath, album.existFile) {
AppContainer.pathResolver.coverDisplayUri(album) AppContainer.pathResolver.coverDisplayUri(album)
} }
CoverAsyncImage( if (!decodeToDisplaySize) {
modelUri = uri, CoverAsyncImage(
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) }, modelUri = uri,
modifier = modifier, stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
shape = RoundedCornerShape(radius), decodeSize = null,
fallbackPadding = 6.dp, modifier = modifier,
) shape = RoundedCornerShape(radius),
fallbackPadding = 6.dp,
)
return
}
BoxWithConstraints(modifier) {
val side = remember(maxWidth, maxHeight) {
measuredSquareSide(maxWidth, maxHeight)
}
CoverAsyncImage(
modelUri = uri,
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
decodeSize = side,
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(radius),
fallbackPadding = 6.dp,
)
}
} }
/** /**
* 艺人头像:48×48 圆形封面。 * 艺人头像:48×48 圆形封面。
* 加载 https `{R2}/LLMP-M/artist_webp/{uid}.webp`(经 Coil 网络请求)。 * 加载 https `{R2}/LLMP-M/artist_webp/{uid}.webp`(经 Coil 网络请求)。
*
* @param decodeToDisplaySize 仅列表缩略开启。
*/ */
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
@@ -98,6 +133,7 @@ fun ArtistPhotoImage(
artist: Artist, artist: Artist,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 48.dp, size: Dp = 48.dp,
decodeToDisplaySize: Boolean = false,
) { ) {
val uri = remember(artist.uid, artist.photo) { val uri = remember(artist.uid, artist.photo) {
artistPhotoDisplayUri(artist) artistPhotoDisplayUri(artist)
@@ -105,6 +141,7 @@ fun ArtistPhotoImage(
CoverAsyncImage( CoverAsyncImage(
modelUri = uri, modelUri = uri,
stableCacheKey = artistPhotoCacheKey(artist.uid), stableCacheKey = artistPhotoCacheKey(artist.uid),
decodeSize = size.takeIf { decodeToDisplaySize },
modifier = Modifier.size(size).then(modifier), modifier = Modifier.size(size).then(modifier),
shape = CircleShape, shape = CircleShape,
fallbackPadding = 6.dp, fallbackPadding = 6.dp,
@@ -120,8 +157,9 @@ private fun artistPhotoDisplayUri(artist: Artist): String? {
return "${Const.R2_OSS_URL}LLMP-M/artist_webp/${artist.uid}.webp" return "${Const.R2_OSS_URL}LLMP-M/artist_webp/${artist.uid}.webp"
} }
/** WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。 /**
* `circular` 为 true 时圆形封面,否则圆角矩形。 * WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。
* 不做按显示尺寸解码(播放页需清晰大图)。
*/ */
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
@@ -136,6 +174,7 @@ fun PlayerMusicCover(
CoverAsyncImage( CoverAsyncImage(
modelUri = uri, modelUri = uri,
stableCacheKey = music?.musicId?.let { musicCoverCacheKey(it) }, stableCacheKey = music?.musicId?.let { musicCoverCacheKey(it) },
decodeSize = null,
modifier = modifier, modifier = modifier,
shape = if (circular) CircleShape else RoundedCornerShape(radius), shape = if (circular) CircleShape else RoundedCornerShape(radius),
fallbackPadding = fallbackPadding, fallbackPadding = fallbackPadding,
@@ -148,22 +187,34 @@ internal fun albumCoverCacheKey(albumId: String): String = "album-cover:$albumId
internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid" internal fun artistPhotoCacheKey(uid: String): String = "artist-photo:$uid"
private fun measuredSquareSide(maxWidth: Dp, maxHeight: Dp): Dp? =
when {
maxWidth.value.isFinite() && maxHeight.value.isFinite() ->
min(maxWidth, maxHeight).takeIf { it > 0.dp }
maxWidth.value.isFinite() -> maxWidth.takeIf { it > 0.dp }
maxHeight.value.isFinite() -> maxHeight.takeIf { it > 0.dp }
else -> null
}
@OptIn(ExperimentalResourceApi::class) @OptIn(ExperimentalResourceApi::class)
@Composable @Composable
private fun CoverAsyncImage( private fun CoverAsyncImage(
modelUri: String?, modelUri: String?,
stableCacheKey: String? = null, stableCacheKey: String? = null,
decodeSize: Dp? = null,
modifier: Modifier, modifier: Modifier,
shape: Shape, shape: Shape,
fallbackPadding: Dp, fallbackPadding: Dp,
) { ) {
val fallback = Res.getUri(FALLBACK_LOGO) val fallback = Res.getUri(FALLBACK_LOGO)
val density = LocalDensity.current
val clipped = modifier.clip(shape).fillMaxSize() val clipped = modifier.clip(shape).fillMaxSize()
if (modelUri.isNullOrBlank()) { if (modelUri.isNullOrBlank()) {
LlmpAsyncImage( LlmpAsyncImage(
model = fallback, model = fallback,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = clipped.padding(fallbackPadding), modifier = clipped.padding(fallbackPadding),
) )
return return
@@ -171,7 +222,7 @@ private fun CoverAsyncImage(
// 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致 // 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致
// Loading(null) → 空白,即使磁盘/内存仍有封面。 // Loading(null) → 空白,即使磁盘/内存仍有封面。
val cacheKey = remember(modelUri, stableCacheKey) { val baseCacheKey = remember(modelUri, stableCacheKey) {
stableCacheKey?.takeIf { it.isNotBlank() } stableCacheKey?.takeIf { it.isNotBlank() }
?: if (modelUri.startsWith("http", ignoreCase = true)) { ?: if (modelUri.startsWith("http", ignoreCase = true)) {
modelUri.toImageCacheKey() modelUri.toImageCacheKey()
@@ -179,6 +230,14 @@ private fun CoverAsyncImage(
modelUri modelUri
} }
} }
val bucketPx = remember(decodeSize, density) {
decodeSize?.let { dp ->
decodeSizeBucketPx(with(density) { dp.roundToPx() })
} ?: 0
}
val cacheKey = remember(baseCacheKey, bucketPx) {
sizedMemoryCacheKey(baseCacheKey, bucketPx)
}
val heldPainter = rememberCoverPainter(cacheKey) val heldPainter = rememberCoverPainter(cacheKey)
var showLogoFallback by remember(cacheKey) { mutableStateOf(false) } var showLogoFallback by remember(cacheKey) { mutableStateOf(false) }
var hasSuccess by remember(cacheKey) { var hasSuccess by remember(cacheKey) {
@@ -198,6 +257,7 @@ private fun CoverAsyncImage(
model = fallback, model = fallback,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(fallbackPadding), .padding(fallbackPadding),
@@ -208,7 +268,8 @@ private fun CoverAsyncImage(
key(cacheKey) { key(cacheKey) {
LlmpAsyncImage( LlmpAsyncImage(
model = modelUri, model = modelUri,
stableCacheKey = cacheKey, stableCacheKey = baseCacheKey,
decodeSize = decodeSize,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -92,7 +92,12 @@ fun SongListItem(
}, },
), ),
) { ) {
MusicCoverImage(music = music, size = ListLeadingCoverSize, radius = 12.dp) MusicCoverImage(
music = music,
size = ListLeadingCoverSize,
radius = 12.dp,
decodeToDisplaySize = true,
)
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
TwoLineListTextColumn( TwoLineListTextColumn(
@@ -10,6 +10,8 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil3.Image import coil3.Image
import coil3.ImageLoader import coil3.ImageLoader
import coil3.PlatformContext import coil3.PlatformContext
@@ -31,6 +33,7 @@ import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.Options import coil3.request.Options
import coil3.request.crossfade import coil3.request.crossfade
import coil3.size.Size
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -137,8 +140,9 @@ internal object ImageRevalidator {
.networkCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED)
.crossfade(false) .crossfade(false)
.listener( .listener(
onSuccess = { _, result -> onSuccess = { _, _ ->
CoverImageHolder.put(cacheKey, result.image) // 只刷新 Coil 磁盘/内存;不写入 CoverImageHolder,
// 避免无尺寸的原图覆盖列表缩略图。
complete(cacheKey) complete(cacheKey)
}, },
onError = { _, result -> onError = { _, result ->
@@ -208,7 +212,8 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
.crossfade(false) .crossfade(false)
.memoryCache { .memoryCache {
MemoryCache.Builder() MemoryCache.Builder()
.maxSizePercent(context, 0.35) // 老机内存紧:略降占比,配合按显示尺寸解码。
.maxSizePercent(context, 0.20)
.build() .build()
} }
.diskCache { .diskCache {
@@ -230,6 +235,23 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
return loader return loader
} }
/**
* 将显示边长收成缓存桶,减少 47/48/49dp 之类碎片键,
* 同时保证列表小图与播放页大图不会互相覆盖。
*/
internal fun decodeSizeBucketPx(px: Int): Int = when {
px <= 0 -> 0
px <= 64 -> 64
px <= 96 -> 96
px <= 144 -> 144
px <= 256 -> 256
px <= 384 -> 384
else -> 512
}
internal fun sizedMemoryCacheKey(baseKey: String, bucketPx: Int): String =
if (bucketPx > 0) "$baseKey@$bucketPx" else baseKey
/** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */ /** 共享 AsyncImage:1 天网络缓存 + 过期先用旧图(无闪烁)。 */
@Composable @Composable
fun LlmpAsyncImage( fun LlmpAsyncImage(
@@ -237,6 +259,8 @@ fun LlmpAsyncImage(
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
stableCacheKey: String? = null, stableCacheKey: String? = null,
/** 正方形解码目标(逻辑 dp)。传入后按像素桶解码,显著降低老机 CPU/内存。 */
decodeSize: Dp? = null,
transform: (State) -> State = DefaultTransform, transform: (State) -> State = DefaultTransform,
onState: ((State) -> Unit)? = null, onState: ((State) -> Unit)? = null,
alignment: Alignment = Alignment.Center, alignment: Alignment = Alignment.Center,
@@ -247,24 +271,44 @@ fun LlmpAsyncImage(
clipToBounds: Boolean = true, clipToBounds: Boolean = true,
) { ) {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val density = LocalDensity.current
val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() } val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() }
val cacheKey = stableCacheKey?.takeIf { it.isNotBlank() } val baseCacheKey = stableCacheKey?.takeIf { it.isNotBlank() }
?: httpUrl?.toImageCacheKey() ?: httpUrl?.toImageCacheKey()
val bucketPx = remember(decodeSize, density) {
decodeSize?.let { dp ->
decodeSizeBucketPx(with(density) { dp.roundToPx() })
} ?: 0
}
val memoryKey = baseCacheKey?.let { sizedMemoryCacheKey(it, bucketPx) }
val resolvedFilterQuality = when {
filterQuality != DefaultFilterQuality -> filterQuality
bucketPx in 1..96 -> FilterQuality.Low
else -> filterQuality
}
// 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面 // 后台重新校验时不重挂载。TTL 刷新后重挂载可见封面
// 可能 Error 一次并使占位图锁定,行仍在屏幕上。 // 可能 Error 一次并使占位图锁定,行仍在屏幕上。
val request = remember(model, httpUrl, cacheKey) { val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx) {
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(model) .data(model)
.crossfade(false) .crossfade(false)
.apply { .apply {
if (cacheKey != null) { if (bucketPx > 0) {
memoryCacheKey(cacheKey) size(Size(bucketPx, bucketPx))
diskCacheKey(cacheKey) }
placeholderMemoryCacheKey(cacheKey) if (baseCacheKey != null) {
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
diskCacheKey(baseCacheKey)
if (memoryKey != null) {
memoryCacheKey(memoryKey)
placeholderMemoryCacheKey(memoryKey)
}
listener( listener(
onSuccess = { _, result -> onSuccess = { _, result ->
CoverImageHolder.put(cacheKey, result.image) if (memoryKey != null) {
CoverImageHolder.put(memoryKey, result.image)
}
}, },
onError = { _, result -> onError = { _, result ->
AppLog.w( AppLog.w(
@@ -287,7 +331,7 @@ fun LlmpAsyncImage(
contentScale = contentScale, contentScale = contentScale,
alpha = alpha, alpha = alpha,
colorFilter = colorFilter, colorFilter = colorFilter,
filterQuality = filterQuality, filterQuality = resolvedFilterQuality,
clipToBounds = clipToBounds, clipToBounds = clipToBounds,
) )
} }
@@ -299,6 +343,7 @@ fun LlmpAsyncImage(
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
stableCacheKey: String? = null, stableCacheKey: String? = null,
decodeSize: Dp? = null,
onLoading: ((State.Loading) -> Unit)? = null, onLoading: ((State.Loading) -> Unit)? = null,
onSuccess: ((State.Success) -> Unit)? = null, onSuccess: ((State.Success) -> Unit)? = null,
onError: ((State.Error) -> Unit)? = null, onError: ((State.Error) -> Unit)? = null,
@@ -314,6 +359,7 @@ fun LlmpAsyncImage(
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
stableCacheKey = stableCacheKey, stableCacheKey = stableCacheKey,
decodeSize = decodeSize,
onState = { state -> onState = { state ->
when (state) { when (state) {
is State.Loading -> onLoading?.invoke(state) is State.Loading -> onLoading?.invoke(state)
@@ -333,6 +333,7 @@ private fun MenuList(
music = coverMusic, music = coverMusic,
size = ListLeadingCoverSize, size = ListLeadingCoverSize,
radius = 12.dp, radius = 12.dp,
decodeToDisplaySize = true,
modifier = heroMod, modifier = heroMod,
) )
} else { } else {