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