Kotlin+Android) 채팅방 구현하기

성승모·2024년 6월 7일
0

헷갈리는 점 정리: 채팅방 로직
abc가 있는 채팅 방에서 a가 채팅을 침 -> 채팅방에 접속해있던 b는 websocket으로 바로 메시지를 받음 -> 접속해 있지 않던 c는 service에서 raw data를 받음 -> raw data를 파싱하여 Room에 '읽지 않은 메시지'로 저장 후 push 알림을 보냄
-> {
1) 알림을 본 c는 알림을 클릭하여 해당 채팅방으로 바로 이동
2) 알림을 지우고 앱을 직접 실행 - Room에서 '읽지 않은 메세지' 조회 후 채팅방 리스트 위에 그 개수를 알려줌.
}
-> 채팅방에 입장한 c의 앱에서 서버에 최근 메시지 리스트를 요청하여 화면에 띄어줌.

헷갈리는 점 정리: 초대 로직
a가 bc와 함께 하는 채팅방을 만듦 -> User table에 저장되어 있는 device token 값으로 해당 기기들을 FirebaseTopic(roomId) 에 구독시킴 -> a가 새로 생성된 채팅방에서 메세지 입력 시 firebase를 통하여 문자를 보냄 -> bc의 앱에선 받은 메시지의 roomId를 확인 후 해당 채팅 방이 없으니 Room에 '채팅방'을 새로 생성 후 '읽지 않은 메시지'에 저장

지금까지 한 것
https://velog.io/@rure00/AndroidKotlinFireBase%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EC%95%8C%EB%A6%BC-%EB%B0%9B%EA%B8%B0

2024.06.10

이제 해야할 것은
1. API 이용 메소드를 추가
2. Room에 채팅방 Table 추가
3. 채팅방 구현
이다. 여기에 테스트까지 완벽히 되면 채팅 부분은 다 끝이다.

API 메소드 추가

RetrofitService interface에 API를 선언한다.

RetrofitService.kt

	@POST("/chat/create/room")
    suspend fun createChatRoom(@Body request: CreateChatRoomRequest)

    @POST("/chat/invite")
    suspend fun inviteToRoom(@Body request: InviteRequest)

    @POST("/chat/exit")
    suspend fun exitChatRoom(@Body request: ExitRoomRequest)

ServerCommunicator.kt
서버와의 통신을 관리하는 ServerCommunicator에서 위 함수들을 implement한다.

	suspend fun createChatRoom(request: CreateChatRoomRequest): ResponseResult = withContext(Dispatchers.IO) {
        val response = service.createChatRoom(request)
        ResponseResult(result = response.isSuccessful, null)
    }

    suspend fun inviteToRoom(request: InviteRequest): ResponseResult = withContext(Dispatchers.IO) {
        val response = service.inviteToRoom(request)
        ResponseResult(result = response.isSuccessful, null)
    }

    suspend fun exitChatRoom(request: ExitRoomRequest): ResponseResult = withContext(Dispatchers.IO) {
        val response = service.exitChatRoom(request)
        ResponseResult(result = response.isSuccessful, null)
    }

Room에 채팅방 Table 추가

ChattingRoom Table을 정의하고 roomId와 lastMessage를 프로퍼티로 가진다.
-> ChatListFragment에서 이용하였던 채팅방들을 Room에서 불러와 채팅방 이름과 마지막 메시지를 보여준다.

Entity
data class ChattingRoom(
    @PrimaryKey
    val roomId: Long,
    val lastMessage: String,
    val roomName: String
)

