fix: 1.修复图片闪烁 2.增大图片轮询间隔
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user