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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user