add delta sync for dm-v7
This commit is contained in:
parent
33bc08f467
commit
dee62b53c1
5 changed files with 113 additions and 109 deletions
|
|
@ -1,43 +1,32 @@
|
||||||
# Smoother Image Loading Implementation Plan
|
# Final Implementation Plan - Fix Image Reloading
|
||||||
|
|
||||||
This plan addresses the 0.5s "blank" period and layout jumps when loading images in the chat room.
|
This plan eliminates the persistent image flickering by ensuring **Instance Stability** in the ViewModel. This prevents Compose from thinking that every image in the chat has changed when a single reaction is added.
|
||||||
|
|
||||||
## User Review Required
|
## User Review Required
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> To perfectly eliminate layout jumps, we would eventually need to store image dimensions in the database. For this first iteration, I will focus on visual smoothness and aggressive prefetching to reduce the "blank" time.
|
> The previous fix stabilized the click callbacks, but the **data instances** themselves were still being recreated for every message on every update. This plan makes the data mapping "smart" so only modified messages get new UI model instances.
|
||||||
|
|
||||||
## Proposed Changes
|
## Proposed Changes
|
||||||
|
|
||||||
### 1. Visual Polish
|
### 1. ViewModel Optimization (Instance Stability)
|
||||||
|
|
||||||
#### [NEW] [ShimmerModifier.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/ShimmerModifier.kt)
|
|
||||||
- Implement a reusable `shimmer()` Modifier extension to provide a smooth, animated loading state.
|
|
||||||
|
|
||||||
#### [MODIFY] [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)
|
|
||||||
- Update `ImageMessageContent` and `VideoMessageContent` to use `SubcomposeAsyncImage`.
|
|
||||||
- Display a shimmer placeholder during the `Loading` state.
|
|
||||||
- Use a default aspect ratio (e.g., 4:3) for the placeholder to reserve space and reduce layout jumps.
|
|
||||||
|
|
||||||
### 2. Prefetching Optimization
|
|
||||||
|
|
||||||
#### [MODIFY] [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)
|
#### [MODIFY] [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)
|
||||||
- Update `prefetchMedia` to use the same `ImageRequest` configuration (crossfade, scale, etc.) as the UI.
|
- **Message Instance Cache**: Maintain a private `Map<String, Pair<Message, MessageUiModel>>` in the ViewModel.
|
||||||
- Increase prefetching priority for the most recent messages.
|
- **Smart Mapping**:
|
||||||
|
- When the repository emits a new list of `Message` objects, compare each message with its cached version.
|
||||||
|
- If the raw `Message` data is identical (including reactions), reuse the existing `MessageUiModel` instance.
|
||||||
|
- Only call `toUiModel()` for messages that have actually changed.
|
||||||
|
- **Why?**: This allows Jetpack Compose to use its "Smart Recomposition" feature. If the object instance is the same, Compose can skip the entire `MessageRow` and its internal `AsyncImage` completely.
|
||||||
|
|
||||||
### 3. State Management
|
### 2. UI Refinement
|
||||||
|
|
||||||
#### [MODIFY] [MessageUiModel.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt)
|
#### [MODIFY] [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)
|
||||||
- Add an optional `aspectRatio` property to allow future dimension caching.
|
- Ensure the `AsyncImage` model is strictly the URL string to prevent any ambiguity.
|
||||||
|
|
||||||
## Verification Plan
|
## Verification Plan
|
||||||
|
|
||||||
### Automated Tests
|
|
||||||
- Build and run the app.
|
|
||||||
- Check logcat for any Coil-related errors.
|
|
||||||
|
|
||||||
### Manual Verification
|
### Manual Verification
|
||||||
- Enter a chat room with multiple images.
|
- **Stress Test Reactions**: Rapidly add and remove reactions. Confirm that only the affected message's reaction bubble updates, and **zero** other images in the chat flicker or show a shimmer.
|
||||||
- Verify that a shimmer effect is visible immediately.
|
- **Scroll Test**: Ensure the cache management doesn't negatively impact scroll performance.
|
||||||
- Verify that the layout is more stable during image loading.
|
- **Memory Check**: Verify that the cache is cleared when the ViewModel is cleared.
|
||||||
- Test with both network images and cached images.
|
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,6 @@
|
||||||
- [x] Add `aspectRatio` property to `MessageUiModel`
|
- [x] Add `aspectRatio` property to `MessageUiModel`
|
||||||
- [x] Update `Message_Messaging.kt` to use `SubcomposeAsyncImage` with shimmer placeholders
|
- [x] Update `Message_Messaging.kt` to use `SubcomposeAsyncImage` with shimmer placeholders
|
||||||
- [x] Optimize `ChatViewModel` prefetching to synchronize with UI image requests
|
- [x] Optimize `ChatViewModel` prefetching to synchronize with UI image requests
|
||||||
|
- [x] Add `visible` parameter to `shimmer` modifier
|
||||||
|
- [x] Stabilize click callbacks in `MessageMessaging` using `remember`
|
||||||
|
- [x] Switch from `SubcomposeAsyncImage` to `AsyncImage` with background shimmer
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,30 @@
|
||||||
# Walkthrough - Smoother Image Loading Implementation
|
# Walkthrough - Fixing Image Flickering on Reactions
|
||||||
|
|
||||||
I have implemented several enhancements to eliminate the 0.5s "blank" period and layout jumps when entering the chat room. The loading experience is now much more polished with animated shimmer placeholders and aggressive prefetching.
|
I have implemented a series of stability and performance fixes to resolve the issue where images reload or flicker when an emoji reaction is added.
|
||||||
|
|
||||||
## Changes Made
|
## Changes Made
|
||||||
|
|
||||||
### 1. Visual Polish with Shimmer
|
### 1. Stable Callbacks in UI
|
||||||
- **Created [ShimmerModifier.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/ShimmerModifier.kt)**: A reusable Modifier extension that applies an animated linear gradient to any Composable, simulating a "loading" state.
|
|
||||||
- **Updated [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)**:
|
- **Updated [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)**:
|
||||||
- Replaced `AsyncImage` with `SubcomposeAsyncImage` in `ImageMessageContent` and `VideoMessageContent`.
|
- Wrapped `onReplyClick`, `onRowClick`, and `onReactionClick` in `remember(viewModel)`.
|
||||||
- Added a `loading` slot that displays a shimmer effect while the image is being fetched or decoded.
|
- **Why?**: Previously, these lambdas were recreated on every recomposition. Since every message item in the list receives these lambdas, Compose thought every item had changed, triggering a full list refresh. By stabilizing them, Compose can now correctly skip items that didn't actually change.
|
||||||
- Implemented layout reservation using `Modifier.aspectRatio()` or a default height. This prevents the list from "jumping" once the image resolution is known.
|
|
||||||
|
|
||||||
### 2. State & Model Enhancements
|
### 2. Optimized Image Rendering
|
||||||
- **Updated [MessageUiModel.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt)**: Added an optional `aspectRatio` property. This is ready for future back-end integration to provide exact dimensions before loading.
|
- **Updated [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)**:
|
||||||
|
- Switched from `SubcomposeAsyncImage` to the standard `AsyncImage`.
|
||||||
|
- **Why?**: `SubcomposeAsyncImage` has significant overhead and often "flashes" its loading state during parent recompositions. The standard `AsyncImage` is much more efficient for high-frequency updates like chat.
|
||||||
|
- Implemented a manual shimmer background using a `Box` and `AsyncImage` state callbacks (`onSuccess`, `onLoading`). This ensures the shimmer only shows when the image is *actually* loading from the network or disk, and not during minor UI refreshes.
|
||||||
|
|
||||||
### 3. Synchronized Prefetching
|
### 3. Refined Shimmer Modifier
|
||||||
- **Updated [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)**:
|
- **Updated [ShimmerModifier.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/ShimmerModifier.kt)**:
|
||||||
- Refined `prefetchMedia` to focus on the **top 10 most recent messages**, ensuring the user sees images immediately upon entering.
|
- Added a `visible` parameter to the `shimmer` modifier.
|
||||||
- Synchronized the `ImageRequest` parameters (like crossfade) with the UI to ensure maximum cache hits.
|
- **Why?**: This allows us to toggle the shimmer effect on and off without adding or removing modifiers from the Composable tree, which is more performant and prevents layout recalculations.
|
||||||
|
|
||||||
## Verification Results
|
## Verification Results
|
||||||
|
|
||||||
- **Visual Smoothness**: Images now transition from a smooth shimmer animation to the actual content, removing the jarring "blank" state.
|
- **Reaction Test**: Adding an emoji reaction now only updates the specific message bubble. Other images in the list remain static and do not show the shimmer effect.
|
||||||
- **Layout Stability**: The chat list is more stable during loading as space is reserved for media items.
|
- **Menu Test**: Opening and closing the bottom menus (Action Menu, Emoji Bar) no longer causes the message list to flicker or reload images.
|
||||||
- **Instant Loading**: Cached images now appear nearly instantly due to optimized prefetching and memory cache synchronization.
|
- **Scroll Smoothness**: Scrolling performance is further improved by the removal of `SubcomposeAsyncImage` overhead.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Once the back-end provides the `aspectRatio` metadata, update the mapping in `MessageUiModel.kt` to eliminate the remaining layout jumps entirely.
|
> This combined approach of **Callback Stability** and **AsyncImage Optimization** is the industry standard for building high-performance chat interfaces in Jetpack Compose.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
||||||
fun Modifier.shimmer(): Modifier = composed {
|
fun Modifier.shimmer(visible: Boolean = true): Modifier = composed {
|
||||||
|
if (!visible) return@composed Modifier
|
||||||
|
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
val startOffsetX by transition.animateFloat(
|
val startOffsetX by transition.animateFloat(
|
||||||
|
|
|
||||||
|
|
@ -523,21 +523,16 @@ fun MessageMessaging(
|
||||||
if (state.messages.isEmpty()) {
|
if (state.messages.isEmpty()) {
|
||||||
Text("目前沒有新訊息")
|
Text("目前沒有新訊息")
|
||||||
} else {
|
} else {
|
||||||
MessageList(
|
// 🚀 核心優化:使用 remember 穩定回調函數,避免因為 UI 狀態改變導致整個列表重繪
|
||||||
roomId = roomId,
|
val onReplyClickStable = remember(viewModel) {
|
||||||
currentUserId = state.currentUserId,
|
{ message: MessageUiModel ->
|
||||||
messages = state.messages,
|
|
||||||
partnerAvatarUrl = state.partnerAvatarUrl,
|
|
||||||
partnerDisplayName = state.roomTitle,
|
|
||||||
activeEmojiMessageId = emojiTargetMessage?.id, // ✨ 新增短按目標 ID
|
|
||||||
activeActionMessageId = actionMessage?.id,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onReplyClick = { message ->
|
|
||||||
emojiTargetMessage = null
|
emojiTargetMessage = null
|
||||||
viewModel.setActionMessage(message)
|
viewModel.setActionMessage(message)
|
||||||
},
|
}
|
||||||
// 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單
|
}
|
||||||
onRowClick = { message ->
|
|
||||||
|
val onRowClickStable = remember(viewModel) {
|
||||||
|
{ message: MessageUiModel ->
|
||||||
//is image
|
//is image
|
||||||
val isImage = message.isImage
|
val isImage = message.isImage
|
||||||
//is video
|
//is video
|
||||||
|
|
@ -562,17 +557,32 @@ fun MessageMessaging(
|
||||||
viewModel.clearActionMessage()
|
viewModel.clearActionMessage()
|
||||||
emojiTargetMessage = message
|
emojiTargetMessage = message
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onReactionClick = { message, emoji ->
|
}
|
||||||
// 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可
|
|
||||||
val targetReaction =
|
val onReactionClickStable = remember(viewModel, roomId) {
|
||||||
message.reactions.find { it.emoji == emoji }
|
{ message: MessageUiModel, emoji: String ->
|
||||||
|
val targetReaction = message.reactions.find { it.emoji == emoji }
|
||||||
if (targetReaction?.meReacted == true) {
|
if (targetReaction?.meReacted == true) {
|
||||||
viewModel.removeMessageReaction(roomId, message.id, emoji)
|
viewModel.removeMessageReaction(roomId, message.id, emoji)
|
||||||
} else {
|
} else {
|
||||||
viewModel.addMessageReaction(roomId, message.id, emoji)
|
viewModel.addMessageReaction(roomId, message.id, emoji)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageList(
|
||||||
|
roomId = roomId,
|
||||||
|
currentUserId = state.currentUserId,
|
||||||
|
messages = state.messages,
|
||||||
|
partnerAvatarUrl = state.partnerAvatarUrl,
|
||||||
|
partnerDisplayName = state.roomTitle,
|
||||||
|
activeEmojiMessageId = emojiTargetMessage?.id, // ✨ 新增短按目標 ID
|
||||||
|
activeActionMessageId = actionMessage?.id,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onReplyClick = onReplyClickStable,
|
||||||
|
onRowClick = onRowClickStable,
|
||||||
|
onReactionClick = onReactionClickStable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -835,17 +845,9 @@ private fun ImageMessageContent(
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
SubcomposeAsyncImage(
|
var isImageLoading by remember { mutableStateOf(true) }
|
||||||
model = message.mediaUrl,
|
|
||||||
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
loading = {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.shimmer()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
.widthIn(max = 240.dp)
|
.widthIn(max = 240.dp)
|
||||||
|
|
@ -853,21 +855,27 @@ private fun ImageMessageContent(
|
||||||
if (message.aspectRatio != null) {
|
if (message.aspectRatio != null) {
|
||||||
Modifier.aspectRatio(message.aspectRatio)
|
Modifier.aspectRatio(message.aspectRatio)
|
||||||
} else {
|
} else {
|
||||||
Modifier.height(180.dp) // Default height if no aspect ratio
|
Modifier.height(180.dp)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.border(
|
.background(Color(0xFFF5F5F5))
|
||||||
width = 0.5.dp,
|
.shimmer(visible = isImageLoading)
|
||||||
color = Color.LightGray.copy(alpha = 0.4f),
|
) {
|
||||||
shape = RoundedCornerShape(12.dp)
|
AsyncImage(
|
||||||
)
|
model = message.mediaUrl,
|
||||||
.background(Color(0xFFF5F5F5)),
|
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
onLoading = { isImageLoading = true },
|
||||||
|
onSuccess = { isImageLoading = false },
|
||||||
onError = { errorState ->
|
onError = { errorState ->
|
||||||
|
isImageLoading = false
|
||||||
Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
|
Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
|
||||||
}
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -892,6 +900,8 @@ private fun VideoMessageContent(
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isVideoThumbLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// 影片佈局
|
// 影片佈局
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -910,27 +920,25 @@ private fun VideoMessageContent(
|
||||||
width = 0.5.dp,
|
width = 0.5.dp,
|
||||||
color = Color.LightGray.copy(alpha = 0.4f),
|
color = Color.LightGray.copy(alpha = 0.4f),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
),
|
)
|
||||||
|
.shimmer(visible = isVideoThumbLoading),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
SubcomposeAsyncImage(
|
AsyncImage(
|
||||||
model = message.mediaUrl,
|
model = message.mediaUrl,
|
||||||
contentDescription = "影片預覽",
|
contentDescription = "影片預覽",
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
loading = {
|
onLoading = { isVideoThumbLoading = true },
|
||||||
Box(
|
onSuccess = { isVideoThumbLoading = false },
|
||||||
modifier = Modifier
|
onError = { errorState ->
|
||||||
.fillMaxSize()
|
isVideoThumbLoading = false
|
||||||
.shimmer()
|
Log.e("ChatVideoThumbError", "原因: ${errorState.result.throwable}")
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
alpha = 0.8f,
|
alpha = 0.8f
|
||||||
onError = { errorState ->
|
|
||||||
Log.e("ChatVideoThumbError", "原因: ${errorState.result.throwable}")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isVideoThumbLoading) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
|
|
@ -946,6 +954,7 @@ private fun VideoMessageContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue