ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android/ kotlin] STOMP 를 이용한 실시간 양방향 채팅 기능 구현
    Android/kotlin 2023. 2. 16. 02:25

    Spring boot에서 

    STOMP, Redis, Websocket을 이용해 채팅서버를 만들게 된다.

     

    그렇다면 안드로이드에서는 stomp를 활용한 채팅기능을 구현하려면 어떻게 코드를 구성해야 할까?

     

     

    1. 퍼미션 추가

    //chat
    implementation fileTree(dir: "libs", include: ["*.jar"])
    // stomp 라이브러리 추가
    api "com.github.NaikSoftware:StompProtocolAndroid:1.6.4"
    //implementation 'com.github.NaikSoftware:StompProtocolAndroid:1.6.5'
    
    implementation 'com.github.bishoybasily:stomp:2.0.5'
    implementation 'org.java-websocket:Java-WebSocket:1.3.0'
    implementation 'com.squareup.okhttp3:okhttp:3.12.1'
    implementation 'io.reactivex.rxjava2:rxjava:2.2.5'
    implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0'
    implementation 'com.squareup.retrofit2:retrofit:2.5.0'
    
    implementation 'com.beust:klaxon:5.0.1'

     

    2. Constant 파일을 만들기 

    object Constant{
    
        val MESSAGE_TYPE_ENTER: String = "ENTER"
        val MESSAGE_TYPE_TALK: String = "TALK"
        var SENDER: String = "DEFAULT"
        val URL: String = "ws://172.20.14.160:8080/chat/chatting/websocket"
        //ws://[도메인]/[엔드포인트]/websocket
        var CHATROOM_ID: String = "0"
    
        fun set(sender: String, chatRoomId: String){
            SENDER = sender
            CHATROOM_ID = chatRoomId
        }
    }

    통신 url은 다음과 같이 작성

    ws://[도메인]/[엔드포인트]/websocket

     

    2.ChatAdapter 만들기 : 채팅 메시지를 나타낸다

    class ChatAdapter(val context: Context)
        : RecyclerView.Adapter<ChatAdapter.Holder>()
    {
        val MY_VIEW = 1
        val RECEIVE_VIEW = 2
        val ENTER_VIEW = 3
    
        val constant = Constant
        var receiverImage=""
        var chatDatas = ArrayList<Chat>()
    
        fun addItem(item: Chat,image:String){
            chatDatas.add(item)
            receiverImage=image
            notifyDataSetChanged()
        }
    
        inner class Holder(itemView: View): RecyclerView.ViewHolder(itemView){
    
            val mid = itemView.findViewById<TextView>(R.id.mid)
            val chatItem = itemView.findViewById<TextView>(R.id.messageTextView)
            val senderImage = itemView.findViewById<ImageView>(R.id.sender_img)
            fun bind(chatData: Chat, context: Context){
                val viewType = itemViewType
    
                when(viewType){
                    ENTER_VIEW -> chatItem.text = chatData.message
                    MY_VIEW -> {
                        //mid.text = chatData.sender
                        chatItem.text = chatData.message}
                    else -> {
                        //senderImage
                        val defaultImage = R.drawable.profile_none
                        Glide.with(itemView)
                            .load(receiverImage) // 불러올 이미지 url
                            .placeholder(defaultImage) // 이미지 로딩 시작하기 전 표시할 이미지
                            .error(defaultImage) // 로딩 에러 발생 시 표시할 이미지
                            .fallback(defaultImage) // 로드할 url 이 비어있을(null 등) 경우 표시할 이미지
                            .circleCrop() // 동그랗게 자르기
                            .into(senderImage)
    
                        mid.text = chatData.sender
    
                            chatItem.text = chatData.message
    
                    }
                }
            }
        }
    
        override fun getItemViewType(position: Int) : Int{
            val currentItem: Chat = chatDatas[position]
            val msgType = currentItem.type
            val sender = currentItem.sender
    
            when(msgType){
                constant.MESSAGE_TYPE_ENTER -> {
                    return ENTER_VIEW
                }
                constant.MESSAGE_TYPE_TALK -> {
                    when(sender){
                        constant.SENDER -> return MY_VIEW
                        else -> return RECEIVE_VIEW
                    }
                }
            }
    
            return super.getItemViewType(position)
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
    
            val view = when(viewType){
                MY_VIEW -> LayoutInflater.from(context).inflate(R.layout.item_my_msg, parent, false)
                RECEIVE_VIEW -> LayoutInflater.from(context).inflate(R.layout.item_receive_msg, parent, false)
                else -> LayoutInflater.from(context).inflate(R.layout.item_enter_msg, parent, false)
            }
    
            return Holder(view)
        }
    
        override fun getItemCount(): Int {
            return chatDatas.size
        }
    
        override fun onBindViewHolder(holder: Holder, position: Int) {
            holder.bind(chatDatas[position], context)
        }
    
        fun RecyclerView.smoothSnapToPosition(position: Int, snapMode: Int = LinearSmoothScroller.SNAP_TO_START) {
            val smoothScroller = object: LinearSmoothScroller(this.context) {
                override fun getVerticalSnapPreference(): Int {
                    return snapMode
                }
    
                override fun getHorizontalSnapPreference(): Int {
                    return snapMode
                }
            }
            smoothScroller.targetPosition = position
            layoutManager?.startSmoothScroll(smoothScroller)
        }
    }

    수신자와 발신자 타입에 따라서 뷰를 다르게 보여준다.

     

    3. ChatFramgent : 채팅 코드 구현

    class ChatFragment: Fragment() {
    
        lateinit var cAdapter: ChatAdapter
    
        var jsonObject = JSONObject()
        val constant: Constant = Constant
        lateinit var stompConnection: Disposable
        lateinit var topic: Disposable
        var receiverImage=""
        var receiver=""
        var selectEmoticonUrl=""
        var url1=""
        var url2=""
        var url3=""
        var url4=""
        @RequiresApi(Build.VERSION_CODES.O)
        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            var root = inflater.inflate(R.layout.fragment_chat, container, false)
    
            val bundle = arguments
    
            if(bundle != null) {
                constant.set(App.prefs.userId.toString(), bundle.getString("roomId")!!)
                root.chat_sender.text=bundle.getString("receiver")
                receiver= bundle.getString("receiver")!!
                receiverImage = bundle.getString("receiverImage")!!
                //Log.e("chatIntent",bundle.getString("roomId")!!)
            }
    
    
            cAdapter = ChatAdapter(requireContext())
            root.recycler_chat.adapter = cAdapter
            root.recycler_chat.layoutManager = LinearLayoutManager(requireContext())
            root.recycler_chat.setHasFixedSize(true)
    
          
            //원래 채팅기록 가져오기
            init_chat()
    
            //채팅
            //1. STOMP init
            // url: ws://[도메인]/[엔드포인트]/websocket
            val url = constant.URL
            val intervalMillis = 5000L
            val client = OkHttpClient.Builder()
                .readTimeout(10, TimeUnit.SECONDS)
                .writeTimeout(10, TimeUnit.SECONDS)
                .connectTimeout(10, TimeUnit.SECONDS)
                .build()
    
            val stomp = StompClient(client, intervalMillis).apply { this@apply.url = url }
    
            // 2. connect
            stompConnection = stomp.connect().subscribe {
                when (it.type) {
                    Event.Type.OPENED -> {
                        // subscribe 채널구독
                        // 메세지 받아오기
                        Log.e("web","open")
                        topic = stomp.join("/chat/sub/room/" + constant.CHATROOM_ID).subscribe{
                                stompMessage ->
                            val result = Klaxon()
                                .parse<Chat>(stompMessage)
                                requireActivity().runOnUiThread {
                                if (result != null) {
                                    //imgae
                                    cAdapter.addItem(result,receiverImage)
                                    root.recycler_chat.smoothScrollToPosition(cAdapter.itemCount)
                                }
                            }
                        }
    
                        // 처음 입장
                        try {
                            jsonObject.put("type", "ENTER")
                            jsonObject.put("roomId", constant.CHATROOM_ID)
                            jsonObject.put("sender", constant.SENDER)
                       
                        } catch (e: JSONException) {
                            e.printStackTrace()
                        }
                        stomp.send("/chat/pub/message", jsonObject.toString()).subscribe()
    
                        root.send.setOnClickListener {
                            root.chat_emoticon_group.visibility=View.GONE
                            try {
                                jsonObject.put("type", "TALK")
                                jsonObject.put("roomId", constant.CHATROOM_ID)
                                jsonObject.put("sender", constant.SENDER)
                                jsonObject.put("message", root.message.text.toString())
                             
                            } catch (e: JSONException) {
                                e.printStackTrace()
                            }
                            // send
                            stomp.send("/chat/pub/message", jsonObject.toString()).subscribe()
                            root.message.text = null
                        }
                        // unsubscribe
                        //topic.dispose()
                    }
                    Event.Type.CLOSED -> {
    
                    }
                    Event.Type.ERROR -> {
                        Log.e("web","err")
                    }
                }
            }
            return root
        }
    
        fun init_chat(){
    
            val okHttpClient = OkHttpClient.Builder().addInterceptor(AuthInterceptor()).build()
            var retrofit = Retrofit.Builder()
                    .client(okHttpClient)
                    .baseUrl(ChatService.API_URL)
                    .addConverterFactory(GsonConverterFactory.create()).build()
            var apiService = retrofit.create(ChatService::class.java)
            var tests = apiService.get_chatMessage(constant.CHATROOM_ID)
            tests.enqueue(object : Callback<chatMessageGetBody> {
                override fun onResponse(call: Call<chatMessageGetBody>, response: Response<chatMessageGetBody>) {
                    if (response.isSuccessful) {
                        var mList = response.body()!!
    
                        for( i: Int in 0..mList.list.size-1) {
                            if (mList.list[i].type != "ENTER") {
                                cAdapter.chatDatas.add(mList.list[i])
                            } } } }
    
                override fun onFailure(call: Call<chatMessageGetBody>, t: Throwable) {
                    Log.e("teamDialog", "OnFailuer+${t.message}") } })
        }
    
      
    
    }

    init_chat() 함수는 기존에 데이터베이스에 저장한 채팅메시지들을 불러오는 함수입니다. 기존 내용을 불러오지 않아도 되면 굳이 작성하지 않아도 됩니다.

     

    1) stomp를 초기화하는 과정

    //1. STOMP init
    // url: ws://[도메인]/[엔드포인트]/websocket
    val url = constant.URL
    val intervalMillis = 5000L
    val client = OkHttpClient.Builder()
        .readTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .connectTimeout(10, TimeUnit.SECONDS)
        .build()
    
    val stomp = StompClient(client, intervalMillis).apply { this@apply.url = url }

    2. 연결 과정

    stompConnection = stomp.connect().subscribe {
        when (it.type) {
            Event.Type.OPENED -> {
                // subscribe 채널구독
                // 메세지 받아오기
                Log.e("web","open")
                topic = stomp.join("/chat/sub/room/" + constant.CHATROOM_ID).subscribe{
                        stompMessage ->
                    val result = Klaxon()
                        .parse<Chat>(stompMessage)
                        requireActivity().runOnUiThread {
                        if (result != null) {
                            //imgae
                            cAdapter.addItem(result,receiverImage)
                            root.recycler_chat.smoothScrollToPosition(cAdapter.itemCount)
                        }
                    }
                }
    
                // 처음 입장
                try {
                    jsonObject.put("type", "ENTER")
                    jsonObject.put("roomId", constant.CHATROOM_ID)
                    jsonObject.put("sender", constant.SENDER)
               
                } catch (e: JSONException) {
                    e.printStackTrace()
                }
                stomp.send("/chat/pub/message", jsonObject.toString()).subscribe()
    
                root.send.setOnClickListener {
                    root.chat_emoticon_group.visibility=View.GONE
                    try {
                        jsonObject.put("type", "TALK")
                        jsonObject.put("roomId", constant.CHATROOM_ID)
                        jsonObject.put("sender", constant.SENDER)
                        jsonObject.put("message", root.message.text.toString())
                   
                    } catch (e: JSONException) {
                        e.printStackTrace()
                    }
                    // send
                    stomp.send("/chat/pub/message", jsonObject.toString()).subscribe()
                    root.message.text = null
                }
                // unsubscribe
                //topic.dispose()
            }
            Event.Type.CLOSED -> {
    
            }
            Event.Type.ERROR -> {
                Log.e("web","err")
            }
        }
    }

    Type을 open, closed, error를 구별합니다.

    방을 가입하고 입장시 "ENTER"타입으로 메시지를 보내게 됩니다.

    메시지를 누르면 서버가 지정한 /chat/pub/message 주소를 통해 실시간으로 통신하게 됩니다.

     

    *roomid와 sender정보는 chatfragment 전에 chatListFramgmet를 만들어 채팅방 리스트를 가져옵니다.

    리스트 중에 채팅방을 하나 클릭하면 ChatFramgent로 넘어오면서 채팅방정보와 사용자정보를 넘겨주면 됩니다.

     

    *결과물

     

     

Designed by Tistory.