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

View File

@@ -182,6 +182,8 @@ private fun spannableStringToAnnotatedString(
actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI()
actual fun clearImageCaches() {}
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)

View File

@@ -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 ->

View File

@@ -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),

View File

@@ -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

View File

@@ -21,12 +21,19 @@ import kotlin.math.sqrt
private fun errorBitmap(): ImageBitmap =
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap()
private val base64BitmapCache = Collections.synchronizedMap(object : LinkedHashMap<String, ImageBitmap>(200, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, ImageBitmap>): Boolean = size > 200
})
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
base64BitmapCache[base64ImageString]?.let { return it }
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
return try {
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap()
ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap().also {
base64BitmapCache[base64ImageString] = it
}
} catch (e: Throwable) {
Log.e(TAG, "base64ToBitmap error: $e")
errorBitmap()

View File

@@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.item
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import chat.simplex.common.model.CIFile
import chat.simplex.common.platform.*
@@ -17,7 +18,7 @@ actual fun SimpleAndAnimatedImageView(
ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit
) {
// LALAL make it animated too
ImageView(imageBitmap.toAwtImage().toPainter()) {
ImageView(BitmapPainter(imageBitmap)) {
if (getLoadedFilePath(file) != null) {
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import java.util.*
import javax.imageio.ImageIO
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -128,6 +129,14 @@ actual fun getAppFileUri(fileName: String): URI {
}
}
private val loadedImageCache = Collections.synchronizedMap(object : LinkedHashMap<String, Pair<ImageBitmap, ByteArray>>(30, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Pair<ImageBitmap, ByteArray>>): Boolean = size > 30
})
actual fun clearImageCaches() {
loadedImageCache.clear()
}
actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
var filePath = getLoadedFilePath(file)
if (chatModel.connectedToRemote() && filePath == null) {
@@ -135,10 +144,10 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
filePath = getLoadedFilePath(file)
}
return if (filePath != null) {
try {
loadedImageCache[filePath] ?: try {
val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes()
val bitmap = getBitmapFromByteArray(data, false)
if (bitmap != null) bitmap to data else null
if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null
} catch (e: Exception) {
Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString())
null