feat(cache): 添加本地缓存功能支持聊天和联系人数据

- 实现 LocalImCache 用于缓存聊天会话和消息历史记录
- 实现 LocalContactCache 用于缓存联系人好友数据
- 在 ChatViewModel 中集成消息历史分页加载和本地搜索功能
- 在 ContactViewModel 中集成联系人列表本地缓存读取
- 添加 ConversationViewModel 管理会话列表的本地缓存
- 集成缓存与实时消息同步机制,确保数据一致性
- 添加完整的 README.md 文档说明 SDK 架构和使用方法
这个提交包含在:
XuqmGroup 2026-04-27 19:47:48 +08:00
父节点 efe2a32a00
当前提交 36f044f7b7
共有 6 个文件被更改,包括 189 次插入3 次删除

查看文件

@ -133,6 +133,15 @@ val service = RetrofitFactory.create(MyApiService::class.java)
## sdk-im ## sdk-im
### Android sample 已具备
- 会话列表先读本地缓存,再刷新网络
- 联系人列表先读本地缓存,再刷新网络
- 聊天历史分页加载
- 当前会话本地搜索
- IM 连接状态提示
- SDK 登录态恢复后自动重连
### ImClient ### ImClient
```kotlin ```kotlin

查看文件

@ -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<String> =
prefs.getString(KEY_FRIEND_IDS, null)?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }
?: emptyList()
fun saveFriendIds(friendIds: List<String>) {
prefs.edit().putString(KEY_FRIEND_IDS, friendIds.distinct().joinToString(",")).apply()
}
fun loadProfiles(): List<UserData> = readProfiles()
fun saveProfiles(profiles: List<UserData>) {
val merged = (loadProfiles() + profiles)
.distinctBy { it.userId }
.sortedBy { it.userId }
prefs.edit().putString(KEY_PROFILES, serializeProfiles(merged)).apply()
}
fun resolveFriends(friendIds: List<String>): List<UserData> {
val profiles = loadProfiles().associateBy { it.userId }
return friendIds.mapNotNull { friendId ->
profiles[friendId]?.copy(userId = friendId)
}
}
fun searchProfiles(keyword: String): List<UserData> {
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<UserData> {
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<UserData>): 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"
}
}

查看文件

@ -16,6 +16,21 @@ class LocalImCache(context: Context) {
prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply() 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<ImMessage> = fun loadHistory(targetId: String, chatType: String): List<ImMessage> =
readMessageList(historyKey(targetId, chatType)) readMessageList(historyKey(targetId, chatType))

查看文件

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.listener.ImEventListener 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.im.model.ImMessage
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -43,6 +44,18 @@ class ChatViewModel : ViewModel() {
if (isRelevant(message)) { if (isRelevant(message)) {
prependMessage(message) prependMessage(message)
cache.mergeHistory(targetId, chatType, _messages.value) 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()) { if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
} }
@ -54,6 +67,18 @@ class ChatViewModel : ViewModel() {
if (isRelevant(message)) { if (isRelevant(message)) {
prependMessage(message) prependMessage(message)
cache.mergeHistory(targetId, chatType, _messages.value) 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()) { if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
} }
@ -106,6 +131,20 @@ class ChatViewModel : ViewModel() {
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE _hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
if (history.isNotEmpty()) { if (history.isNotEmpty()) {
cache.saveHistory(targetId, chatType, mergeHistory(_messages.value)) 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 _isLoadingMore.value = false
} }
@ -134,6 +173,18 @@ class ChatViewModel : ViewModel() {
fun sendText(content: String) { fun sendText(content: String) {
if (content.isBlank()) return if (content.isBlank()) return
ImSDK.sendMessage(targetId, chatType, "TEXT", content) 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) { fun searchCachedMessages(query: String) {

查看文件

@ -26,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.sample.data.api.UserData 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.data.repo.AuthRepository
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -39,13 +40,17 @@ class ContactViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) : ViewModel() { ) : ViewModel() {
private val cache: LocalContactCache = AppDependencies.localContactCache
private val _friends = MutableStateFlow<List<UserData>>(emptyList()) private val _friends = MutableStateFlow<List<UserData>>(emptyList())
val friends: StateFlow<List<UserData>> = _friends val friends: StateFlow<List<UserData>> = _friends
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList()) private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
val searchResults: StateFlow<List<UserData>> = _searchResults val searchResults: StateFlow<List<UserData>> = _searchResults
init { loadFriends() } init {
_friends.value = cache.resolveFriends(cache.loadFriendIds())
loadFriends()
}
fun loadFriends() { fun loadFriends() {
viewModelScope.launch { viewModelScope.launch {
@ -54,15 +59,26 @@ class ContactViewModel(
authRepository.searchUsers(friendId).getOrDefault(emptyList()) authRepository.searchUsers(friendId).getOrDefault(emptyList())
.firstOrNull { it.userId == friendId } .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) { fun search(keyword: String) {
if (keyword.isBlank()) { _searchResults.value = emptyList(); return } if (keyword.isBlank()) { _searchResults.value = emptyList(); return }
_searchResults.value = cache.searchProfiles(keyword)
viewModelScope.launch { viewModelScope.launch {
authRepository.searchUsers(keyword) authRepository.searchUsers(keyword)
.onSuccess { _searchResults.value = it } .onSuccess { list ->
_searchResults.value = list
cache.saveProfiles(list)
}
} }
} }

查看文件

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xuqm.sdk.im.ImSDK import com.xuqm.sdk.im.ImSDK
import com.xuqm.sdk.im.listener.ImEventListener 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.ConversationData
import com.xuqm.sdk.im.model.ImMessage import com.xuqm.sdk.im.model.ImMessage
import com.xuqm.sdk.sample.di.AppDependencies import com.xuqm.sdk.sample.di.AppDependencies
@ -26,6 +27,13 @@ class ConversationViewModel : ViewModel() {
ImSDK.addListener(listener) ImSDK.addListener(listener)
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
refresh() refresh()
viewModelScope.launch {
ImSDK.connectionState.collect { state ->
if (state is ImConnectionState.Connected) {
refresh()
}
}
}
} }
fun refresh() { fun refresh() {