fix: 1.修复图片闪烁 2.增大图片轮询间隔
This commit is contained in:
@@ -8,7 +8,7 @@ DEFAULT_MAX_IMAGES_CAP = 8
|
|||||||
DEFAULT_IMAGE_PROVIDER = "liblib"
|
DEFAULT_IMAGE_PROVIDER = "liblib"
|
||||||
DEFAULT_IMAGE_STYLE = "watercolor"
|
DEFAULT_IMAGE_STYLE = "watercolor"
|
||||||
DEFAULT_IMAGE_SIZE = "1280x720"
|
DEFAULT_IMAGE_SIZE = "1280x720"
|
||||||
DEFAULT_POLL_INTERVAL_SECONDS = 3
|
DEFAULT_POLL_INTERVAL_SECONDS = 5
|
||||||
DEFAULT_MAX_ATTEMPTS = 60
|
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]) {
|
when (val block = blocks[index]) {
|
||||||
is MemoirContentBlock.Text -> {
|
is MemoirContentBlock.Text -> {
|
||||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||||
|
|||||||
@@ -74,30 +74,37 @@ fun FullTextReadingView(
|
|||||||
splitMemoirContent(chapter.content, chapter.images)
|
splitMemoirContent(chapter.content, chapter.images)
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks.forEach { block ->
|
blocks.forEachIndexed { blockIndex, block ->
|
||||||
when (block) {
|
key(
|
||||||
is MemoirContentBlock.Text -> {
|
when (block) {
|
||||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
is MemoirContentBlock.Text -> "text-$blockIndex"
|
||||||
TextUtils.removeInlineChapterHeadings(block.content)
|
is MemoirContentBlock.Image -> "img-${block.image.index}"
|
||||||
)
|
|
||||||
MarkdownText(
|
|
||||||
content = processedContent,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
|
||||||
textColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontSize = AppTypography.titleMedium,
|
|
||||||
lineHeight = AppTypography.lineHeightXLoose
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is MemoirContentBlock.Image -> {
|
) {
|
||||||
MemoirInlineImage(
|
when (block) {
|
||||||
image = block.image,
|
is MemoirContentBlock.Text -> {
|
||||||
onClick = {
|
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||||
if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) {
|
TextUtils.removeInlineChapterHeadings(block.content)
|
||||||
viewerImage = block.image
|
)
|
||||||
}
|
MarkdownText(
|
||||||
},
|
content = processedContent,
|
||||||
modifier = Modifier.padding(vertical = 12.dp)
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.SubcomposeAsyncImage
|
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.ChapterImageDto
|
||||||
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_COMPLETED
|
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_COMPLETED
|
||||||
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_FAILED
|
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_FAILED
|
||||||
@@ -38,6 +43,90 @@ internal fun memoirImageAspectRatio(size: String?): Float {
|
|||||||
return width / height
|
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
|
@Composable
|
||||||
private fun MemoirImageLoadingPlaceholder(
|
private fun MemoirImageLoadingPlaceholder(
|
||||||
image: ChapterImageDto,
|
image: ChapterImageDto,
|
||||||
@@ -55,18 +144,16 @@ private fun MemoirImageLoadingPlaceholder(
|
|||||||
label = "shimmer-alpha"
|
label = "shimmer-alpha"
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = memoirImageModifier(
|
||||||
.fillMaxWidth()
|
image = image,
|
||||||
.aspectRatio(memoirImageAspectRatio(image.size))
|
modifier = modifier,
|
||||||
.clip(RoundedCornerShape(16.dp))
|
testTag = "memoir-image-loading-${image.index}",
|
||||||
.background(LightPurple.copy(alpha = alpha))
|
),
|
||||||
.testTag("memoir-image-loading-${image.index}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
MemoirImageLoadingContent(
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = AppTypography.bodyMedium,
|
alpha = alpha,
|
||||||
color = SlatePurple.copy(alpha = 0.6f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,19 +165,14 @@ private fun MemoirImageStatusPlaceholder(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = memoirImageModifier(
|
||||||
.fillMaxWidth()
|
image = image,
|
||||||
.aspectRatio(memoirImageAspectRatio(image.size))
|
modifier = modifier,
|
||||||
.clip(RoundedCornerShape(16.dp))
|
testTag = "memoir-image-error-${image.index}",
|
||||||
.background(LightPurple.copy(alpha = 0.16f))
|
),
|
||||||
.testTag("memoir-image-error-${image.index}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
MemoirImageStatusContent(text = text)
|
||||||
text = text,
|
|
||||||
fontSize = AppTypography.bodyMedium,
|
|
||||||
color = SlatePurple.copy(alpha = 0.72f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,30 +201,43 @@ fun MemoirInlineImage(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val request = rememberMemoirImageRequest(image)
|
||||||
|
|
||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = image.url,
|
model = request,
|
||||||
contentDescription = image.description,
|
contentDescription = image.description,
|
||||||
|
modifier = memoirImageModifier(
|
||||||
|
image = image,
|
||||||
|
modifier = modifier,
|
||||||
|
testTag = "memoir-image-${image.index}",
|
||||||
|
onClick = onClick,
|
||||||
|
),
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
loading = {
|
loading = {
|
||||||
MemoirImageLoadingPlaceholder(
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
image = image,
|
val alpha by transition.animateFloat(
|
||||||
modifier = modifier,
|
initialValue = 0.10f,
|
||||||
text = "图片加载中…",
|
targetValue = 0.25f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(800),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "shimmer-alpha"
|
||||||
)
|
)
|
||||||
|
MemoirImageLoadingContent(
|
||||||
|
text = "图片加载中…",
|
||||||
|
alpha = alpha,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
success = {
|
||||||
|
SubcomposeAsyncImageContent()
|
||||||
},
|
},
|
||||||
error = {
|
error = {
|
||||||
MemoirImageStatusPlaceholder(
|
MemoirImageStatusContent(text = "图片暂不可用")
|
||||||
image = image,
|
|
||||||
text = "图片暂不可用",
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = modifier
|
onLoading = { },
|
||||||
.fillMaxWidth()
|
onSuccess = { },
|
||||||
.aspectRatio(memoirImageAspectRatio(image.size))
|
onError = { },
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.testTag("memoir-image-${image.index}")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_PENDING
|
||||||
import com.huaga.life_echo.model.MEMOIR_IMAGE_STATUS_PROCESSING
|
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_PROVIDER_MAX_ATTEMPTS = 60L
|
||||||
internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L
|
internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L
|
||||||
|
|
||||||
|
|||||||
@@ -253,8 +253,11 @@ fun MyMemoirScreen(
|
|||||||
val idx = chapterDtos.indexOfFirst { it.id == selectedChapter!!.id }
|
val idx = chapterDtos.indexOfFirst { it.id == selectedChapter!!.id }
|
||||||
if (idx >= 0) idx + 1 else null
|
if (idx >= 0) idx + 1 else null
|
||||||
}
|
}
|
||||||
val chapterContent = remember(selectedChapter, chapterDtos) {
|
val selectedChapterDto = remember(selectedChapter!!.id, chapterDtos) {
|
||||||
val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id }
|
chapterDtos.find { it.id == selectedChapter!!.id }
|
||||||
|
}
|
||||||
|
val selectedImages = selectedChapterDto?.images ?: emptyList()
|
||||||
|
val chapterContent = remember(selectedChapter, selectedImages) {
|
||||||
ChapterContentDto(
|
ChapterContentDto(
|
||||||
id = selectedChapter!!.id,
|
id = selectedChapter!!.id,
|
||||||
title = selectedChapter!!.title,
|
title = selectedChapter!!.title,
|
||||||
@@ -265,7 +268,7 @@ fun MyMemoirScreen(
|
|||||||
pageCount = null,
|
pageCount = null,
|
||||||
updatedAt = selectedChapter!!.updatedAt,
|
updatedAt = selectedChapter!!.updatedAt,
|
||||||
quotes = emptyList(),
|
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 com.huaga.life_echo.model.ChapterImageDto
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MemoirInlineImageStateTest {
|
class MemoirInlineImageStateTest {
|
||||||
@@ -27,10 +28,65 @@ class MemoirInlineImageStateTest {
|
|||||||
assertEquals("图片暂不可用", memoirImageFailureText(image))
|
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(
|
private fun image(
|
||||||
status: String,
|
status: String,
|
||||||
retryable: Boolean?,
|
retryable: Boolean?,
|
||||||
url: String? = null,
|
url: String? = null,
|
||||||
|
updatedAt: String? = null,
|
||||||
): ChapterImageDto {
|
): ChapterImageDto {
|
||||||
return ChapterImageDto(
|
return ChapterImageDto(
|
||||||
index = 0,
|
index = 0,
|
||||||
@@ -45,7 +101,7 @@ class MemoirInlineImageStateTest {
|
|||||||
size = "1024x1024",
|
size = "1024x1024",
|
||||||
error = null,
|
error = null,
|
||||||
created_at = null,
|
created_at = null,
|
||||||
updated_at = null,
|
updated_at = updatedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user