mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-30 22:55:48 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user