여기까지 해보니 NotReadMsg와의 관계를 어떻게 해야할지 고민이 된다... 마지막 메시지를 보여주는 기능에서 ChattingRoom.lastMessage와 NotReadMsg.body를 따로따로 관리해야 한다는건 좋지 않은것 같다... 이를 합쳐야겠다.
-> 채팅방을 클릭하여 입장하면 해당 채팅방의 메시지를 가져와야 하므로 NotReadMsg를 삭제하고 ChattingRoom에 boolean 값을 넣어야겠다.

  • ChattingRoom에 var isNotRead: Boolean를 추가.
  • NotReadMsg 삭제
  • 이에 따라 백그라운드에서 메시지를 받는 Service 수정
override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)
       
        val data = message.data
        val receivedMessage = ChatMessage(
            id = data["id"]!!.toLong(),
            sender = data["sender"]!!,
            body = data["body"]!!,
            title = data["title"]!!,
            date = LocalDateTime.parse(data["date"]!!),
            roomId = data["roomId"]!!.toLong(),
        )
        // 기존 NotReadMsg 업데이트 메서드: roomDB.roomDao.insertNotReadMsg(NotReadMessage(receivedMessage))
        CoroutineScope(Dispatchers.IO).launch {
            roomDB.roomDao.notifyNewMessage(receivedMessage.roomId, receivedMessage.body)
        }

        showNotification(receivedMessage)
    }

2024.06.20

서버 삽질하고 돌아왔다...
우선 채팅 탭을 눌러 ChatListFragment를 들어오면 사용자가 참여해있는 채팅방을 보여준다. 그 중 하나를 탭하여 ChatRoomFragment로 이동하고 최근 메시지부터 보여주고 입력란을 띄어준다.

그럼 순서는

1. ChatListFragment
   -> 사용자가 참여한 채팅방을 API로불러오기
   -> Lazy Row로 여러 채팅방을 생성
   -> 채팅방 탭 시 ChatRoomFragment로

2. ChatRoomFragment**
   -> 기본적인 UI 배치 ex) 메시지, 이름, 입력란
   -> 최근 메시지 조회 후 배치
   -> 메시지 입력 후 전송 누르면 서버에 보내기

ChatListFragment

  1. API 이용하여 채팅방 가져오기
  우선 ServerCommunicator에서 정의해보자.

	suspend fun getChatRooms() : ResponseResult = withContext(Dispatchers.IO) {
        val response = service.getRooms()
        ResponseResult(result = response.isSuccessful, response.body())
    }

    이후 이를 호출하면 될듯 하지만 문제가 생겼다.

만약 앱이 실행 중이고 ChatListFrament에 있던 사용자가 새 메시지를 받았을때, 어떻게 recomposition이 일어나게 하지??
-> LiveData를 이용하여 room을 observe하자!
(Room을 이용하여 저장한 ChattingRoom의 역할이 애매하다고 생각했었는데, 다행히 잘 이용될것 같다.)

우선 viewModel을 정의해보자.

class MainViewModel(application: Application): AndroidViewModel(application) {
    private val roomDao = MoyeobaRoomDataBase.getInstance(application.baseContext).roomDao
    val allChatRooms: LiveData<List<ChattingRoom>> = roomDao.getAllChatRoom()
}

이후 ChatListFragment에서 Observe한다.

	val mainViewModel = MainViewModelFactory(
        MoyeobaRoomDataBase.getInstance(LocalContext.current).roomDao
    ).create(MainViewModel::class.java)
    
    val chatRoomsInLocal: List<ChattingRoom> by mainViewModel.allChatRooms.observeAsState(
        mainViewModel.getAllRoomsFromLocal().value!!
    )
    val chatRoomsInServer = mutableMapOf<Long, String>()
    LaunchedEffect(key1 = chatRoomsInLocal) {
        val response = ServerCommunicator.getChatRooms().response as GetRoomsResponse
        for(i in response.roomIdList.indices) {
            chatRoomsInServer[response.roomIdList[i]] = response.lastMsg[i]
        }
    }

Local에 있는 ChatRoom과 Server에서 받은 ChatRoom를 가져온다. 이를 비교하여 동기화? 더블체킹? 하고 싶은데 좋은 방식인지는 모르겠다. 읽었는지 안읽었는지를 서버 단에서 검사해야할지... 아직 생각해보아야 할 거리들이 많다...

