본문 바로가기

프로그래밍/안드로이드

[Android/Firebase] 실시간 데이터 수신 (+초 단순한 대화앱)

Firestore에는 데이터에 변화가 생길 경우(추가, 수정, 삭제), 해당 데이터를 실시간으로 수신하는 기능이 있다.

addSnapshotListener를 이용해서 아주 간단한 채팅앱을 만들어 보았다. (작동만 하는 수준이다.)

 

Cloud Firestore로 실시간 업데이트 가져오기  |  Firebase (google.com)

 

Cloud Firestore로 실시간 업데이트 가져오기  |  Firebase

onSnapshot() 메서드로 문서를 수신 대기할 수 있습니다. 사용자가 제공하는 콜백이 최초로 호출될 때는 단일 문서의 현재 내용으로 문서 스냅샷이 즉시 생성됩니다. 그런 다음 내용이 변경될 때마

firebase.google.com


앱은 로그인 프래그먼트와 채팅 프래그먼트로 구성했다.

로그인 프래그먼트에서는 채팅방에서 사용할 닉네임을 정한다.

채팅 프래그먼트에서는 입력한 대화 내용이 Firestore에 문서로 저장되고, 추가된 문서들을 수신해 리사이클러 뷰에 추가한다.

데이터 구조

Firestore 데이터 구조는 다음과 같이 했다.

 

Firestore

nickname: 글쓴이

contents: 대화 내용

time: 작성 시간

로그인 프래그먼트

fragment_login.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:focusable="true"
    android:focusableInTouchMode="true">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:text="사용할 닉네임을 입력해주세요"
        app:layout_constraintBottom_toTopOf="@+id/et_nickname"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <EditText
        android:id="@+id/et_nickname"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:hint="닉네임 입력 (10자)"
        android:inputType="textPersonName"
        android:maxLength="10"
        android:singleLine="true"
        android:textAlignment="center"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn_enter"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="32dp"
        android:text="입장"
        android:textStyle="bold"
        android:enabled="false"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/et_nickname" />

</androidx.constraintlayout.widget.ConstraintLayout>

fragment_login.xml 레이아웃 모습

 

LoginFragment.kt
 
class LoginFragment: Fragment() {
    private var _binding: FragmentLoginBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        _binding = FragmentLoginBinding.inflate(inflater, container, false)
        val view = binding.root

        // 닉네임이 공백일 경우 버튼 비활성화
        binding.etNickname.addTextChangedListener { text ->
            binding.btnEnter.isEnabled = text.toString().replace(" ", "") != ""
        }

        binding.btnEnter.setOnClickListener {
            // 입력한 닉네임을 Bundle에 담아 ChatFragment로 보냄
            val nickname = binding.etNickname.text.toString()
            val bundle = Bundle()
            bundle.putString("nickname", nickname)
            // ChatFragment로 이동
            (activity as MainActivity).replaceFragment(bundle)
        }

        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

로그인 프래그먼트에서는 10자까지 입력할 수 있는 EditText를 배치하고, 공백이 아닐 경우에만 입장 버튼을 누를 수 있게 했다.

입장 버튼을 누르면 EditText에 담긴 닉네임을 Bundle에 담아 채팅 프래그먼트로 보낸다. 이 때 프래그먼트 교체시에는 메인 액티비티에서 작성한 replaceFragment 함수를 이용한다.

채팅 프래그먼트

fragment_chat.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="#93A1CA"
        app:layout_constraintBottom_toTopOf="@+id/constraintLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintVertical_weight="1">

