Socket 통신을 통해 참여자 간 실시간 채팅기능을 제공하는 앱
- 구현 사항을 단계별로 정의 후 필요 기능을 이슈로 남기고 GitHub Project로 관리함으로써 체계적으로 요구기능명세에 따른 개발을 할 수 있도록 목표를 잡았습니다 (구현 Project, issue board).
- 개선 프로젝트와 issue를 통해 필요하다 판단한 사용자 편의 기능과 수정해야할 버그를 정리하여 업데이트하고 있습니다 (개선 프로젝트, on-going issue board, closed issue board)
- 구현 완료 후 지속적으로 개선 중인 내용은 여기를 통해 확인하실 수 있습니다.
향후 기능 수정 및 추가가 이루어지더라도 요구한 기능 명세에 따라 동작함을 보장하기 위해 유닛 테스트를 수행할 수 있도록 MVVM 아키텍쳐를 적용하여 뷰와 로직을 분리 하였습니다.
각 View 요소가 어떠한 속성을 가지고 초기화되어 있고, auto-layout을 통해 View들 간 어떠한 제약 관계를 가지고 있는지를 명확히 표현하기 위해 스토리보드 대신 코드를 통해 UI를 구성하였습니다.
Category | Stacks |
---|---|
UI | - UIKit |
Networking | - Stream - StreamDelegate |
Logging | - OSLog |
채팅에 참가 중인 사용자들에게 실시간으로 메시지를 보낼 수 있습니다.
채팅에 참가 중인 사용자의 메시지를 받아 실시간으로 보여줍니다.
사용자의 입퇴장을 화면에 보여줍니다.
메시지 입력을 위해 입력창을 탭하면 최근 대화 위치로 이동하여 마지막으로 이루어진 대화를 보여줍니다.
사용자가 원하는 이름을 지정하여 채팅방에 입장할 수 있으며, 퇴장 시 퇴장 메시지를 띄우도록 메시지를 전달합니다.
통합 로깅 시스템을 통해 앱의 실행 상황과 발생한 에러를 감시하여 보여주고, console app에 기록합니다.
ChatRoomViewController
의 요소 중 하나인 MessageInputBarView
의 textView를 통해 보낼 메시지를 입력한 후 textView 우측에 위치한 보내기 버튼을 탭하면 target으로 등록된 메서드가 실행되며 MessageInputBarViewDelegate
타입인 delegate의
didTapSendButton(message:)` 메서드를 통해 보낼 메시지를 전달합니다.
// MARK: - MessageInputBarView
@objc private func sendButtonTapped() {
guard let delegate = delegate,
let message = inputTextView.text,
!message.isEmpty else { return }
guard !message.contains(StreamData.Infix.receive) else {
delegate.showForbiddenStringContainedAlert()
return
}
delegate.didTapSendButton(message: message)
inputTextView.text = ""
}
MessageInputBarViewDelegate
를 구현하고 있는 ChatRoomViewController
는 전달받은 메시지를 ChatRoomViewModel
의 send(message:)
메서드를 통해 전달합니다.
// MARK: - MessageInputBarViewDelegate
extension ChatRoomViewController: MessageInputBarViewDelegate {
func didTapSendButton(message: String) {
chatRoomViewModel?.send(message: message)
}
}
해당 메서드는 ChatRoomSocketProvidable
프로토콜을 준수하는 ChatRoomSocket
의 send(message:)
를 실행시켜 최종적으로 OutputStream
에 write
작업을 실행합니다.
// MARK: - ChatRoomViewModel
func send(message: String) {
chatRoomSocket.send(message: message)
}
// MARK - ChatRoomSocket
func send(message: String) {
guard let sendingStreamData: Data = StreamData.make(.send(message: message)) else {
Log.logic.error("\(StreamChatError.failedToConvertStringToStreamData(location: #function).localizedDescription)")
return
}
write(sendingStreamData)
}
private func write(_ streamData: Data) {
streamData.withUnsafeBytes { rawBufferPointer in
guard let pointer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
Log.network.error("\(StreamChatError.failedToWriteOnStream.localizedDescription)")
return
}
outputStream?.write(pointer, maxLength: streamData.count)
}
}
ChatRoomSocket
이 StreamDelegate
의 stream(_:handle:)
을 구현함으로써 InputStream
에 전달된 메시지를 읽어옵니다.
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .openCompleted:
Log.flowCheck.debug("연결 성공!")
case .hasBytesAvailable:
readAvailableBytes(from: aStream)
case .endEncountered:
leave()
disconnect()
case .errorOccurred:
Log.network.notice("\(StreamChatError.errorOccurredAtStream.localizedDescription)")
case .hasSpaceAvailable:
Log.network.info("더 사용할 수 있는 버퍼가 있어요. case: hasSpaceAvailable")
default:
Log.network.notice("\(StreamChatError.unknown(location: #function).localizedDescription)")
}
}
eventCode
케이스 중 hasBytesAvailable
는 InputStream
으로부터 읽어올 수 있는 바이트가 있다는 의미이므로 해당 바이트를 읽어와 String
타입으로 변환하여 메시지를 구성할 수 있습니다.
private func readAvailableBytes(from stream: Stream) {
guard let stream = stream as? InputStream else { return }
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: ConnectionSetting.maxReadLength)
while stream.hasBytesAvailable {
guard let bytesRead = inputStream?.read(buffer, maxLength: ConnectionSetting.maxReadLength) else { return }
if let error = stream.streamError, bytesRead < .zero {
Log.network.error("\(StreamChatError.streamDataReadingFailed(error: error).localizedDescription)")
break
}
if let message = constructMessage(with: buffer, length: bytesRead) {
delegate?.didReceiveMessage(message)
}
}
}
private func constructMessage(with buffer: UnsafeMutablePointer<UInt8>, length: Int) -> Message? {
guard let strings = String(bytesNoCopy: buffer, length: length, encoding: .utf8, freeWhenDone: true)?
.components(separatedBy: StreamData.Infix.receive),
let name = strings.first,
let message = strings.last else {
Log.logic.error("\(StreamChatError.failedToConvertByteToString.localizedDescription)")
return nil
}
let isSystemMessage: Bool = strings.count <= 1
if isSystemMessage {
return Message(sender: ChatRoomSocket.system, text: message, dateTime: Date())
} else {
guard let sender = (name == user?.name) ? user : User(name: name, senderType: .someoneElse) else {
return nil
}
return Message(sender: sender, text: message, dateTime: Date())
}
}
constructMessage(with:length:)
메서드를 보시면 아실 수 있으시듯이 시스템 메시지인지 여부를 판단하여 보내는 사람이 시스템인지 판단하고, 이후 아이디를 통해 자신인지 타인인지를 판단하여 메시지를 구성합니다.
사용자 입퇴장 역시 메시지 수신과 마찬가지로 InputStream
에 읽어올 수 있는 바이트가 있을 경우에 실행되지만, 시스템 메시지의 특성을 이용하여 시스템이 보낸 메시지인지 여부를 판단합니다. 메시지는 보통 구분자인 USR_NAME::{ID}::END
, MSG::{message}::END
와 같이 ::
로 구분되는데, 시스템 메시지는 이러한 구분자를 사용하지 않습니다. 결과적으로 시스템 메시지를 읽어와 구분자로 구분했을 때 요소는 1개라고 판단할 수 있습니다.
private func constructMessage(with buffer: UnsafeMutablePointer<UInt8>, length: Int) -> Message? {
guard let strings = String(bytesNoCopy: buffer, length: length, encoding: .utf8, freeWhenDone: true)?
.components(separatedBy: StreamData.Infix.receive),
let name = strings.first,
let message = strings.last else {
Log.logic.error("\(StreamChatError.failedToConvertByteToString.localizedDescription)")
return nil
}
let isSystemMessage: Bool = strings.count <= 1
if isSystemMessage {
return Message(sender: ChatRoomSocket.system, text: message, dateTime: Date())
} else {
guard let sender = (name == user?.name) ? user : User(name: name, senderType: .someoneElse) else {
return nil
}
return Message(sender: sender, text: message, dateTime: Date())
}
}
이후 ChatRoomViewController
에 위치한 tableView에서 메시지를 표시할 새로운 cell을 생성할 때 메시지를 구성할 때 결정되었던 sender
를 통해 Cell 타입을 구분하여 새로운 셀을 생성합니다.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let message = chatRoomViewModel?.message(at: indexPath.row) else {
Log.ui.error("\(StreamChatError.messageNotFound.localizedDescription)")
return MessageTableViewCell()
}
switch message.sender.senderType {
case .system:
guard let systemMessageCell = tableView.dequeueReusableCell(
withIdentifier: SystemMessageTableViewCell.reuseIdentifier,
for: indexPath) as? SystemMessageTableViewCell else {
Log.ui.error("\(StreamChatError.cellTypecastingFailed(toType: SystemMessageTableViewCell.description()).localizedDescription)")
return SystemMessageTableViewCell()
}
systemMessageCell.configure(with: message)
return systemMessageCell
default:
guard let messageCell = tableView.dequeueReusableCell(
withIdentifier: MessageTableViewCell.reuseIdentifier,
for: indexPath) as? MessageTableViewCell else {
Log.ui.error("\(StreamChatError.cellTypecastingFailed(toType: MessageTableViewCell.description()).localizedDescription)")
return MessageTableViewCell()
}
messageCell.configure(with: message)
return messageCell
}
}
사용자가 메시지를 입력하기 위해 MessageInputBarView
의 textView를 탭하면 해당 textView가 firstResponder가 되어 NotificationCenter
로부터 keyboardWillShow
알림을 받을 수 있습니다. 이를 통해 아래와 같이 키보드의 레이아웃에 따라 View의 레이아웃을 조정하는 메서드를 구성할 수 있으며, 여기에 마지막 메시지로 이동할 수 있는 기능을 추가하여 키보드 등장 시 마지막 메시지로 이동할 수 있는 기능을 구현하였습니다.
@objc private func keyboardWillShow(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
bottomConstraint?.constant = -keyboardFrame.height
guard let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval else { return }
UIView.animate(withDuration: duration) {
self.messagesInputBarView.rightComponentStackView.isHidden = false
self.view.layoutIfNeeded()
self.scrollToLastMessage()
}
}
초기 화면인 JoinChatRoomViewController
에서 textField를 통해 사용자 이름을 입력 받아 사용합니다.
@objc private func joinButtonTapped() {
let chatRoomSocket = ChatRoomSocket()
let chatRoomViewModel = ChatRoomViewModel(chatRoomSocket: chatRoomSocket)
let chatRoomViewController = ChatRoomViewController()
chatRoomViewController.chatRoomViewModel = chatRoomViewModel
guard let username = usernameTextField.text,
!username.isEmpty else {
showUsernameRequiredAlert()
return
}
guard !username.contains(StreamData.Infix.receive) else {
showForbiddenStringContainedAlert()
return
}
chatRoomViewController.join(with: username)
navigationController?.pushViewController(chatRoomViewController, animated: true)
}
입장 알림은 위 구현에서 chatRoomViewController.join(with: username)
을 통해 최종적으로 ChatRoomSocket
에 사용자 이름을 전달하여 OutputStream
에 입장 메시지를 전달하도록 구성하였습니다.
func join(with username: String) {
user = User(name: username, senderType: .me)
guard let joiningStreamData: Data = StreamData.make(.join(username: username)) else {
Log.logic.error("\(StreamChatError.failedToConvertStringToStreamData(location: #function).localizedDescription)")
return
}
write(joiningStreamData)
}
애플의 통합 로깅 시스템 (Unified Logging System)인 OSLog를 이용해 발생한 에러 또는 실행이 정상적으로 이루어지는지 여부를 기록합니다. 기록된 내용은 xcode의 콘솔 또는 mac OS의 콘솔 앱을 통해서 확인할 수 있습니다.
Log.network.error("\(StreamChatError.failedToWriteOnStream.localizedDescription)")
Log.logic.error("\(StreamChatError.failedToConvertStringToStreamData(location: #function).localizedDescription)")
Log.network.error("\(StreamChatError.streamDataReadingFailed(error: error).localizedDescription)")
Log.ui.error("\(StreamChatError.messageNotFound.localizedDescription)")
...
상대방과 나의 메시지를 표현하는 셀 타입은 MessageTableViewCell
으로, 해당 타입은 사용자 이름의 이니셜을 표현하는 아이콘과 사용자 이름, 메시지 내용, 보낸 날짜와 같은 요소를 포함하고 있습니다. 메시지가 ChatRoomSocket
을 통해 구성되는 중 sender
가 결정되고, 이를 통해 ChatRoomViewController
의 tableView에서 dequeueReuseableCell(withIdentifier:for:)
되는 됩니다. 이후 셀의 스타일을 결정하는 메서드인 configure(with:)
를 실행하면 전달된 메시지를 통해 sender
를 알 수 있게되고, 이를 통해 필요한 요소를 스타일링 하는 작업을 수행함으로써 하나의 Cell 타입을 통해 복수의 스타일링을 지원하는 메시지 UI를 구성하였습니다.
func configure(with message: Message) {
usernameInitialLabel.text = String(message.sender.name.first ?? Style.unknownUserInitial)
usernameLabel.text = message.sender.name
messageLabel.text = message.text
dateTimeLabel.text = message.dateTime.formatted
setStyleByUser(with: message) // Sender에 따라 스타일링 수행
setNeedsLayout()
}
private func setStyleByUser(with message: Message) {
if message.sender.senderType == .me { // sender가 나인 경우
usernameInitialLabel.isHidden = true
usernameLabel.isHidden = true
contentStackView.alignment = .trailing
messageLabel.textColor = Style.MessageLabel.textColorForMe
messageLabel.backgroundColor = Style.MessageLabel.backgroundColorForMe
} else { // sender가 타인인 경우
usernameInitialLabel.isHidden = false
usernameLabel.isHidden = false
contentStackView.alignment = .leading
messageLabel.textColor = Style.MessageLabel.textColorForSomeoneElse
messageLabel.backgroundColor = Style.MessageLabel.backgroundColorForSomeoneElse
}
}
메시지 입력창 최대 높이 제한 및 스크롤링 기능 추가 (Issue #10)
내용이 긴 메시지를 입력할 때 메시지 입력창의 높이가 과도하게 늘어나 채팅창 자체를 가리는 문제가 발견되었습니다.
이 문제를 일정 높이를 초과하면 스크롤링을 지원하는 textView가 되게끔 textView를 구성하여 기능을 개선하였습니다.
먼저, UITextViewDelegate
의 메서드인 textViewDidChange(_:)
를 통해 매 글자가 작성될 때마다 textView
의 contentSize.height
를 확인하여 지정한 높이를 초과하면 isOversized
프로퍼티를 true
로 변경합니다.
func textViewDidChange(_ textView: UITextView) {
isOversized = textView.contentSize.height > Style.InputTextView.maxHeight
setInputTextCountLabel(to: textView.text.count)
showInputTextCountLabel(with: textView, whenExceeds: Style.InputTextCountLabel.numberOfLinesToShowLabel)
}
이후 isOversized
프로퍼티는 프로퍼티 옵저버를 통해 inputTextView
의 제약을 조정하는 메서드인 adjustInputTextViewConstraint()
를 호출합니다.
private var isOversized = false {
didSet {
guard oldValue != isOversized else { return }
adjustInputTextViewConstraint()
}
}
호출된 메서드는 textView의 스크롤 기능을 활성화 시키고, 제약 조절을 통해 높이를 조정하는 등 레이아웃을 조정하는 작업을 수행함으로써 원하는 레이아웃을 구성합니다.
private func adjustInputTextViewConstraint() {
inputTextView.isScrollEnabled = isOversized
textViewHeightConstraint?.isActive = isOversized
inputTextView.setNeedsUpdateConstraints()
inputTextView.layoutIfNeeded()
}
메시지 입력 시 최대 글자수 제한, 작성 중인 글자수 표시 및 제한 초과 알림 (Issue #11)
서버의 요청으로 최대 글자수를 300자로 제한하는 기능을 추가하였습니다.
기존 단일 버튼으로 구성된 sendButton
을 글자수를 표시할 수 있는 inputTextCountLabel
과 함께 stackView에 넣고 기존의 sendButton
과 동일한 위치에 위치하도록 하였습니다.
이후 textViewDidChange(_:)
메서드를 통해 글자를 입력할 때마다 inputTextCountLabel
을 업데이트하도록 구성하였습니다.
func textViewDidChange(_ textView: UITextView) {
isOversized = textView.contentSize.height > Style.InputTextView.maxHeight
setInputTextCountLabel(to: textView.text.count)
showInputTextCountLabel(with: textView, whenExceeds: Style.InputTextCountLabel.numberOfLinesToShowLabel)
}
private func setInputTextCountLabel(to count: Int) {
inputTextCountLabel.text = "\(String(count))/\(Style.InputTextCountLabel.maxCount)"
}
이에 더하여 메시지 입력 창이 두 줄 이상 되지 않았을 경우 글자수를 표시할 필요는 없으므로 한 줄 이상이 되었을 때만 inputTextCountLabel
이 표시되도록 구성하였습니다.
private func showInputTextCountLabel(with textView: UITextView, whenExceeds numberOfLines: CGFloat) {
guard let fontHeight = textView.font?.lineHeight else { return }
let calculatedNumberOfLines: CGFloat
calculatedNumberOfLines = textView.intrinsicContentSize.height > .zero
? textView.intrinsicContentSize.height / fontHeight
: textView.contentSize.height / fontHeight
inputTextCountLabel.isHidden = calculatedNumberOfLines < numberOfLines
}
최대 글자수 초과 로직은 아래 메서드와 같이 기존의 text 수, 블럭으로 선택되어 변경될 text 수와 새로 입력되는 replacementText 수를 통해 계산하는 방식을 이용했습니다.
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
let isWithinMaxLength: Bool = textView.text.count + (text.count - range.length) <= Style.InputTextCountLabel.maxCount
if !isWithinMaxLength {
delegate?.showMaxLengthExceededAlert()
}
return isWithinMaxLength
}
위의 메서드에서 확인하실 수 있으시듯이 최대 글자수 초과 시 MessageInputBarViewDelegate
타입인 delegate
를 통해 최대 글자수 초과를 나타내는 알림을 띄우는 메서드를 호출하게끔 하였습니다. 이 delegate는 ChatRoomViewController
가 구현하고 있습니다.
// MARK: - ChatRoomViewController
func showMaxLengthExceededAlert() {
let alert = UIAlertController(title: Style.Alert.maxLengthExceededTitle,
message: Style.Alert.maxLengthExceededMessage,
preferredStyle: .alert)
present(alert, animated: true)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Style.Alert.timeToDismissMaxLengthExceededAlert) {
alert.dismiss(animated: true)
}
}
이 링크와 같이 발견한 문제를 새로운 기능을 추가함으로써 해결하였습니다.
tableView 상단의 contentInset
을 추가하는 시점이 문제가 되었습니다. contentInset
을 추가하는 시점은 subView들의 레이아웃 작업이 완료된 이후인 viewDidLayoutSubviews()
이후가 되어야 합니다.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
messagesTableView.contentInset.top = Style.MessagesTableView.topContentInset
}