fix: 1.修复图片闪烁 2.增大图片轮询间隔

This commit is contained in:
Kevin
2026-03-13 10:53:42 +08:00
parent 48594a20ea
commit 1cb804fa37
7 changed files with 235 additions and 66 deletions

View File

@@ -8,7 +8,7 @@ DEFAULT_MAX_IMAGES_CAP = 8
DEFAULT_IMAGE_PROVIDER = "liblib"
DEFAULT_IMAGE_STYLE = "watercolor"
DEFAULT_IMAGE_SIZE = "1280x720"
DEFAULT_POLL_INTERVAL_SECONDS = 3
DEFAULT_POLL_INTERVAL_SECONDS = 5
DEFAULT_MAX_ATTEMPTS = 60

View File

@@ -66,7 +66,15 @@ fun ChapterReadingView(
)
}
items(blocks.size) { index ->
items(
count = blocks.size,
key = { index ->
when (val block = blocks[index]) {
is MemoirContentBlock.Text -> "text-$index"
is MemoirContentBlock.Image -> "img-${block.image.index}"
}
},
) { index ->
when (val block = blocks[index]) {
is MemoirContentBlock.Text -> {
val processedContent = TextUtils.addParagraphFirstLineIndent(

View File

@@ -74,30 +74,37 @@ fun FullTextReadingView(
splitMemoirContent(chapter.content, chapter.images)
}
blocks.forEach { block ->
when (block) {
is MemoirContentBlock.Text -> {
val processedContent = TextUtils.addParagraphFirstLineIndent(
TextUtils.removeInlineChapterHeadings(block.content)
)
MarkdownText(
content = processedContent,
modifier = Modifier.padding(bottom = 16.dp),
textColor = MaterialTheme.colorScheme.onSurface,
fontSize = AppTypography.titleMedium,
lineHeight = AppTypography.lineHeightXLoose
)
blocks.forEachIndexed { blockIndex, block ->
key(
when (block) {
is MemoirContentBlock.Text -> "text-$blockIndex"
is MemoirContentBlock.Image -> "img-${block.image.index}"
}
is MemoirContentBlock.Image -> {
MemoirInlineImage(
image = block.image,
onClick = {
if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) {
viewerImage = block.image
}
},
modifier = Modifier.padding(vertical = 12.dp)
)
) {
when (block) {
is MemoirContentBlock.Text -> {
val processedContent = TextUtils.addParagraphFirstLineIndent(
TextUtils.removeInlineChapterHeadings(block.content)
)
MarkdownText(
content = processedContent,
modifier = Modifier.padding(bottom = 16.dp),
textColor = MaterialTheme.colorScheme.onSurface,
fontSize = AppTypography.titleMedium,
lineHeight = AppTypography.lineHeightXLoose
)
}
is MemoirContentBlock.Image -> {
MemoirInlineImage(
image = block.image,
onClick = {
if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) {
viewerImage = block.image
}
},
modifier = Modifier.padding(vertical = 12.dp)
)
}
}
}
}

View File

@@ -9,18 +9,23 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.request.ImageRequest
import com.huaga.life_echo.model.ChapterImageDto
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_COMPLETED
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_FAILED
@@ -38,6 +43,90 @@ internal fun memoirImageAspectRatio(size: String?): Float {
return width / height
}
internal fun memoirImageCanonicalUrl(url: String?): String? {
val normalizedUrl = url?.trim().orEmpty()
if (normalizedUrl.isBlank()) return null
return normalizedUrl.substringBefore('#').substringBefore('?')
}
internal fun memoirImageStableCacheKey(image: ChapterImageDto): String {
return listOf(
"memoir-image",
image.index.toString(),
image.placeholder,
memoirImageCanonicalUrl(image.url).orEmpty(),
image.updated_at.orEmpty(),
image.created_at.orEmpty(),
image.description,
).joinToString("|")
}
@Composable
private fun rememberMemoirImageRequest(image: ChapterImageDto): ImageRequest {
val context = LocalContext.current
val cacheKey = remember(image) { memoirImageStableCacheKey(image) }
// COS 签名 URL 会轮换,但同一张图需要命中同一个缓存身份。
return remember(context, image.url, cacheKey) {
ImageRequest.Builder(context)
.data(image.url)
.memoryCacheKey(cacheKey)
.diskCacheKey(cacheKey)
.build()
}
}
private fun memoirImageModifier(
image: ChapterImageDto,
modifier: Modifier,
testTag: String,
onClick: (() -> Unit)? = null,
): Modifier {
var result = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
if (onClick != null) {
result = result.clickable(onClick = onClick)
}
return result.testTag(testTag)
}
@Composable
private fun MemoirImageLoadingContent(
text: String,
alpha: Float,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LightPurple.copy(alpha = alpha)),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.6f),
)
}
}
@Composable
private fun MemoirImageStatusContent(text: String) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LightPurple.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.72f),
)
}
}
@Composable
private fun MemoirImageLoadingPlaceholder(
image: ChapterImageDto,
@@ -55,18 +144,16 @@ private fun MemoirImageLoadingPlaceholder(
label = "shimmer-alpha"
)
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.background(LightPurple.copy(alpha = alpha))
.testTag("memoir-image-loading-${image.index}"),
modifier = memoirImageModifier(
image = image,
modifier = modifier,
testTag = "memoir-image-loading-${image.index}",
),
contentAlignment = Alignment.Center,
) {
Text(
MemoirImageLoadingContent(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.6f),
alpha = alpha,
)
}
}
@@ -78,19 +165,14 @@ private fun MemoirImageStatusPlaceholder(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.background(LightPurple.copy(alpha = 0.16f))
.testTag("memoir-image-error-${image.index}"),
modifier = memoirImageModifier(
image = image,
modifier = modifier,
testTag = "memoir-image-error-${image.index}",
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.72f),
)
MemoirImageStatusContent(text = text)
}
}
@@ -119,30 +201,43 @@ fun MemoirInlineImage(
modifier = modifier,
)
} else {
val request = rememberMemoirImageRequest(image)
SubcomposeAsyncImage(
model = image.url,
model = request,
contentDescription = image.description,
modifier = memoirImageModifier(
image = image,
modifier = modifier,
testTag = "memoir-image-${image.index}",
onClick = onClick,
),
contentScale = ContentScale.FillWidth,
loading = {
MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片加载中…",
val transition = rememberInfiniteTransition(label = "shimmer")
val alpha by transition.animateFloat(
initialValue = 0.10f,
targetValue = 0.25f,
animationSpec = infiniteRepeatable(
animation = tween(800),
repeatMode = RepeatMode.Reverse
),
label = "shimmer-alpha"
)
MemoirImageLoadingContent(
text = "图片加载中…",
alpha = alpha,
)
},
success = {
SubcomposeAsyncImageContent()
},
error = {
MemoirImageStatusPlaceholder(
image = image,
text = "图片暂不可用",
modifier = modifier,
)
MemoirImageStatusContent(text = "图片暂不可用")
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.testTag("memoir-image-${image.index}")
onLoading = { },
onSuccess = { },
onError = { },
)
}
}

