perf(chat): DiffUtil 및 stableIds 적용으로 채팅 리스트 갱신 최적화

- ChatMessageAdapter에 DiffUtil 기반 submitList 도입으로 불필요한 전체 바인딩 제거
- RecyclerView 연결 시점에만 stableIds 활성화하여 테스트 환경 NPE 회피
- AI 프로필 이미지 중복 로딩 방지(tag 비교)로 네트워크/디코딩 비용 절감
- onViewRecycled에서 애니메이션/리스너/이미지 정리로 메모리 안정성 향상
This commit is contained in:
2025-08-14 18:13:40 +09:00
parent d662bd0b65
commit c9b6623eac

View File

@@ -17,6 +17,7 @@ import androidx.annotation.LayoutRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.DiffUtil
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
@@ -81,6 +82,27 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items: MutableList<ChatListItem> = mutableListOf() 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 접기 상태 (간단 전역 토글) // Notice 접기 상태 (간단 전역 토글)
private var isNoticeCollapsed: Boolean = false private var isNoticeCollapsed: Boolean = false
@@ -117,9 +139,33 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setItems(newItems: List<ChatListItem>) { 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.clear()
items.addAll(newItems) items.addAll(newItems)
notifyDataSetChanged() diff.dispatchUpdatesTo(this)
} }
/** /**
@@ -322,7 +368,12 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용) // 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용)
if (binding.ivProfile.isVisible) { 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 // endregion
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
// RecyclerView와 연결된 시점에 안정 ID 활성화 (JVM 테스트에서 NPE 회피)
setHasStableIds(true)
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
super.onViewAttachedToWindow(holder) super.onViewAttachedToWindow(holder)
if (holder is TypingIndicatorViewHolder) { if (holder is TypingIndicatorViewHolder) {
@@ -433,6 +490,26 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
super.onViewDetachedFromWindow(holder) 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 // endregion