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
|
## 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() {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户