From 52ab485e9c1b348c4a6b2689beb0b7d18e1be72a Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 3 Oct 2026 17:06:37 +0800 Subject: [PATCH] optimize photo scroll smoothly --- .../ui/components/AlbumGridItem.kt | 14 +- .../ui/components/ListItemSpec.kt | 1 + .../ui/components/LlmpFlexibleScrollbar.kt | 168 ++++++++----- .../ui/components/MusicCover.kt | 231 +++++++----------- .../ui/components/NeumorphicButton.kt | 38 +-- .../ui/components/SongListItem.kt | 172 +++++++------ .../lovelivemusicplayer/ui/icons/LlmpIcons.kt | 52 ++-- .../ui/icons/SvgIconStore.kt | 85 +++++++ .../ui/image/LlmpImageCache.kt | 192 +++++++++++++-- .../ui/screens/FeatureScreens.kt | 122 +++++++-- 10 files changed, 706 insertions(+), 369 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/SvgIconStore.kt diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt index a21ec0d..fdd7f08 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/AlbumGridItem.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.zhushenwudi.lovelivemusicplayer.domain.Album @@ -24,10 +25,15 @@ import com.zhushenwudi.lovelivemusicplayer.navigation.hidesAlbum import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme +/** + * @param cellWidth 网格单元宽度,用来决定封面解码尺寸。 + * 由网格统一算好传进来,省掉每个单元各量一次的子组合。 + */ @Composable fun AlbumGridItem( album: Album, onClick: () -> Unit, + cellWidth: Dp, modifier: Modifier = Modifier, ) { val dark = LocalDarkTheme.current @@ -38,7 +44,10 @@ fun AlbumGridItem( .fillMaxWidth() .clickable(onClick = onClick) // Material 3 双行网格单元内边距。 - .padding(horizontal = 5.dp, vertical = AlbumGridItemVerticalPadding), + .padding( + horizontal = AlbumGridItemHorizontalPadding, + vertical = AlbumGridItemVerticalPadding, + ), horizontalAlignment = Alignment.CenterHorizontally, ) { HeroCoverSlot( @@ -52,7 +61,8 @@ fun AlbumGridItem( AlbumCoverImage( album = album, radius = 12.dp, - decodeToDisplaySize = true, + decodeSize = (cellWidth - AlbumGridItemHorizontalPadding * 2) + .takeIf { it > 0.dp }, modifier = Modifier.fillMaxSize().then(heroMod), ) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt index 1da3b39..e8be55a 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/ListItemSpec.kt @@ -17,6 +17,7 @@ internal val ListItemOuterVerticalPadding = 0.dp /** 专辑网格单元外内边距。 */ internal val AlbumGridItemVerticalPadding = 8.dp +internal val AlbumGridItemHorizontalPadding = 5.dp /** 列表行主标题(歌名/艺人名/歌单名)。 */ internal val ListItemTitleSize = 13.sp diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt index 0bbc1e2..16ea84e 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/LlmpFlexibleScrollbar.kt @@ -8,31 +8,32 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -43,30 +44,24 @@ private const val ThumbResizeDurationMs = 1_000 private const val ThumbFadeDelayMs = 650L private const val ThumbFadeDurationMs = 250 +private val ThumbMinHeight = 40.dp +private val ThumbTouchTargetWidth = 24.dp + private data class ScrollbarRequest( val fraction: Float, val gestureId: Long, ) -private data class ScrollbarSnapshot( +/** + * 滑块几何只依赖这几个低频量。 + * 逐帧变化的滚动进度单独放在 [scrollProgress],只在布局阶段读取 —— 否则 + * 滚动的每一帧都会重组整个滑块子树。 + */ +private data class ScrollbarMetrics( val totalItems: Int, val visibleItems: Int, - val firstVisibleIndex: Int, - val firstVisibleOffset: Int, - val firstVisibleSize: Int, val canScroll: Boolean, ) { - val progress: Float - get() { - val maxFirst = (totalItems - visibleItems).coerceAtLeast(1) - val itemOffset = if (firstVisibleSize > 0) { - firstVisibleOffset.toFloat() / firstVisibleSize - } else { - 0f - } - return ((firstVisibleIndex + itemOffset) / maxFirst).coerceIn(0f, 1f) - } - val visibleFraction: Float get() = if (totalItems > 0) { (visibleItems.toFloat() / totalItems).coerceIn(0f, 1f) @@ -80,6 +75,22 @@ private data class ScrollbarSnapshot( } } +private fun scrollProgress( + totalItems: Int, + visibleItems: Int, + firstVisibleIndex: Int, + firstVisibleOffset: Int, + firstVisibleSize: Int, +): Float { + val maxFirst = (totalItems - visibleItems).coerceAtLeast(1) + val itemOffset = if (firstVisibleSize > 0) { + firstVisibleOffset.toFloat() / firstVisibleSize + } else { + 0f + } + return ((firstVisibleIndex + itemOffset) / maxFirst).coerceIn(0f, 1f) +} + /** 原 Flutter FlexibleScrollbar 风格:滚动时显示,支持点击轨道和拖动快速定位。 */ @Composable fun LlmpFlexibleScrollbar( @@ -101,22 +112,33 @@ private fun LazyListScrollbarOverlay( state: LazyListState, modifier: Modifier, ) { - val snapshot by remember(state) { + val metrics = remember(state) { + derivedStateOf(structuralEqualityPolicy()) { + val layout = state.layoutInfo + ScrollbarMetrics( + totalItems = layout.totalItemsCount, + visibleItems = layout.visibleItemsInfo.size, + canScroll = state.canScrollBackward || state.canScrollForward, + ) + } + } + val progress = remember(state) { derivedStateOf { val layout = state.layoutInfo - val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex } - ScrollbarSnapshot( + val first = layout.visibleItemsInfo + .firstOrNull { it.index == state.firstVisibleItemIndex } + scrollProgress( totalItems = layout.totalItemsCount, visibleItems = layout.visibleItemsInfo.size, firstVisibleIndex = state.firstVisibleItemIndex, firstVisibleOffset = state.firstVisibleItemScrollOffset, firstVisibleSize = first?.size ?: 0, - canScroll = state.canScrollBackward || state.canScrollForward, ) } } LlmpFlexibleScrollbarOverlay( - snapshot = snapshot, + metrics = metrics, + progress = progress, isScrollInProgress = state.isScrollInProgress, modifier = modifier, requestScrollToItem = state::requestScrollToItem, @@ -144,22 +166,33 @@ private fun LazyGridScrollbarOverlay( state: LazyGridState, modifier: Modifier, ) { - val snapshot by remember(state) { + val metrics = remember(state) { + derivedStateOf(structuralEqualityPolicy()) { + val layout = state.layoutInfo + ScrollbarMetrics( + totalItems = layout.totalItemsCount, + visibleItems = layout.visibleItemsInfo.size, + canScroll = state.canScrollBackward || state.canScrollForward, + ) + } + } + val progress = remember(state) { derivedStateOf { val layout = state.layoutInfo - val first = layout.visibleItemsInfo.firstOrNull { it.index == state.firstVisibleItemIndex } - ScrollbarSnapshot( + val first = layout.visibleItemsInfo + .firstOrNull { it.index == state.firstVisibleItemIndex } + scrollProgress( totalItems = layout.totalItemsCount, visibleItems = layout.visibleItemsInfo.size, firstVisibleIndex = state.firstVisibleItemIndex, firstVisibleOffset = state.firstVisibleItemScrollOffset, firstVisibleSize = first?.size?.height ?: 0, - canScroll = state.canScrollBackward || state.canScrollForward, ) } } LlmpFlexibleScrollbarOverlay( - snapshot = snapshot, + metrics = metrics, + progress = progress, isScrollInProgress = state.isScrollInProgress, modifier = modifier, requestScrollToItem = state::requestScrollToItem, @@ -168,19 +201,21 @@ private fun LazyGridScrollbarOverlay( @Composable private fun LlmpFlexibleScrollbarOverlay( - snapshot: ScrollbarSnapshot, + metrics: State, + progress: State, isScrollInProgress: Boolean, modifier: Modifier, requestScrollToItem: (Int, Int) -> Unit, ) { var isDragging by remember { mutableStateOf(false) } - var dragFraction by remember { mutableStateOf(0f) } var showThumb by remember { mutableStateOf(false) } + // 拖动中逐帧变化,只在布局阶段读取。 + val dragFraction = remember { mutableFloatStateOf(0f) } val haptic = LocalHapticFeedback.current val density = LocalDensity.current val scrollRequests = remember { Channel(Channel.CONFLATED) } - val latestSnapshot by rememberUpdatedState(snapshot) val latestRequestScrollToItem by rememberUpdatedState(requestScrollToItem) + val canScroll = metrics.value.canScroll // 手指事件可能远多于显示帧:只处理每帧最后一次请求,且不重复跳到同一项。 LaunchedEffect(scrollRequests) { @@ -196,7 +231,7 @@ private fun LlmpFlexibleScrollbarOverlay( lastGestureId = latestRequest.gestureId lastTargetIndex = -1 } - val targetIndex = latestSnapshot.targetIndex(latestRequest.fraction) + val targetIndex = metrics.value.targetIndex(latestRequest.fraction) if (targetIndex != lastTargetIndex) { latestRequestScrollToItem(targetIndex, 0) lastTargetIndex = targetIndex @@ -204,8 +239,8 @@ private fun LlmpFlexibleScrollbarOverlay( } } - LaunchedEffect(isScrollInProgress, isDragging, snapshot.canScroll) { - if (snapshot.canScroll && (isScrollInProgress || isDragging)) { + LaunchedEffect(isScrollInProgress, isDragging, canScroll) { + if (canScroll && (isScrollInProgress || isDragging)) { showThumb = true } else { delay(ThumbFadeDelayMs.milliseconds) @@ -214,7 +249,7 @@ private fun LlmpFlexibleScrollbarOverlay( } val thumbAlpha by animateFloatAsState( - targetValue = if (showThumb && snapshot.canScroll) 1f else 0f, + targetValue = if (showThumb && canScroll) 1f else 0f, animationSpec = tween(ThumbFadeDurationMs), label = "libraryScrollbarAlpha", ) @@ -224,49 +259,41 @@ private fun LlmpFlexibleScrollbarOverlay( label = "libraryScrollbarWidth", ) - BoxWithConstraints(modifier) { - if (!snapshot.canScroll) return@BoxWithConstraints + if (!canScroll) return - val trackHeightPx = with(density) { maxHeight.toPx() }.coerceAtLeast(1f) - val minThumbPx = with(density) { 40.dp.toPx() } - val thumbHeightPx = (trackHeightPx * snapshot.visibleFraction) - .coerceIn(minThumbPx.coerceAtMost(trackHeightPx), trackHeightPx) - val thumbRangePx = (trackHeightPx - thumbHeightPx).coerceAtLeast(0f) - val thumbOffsetPx = thumbRangePx * if (isDragging) dragFraction else snapshot.progress - val thumbHeight = with(density) { thumbHeightPx.toDp() } - val latestThumbHeightPx by rememberUpdatedState(thumbHeightPx) - val latestTrackHeightPx by rememberUpdatedState(trackHeightPx) + val minThumbPx = with(density) { ThumbMinHeight.toPx() } + Box(modifier) { // 24dp 触摸热区覆盖轨道;可点击跳转,也可按住拖动。 Box( Modifier .align(Alignment.CenterEnd) - .width(24.dp) + .width(ThumbTouchTargetWidth) .fillMaxHeight() .pointerInput(Unit) { var gestureId = 0L fun fractionAt(y: Float): Float { - val range = (latestTrackHeightPx - latestThumbHeightPx) - .coerceAtLeast(1f) - return ((y - latestThumbHeightPx / 2f) / range) - .coerceIn(0f, 1f) + val trackPx = size.height.toFloat().coerceAtLeast(1f) + val thumbPx = thumbHeightPx(trackPx, minThumbPx, metrics.value) + val range = (trackPx - thumbPx).coerceAtLeast(1f) + return ((y - thumbPx / 2f) / range).coerceIn(0f, 1f) } awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) gestureId++ - dragFraction = fractionAt(down.position.y) + dragFraction.floatValue = fractionAt(down.position.y) isDragging = true showThumb = true haptic.performHapticFeedback(HapticFeedbackType.LongPress) try { scrollRequests.trySend( - ScrollbarRequest(dragFraction, gestureId), + ScrollbarRequest(dragFraction.floatValue, gestureId), ) drag(down.id) { change -> change.consume() - dragFraction = fractionAt(change.position.y) + dragFraction.floatValue = fractionAt(change.position.y) scrollRequests.trySend( - ScrollbarRequest(dragFraction, gestureId), + ScrollbarRequest(dragFraction.floatValue, gestureId), ) } } finally { @@ -279,14 +306,37 @@ private fun LlmpFlexibleScrollbarOverlay( Box( Modifier .align(Alignment.TopEnd) - .offset { IntOffset(0, thumbOffsetPx.roundToInt()) } .width(thumbWidth) - .height(thumbHeight) .alpha(thumbAlpha) + // 滑块高度在测量阶段算、位置在放置阶段算:滚动时只需重新放置,不重组。 + .layout { measurable, constraints -> + val trackPx = constraints.maxHeight.toFloat().coerceAtLeast(1f) + val thumbPx = thumbHeightPx(trackPx, minThumbPx, metrics.value) + val thumbHeight = thumbPx.roundToInt() + val placeable = measurable.measure( + constraints.copy(minHeight = thumbHeight, maxHeight = thumbHeight), + ) + layout(placeable.width, constraints.maxHeight) { + val range = (trackPx - thumbPx).coerceAtLeast(0f) + val fraction = if (isDragging) { + dragFraction.floatValue + } else { + progress.value + } + placeable.place(0, (range * fraction).roundToInt()) + } + } .background( - color = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.6f), + color = Color.Gray.copy(alpha = 0.6f), shape = RoundedCornerShape(5.dp), ), ) } } + +private fun thumbHeightPx( + trackPx: Float, + minThumbPx: Float, + metrics: ScrollbarMetrics, +): Float = (trackPx * metrics.visibleFraction) + .coerceIn(minThumbPx.coerceAtMost(trackPx), trackPx) diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt index ca2b987..789e897 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/MusicCover.kt @@ -1,28 +1,23 @@ 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.background +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color 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 import com.zhushenwudi.lovelivemusicplayer.Const @@ -30,10 +25,10 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Album import com.zhushenwudi.lovelivemusicplayer.domain.Artist 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.theme.ColorMs +import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.ui.image.decodeSizeBucketPx -import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverPainter +import com.zhushenwudi.lovelivemusicplayer.ui.image.rememberCoverImage import com.zhushenwudi.lovelivemusicplayer.ui.image.sizedMemoryCacheKey import com.zhushenwudi.lovelivemusicplayer.ui.image.toImageCacheKey @@ -82,7 +77,11 @@ fun MusicCoverImage( } /** - * @param decodeToDisplaySize 仅网格/列表缩略开启。详情大图勿开。 + * @param decodeSize 网格/列表缩略图传显示边长,按它解码省内存;详情大图传 null。 + * + * 这里刻意不用 BoxWithConstraints 自己量尺寸:那是一次 SubcomposeLayout, + * pad 横屏一行就有十来个网格单元,每个都要多走一次子组合。 + * 调用方本来就知道单元宽度,直接传进来即可。 */ @OptIn(ExperimentalResourceApi::class) @Composable @@ -90,35 +89,19 @@ fun AlbumCoverImage( album: Album, modifier: Modifier = Modifier, radius: Dp = 12.dp, - decodeToDisplaySize: Boolean = false, + decodeSize: Dp? = null, ) { val uri = remember(album.albumId, album.coverPath, album.existFile) { AppContainer.pathResolver.coverDisplayUri(album) } - 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, - ) - } + CoverAsyncImage( + modelUri = uri, + stableCacheKey = album.albumId?.let { albumCoverCacheKey(it) }, + decodeSize = decodeSize, + modifier = modifier, + shape = RoundedCornerShape(radius), + fallbackPadding = 6.dp, + ) } /** @@ -187,16 +170,9 @@ 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 - } +/** 播放页/详情大图没给解码尺寸,按这个上限解码,避免整张原图进内存。 */ +private const val LargeCoverDecodePx = 1024 -@OptIn(ExperimentalResourceApi::class) @Composable private fun CoverAsyncImage( modelUri: String?, @@ -206,111 +182,80 @@ private fun CoverAsyncImage( 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 - } - - // 进程级 Image 底图:LazyList dispose 会丢弃 remember(Painter),导致 - // Loading(null) → 空白,即使磁盘/内存仍有封面。 - val baseCacheKey = remember(modelUri, stableCacheKey) { - if ( - modelUri.startsWith("http://", ignoreCase = true) || - modelUri.startsWith("https://", ignoreCase = true) - ) { - // 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。 - modelUri.toImageCacheKey() - } else { - stableCacheKey?.takeIf { it.isNotBlank() } ?: 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) { - mutableStateOf(CoverImageHolder.get(cacheKey) != null) - } - - Box(clipped) { - if (heldPainter != null) { - Image( - painter = heldPainter, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else if (showLogoFallback && !hasSuccess) { - LlmpAsyncImage( - model = fallback, - contentDescription = null, - contentScale = ContentScale.Fit, - decodeSize = decodeSize, - modifier = Modifier - .fillMaxSize() - .padding(fallbackPadding), - ) + val baseCacheKey = remember(modelUri, stableCacheKey) { + when { + modelUri.isNullOrBlank() -> null + // 远程封面以 URL 为内容身份:同一专辑在列表、详情和歌曲条目间共享缓存。 + modelUri.startsWith("http://", ignoreCase = true) || + modelUri.startsWith("https://", ignoreCase = true) -> modelUri.toImageCacheKey() + else -> stableCacheKey?.takeIf { it.isNotBlank() } ?: modelUri } - // key(cacheKey):LazyList 复用不得保留上一首歌的 AsyncImagePainter。 - // 首次加载空白可接受。同 key 缓存 → heldPainter / placeholderMemoryCacheKey。 - key(cacheKey) { - LlmpAsyncImage( - model = modelUri, - stableCacheKey = baseCacheKey, - decodeSize = decodeSize, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - transform = { state -> - when (state) { - is State.Loading -> { - // 仅同 key 底图。绝不复用其他 model 的 painter - // (滚动时曾导致错误封面闪现)。 - if (heldPainter != null) State.Loading(heldPainter) else state - } + } + val memoryKey = remember(baseCacheKey, bucketPx) { + baseCacheKey?.let { sizedMemoryCacheKey(it, bucketPx) } + } + val cover = rememberCoverImage( + model = modelUri, + diskCacheKey = baseCacheKey, + memoryCacheKey = memoryKey, + decodeSizePx = if (bucketPx > 0) bucketPx else LargeCoverDecodePx, + ) - is State.Error -> { - if (heldPainter != null) State.Error( - heldPainter, - state.result - ) else state - } - - else -> state - } - }, - onState = { state -> - when (state) { - is State.Success -> { - hasSuccess = true - showLogoFallback = false - } - - is State.Error -> { - if (CoverImageHolder.get(cacheKey) == null && !hasSuccess) { - showLogoFallback = true - } - } - - else -> Unit - } - }, - ) - } + // 不套外层 Box:列表每行都要新建这些节点,占位交给各分支自己的 modifier。 + val painter = cover.painter + when { + painter != null -> Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = clipped, + ) + // 没有封面可解析,或确定加载失败:落默认 Logo。 + modelUri.isNullOrBlank() || cover.failed -> CoverFallbackLogo( + modifier = clipped, + bucketPx = bucketPx, + padding = fallbackPadding, + ) + // 加载中:画一块底色。留透明的话在深色主题下就是纯黑, + // 滚动时一行行闪黑比占位块难看得多。这里也不放 Logo, + // 免得出图瞬间又闪一下。 + else -> Spacer(clipped.background(coverPlaceholderColor())) } } + +/** 封面就位前的占位底色,跟列表背景有区分但不抢眼。 */ +@Composable +private fun coverPlaceholderColor(): Color = + if (LocalDarkTheme.current) ColorMs.Dark2B333A else ColorMs.GrayEEE + +/** 默认 Logo 也走封面缓存,每个尺寸档只解码一次。 */ +@OptIn(ExperimentalResourceApi::class) +@Composable +private fun CoverFallbackLogo(modifier: Modifier, bucketPx: Int, padding: Dp) { + val model = remember { Res.getUri(FALLBACK_LOGO) } + val memoryKey = remember(bucketPx) { sizedMemoryCacheKey(FALLBACK_LOGO, bucketPx) } + val logo = rememberCoverImage( + model = model, + diskCacheKey = FALLBACK_LOGO, + memoryCacheKey = memoryKey, + decodeSizePx = if (bucketPx > 0) bucketPx else LargeCoverDecodePx, + ) + val painter = logo.painter + if (painter == null) { + Spacer(modifier) + return + } + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.padding(padding), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt index d6b2d52..40a930d 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/NeumorphicButton.kt @@ -23,10 +23,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -243,10 +241,17 @@ fun NeumorphicSurface( Box( modifier = modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - } + // 静止态跳过 graphicsLayer:列表每行两个按钮,白挂图层就是两个多余 RenderNode。 + .then( + if (scale != 1f) { + Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + } + } else { + Modifier + }, + ) .then( if (hasShadow) { Modifier.drawBehind { @@ -314,20 +319,17 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawSoftRoundRect( topLeft: Offset, corner: CornerRadius, ) { + // 两层外扩模拟柔边。用 drawRoundRect 走 Skia 的 RRect 快路径, + // 不像 Path 那样每次绘制都要分配对象并走通用路径填充。 for (i in 2 downTo 1) { val expand = (2 - i) * 0.55f val alphaScale = i / 3f - val path = Path().apply { - addRoundRect( - RoundRect( - left = topLeft.x - expand, - top = topLeft.y - expand, - right = size.width + topLeft.x + expand, - bottom = size.height + topLeft.y + expand, - cornerRadius = CornerRadius(corner.x + expand, corner.y + expand), - ), - ) - } - drawPath(path, color = color.copy(alpha = color.alpha * alphaScale), style = Fill) + drawRoundRect( + color = color.copy(alpha = color.alpha * alphaScale), + topLeft = Offset(topLeft.x - expand, topLeft.y - expand), + size = Size(size.width + expand * 2f, size.height + expand * 2f), + cornerRadius = CornerRadius(corner.x + expand, corner.y + expand), + style = Fill, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt index 85f15c9..2f402ca 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/components/SongListItem.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.RadioButtonUnchecked @@ -33,6 +35,9 @@ import com.zhushenwudi.lovelivemusicplayer.theme.ColorMs import com.zhushenwudi.lovelivemusicplayer.theme.LocalDarkTheme import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons +/** 正在播放的行左侧竖条。 */ +private val PlayingIndicatorWidth = 5.dp + @Composable fun SongListItem( music: Music, @@ -57,105 +62,98 @@ fun SongListItem( } val actionColor = if (dark) ColorMs.GrayDFD else ColorMs.GrayCCC + val rowClick: () -> Unit = { + if (selectMode) onCheckedChange?.invoke(!checked) else onClick() + } + // 单层 Row:指示条改成直接绘制,省掉一层 Row 和一个 Box。 + // 列表每滑入一行都要新建这些节点,层级越浅,甩动时的组合/测量越便宜。 Row( modifier = modifier .fillMaxWidth() - .height(TwoLineListItemHeight), + .height(TwoLineListItemHeight) + .drawBehind { + if (isPlaying) { + drawRect( + color = ColorMs.PlayingOrange, + size = Size(PlayingIndicatorWidth.toPx(), size.height), + ) + } + } + .padding(start = PlayingIndicatorWidth + 11.dp, end = 11.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box( - Modifier - .width(5.dp) - .height(TwoLineListItemHeight) - .background(if (isPlaying) ColorMs.PlayingOrange else Color.Transparent), + if (selectMode) { + CircularCheckBox( + checked = checked, + onCheckedChange = { onCheckedChange?.invoke(it) }, + ) + Spacer(Modifier.width(10.dp)) + } + MusicCoverImage( + music = music, + size = ListLeadingCoverSize, + radius = 12.dp, + decodeToDisplaySize = true, + modifier = Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = rowClick, + ), ) - Row( - Modifier + Spacer(Modifier.width(10.dp)) + TwoLineListTextColumn( + modifier = Modifier .weight(1f) - .fillMaxWidth() - .padding(horizontal = 11.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (selectMode) { - CircularCheckBox( - checked = checked, - onCheckedChange = { onCheckedChange?.invoke(it) }, - ) - Spacer(Modifier.width(10.dp)) - } - Box( - Modifier.clickable( + .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (selectMode) onCheckedChange?.invoke(!checked) else onClick() - }, + onClick = rowClick, ), - ) { - MusicCoverImage( - music = music, - size = ListLeadingCoverSize, - radius = 12.dp, - decodeToDisplaySize = true, + title = { + Text( + text = music.musicName ?: tr("unknown_song"), + color = titleColor, + fontSize = ListItemTitleSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = ListItemTitleLineHeight, ) - } - Spacer(Modifier.width(10.dp)) - TwoLineListTextColumn( - modifier = Modifier - .weight(1f) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - if (selectMode) onCheckedChange?.invoke(!checked) else onClick() - }, - ), - title = { - Text( - text = music.musicName ?: tr("unknown_song"), - color = titleColor, - fontSize = ListItemTitleSize, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - lineHeight = ListItemTitleLineHeight, - ) - }, - subtitle = { - Text( - text = music.artist.orEmpty(), - color = artistColor, - fontSize = ListItemSubtitleSize, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - lineHeight = ListItemSubtitleLineHeight, - ) - }, + }, + subtitle = { + Text( + text = music.artist.orEmpty(), + color = artistColor, + fontSize = ListItemSubtitleSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = ListItemSubtitleLineHeight, + ) + }, + ) + if (!selectMode) { + NeumorphicIconButton( + icon = LlmpIcons.AddNext, + onClick = onPlayNext, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, + iconColor = actionColor, + hasShadow = false, + modifier = Modifier.padding(horizontal = 3.dp), + ) + NeumorphicIconButton( + icon = LlmpIcons.More, + onClick = onMore, + size = LibraryActionButtonSize, + iconSize = LibraryActionIconSize, + radius = LibraryActionButtonRadius, + contentPadding = LibraryActionIconPadding, + iconColor = actionColor, + hasShadow = false, + modifier = Modifier.padding(horizontal = 3.dp), ) - if (!selectMode) { - NeumorphicIconButton( - icon = LlmpIcons.AddNext, - onClick = onPlayNext, - size = LibraryActionButtonSize, - iconSize = LibraryActionIconSize, - radius = LibraryActionButtonRadius, - contentPadding = LibraryActionIconPadding, - iconColor = actionColor, - hasShadow = false, - modifier = Modifier.padding(horizontal = 3.dp), - ) - NeumorphicIconButton( - icon = LlmpIcons.More, - onClick = onMore, - size = LibraryActionButtonSize, - iconSize = LibraryActionIconSize, - radius = LibraryActionButtonRadius, - contentPadding = LibraryActionIconPadding, - iconColor = actionColor, - hasShadow = false, - modifier = Modifier.padding(horizontal = 3.dp), - ) - } } } } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt index f0228f5..8c09be5 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/LlmpIcons.kt @@ -1,5 +1,7 @@ package com.zhushenwudi.lovelivemusicplayer.ui.icons +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -10,13 +12,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import org.jetbrains.compose.resources.ExperimentalResourceApi -import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res /** * SVG 图标资源,位于 composeResources/drawable。 - * 使用 Coil3 + coil-svg 渲染,不用 painterResource(Android 不支持)。 + * 使用 Coil3 + coil-svg 解码(不用 painterResource,Android 不支持 SVG), + * 解码结果由 [SvgIconStore] 常驻缓存后同步绘制。 */ object LlmpIcons { val TabMusic = SvgAsset("drawable/tab_tab_music.svg") @@ -46,7 +46,6 @@ object LlmpIcons { data class SvgAsset(val path: String) -@OptIn(ExperimentalResourceApi::class) @Composable fun TintIcon( asset: SvgAsset, @@ -55,12 +54,12 @@ fun TintIcon( size: Dp = 18.dp, contentDescription: String? = null, ) { - AsyncImage( - model = Res.getUri(asset.path), + SvgAssetImage( + asset = asset, + modifier = modifier, + size = size, + tint = tint, contentDescription = contentDescription, - colorFilter = ColorFilter.tint(tint), - contentScale = ContentScale.Fit, - modifier = modifier.size(size), ) } @@ -80,7 +79,6 @@ fun TintIcon( ) } -@OptIn(ExperimentalResourceApi::class) @Composable fun SvgIcon( asset: SvgAsset, @@ -89,11 +87,35 @@ fun SvgIcon( tint: Color? = null, contentDescription: String? = null, ) { - AsyncImage( - model = Res.getUri(asset.path), + SvgAssetImage( + asset = asset, + modifier = modifier, + size = size, + tint = tint, contentDescription = contentDescription, - colorFilter = tint?.let { ColorFilter.tint(it) }, + ) +} + +/** 解码完成前留出等尺寸占位,避免图标就位时整行重新测量。 */ +@Composable +private fun SvgAssetImage( + asset: SvgAsset, + modifier: Modifier, + size: Dp, + tint: Color?, + contentDescription: String?, +) { + val sized = modifier.size(size) + val painter = rememberSvgIconPainter(asset, size) + if (painter == null) { + Spacer(sized) + return + } + Image( + painter = painter, + contentDescription = contentDescription, contentScale = ContentScale.Fit, - modifier = modifier.size(size), + colorFilter = tint?.let(ColorFilter::tint), + modifier = sized, ) } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/SvgIconStore.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/SvgIconStore.kt new file mode 100644 index 0000000..0afd924 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/icons/SvgIconStore.kt @@ -0,0 +1,85 @@ +package com.zhushenwudi.lovelivemusicplayer.ui.icons + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import coil3.Image +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.compose.LocalPlatformContext +import coil3.compose.asPainter +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.size.Size +import com.zhushenwudi.lovelivemusicplayer.generated.resources.Res +import org.jetbrains.compose.resources.ExperimentalResourceApi + +/** + * 按「资源路径 + 像素边长」解码一次 SVG 图标后常驻内存。 + * + * 图标集固定(约 20 个 × 少数几档尺寸),不做淘汰。 + * 列表项因此不必各自挂 AsyncImagePainter:Coil 的尺寸解析会挂起到布局阶段, + * 每个图标都要多走一次协程和一次重组,LazyColumn 快速滚动时这笔开销会累成掉帧。 + */ +internal object SvgIconStore { + private val decoded = mutableMapOf() + private val inFlight = mutableSetOf() + + /** 新图标解码完成后自增,驱动仍在等待的图标重组。 */ + private var revision by mutableIntStateOf(0) + + fun key(path: String, sizePx: Int): String = "$path@$sizePx" + + /** + * 缓存命中直接返回;未命中时订阅 [revision],解码完成后调用方会重组。 + * 必须在组合期间调用才能建立订阅。 + */ + fun peek(key: String): Image? { + decoded[key]?.let { return it } + @Suppress("UNUSED_EXPRESSION") + revision + return null + } + + @OptIn(ExperimentalResourceApi::class) + suspend fun load(context: PlatformContext, path: String, sizePx: Int) { + val key = key(path, sizePx) + if (decoded.containsKey(key)) return + // 同一图标被整屏列表项同时请求时只解码一次。 + if (!inFlight.add(key)) return + val result = runCatching { + SingletonImageLoader.get(context).execute( + ImageRequest.Builder(context) + .data(Res.getUri(path)) + .size(Size(sizePx, sizePx)) + .build(), + ) + }.getOrNull() + inFlight.remove(key) + val image = (result as? SuccessResult)?.image ?: return + decoded[key] = image + revision++ + } +} + +/** 图标已解码则返回可同步绘制的 Painter,否则返回 null 并触发一次解码。 */ +@Composable +internal fun rememberSvgIconPainter(asset: SvgAsset, size: Dp): Painter? { + val context = LocalPlatformContext.current + val sizePx = with(LocalDensity.current) { size.roundToPx() } + val key = SvgIconStore.key(asset.path, sizePx) + val image = if (sizePx > 0) SvgIconStore.peek(key) else null + if (image == null) { + LaunchedEffect(key) { + if (sizePx > 0) SvgIconStore.load(context, asset.path, sizePx) + } + return null + } + return remember(image, context) { image.asPainter(context) } +} diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt index d210148..a935810 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/image/LlmpImageCache.kt @@ -1,6 +1,8 @@ package com.zhushenwudi.lovelivemusicplayer.ui.image import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,6 +20,7 @@ import androidx.compose.ui.unit.Dp import coil3.Image import coil3.ImageLoader import coil3.PlatformContext +import coil3.SingletonImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter.Companion.DefaultTransform @@ -34,12 +37,20 @@ import coil3.network.NetworkRequest import coil3.network.NetworkResponse import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.CachePolicy +import coil3.request.ErrorResult import coil3.request.ImageRequest +import coil3.request.ImageResult import coil3.request.Options +import coil3.request.SuccessResult import coil3.request.crossfade import coil3.size.Size import coil3.svg.SvgDecoder import com.zhushenwudi.lovelivemusicplayer.platform.AppLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds private const val IMG_TAG = "LLMP-Img" @@ -133,50 +144,188 @@ fun clearLlmpImageCache() { /** - * 封面解码 [androidx.compose.ui.graphics.Image] 的强引用。 - * AsyncImage dispose 后持有 [androidx.compose.ui.graphics.painter.Painter] 不安全(RememberObserver.onForgotten)。 - * 保留 [androidx.compose.ui.graphics.Image] 可在底图/Loading 时重建 Painter,避免空白帧。 + * 封面解码 [coil3.Image] 的强引用。 + * AsyncImage dispose 后持有 [androidx.compose.ui.graphics.painter.Painter] 不安全(RememberObserver.onForgotten); + * 保留 [coil3.Image] 可随时重建 Painter,列表复用时不会出现空白帧。 + * + * 每个 key 一个独立 [MutableState]:组合期间只订阅自己那一项, + * 别的封面入缓存不会让整屏列表项重组。 + * + * [order] 限制同时持有的图片数;[entries] 只增不减,但 key 数受曲库规模约束 + * (每首歌每个尺寸档一个空容器,量级在几百 KB)。 */ internal object CoverImageHolder { - private const val CAP = 96 - private val keys = ArrayDeque() - private val map = mutableMapOf() + /** + * 按字节限容,不按张数。 + * + * 两者差别很大:歌曲列表 48dp 缩略图一张几十 KB,pad 横屏专辑网格一张两三百 KB, + * 播放页大图是 MB 级。按张数限容时,同一个上限在网格里会膨胀成上百兆强引用, + * 而强引用又挡着 Coil 自己的 LRU 回收不掉 —— 直接表现为滚动时的 GC 卡顿。 + */ + private const val MAX_BYTES = 24L * 1024 * 1024 - fun get(key: String): Image? = map[key] + private val order = ArrayDeque() + private val entries = mutableMapOf>() + private var heldBytes = 0L + + private fun entry(key: String): MutableState = + entries.getOrPut(key) { mutableStateOf(null) } + + fun get(key: String): Image? = entries[key]?.value + + /** 须在组合期间调用:建立对该 key 的订阅,入缓存后调用方重组。 */ + @Composable + fun observe(key: String): Image? = entry(key).value fun put(key: String, image: Image) { - if (map[key] === image) return - if (map.put(key, image) == null) { - keys.addLast(key) - } - while (keys.size > CAP) { - val old = keys.removeFirst() - map.remove(old) + val state = entry(key) + val previous = state.value + if (previous === image) return + heldBytes -= previous?.size ?: 0L + state.value = image + heldBytes += image.size + // key 可能重复入队(淘汰过又重新加载),出队时按实际持有量结算, + // 空条目直接跳过,所以重复项不会算错账。 + order.addLast(key) + while (heldBytes > MAX_BYTES && order.isNotEmpty()) { + val slot = entries[order.removeFirst()] ?: continue + // 只释放图片,保留 state 容器:组件订阅的是容器本身, + // 换成新容器会让它收不到重新加载的通知,封面就永久空白了。 + val released = slot.value ?: continue + heldBytes -= released.size + slot.value = null } } fun clear() { - keys.clear() - map.clear() + order.clear() + heldBytes = 0L + entries.values.forEach { it.value = null } } } /** 规范化缓存键,使原始 `'`(如 μ's)与 `%27` 编码一致。 */ internal fun String.toImageCacheKey(): String = replace("'", "%27") +/** [rememberCoverImage] 的结果:painter 就绪即可同步绘制,failed 时由调用方落到 Logo。 */ +internal class CoverImageState( + val painter: Painter?, + val failed: Boolean, +) + +/** + * 首次加载前的静置时间。 + * + * 甩动时一行在视口里待不到这么久,协程会随 item 回收一起取消,于是飞过去的几十行 + * 一次磁盘读都不会发起 —— 否则这些解码会把主线程和 IO 占满,fling 直接被拖停。 + * 缓存命中的行不走这条路径,回滚时仍然立刻出图。 + */ +private const val CoverSettleDelayMs = 80L + +/** + * 封面加载:解码尺寸由调用方给定,直接走 [ImageLoader.execute] 并把结果存进 + * [CoverImageHolder],命中后以普通 [androidx.compose.foundation.Image] 同步绘制。 + * + * 不用 AsyncImage:它的 ConstraintsSizeResolver 要挂起到布局阶段才能拿到尺寸, + * 每个列表项都会多一次协程调度和一次重组;LazyColumn 快速滚动时每秒滑入几十项, + * 这笔固定开销会直接累成掉帧。封面尺寸这里本来就已知,不需要等布局。 + */ @Composable -internal fun rememberCoverPainter(cacheKey: String?): Painter? { - if (cacheKey.isNullOrBlank()) return null +internal fun rememberCoverImage( + model: String?, + diskCacheKey: String?, + memoryCacheKey: String?, + decodeSizePx: Int, +): CoverImageState { val context = LocalPlatformContext.current - val image = CoverImageHolder.get(cacheKey) ?: return null - return remember(cacheKey, image) { image.asPainter(context) } + val key = memoryCacheKey?.takeIf { it.isNotBlank() } + // 订阅 Holder:异步加载完成后靠它触发重组。 + val held = if (key == null) null else CoverImageHolder.observe(key) + // Holder 是 FIFO 淘汰,容量满时会误伤仍在屏上的行;而 Coil 自己的内存缓存 + // 通常还留着这张图。组合期间同步取一次,本帧就能画出来 —— 否则回滚到看过的 + // 行要等协程跑完才出图,那段空白就是肉眼看到的闪黑。 + val cached = held ?: key?.let { peekMemoryCache(context, it) } + var failed by remember(model, key) { mutableStateOf(false) } + if (cached == null && !model.isNullOrBlank()) { + LaunchedEffect(model, key, decodeSizePx) { + delay(CoverSettleDelayMs.milliseconds) + failed = !loadCoverIntoHolder( + context = context, + model = model, + diskCacheKey = diskCacheKey, + memoryCacheKey = key, + decodeSizePx = decodeSizePx, + ) + } + } + val painter = remember(cached, context) { cached?.asPainter(context) } + return CoverImageState(painter = painter, failed = failed) } +/** 同步读 Coil 内存缓存,不触发任何加载。 */ +private fun peekMemoryCache(context: PlatformContext, key: String): Image? = + SingletonImageLoader.get(context).memoryCache?.get(MemoryCache.Key(key))?.image + +private suspend fun loadCoverIntoHolder( + context: PlatformContext, + model: String, + diskCacheKey: String?, + memoryCacheKey: String?, + decodeSizePx: Int, +): Boolean { + val loader = SingletonImageLoader.get(context) + + suspend fun execute(skipDiskRead: Boolean): ImageResult = loader.execute( + ImageRequest.Builder(context) + .data(model) + .crossfade(false) + .apply { + if (decodeSizePx > 0) size(Size(decodeSizePx, decodeSizePx)) + if (skipDiskRead) diskCachePolicy(CachePolicy.WRITE_ONLY) + if (diskCacheKey != null) { + // 磁盘存原图字节(无尺寸后缀);内存按显示尺寸分桶。 + diskCacheKey(diskCacheKey) + } + if (memoryCacheKey != null) { + memoryCacheKey(memoryCacheKey) + placeholderMemoryCacheKey(memoryCacheKey) + } + } + .build(), + ) + + var result = runCatching { execute(skipDiskRead = false) }.getOrNull() + val error = (result as? ErrorResult)?.throwable + if (error != null && error.isUndecodableImage() && diskCacheKey != null) { + // 磁盘字节坏掉:丢弃条目后重拉一次,同一条目内不再循环。 + ImageRevalidator.evictUndecodable(diskCacheKey, memoryCacheKey) + result = runCatching { execute(skipDiskRead = true) }.getOrNull() + } + val image = (result as? SuccessResult)?.image + if (image == null) { + AppLog.w(IMG_TAG, "cover load failed url=$model") + return false + } + if (!memoryCacheKey.isNullOrBlank()) { + CoverImageHolder.put(memoryCacheKey, image) + } + return true +} + +/** + * 解码并发上限。默认的 [Dispatchers.IO] 不限并发,一屏封面会同时解码: + * 线程竞争和 GC 峰值都会打到主线程上。限到 3 条后改为渐进出图,滚动更稳。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +private val coverDecodeDispatcher = Dispatchers.IO.limitedParallelism(3) + @OptIn(ExperimentalCoilApi::class) fun createLlmpImageLoader(context: PlatformContext): ImageLoader { val diskCacheDir = persistentImageCacheDirectory(context) val loader = ImageLoader.Builder(context) .crossfade(false) + .decoderCoroutineContext(coverDecodeDispatcher) + .fetcherCoroutineContext(coverDecodeDispatcher) .memoryCache { MemoryCache.Builder() // 老机内存紧:略降占比,配合按显示尺寸解码。 @@ -215,6 +364,9 @@ internal fun decodeSizeBucketPx(px: Int): Int = when { px <= 96 -> 96 px <= 144 -> 144 px <= 256 -> 256 + // 高密度屏上的网格单元常落在 260~320px,少了这一档会被抬到 384, + // 单张就从 327KB 涨到 590KB。 + px <= 320 -> 320 px <= 384 -> 384 else -> 512 } diff --git a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt index 9e5b4a4..4c27b40 100644 --- a/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt +++ b/composeApp/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/ui/screens/FeatureScreens.kt @@ -3,6 +3,7 @@ package com.zhushenwudi.lovelivemusicplayer.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -37,6 +38,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -93,7 +97,7 @@ import com.zhushenwudi.lovelivemusicplayer.ui.components.addMusicPlayNext import com.zhushenwudi.lovelivemusicplayer.ui.icons.LlmpIcons import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalHomeListBottomInset import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalIsLandscape - +import com.zhushenwudi.lovelivemusicplayer.ui.theme.LocalUnscaledDensity @Composable fun LibraryTabContent( tab: LibraryTab, @@ -258,39 +262,107 @@ private fun AlbumList( return } val listBottomInset = LocalHomeListBottomInset.current + val landscape = LocalIsLandscape.current + val largeScreen = isLargeScreenDevice() LlmpFlexibleScrollbar( state = resolvedGridState, modifier = Modifier.fillMaxSize(), ) { - LazyVerticalGrid( - columns = if (LocalIsLandscape.current) { - GridCells.Adaptive(108.dp) - } else { - GridCells.Fixed(3) - }, - state = resolvedGridState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - bottom = listBottomInset, - ), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - items( - items = albums, - key = { it.albumId ?: "${it.albumName}-${it.coverPath}" }, - contentType = { "album" }, - ) { album -> - AlbumGridItem( - album = album, - onClick = { album.albumId?.let(onOpen) }, - ) + // 整个网格量一次宽度,算出单元宽度后传给所有单元决定封面解码尺寸。 + // 以前是每个单元自己套 BoxWithConstraints 量,pad 横屏一行十来个单元 + // 就是十来次子组合。 + BoxWithConstraints(Modifier.fillMaxSize()) { + val density = LocalDensity.current + val availablePx = constraints.maxWidth + val columns = remember(availablePx, landscape, largeScreen, density) { + albumGridColumns(availablePx, landscape, largeScreen, density) + } + val cellWidth = remember(availablePx, columns, density) { + albumGridCellWidth(availablePx, columns, density) + } + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + state = resolvedGridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = AlbumGridHorizontalPadding, + end = AlbumGridHorizontalPadding, + bottom = listBottomInset, + ), + horizontalArrangement = Arrangement.spacedBy(AlbumGridSpacing), + ) { + items( + items = albums, + key = { it.albumId ?: "${it.albumName}-${it.coverPath}" }, + contentType = { "album" }, + ) { album -> + AlbumGridItem( + album = album, + onClick = { album.albumId?.let(onOpen) }, + cellWidth = cellWidth, + ) + } } } } } +private val AlbumGridHorizontalPadding = 16.dp +private val AlbumGridSpacing = 10.dp +private val AlbumGridMinCellWidth = 108.dp + +/** + * 大屏(平板、展开的折叠屏)竖屏 4 列、横屏 6 列;手机竖屏 3 列。 + * + * 手机横屏理论上走不到 —— 原生侧只给大屏放开旋转(见 ScreenOrientationPolicy), + * 这里仍按最小单元宽兜底,避免真遇上时单元被拉成一整屏宽。 + * 兜底公式照搬 GridCells.Adaptive,连取整方式也一致。 + */ +private fun albumGridColumns( + gridWidthPx: Int, + landscape: Boolean, + largeScreen: Boolean, + density: Density, +): Int { + if (largeScreen) return if (landscape) 6 else 4 + if (!landscape) return 3 + with(density) { + val available = gridWidthPx - AlbumGridHorizontalPadding.roundToPx() * 2 + if (available <= 0) return 3 + val spacing = AlbumGridSpacing.roundToPx() + val minSize = AlbumGridMinCellWidth.roundToPx() + return ((available + spacing) / (minSize + spacing)).coerceAtLeast(1) + } +} + +/** 与原生旋转策略同一个判据,见 ScreenOrientationPolicy 的 600dp 阈值。 */ +private val LargeScreenMinSmallestWidth = 600.dp + +/** + * 最小边是否达到大屏阈值。 + * + * 必须用未缩放的设备密度:ProvideDesignDensity 会按设计稿把密度放大到 1.35 倍, + * 拿缩放后的 dp 去比,800dp 的平板会算成 593dp 被误判成手机。 + */ +@Composable +private fun isLargeScreenDevice(): Boolean { + val container = LocalWindowInfo.current.containerSize + if (container.width <= 0 || container.height <= 0) return false + val density = LocalUnscaledDensity.current ?: LocalDensity.current + return with(density) { + minOf(container.width, container.height).toDp() >= LargeScreenMinSmallestWidth + } +} + +/** 只用来选封面解码档位,差几像素不影响结果。 */ +private fun albumGridCellWidth(gridWidthPx: Int, columns: Int, density: Density): Dp { + with(density) { + val available = gridWidthPx - AlbumGridHorizontalPadding.roundToPx() * 2 - + AlbumGridSpacing.roundToPx() * (columns - 1) + return (available / columns).coerceAtLeast(0).toDp() + } +} + @Composable private fun ArtistList( artists: List,