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(
album = album,
radius = 12.dp,
decodeToDisplaySize = true,
modifier = Modifier.fillMaxSize().then(heroMod),
)
}
@@ -51,6 +51,7 @@ fun ArtistListItem(
ArtistPhotoImage(
artist = artist,
size = ListLeadingCoverSize,
decodeToDisplaySize = true,
modifier = Modifier.fillMaxSize().then(heroMod),
)
}
@@ -255,7 +255,12 @@ private fun AddSheetMenuRow(
verticalAlignment = Alignment.CenterVertically,
) {
if (coverMusic != null) {
MusicCoverImage(music = coverMusic, size = 48.dp, radius = 12.dp)
MusicCoverImage(
music = coverMusic,
size = 48.dp,
radius = 12.dp,
decodeToDisplaySize = true,
)
} else {
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.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.graphics.Shape
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.min
import coil3.compose.AsyncImagePainter.State
import org.jetbrains.compose.resources.ExperimentalResourceApi
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.ui.image.CoverImageHolder
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.sizedMemoryCacheKey
import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey
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) }
}
/** 封面可解析且可加载时显示,否则显示默认 Logo。 */
/**
* 封面可解析且可加载时显示,否则显示默认 Logo。
*
* @param decodeToDisplaySize 仅列表缩略图开启:按 [size] 解码以省内存/CPU。
* 播放页/大图保持默认 false。
*/
@OptIn(ExperimentalResourceApi::class)
@Composable
fun MusicCoverImage(
@@ -57,11 +67,13 @@ fun MusicCoverImage(
modifier: Modifier = Modifier,
size: Dp = 48.dp,
radius: Dp = 12.dp,
decodeToDisplaySize: Boolean = false,
) {
val uri = rememberMusicCoverUri(music)
CoverAsyncImage(
modelUri = uri,
stableCacheKey = music.musicId?.let { musicCoverCacheKey(it) },
decodeSize = size.takeIf { decodeToDisplaySize },
// 先设置尺寸,再应用调用方修饰符(如 sharedElement 在 size 之后)。
modifier = Modifier.size(size).then(modifier),
shape = RoundedCornerShape(radius),
@@ -69,28 +81,51 @@ fun MusicCoverImage(
)
}
/**
* @param decodeToDisplaySize 仅网格/列表缩略开启。详情大图勿开。
*/
@OptIn(ExperimentalResourceApi::class)
@Composable
fun AlbumCoverImage(
album: Album,
modifier: Modifier = Modifier,
radius: Dp = 12.dp,
decodeToDisplaySize: Boolean = false,
) {
val uri = remember(album.albumId, album.coverPath, album.existFile) {
AppContainer.pathResolver.coverDisplayUri(album)
}
CoverAsyncImage(
modelUri = uri,
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
modifier = modifier,
shape = RoundedCornerShape(radius),
fallbackPadding = 6.dp,
)
if (!decodeToDisplaySize) {
CoverAsyncImage(
modelUri = uri,
stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) },
decodeSize = null,
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 圆形封面。
* 加载 https `{R2}/LLMP-M/artist_webp/{uid}.webp`(经 Coil 网络请求)。
*
* @param decodeToDisplaySize 仅列表缩略开启。
*/
@OptIn(ExperimentalResourceApi::class)
@Composable
@@ -98,6 +133,7 @@ fun ArtistPhotoImage(
artist: Artist,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
decodeToDisplaySize: Boolean = false,
) {
val uri = remember(artist.uid, artist.photo) {
artistPhotoDisplayUri(artist)
@@ -105,6 +141,7 @@ fun ArtistPhotoImage(
CoverAsyncImage(
modelUri = uri,
stableCacheKey = artistPhotoCacheKey(artist.uid),
decodeSize = size.takeIf { decodeToDisplaySize },
modifier = Modifier.size(size).then(modifier),
shape = CircleShape,
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"
}
/** WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。
* `circular` 为 true 时圆形封面,否则圆角矩形。
/**
* WeSlide 迷你栏/全屏播放器/驾驶模式共用的封面组件。
* 不做按显示尺寸解码(播放页需清晰大图)。
*/
@OptIn(ExperimentalResourceApi::class)
@Composable
@@ -136,6 +174,7 @@ fun PlayerMusicCover(
CoverAsyncImage(
modelUri = uri,
stableCacheKey = music?.musicId?.let { musicCoverCacheKey(it) },
decodeSize = null,
modifier = modifier,
shape = if (circular) CircleShape else RoundedCornerShape(radius),
fallbackPadding = fallbackPadding,
@@ -148,22 +187,34 @@ internal fun albumCoverCacheKey(albumId: String): String = "album-cover:$albumId
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)
@Composable
private fun CoverAsyncImage(
modelUri: String?,
stableCacheKey: String? = null,
decodeSize: Dp? = null,
modifier: Modifier,
shape: Shape,
fallbackPadding: Dp,
) {
val fallback = Res.getUri(FALLBACK_LOGO)
val density = LocalDensity.current
val clipped = modifier.clip(shape).fillMaxSize()
if (modelUri.isNullOrBlank()) {
LlmpAsyncImage(
model = fallback,
contentDescription = null,
contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = clipped.padding(fallbackPadding),
)
return
@@ -171,7 +222,7 @@ private fun CoverAsyncImage(
// 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致
// Loading(null) → 空白,即使磁盘/内存仍有封面。
val cacheKey = remember(modelUri, stableCacheKey) {
val baseCacheKey = remember(modelUri, stableCacheKey) {
stableCacheKey?.takeIf { it.isNotBlank() }
?: if (modelUri.startsWith("http", ignoreCase = true)) {
modelUri.toImageCacheKey()
@@ -179,6 +230,14 @@ private fun CoverAsyncImage(
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)
var showLogoFallback by remember(cacheKey) { mutableStateOf(false) }
var hasSuccess by remember(cacheKey) {
@@ -198,6 +257,7 @@ private fun CoverAsyncImage(
model = fallback,
contentDescription = null,
contentScale = ContentScale.Fit,
decodeSize = decodeSize,
modifier = Modifier
.fillMaxSize()
.padding(fallbackPadding),
@@ -208,7 +268,8 @@ private fun CoverAsyncImage(
key(cacheKey) {
LlmpAsyncImage(
model = modelUri,
stableCacheKey = cacheKey,
stableCacheKey = baseCacheKey,
decodeSize = decodeSize,
contentDescription = null,
contentScale = ContentScale.Crop,
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))
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.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil3.Image
import coil3.ImageLoader
import coil3.PlatformContext
@@ -31,6 +33,7 @@ import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.Options
import coil3.request.crossfade
import coil3.size.Size
import coil3.svg.SvgDecoder
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -137,8 +140,9 @@ internal object ImageRevalidator {
.networkCachePolicy(CachePolicy.ENABLED)
.crossfade(false)
.listener(
onSuccess = { _, result ->
CoverImageHolder.put(cacheKey, result.image)
onSuccess = { _, _ ->
// 只刷新 Coil 磁盘/内存;不写入 CoverImageHolder,
// 避免无尺寸的原图覆盖列表缩略图。
complete(cacheKey)
},
onError = { _, result ->
@@ -208,7 +212,8 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
.crossfade(false)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.35)
// 老机内存紧:略降占比,配合按显示尺寸解码。
.maxSizePercent(context, 0.20)
.build()
}
.diskCache {
@@ -230,6 +235,23 @@ fun createLlmpImageLoader(context: PlatformContext): ImageLoader {
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 天网络缓存 + 过期先用旧图(无闪烁)。 */
@Composable
fun LlmpAsyncImage(
@@ -237,6 +259,8 @@ fun LlmpAsyncImage(
contentDescription: String?,
modifier: Modifier = Modifier,
stableCacheKey: String? = null,
/** 正方形解码目标(逻辑 dp)。传入后按像素桶解码,显著降低老机 CPU/内存。 */
decodeSize: Dp? = null,
transform: (State) -> State = DefaultTransform,
onState: ((State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
@@ -247,24 +271,44 @@ fun LlmpAsyncImage(
clipToBounds: Boolean = true,
) {
val context = LocalPlatformContext.current
val density = LocalDensity.current
val httpUrl = (model as? String)?.takeIf { it.isHttpUrl() }
val cacheKey = stableCacheKey?.takeIf { it.isNotBlank() }
val baseCacheKey = stableCacheKey?.takeIf { it.isNotBlank() }
?: 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 刷新后重挂载可见封面
// 可能 Error 一次并使占位图锁定,行仍在屏幕上。
val request = remember(model, httpUrl, cacheKey) {
val request = remember(model, httpUrl, baseCacheKey, memoryKey, bucketPx) {
ImageRequest.Builder(context)
.data(model)
.crossfade(false)
.apply {
if (cacheKey != null) {
memoryCacheKey(cacheKey)
diskCacheKey(cacheKey)
placeholderMemoryCacheKey(cacheKey)
if (bucketPx > 0) {
size(Size(bucketPx, bucketPx))
}
if (baseCacheKey != null) {
// 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。
diskCacheKey(baseCacheKey)
if (memoryKey != null) {
memoryCacheKey(memoryKey)
placeholderMemoryCacheKey(memoryKey)
}
listener(
onSuccess = { _, result ->
CoverImageHolder.put(cacheKey, result.image)
if (memoryKey != null) {
CoverImageHolder.put(memoryKey, result.image)
}
},
onError = { _, result ->
AppLog.w(
@@ -287,7 +331,7 @@ fun LlmpAsyncImage(
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
filterQuality = resolvedFilterQuality,
clipToBounds = clipToBounds,
)
}
@@ -299,6 +343,7 @@ fun LlmpAsyncImage(
contentDescription: String?,
modifier: Modifier = Modifier,
stableCacheKey: String? = null,
decodeSize: Dp? = null,
onLoading: ((State.Loading) -> Unit)? = null,
onSuccess: ((State.Success) -> Unit)? = null,
onError: ((State.Error) -> Unit)? = null,
@@ -314,6 +359,7 @@ fun LlmpAsyncImage(
contentDescription = contentDescription,
modifier = modifier,
stableCacheKey = stableCacheKey,
decodeSize = decodeSize,
onState = { state ->
when (state) {
is State.Loading -> onLoading?.invoke(state)
@@ -333,6 +333,7 @@ private fun MenuList(
music = coverMusic,
size = ListLeadingCoverSize,
radius = 12.dp,
decodeToDisplaySize = true,
modifier = heroMod,
)
} else {