From 36f044f7b727931d12309cead00ca17c75e0207e Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Apr 2026 19:47:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=BC=93=E5=AD=98=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=92=8C=E8=81=94=E7=B3=BB=E4=BA=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 LocalImCache 用于缓存聊天会话和消息历史记录 - 实现 LocalContactCache 用于缓存联系人好友数据 - 在 ChatViewModel 中集成消息历史分页加载和本地搜索功能 - 在 ContactViewModel 中集成联系人列表本地缓存读取 - 添加 ConversationViewModel 管理会话列表的本地缓存 - 集成缓存与实时消息同步机制,确保数据一致性 - 添加完整的 README.md 文档说明 SDK 架构和使用方法 --- README.md | 9 ++ .../sample/data/local/LocalContactCache.kt | 87 +++++++++++++++++++ .../sdk/sample/data/local/LocalImCache.kt | 15 ++++ .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 51 +++++++++++ .../sdk/sample/ui/contact/ContactScreen.kt | 22 ++++- .../ui/conversation/ConversationViewModel.kt | 8 ++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalContactCache.kt diff --git a/README.md b/README.md index 6ef6f2f..baca223 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ val service = RetrofitFactory.create(MyApiService::class.java) ## sdk-im +### Android sample 已具备 + +- 会话列表先读本地缓存,再刷新网络 +- 联系人列表先读本地缓存,再刷新网络 +- 聊天历史分页加载 +- 当前会话本地搜索 +- IM 连接状态提示 +- SDK 登录态恢复后自动重连 + ### ImClient ```kotlin diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalContactCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalContactCache.kt new file mode 100644 index 0000000..2a3e930 --- /dev/null +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalContactCache.kt @@ -0,0 +1,87 @@ +package com.xuqm.sdk.sample.data.local + +import android.content.Context +import com.xuqm.sdk.sample.data.api.UserData +import org.json.JSONArray +import org.json.JSONObject + +class LocalContactCache(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun loadFriendIds(): List = + prefs.getString(KEY_FRIEND_IDS, null)?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } + ?: emptyList() + + fun saveFriendIds(friendIds: List) { + prefs.edit().putString(KEY_FRIEND_IDS, friendIds.distinct().joinToString(",")).apply() + } + + fun loadProfiles(): List = readProfiles() + + fun saveProfiles(profiles: List) { + val merged = (loadProfiles() + profiles) + .distinctBy { it.userId } + .sortedBy { it.userId } + prefs.edit().putString(KEY_PROFILES, serializeProfiles(merged)).apply() + } + + fun resolveFriends(friendIds: List): List { + val profiles = loadProfiles().associateBy { it.userId } + return friendIds.mapNotNull { friendId -> + profiles[friendId]?.copy(userId = friendId) + } + } + + fun searchProfiles(keyword: String): List { + val normalized = keyword.trim() + if (normalized.isBlank()) return emptyList() + return loadProfiles() + .filter { + it.userId.contains(normalized, ignoreCase = true) || + it.nickname.contains(normalized, ignoreCase = true) + } + .sortedBy { it.userId } + } + + private fun readProfiles(): List { + val raw = prefs.getString(KEY_PROFILES, null) ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + add( + UserData( + userId = obj.optString("userId"), + nickname = obj.optString("nickname"), + avatar = obj.optString("avatar").takeIf { it.isNotBlank() }, + gender = obj.optString("gender").takeIf { it.isNotBlank() }, + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun serializeProfiles(profiles: List): String { + val array = JSONArray() + profiles.forEach { user -> + array.put( + JSONObject().apply { + put("userId", user.userId) + put("nickname", user.nickname) + put("avatar", user.avatar ?: "") + put("gender", user.gender ?: "") + } + ) + } + return array.toString() + } + + private companion object { + const val PREFS_NAME = "xuqm_demo_contacts" + const val KEY_FRIEND_IDS = "friend_ids" + const val KEY_PROFILES = "profiles" + } +} diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt index dd2c6d9..324433c 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt @@ -16,6 +16,21 @@ class LocalImCache(context: Context) { prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply() } + fun upsertConversation(conversation: ConversationData) { + val existing = loadConversations().firstOrNull { + it.targetId == conversation.targetId && it.chatType == conversation.chatType + } + val mergedConversation = conversation.copy( + unreadCount = existing?.unreadCount ?: conversation.unreadCount, + isMuted = existing?.isMuted ?: conversation.isMuted, + isPinned = existing?.isPinned ?: conversation.isPinned, + ) + val updated = (loadConversations().filterNot { + it.targetId == conversation.targetId && it.chatType == conversation.chatType + } + mergedConversation).sortedByDescending { it.lastMsgTime } + saveConversations(updated) + } + fun loadHistory(targetId: String, chatType: String): List = readMessageList(historyKey(targetId, chatType)) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index 0bf1b1c..f88afe5 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.sample.di.AppDependencies import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +44,18 @@ class ChatViewModel : ViewModel() { if (isRelevant(message)) { prependMessage(message) cache.mergeHistory(targetId, chatType, _messages.value) + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = message.content, + lastMsgType = message.msgType, + lastMsgTime = message.createdAt, + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) if (_searchQuery.value.isNotBlank()) { _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) } @@ -54,6 +67,18 @@ class ChatViewModel : ViewModel() { if (isRelevant(message)) { prependMessage(message) cache.mergeHistory(targetId, chatType, _messages.value) + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = message.content, + lastMsgType = message.msgType, + lastMsgTime = message.createdAt, + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) if (_searchQuery.value.isNotBlank()) { _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) } @@ -106,6 +131,20 @@ class ChatViewModel : ViewModel() { _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE if (history.isNotEmpty()) { cache.saveHistory(targetId, chatType, mergeHistory(_messages.value)) + history.firstOrNull()?.let { last -> + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = last.content, + lastMsgType = last.msgType, + lastMsgTime = last.createdAt, + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) + } } _isLoadingMore.value = false } @@ -134,6 +173,18 @@ class ChatViewModel : ViewModel() { fun sendText(content: String) { if (content.isBlank()) return ImSDK.sendMessage(targetId, chatType, "TEXT", content) + cache.upsertConversation( + ConversationData( + targetId = targetId, + chatType = chatType, + lastMsgContent = content, + lastMsgType = "TEXT", + lastMsgTime = System.currentTimeMillis(), + unreadCount = 0, + isMuted = false, + isPinned = false, + ) + ) } fun searchCachedMessages(query: String) { diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt index beb1ff7..a8f3a32 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/contact/ContactScreen.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.sample.data.api.UserData +import com.xuqm.sdk.sample.data.local.LocalContactCache import com.xuqm.sdk.sample.data.repo.AuthRepository import com.xuqm.sdk.sample.di.AppDependencies import kotlinx.coroutines.flow.MutableStateFlow @@ -39,13 +40,17 @@ class ContactViewModel( private val authRepository: AuthRepository, ) : ViewModel() { + private val cache: LocalContactCache = AppDependencies.localContactCache private val _friends = MutableStateFlow>(emptyList()) val friends: StateFlow> = _friends private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults - init { loadFriends() } + init { + _friends.value = cache.resolveFriends(cache.loadFriendIds()) + loadFriends() + } fun loadFriends() { viewModelScope.launch { @@ -54,15 +59,26 @@ class ContactViewModel( authRepository.searchUsers(friendId).getOrDefault(emptyList()) .firstOrNull { it.userId == friendId } } - }.onSuccess { _friends.value = it } + }.onSuccess { list -> + _friends.value = list + cache.saveFriendIds(list.map { it.userId }) + cache.saveProfiles(list) + }.onFailure { + val cached = cache.resolveFriends(cache.loadFriendIds()) + if (cached.isNotEmpty()) _friends.value = cached + } } } fun search(keyword: String) { if (keyword.isBlank()) { _searchResults.value = emptyList(); return } + _searchResults.value = cache.searchProfiles(keyword) viewModelScope.launch { authRepository.searchUsers(keyword) - .onSuccess { _searchResults.value = it } + .onSuccess { list -> + _searchResults.value = list + cache.saveProfiles(list) + } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt index 1cc0707..1fb4e3f 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.listener.ImEventListener +import com.xuqm.sdk.im.model.ImConnectionState import com.xuqm.sdk.im.model.ConversationData import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.sample.di.AppDependencies @@ -26,6 +27,13 @@ class ConversationViewModel : ViewModel() { ImSDK.addListener(listener) _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } refresh() + viewModelScope.launch { + ImSDK.connectionState.collect { state -> + if (state is ImConnectionState.Connected) { + refresh() + } + } + } } fun refresh() {