View File

@@ -4,7 +4,7 @@ import com.huaga.life_echo.model.ChapterDto
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_PENDING
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_PROCESSING
internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L
internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 5_000L
internal const val MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS = 60L
internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L

View File

@@ -253,8 +253,11 @@ fun MyMemoirScreen(
val idx = chapterDtos.indexOfFirst { it.id == selectedChapter!!.id }
if (idx >= 0) idx + 1 else null
}
val chapterContent = remember(selectedChapter, chapterDtos) {
val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id }
val selectedChapterDto = remember(selectedChapter!!.id, chapterDtos) {
chapterDtos.find { it.id == selectedChapter!!.id }
}
val selectedImages = selectedChapterDto?.images ?: emptyList()
val chapterContent = remember(selectedChapter, selectedImages) {
ChapterContentDto(
id = selectedChapter!!.id,
title = selectedChapter!!.title,
@@ -265,7 +268,7 @@ fun MyMemoirScreen(
pageCount = null,
updatedAt = selectedChapter!!.updatedAt,
quotes = emptyList(),
images = chapterDto?.images ?: emptyList()
images = selectedImages
)
}

View File

@@ -2,6 +2,7 @@ package com.huaga.life_echo.ui.components.memoir
import com.huaga.life_echo.model.ChapterImageDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class MemoirInlineImageStateTest {
@@ -27,10 +28,65 @@ class MemoirInlineImageStateTest {
assertEquals("图片暂不可用", memoirImageFailureText(image))
}
@Test
fun memoirImageCanonicalUrl_stripsSignedQueryParameters() {
val canonicalUrl = memoirImageCanonicalUrl(
"https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1&q-ak=demo&q-sign-time=1"
)
assertEquals(
"https://cdn.example.com/memoirs/u1/c1/0-demo.png",
canonicalUrl,
)
}
@Test
fun memoirImageStableCacheKey_ignoresSignedUrlRotation_forSameImage() {
val first = image(
status = "completed",
retryable = null,
url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=1&q-signature=a",
updatedAt = "2026-03-13T10:00:00Z",
)
val second = image(
status = "completed",
retryable = null,
url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=2&q-signature=b",
updatedAt = "2026-03-13T10:00:00Z",
)
assertEquals(
memoirImageStableCacheKey(first),
memoirImageStableCacheKey(second),
)
}
@Test
fun memoirImageStableCacheKey_changes_whenImageVersionChanges() {
val original = image(
status = "completed",
retryable = null,
url = "https://cdn.example.com/memoirs/u1/c1/0-demo.png?q-sign-time=1",
updatedAt = "2026-03-13T10:00:00Z",
)
val regenerated = image(
status = "completed",
retryable = null,
url = "https://cdn.example.com/memoirs/u1/c1/0-demo-v2.png?q-sign-time=2",
updatedAt = "2026-03-13T10:05:00Z",
)
assertNotEquals(
memoirImageStableCacheKey(original),
memoirImageStableCacheKey(regenerated),
)
}
private fun image(
status: String,
retryable: Boolean?,
url: String? = null,
updatedAt: String? = null,
): ChapterImageDto {
return ChapterImageDto(
index = 0,
@@ -45,7 +101,7 @@ class MemoirInlineImageStateTest {
size = "1024x1024",
error = null,
created_at = null,
updated_at = null,
updated_at = updatedAt,
)
}
}