        <EditText
            android:id="@+id/et_chatting"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:ems="10"
            android:hint="내용 입력 (128자 제한)"
            android:inputType="textMultiLine"
            android:maxLength="128"
            android:scrollHorizontally="false"
            android:textSize="14sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/btn_send"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <Button
            android:id="@+id/btn_send"
            style="@android:style/Widget.Material.Light.Button.Small"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:text="입력"
            android:enabled="false"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

fragment_chat.xml 레이아웃 모습

 

ChatFragment.kt
 
class ChatFragment: Fragment() {
    private var _binding: FragmentChatBinding? = null
    private val binding get() = _binding!!
    private lateinit var currentUser: String            // 현재 닉네임
    private val db = FirebaseFirestore.getInstance()    // Firestore 인스턴스
    private lateinit var registration: ListenerRegistration    // 문서 수신
    private val chatList = arrayListOf<ChatLayout>()    // 리사이클러 뷰 목록
    private lateinit var adapter: ChatAdapter   // 리사이클러 뷰 어댑터

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // LoginFragment 에서 입력한 닉네임을 가져옴
        arguments?.let {
            currentUser = it.getString("nickname").toString()
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        _binding = FragmentChatBinding.inflate(inflater, container, false)
        val view = binding.root
        Toast.makeText(context, "현재 닉네임은 ${currentUser}입니다.", Toast.LENGTH_SHORT).show()

        // 리사이클러 뷰 설정
        binding.rvList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
        adapter = ChatAdapter(currentUser, chatList)
        binding.rvList.adapter = adapter

        // 채팅창이 공백일 경우 버튼 비활성화
        binding.etChatting.addTextChangedListener { text ->
            binding.btnSend.isEnabled = text.toString() != ""
        }

        // 입력 버튼
        binding.btnSend.setOnClickListener {
            // 입력 데이터
            val data = hashMapOf(
                "nickname" to currentUser,
                "contents" to binding.etChatting.text.toString(),
                "time" to Timestamp.now()
            )
            // Firestore에 기록
            db.collection("Chat").add(data)
                    .addOnSuccessListener {
                        binding.etChatting.text.clear()
                        Log.w("ChatFragment", "Document added: $it")
                    }
                    .addOnFailureListener { e ->
                        Toast.makeText(context, "전송하는데 실패했습니다", Toast.LENGTH_SHORT).show()
                        Log.w("ChatFragment", "Error occurs: $e")
                    }
        }

        return view
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        chatList.add(ChatLayout("알림", "$currentUser 닉네임으로 입장했습니다.", ""))
        val enterTime = Date(System.currentTimeMillis())

        registration = db.collection("Chat")
                .orderBy("time", Query.Direction.DESCENDING)
                .limit(1)
                .addSnapshotListener { snapshots, e ->
                    // 오류 발생 시
                    if (e != null) {
                        Log.w("ChatFragment", "Listen failed: $e")
                        return@addSnapshotListener
                    }

                    // 원하지 않는 문서 무시
                    if (snapshots!!.metadata.isFromCache) return@addSnapshotListener

                    // 문서 수신
                    for (doc in snapshots.documentChanges) {
                        val timestamp = doc.document["time"] as Timestamp

                        // 문서가 추가될 경우 리사이클러 뷰에 추가
                        if (doc.type == DocumentChange.Type.ADDED && timestamp.toDate() > enterTime) {
                            val nickname = doc.document["nickname"].toString()
                            val contents = doc.document["contents"].toString()

                            // 타임스탬프를 한국 시간, 문자열로 바꿈
                            val sf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.KOREA)
                            sf.timeZone = TimeZone.getTimeZone("Asia/Seoul")
                            val time = sf.format(timestamp.toDate())

                            val item = ChatLayout(nickname, contents, time)
                            chatList.add(item)
                        }
                        adapter.notifyDataSetChanged()
                    }
                }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        registration.remove()
        _binding = null
    }

}

원하지 않는 문서 무시는 채팅방 입장 시 데이터베이스에 추가 되어있던 문서 & 앱 재실행 시 이전 캐시에 남아있던 문서들이 리사이클러 뷰에 추가되는 것을 방지하기 위한 것이다.

 

스냅샷 리스너를 통해 수신된 문서들은 documentChanges에 추가된다. 이 때 특정 변경사항만 이용하려면 조건문을 이용해 문서의 type을 비교한다.

 

DocumentChange.Type.ADDED: 추가된 문서