2024.06.21

위에서 MainViewModel을 AndroidViewModel을 이용하였는데 이는 ViewModel을 확장한 클래스로 context를 다룰 수 있다. 하지만 UnitTest에 번거로움이 있기 때문에 Context보다는 Dao를 넘겨주려고 한다.

val mainViewModel = MainViewModelFactory(
        MoyeobaRoomDataBase.getInstance(LocalContext.current).roomDao
    ).create(MainViewModel::class.java)

채팅방 아이템을 만들어보자.
ChattingRoom과 onClickEvent를 인자로 받는다.

@Composable
fun ChatRoomComponent(chattingRoom: ChattingRoom, onClick: (Long) -> (Unit)) {
    Row(modifier = Modifier
        .fillMaxWidth()
        .background(Color.White)
        .padding(5.dp)
        .clickable { onClick(chattingRoom.roomId) },
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Column{
            Text(
                modifier = Modifier,
                text = chattingRoom.roomName,
                fontSize = 20.sp,
                maxLines = 1
            )
            Text(
                modifier = Modifier.align(Alignment.Start),
                text = chattingRoom.lastMessage,
                color = Gray,
                fontSize = 18.sp,
                maxLines = 1
            )
        }
        Box(
            modifier = Modifier
                .size(25.dp)
                .clip(CircleShape)
                .background(Color.Red)
                .align(Alignment.CenterVertically)
        ) {
            if(chattingRoom.isNotRead) {
                Text(
                    modifier = Modifier.align(Alignment.Center).padding(),
                    text = chattingRoom.notReadMsgNum.toString(),
                    fontSize = 18.sp,
                    color = Color.White,
                    )
            }

        }
    }
}


ChatListFragment.kt

마지막 message를 받은 시간을 Room에 LocalDateTime으로 저장하려 하니 오류가 발생했다.
Cannot figure out how to save this field into database
Room에서 어떻게 변환해야할지 모르겠다는 말이다. 이를 해결하기 위하여 Converter를 정의하고 Room에 배정해줘야한다.
Converter.kt

class Converter {
    @TypeConverter
    fun fromTimestamp(value: String?): LocalDateTime? {
        return value?.let { LocalDateTime.parse(it) }
    }
    @TypeConverter
    fun dateToTimestamp(date: LocalDateTime?): String? {
        return date?.toString()
    }
}

이제 RoomDataBase에 어노테이션을 추가한다.

@TypeConverters(Converter::class)

이제 Lazy Loading을 이용하여 채팅방 개수만큼 ChatRoomComponent를 생성, 배치 해보자!

val chatRoomsInServer = mutableMapOf<Long, String>()
    LaunchedEffect(key1 = chatRoomsInLocal) {
        val response = ServerCommunicator.getChatRooms().response as GetRoomsResponse
        for(i in response.roomIdList.indices) {
            chatRoomsInServer[response.roomIdList[i]] = response.lastMsg[i]
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        Box(modifier = Modifier) {
            Text(
                modifier = Modifier.padding(5.dp),
                text = "채팅방",
                fontSize = 30.sp,
                fontStyle = FontStyle.Normal
            )
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(2.dp)
                .background(Color.LightGray),
        )
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .align(Alignment.Start)
        ) {
            items(
                items = chatRoomsInLocal.toTypedArray(),
                key = { chatRoom -> chatRoom.roomId }
            ) {chatRoom ->
                ChatRoomComponent(chattingRoom = chatRoom) {
                    openChatRoom(chatRoom.roomId)
                }
                Divider(color = Color.LightGray)
            }
        }
    }

items에는 key 값을 지정해주어 recomposition 시 성능을 높여준다.

(Mock 데이터로 테스트)


profile
안녕하세요!

0개의 댓글