From c9b6623eac6f5f01a1cf40648319062da6bbc3e4 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 14 Aug 2025 18:13:40 +0900 Subject: [PATCH] =?UTF-8?q?perf(chat):=20DiffUtil=20=EB=B0=8F=20stableIds?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B0=B1=EC=8B=A0=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatMessageAdapter에 DiffUtil 기반 submitList 도입으로 불필요한 전체 바인딩 제거 - RecyclerView 연결 시점에만 stableIds 활성화하여 테스트 환경 NPE 회피 - AI 프로필 이미지 중복 로딩 방지(tag 비교)로 네트워크/디코딩 비용 절감 - onViewRecycled에서 애니메이션/리스너/이미지 정리로 메모리 안정성 향상 --- .../chat/talk/room/ChatMessageAdapter.kt | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt index 7ee7b625..b6d934aa 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -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() { private val items: MutableList = 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() { @SuppressLint("NotifyDataSetChanged") fun setItems(newItems: List) { + // 하위 호환용: DiffUtil을 사용해 변경분만 반영 + submitList(newItems) + } + + /** + * DiffUtil을 사용해 최소 변경만 반영하는 submitList + */ + fun submitList(newItems: List) { + 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() { // 프로필 이미지 로딩 (공용 유틸 + 둥근 모서리 적용) 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() { } // 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() { } super.onViewDetachedFromWindow(holder) } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + when (holder) { + is TypingIndicatorViewHolder -> holder.stopTypingAnimation() + is UserMessageViewHolder -> { + // 클릭 리스너 제거로 누수 및 중복 클릭 방지 + holder.itemView.findViewById(R.id.iv_retry)?.setOnClickListener(null) + } + is AiMessageViewHolder -> { + // 이미지 태그/리소스 정리로 잘못된 재활용 방지 + holder.itemView.findViewById(R.id.iv_profile)?.let { v -> + (v as? android.widget.ImageView)?.apply { + setImageDrawable(null) + tag = null + } + } + } + } + super.onViewRecycled(holder) + } } // endregion