DocumentChange.Type.MODIFIED: 수정된 문서

DocumentChange.Type.REMOVED: 삭제된 문서

 

※ 참고: 스냅샷 리스너를 추가하면 처음에 Firestore에 들어있던 문서들이 ADDED 타입으로 documentChanges에 추가된다.

리사이클러 뷰 레이아웃, 어댑터

chat_layout.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/chat_card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    app:cardCornerRadius="4dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/chat_tv_nickname"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:text="닉네임"
            android:textColor="?android:attr/textColorPrimary"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/chat_tv_contents"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:text="채팅 내용"
            android:textColor="?android:attr/textColorPrimary"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@+id/chat_tv_nickname"
            app:layout_constraintTop_toBottomOf="@+id/chat_tv_nickname" />

        <TextView
            android:id="@+id/chat_tv_time"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="4dp"
            android:text="작성시간"
            android:textSize="12sp"
            app:layout_constraintBottom_toTopOf="@+id/chat_tv_contents"
            app:layout_constraintEnd_toEndOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

chat_layout.xml 레이아웃 모습

 

ChatLayout.kt
 
// 리사이클러 뷰 아이템 클래스
class ChatLayout(val nickname: String, val contents: String, val time: String)

 

ChatAdapter.kt
 
// 리사이클러 뷰 어댑터 클래스
class ChatAdapter(val currentUser: String, val itemList: ArrayList<ChatLayout>): RecyclerView.Adapter<ChatAdapter.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatAdapter.ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.chat_layout, parent, false)
        return ViewHolder(view)
    }

    override fun getItemCount(): Int {
        return itemList.size
    }

    override fun onBindViewHolder(holder: ChatAdapter.ViewHolder, position: Int) {
        // 현재 닉네임과 글쓴이의 닉네임이 같을 경우 배경을 노란색으로 변경
        if (currentUser == itemList[position].nickname) {
            holder.card.setCardBackgroundColor(Color.parseColor("#FFF176"))
        }
        holder.nickname.text = itemList[position].nickname
        holder.contents.text = itemList[position].contents
        holder.time.text = itemList[position].time
    }

    class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
        val card: CardView = itemView.findViewById(R.id.chat_card_view)
        val nickname: TextView = itemView.findViewById(R.id.chat_tv_nickname)
        val contents: TextView = itemView.findViewById(R.id.chat_tv_contents)
        val time: TextView = itemView.findViewById(R.id.chat_tv_time)
    }
}

어댑터에서는 현재 닉네임을 매개변수로 받은 후, 아이템을 등록할 때 아이템의 닉네임과 비교해서 같을 경우 배경색이 노란색이 되도록 작성했다.

메인 액티비티

activity_main.xml
 
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    
    <!-- 프래그먼트를 띄울 레이아웃 -->
    <FrameLayout
        android:id="@+id/layout_frame"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>

메인 액티비티에는 프래그먼트를 띄우는데 사용할 FrameLayout 하나만 추가했다.

 

MainActivity.kt
 
class MainActivity : AppCompatActivity() {
    private lateinit var binding : ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
        
        // 앱 구동시 LoginFragment 표시
        supportFragmentManager.beginTransaction()
                .replace(R.id.layout_frame, LoginFragment())
                .commit()
    }

    // ChatFragment로 프래그먼트 교체 (LoginFragment에서 호출할 예정)
    fun replaceFragment(bundle: Bundle) {
        val destination = ChatFragment()
        destination.arguments = bundle      // 닉네임을 받아옴
        supportFragmentManager.beginTransaction()
                .replace(R.id.layout_frame, destination)
                .commit()
    }
}

replaceFragment 함수는 닉네임이 남긴 Bundle을 매개변수로 받아 ChatFragment의 인자로 보내고, 프래그먼트를 교체하는 기능을 한다. LoginFragment가 직접 프래그먼트를 교체할 수 없으므로 메인 액티비티에 있는 이 함수를 참조해서 호출한다.

실행 결과

실행 결과