perf(chat): DiffUtil 및 stableIds 적용으로 채팅 리스트 갱신 최적화
- ChatMessageAdapter에 DiffUtil 기반 submitList 도입으로 불필요한 전체 바인딩 제거 - RecyclerView 연결 시점에만 stableIds 활성화하여 테스트 환경 NPE 회피 - AI 프로필 이미지 중복 로딩 방지(tag 비교)로 네트워크/디코딩 비용 절감 - onViewRecycled에서 애니메이션/리스너/이미지 정리로 메모리 안정성 향상
This commit is contained in:
@@ -17,6 +17,7 @@ import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
|
||||
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
|
||||
@@ -81,6 +82,27 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private val items: MutableList<ChatListItem> = mutableListOf()
|
||||
|
||||
private fun getStableIdFor(item: ChatListItem): Long {
|
||||
return when (item) {
|
||||
is ChatListItem.UserMessage -> {
|
||||
val data = item.data
|
||||
if (data.messageId != 0L) data.messageId
|
||||
else (data.localId?.hashCode()?.toLong() ?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong())
|
||||
}
|
||||
is ChatListItem.AiMessage -> {
|
||||
val data = item.data
|
||||
if (data.messageId != 0L) data.messageId
|
||||
else (data.localId?.hashCode()?.toLong() ?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong())
|
||||
}
|
||||
is ChatListItem.Notice -> ("notice:" + item.text).hashCode().toLong()
|
||||
is ChatListItem.TypingIndicator -> Long.MIN_VALUE // 고정 ID
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return getStableIdFor(items[position])
|
||||
}
|
||||
|
||||
// Notice 접기 상태 (간단 전역 토글)
|
||||
private var isNoticeCollapsed: Boolean = false
|
||||
|
||||
@@ -117,9 +139,33 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setItems(newItems: List<ChatListItem>) {
|
||||
// 하위 호환용: DiffUtil을 사용해 변경분만 반영
|
||||
submitList(newItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffUtil을 사용해 최소 변경만 반영하는 submitList
|
||||
*/
|
||||
fun submitList(newItems: List<ChatListItem>) {
|
||||
val old = items.toList()
|
||||
val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = old.size
|
||||
override fun getNewListSize(): Int = newItems.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val o = old[oldItemPosition]
|
||||
val n = newItems[newItemPosition]
|
||||
// 안정 ID 기준 비교와 동일한 로직 적용
|
||||
return getStableIdFor(o) == getStableIdFor(n)
|
||||
}
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val o = old[oldItemPosition]
|
||||
val n = newItems[newItemPosition]
|
||||
return o == n
|
||||
}
|
||||
})
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
diff.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +368,12 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
// 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용)
|
||||
if (binding.ivProfile.isVisible) {
|
||||
loadProfileImage(binding.ivProfile, data.profileImageUrl)
|
||||
val oldUrl = binding.ivProfile.tag as? String
|
||||
val newUrl = data.profileImageUrl
|
||||
if (oldUrl != newUrl) {
|
||||
loadProfileImage(binding.ivProfile, newUrl)
|
||||
binding.ivProfile.tag = newUrl
|
||||
}
|
||||
}
|
||||
|
||||
// 그룹 내부 간격 최소화 (상단 패딩 축소)
|
||||
@@ -420,6 +471,12 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
// endregion
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
super.onAttachedToRecyclerView(recyclerView)
|
||||
// RecyclerView와 연결된 시점에 안정 ID 활성화 (JVM 테스트에서 NPE 회피)
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewAttachedToWindow(holder)
|
||||
if (holder is TypingIndicatorViewHolder) {
|
||||
@@ -433,6 +490,26 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
super.onViewDetachedFromWindow(holder)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
when (holder) {
|
||||
is TypingIndicatorViewHolder -> holder.stopTypingAnimation()
|
||||
is UserMessageViewHolder -> {
|
||||
// 클릭 리스너 제거로 누수 및 중복 클릭 방지
|
||||
holder.itemView.findViewById<View?>(R.id.iv_retry)?.setOnClickListener(null)
|
||||
}
|
||||
is AiMessageViewHolder -> {
|
||||
// 이미지 태그/리소스 정리로 잘못된 재활용 방지
|
||||
holder.itemView.findViewById<View?>(R.id.iv_profile)?.let { v ->
|
||||
(v as? android.widget.ImageView)?.apply {
|
||||
setImageDrawable(null)
|
||||
tag = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super.onViewRecycled(holder)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
Reference in New Issue
Block a user