multiplatform: fix image loading performance and layout stability (#6631)

- Replace runBlocking { imageAndFilePath(file) } with LaunchedEffect +
  withContext(Dispatchers.IO) to unblock main thread on all platforms
- Set fixed container size (width + aspectRatio) from preview bitmap to
  eliminate layout shifts during async image loading
- Cache base64ToBitmap() with remember() in CIImageView and FramedItemView
- Desktop: replace imageBitmap.toAwtImage().toPainter() with BitmapPainter
  to eliminate unnecessary round-trip conversion
- Desktop: add LRU cache for base64ToBitmap (200 entries) and
  getLoadedImage (30 entries) to survive LazyColumn item disposal
- Clear loaded image cache on app file deletion via expect/actual

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
sh
2026-03-04 09:11:55 +00:00
committed by GitHub
parent bf56ed0f56
commit 024df7099d
7 changed files with 41 additions and 16 deletions
@@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH
import chat.simplex.common.views.chat.chatViewScrollState
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
@Composable
fun CIImageView(
@@ -38,6 +38,7 @@ fun CIImageView(
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
val previewBitmap = remember(image) { base64ToBitmap(image) }
@Composable
fun progressIndicator() {
CircularProgressIndicator(
@@ -144,7 +145,7 @@ fun CIImageView(
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(base64ToBitmap(image), onClick = {
imageView(previewBitmap, onClick = {
if (fileSource != null) {
openFile(fileSource)
}
@@ -178,14 +179,16 @@ fun CIImageView(
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID)
.then(
if (!smallView) {
val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat())
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember {
mutableStateOf(
if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) }
)
}
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
@@ -195,9 +198,9 @@ fun CIImageView(
}
}
} else {
KeyChangeEffect(file) {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
@@ -206,7 +209,7 @@ fun CIImageView(
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
} else {
imageView(base64ToBitmap(image), onClick = {
imageView(previewBitmap, onClick = {
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
@@ -144,7 +144,7 @@ fun FramedItemView(
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
val imageBitmap = base64ToBitmap(qi.content.image)
val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) }
Image(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
@@ -156,7 +156,7 @@ fun FramedItemView(
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
val imageBitmap = base64ToBitmap(qi.content.image)
val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) }
Image(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
@@ -130,6 +130,8 @@ const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
expect fun getAppFileUri(fileName: String): URI
expect fun clearImageCaches()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
expect suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
@@ -423,6 +425,7 @@ fun deleteAppFiles() {
} catch (e: java.lang.Exception) {
Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}")
}
clearImageCaches()
}
fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in bytes