feat(cache): 添加本地缓存功能支持聊天和联系人数据
- 实现 LocalImCache 用于缓存聊天会话和消息历史记录 - 实现 LocalContactCache 用于缓存联系人好友数据 - 在 ChatViewModel 中集成消息历史分页加载和本地搜索功能 - 在 ContactViewModel 中集成联系人列表本地缓存读取 - 添加 ConversationViewModel 管理会话列表的本地缓存 - 集成缓存与实时消息同步机制,确保数据一致性 - 添加完整的 README.md 文档说明 SDK 架构和使用方法
这个提交包含在:
父节点
efe2a32a00
当前提交
36f044f7b7
@ -133,6 +133,15 @@ val service = RetrofitFactory.create(MyApiService::class.java)
|
||||
|
||||
## sdk-im
|
||||
|
||||
### Android sample 已具备
|
||||
|
||||
- 会话列表先读本地缓存,再刷新网络
|
||||
- 联系人列表先读本地缓存,再刷新网络
|
||||
- 聊天历史分页加载
|
||||
- 当前会话本地搜索
|
||||
- IM 连接状态提示
|
||||
- SDK 登录态恢复后自动重连
|
||||
|
||||
### ImClient
|
||||
|
||||
```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()
|
||||
}
|
||||
|
||||
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> =
|
||||
readMessageList(historyKey(targetId, chatType))
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<List<UserData>>(emptyList())
|
||||
val friends: StateFlow<List<UserData>> = _friends
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<UserData>>(emptyList())
|
||||
val searchResults: StateFlow<List<UserData>> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户