Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- HIG
- Xcode
- ios
- 리펙토링
- UITextView
- swiftUI
- Clean Code
- tableView
- 클린 코드
- swift documentation
- collectionview
- UICollectionView
- 애니메이션
- 리펙터링
- uitableview
- ribs
- 스위프트
- combine
- clean architecture
- SWIFT
- Refactoring
- map
- RxCocoa
- Protocol
- MVVM
- Human interface guide
- 리팩토링
- Observable
- rxswift
- uiscrollview
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] 2. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - Firestore 본문
iOS 응용 (swift)
[iOS - swift] 2. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - Firestore
jake-kim 2021. 11. 16. 23:241. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - 채팅 UI
2. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - Firestore
Firebase 연동
- App 생성 후 GoogleService-info.plist 파일 추가
- Firebase Authentication
- 로그인 없이도 사용할 수 있도록 익명 선택
- Firebase Storage 연동
- 이미지를 저정하는 서버 제공 - 아래 페이지에서 시작하기 클릭
SPM으로 Firebase SDK 설치
https://github.com/firebase/firebase-ios-sdk.git
- FirebaseAuth, FirebaseFirestore, FirebaseFirestoreSwift-Beta, FirebaseStorage 체크
FirebaseAuth
- Firebase 초기화
// AppDelegate.swift
import Firebase
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
...
FirebaseApp.configure() // <- 추가
- 로그인 화면에서 로그인 버튼을 누른 경우, FirebaseAuth 로그인 후 이동
- LoginVC
// LoginVC.swift
import FirebaseAuth
class LoginVC: BaseViewController {
...
@objc func didTapButton() {
Auth.auth().signInAnonymously()
navigationController?.setViewControllers([ChannelVC()], animated: true)
}
}
Firestore
- 채팅 DB 구조
{
"channels": [{
"MOuL1sdbrnh0x1zGuXn7": { // channel id
"name": "Puppies",
"thread": [{
"3a6Fo5rrUcBqhUJcLsP0": { // message id
"content": "Wow, that's so cute!",
"created": "April 12, 2021 at 10:44:11 PM UTC-5",
"senderId": "YCrPJF3shzWSHagmr0Zl2WZFBgT2",
"senderName": "naturaln0va",
},
"4LXlVnWnoqyZEuKiiubh": { // message id
"content": "Yes he is.",
"created": "April 12, 2021 at 10:40:05 PM UTC-5",
"senderId": "f84PFeGl2yaqUDaSiTVeqe9gHfD3",
"senderName": "lumberjack16",
},
}]
},
}]
}
AppController 추가
- AppDelegate에서 싱글톤으로 호출하여, Firebase 관련 로그인 체크 및 자동로그인 실행
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
AppController.shared.show(in: window) // <- 요기
...
- FirebaseAuth에 있는 Auth.auth().currentUser 프로퍼티 값이 nil이 아니면 Channel화면으로 이동, 아니면 로그인 화면으로 이동
- init()에서 Firebase 초기화, NotificationCenter를 통해 FirebaseAuth관련 로그인, 로그아웃을 구독
// AppController.swift
init() {
FirebaseApp.configure()
NotificationCenter.default.addObserver(self,
selector: #selector(checkSignIn),
name: .AuthStateDidChange,
object: nil)
}
@objc private func checkSignIn() {
if let user = Auth.auth().currentUser {
setCahnnelScene(with: user)
} else {
setLoginScene()
}
}
- AppDelegate에서 호출되는 show(in:) 메소드 정의
- window설정과 signIn을 확인하여 첫 화면 결정
func show(in window: UIWindow?) {
guard let window = window else {
fatalError("Cannot layout app with a nil window.")
}
self.window = window
window.tintColor = .primary
window.backgroundColor = .systemBackground
window.makeKeyAndVisible()
if Auth.auth().currentUser == nil {
checkSignIn()
}
}
- AppController 전체 코드
import Firebase
import UIKit
final class AppController {
static let shared = AppController()
private var window: UIWindow!
private var rootViewController: UIViewController? {
didSet {
window.rootViewController = rootViewController
}
}
init() {
FirebaseApp.configure()
NotificationCenter.default.addObserver(self,
selector: #selector(checkSignIn),
name: .AuthStateDidChange,
object: nil)
}
func show(in window: UIWindow?) {
guard let window = window else {
fatalError("Cannot layout app with a nil window.")
}
self.window = window
window.tintColor = .primary
window.backgroundColor = .systemBackground
window.makeKeyAndVisible()
if Auth.auth().currentUser == nil {
checkSignIn()
}
}
@objc private func checkSignIn() {
if let user = Auth.auth().currentUser {
setCahnnelScene(with: user)
} else {
setLoginScene()
}
}
private func setCahnnelScene(with user: User) {
let channelVC = ChannelVC(currentUser: user)
rootViewController = BaseNavigationController(rootViewController: channelVC)
}
private func setLoginScene() {
rootViewController = BaseNavigationController(rootViewController: LoginVC())
}
}
Firebase 로그인
- 아무곳이나 Auth.auth().signInAnonymously() 호출 시 위 AppController의 checkSignIn()이 호출되어서 적절한 화면으로 이동
// LoginVC.swift
@objc func didTapButton() {
guard let name = nameTextField.text else { return }
UserDefaultManager.displayName = name
Auth.auth().signInAnonymously()
}
Channel, Message 모델에 Firestore의 Document 생성자 추가
- DatabaseRepresentation 정의
- 이 프로토콜을 채택한 구현체는 Firestore에 json형식으로 전달하기에 용이
- 각 모델 (Channel, Message)에서 해당 프로토콜 채택
- cf) Codable을 활용한 방법은 여기 참고
// DatabaseRepresentation.swift
protocol DatabaseRepresentation {
var representation: [String: Any] { get }
}
- Channel 생성자에 QueryDocumentSnapshot을 인수로하는 생성자 추가
// Channel.swift
init?(_ document: QueryDocumentSnapshot) {
let data = document.data()
guard let name = data["name"] as? String else {
return nil
}
id = document.documentID
self.name = name
}
extension Channel: DatabaseRepresentation {
var representation: [String: Any] {
var rep = ["name": name]
if let id = id {
rep["id"] = id
}
return rep
}
}
- Message 생성자에 QueryDocumentSnapshot을 인수로 하는 생성자 추가
// Message.swift
init?(document: QueryDocumentSnapshot) {
let data = document.data()
guard let sentDate = data["created"] as? Timestamp,
let senderId = data["senderId"] as? String,
let senderName = data["senderName"] as? String else { return nil }
id = document.documentID
self.sentDate = sentDate.dateValue()
sender = Sender(senderId: senderId, displayName: senderName)
if let content = data["content"] as? String {
self.content = content
downloadURL = nil
} else if let urlString = data["url"] as? String, let url = URL(string: urlString) {
downloadURL = url
content = ""
} else {
return nil
}
}
extension Message: DatabaseRepresentation {
var representation: [String : Any] {
var representation: [String: Any] = [
"created": sentDate,
"senderId": sender.senderId,
"senderName": sender.displayName
]
if let url = downloadURL {
representation["url"] = url.absoluteString
} else {
representation["content"] = content
}
return representation
}
}
Channel 화면 - Firestore 사용까지
- Firestore 구독
- 채널 생성 > Firestore에 channel 생성 쿼리 > Firestore에 구독하고 있던 listener를 통해 데이터 획득, 업데이트
- ChannelVC에서 사용할 Firestore 사용 모듈 정의
import FirebaseFirestore
import FirebaseStorage
import FirebaseFirestoreSwift
class ChannelFirestoreStream {
private let storage = Storage.storage().reference()
let firestoreDatabase = Firestore.firestore()
var listener: ListenerRegistration?
lazy var ChannelListener: CollectionReference = {
return firestoreDatabase.collection("channels")
}()
func createChannel(with channelName: String) {
let channel = Channel(name: channelName)
ChannelListener.addDocument(data: channel.representation) { error in
if let error = error {
print("Error saving Channel: \(error.localizedDescription)")
}
}
}
func subscribe(completion: @escaping (Result<[(Channel, DocumentChangeType)], Error>) -> Void) {
ChannelListener.addSnapshotListener { snaphot, error in
guard let snapshot = snaphot else {
completion(.failure(error!))
return
}
let result = snapshot.documentChanges
.filter { Channel($0.document) != nil }
.compactMap { (Channel($0.document)!, $0.type) }
completion(.success(result))
}
}
func removeListener() {
listener?.remove()
}
}
- ChannelVC에서 Firestore 사용
// ChannelVC.swift
class ChannelVC: BaseViewController {
...
private let channelStream = ChannelFirestoreStream()
...
private func setupListener() {
channelStream.subscribe { [weak self] result in
switch result {
case .success(let data):
self?.updateCell(to: data)
case .failure(let error):
print(error)
}
}
}
private func updateCell(to data: [(Channel, DocumentChangeType)]) {
data.forEach { (channel, documentChangeType) in
switch documentChangeType {
case .added:
addChannelToTable(channel)
case .modified:
updateChannelInTable(channel)
case .removed:
removeChannelFromTable(channel)
}
}
}
private func addChannelToTable(_ channel: Channel) {
guard channels.contains(channel) == false else { return }
channels.append(channel)
channels.sort()
guard let index = channels.firstIndex(of: channel) else { return }
channelTableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
}
private func updateChannelInTable(_ channel: Channel) {
guard let index = channels.firstIndex(of: channel) else { return }
channels[index] = channel
channelTableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
}
private func removeChannelFromTable(_ channel: Channel) {
guard let index = channels.firstIndex(of: channel) else { return }
channels.remove(at: index)
channelTableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
}
...
}
Firebase Storage에 이미지를 업로드, 다운받는 모듈 구현
- uploadImage(): 이미지 피커를 통해 이미지를 선택한 경우 호출
- downloadImage(): firestore에 이미지 url과 같이 업데이트를 한 경우, 구독하고 있던 listener에서 반응하고 cell에 이미지 업데이트
// FirebaseStorageManager.swift
import FirebaseStorage
import UIKit
struct FirebaseStorageManager {
static func uploadImage(image: UIImage, channel: Channel, completion: @escaping (URL?) -> Void) {
guard let channelId = channel.id,
let scaledImage = image.scaledToSafeUploadSize,
let data = scaledImage.jpegData(compressionQuality: 0.4) else { return completion(nil)}
let metaData = StorageMetadata()
metaData.contentType = "image/jpeg"
let imageName = UUID().uuidString + String(Date().timeIntervalSince1970)
let imageReference = Storage.storage().reference().child("\(channelId)/\(imageName)")
imageReference.putData(data, metadata: metaData) { _, _ in
imageReference.downloadURL { url, _ in
completion(url)
}
}
}
static func downloadImage(url: URL, completion: @escaping (UIImage?) -> Void) {
let reference = Storage.storage().reference(forURL: url.absoluteString)
let megaByte = Int64(1 * 1024 * 1024)
reference.getData(maxSize: megaByte) { data, error in
guard let imageData = data else {
completion(nil)
return
}
completion(UIImage(data: imageData))
}
}
}
Chat 화면 - Firestore 사용까지
- Firestore 구독
- 채팅 > Firestore에 채팅 내역 저장 > Firestore에 구독하고 있던 listener를 통해 데이터 획득, 업데이트
- ChatVC에서 사용할 Firestore 사용 모듈 정의
import Foundation
import FirebaseFirestore
import FirebaseStorage
import FirebaseFirestoreSwift
class ChatFirestoreStream {
private let storage = Storage.storage().reference()
let firestoreDataBase = Firestore.firestore()
var listener: ListenerRegistration?
var collectionListener: CollectionReference?
func subscribe(id: String, completion: @escaping (Result<[Message], StreamError>) -> Void) {
let streamPath = "channels/\(id)/thread"
removeListener()
collectionListener = firestoreDataBase.collection(streamPath)
listener = collectionListener?
.addSnapshotListener { snapshot, error in
guard let snapshot = snapshot else {
completion(.failure(StreamError.firestoreError(error)))
return
}
var messages = [Message]()
snapshot.documentChanges.forEach { change in
if let message = Message(document: change.document) {
if case .added = change.type {
messages.append(message)
}
}
}
completion(.success(messages))
}
}
func save(_ message: Message, completion: ((Error?) -> Void)? = nil) {
collectionListener?.addDocument(data: message.representation) { error in
completion?(error)
}
}
func removeListener() {
listener?.remove()
}
}
- ChatVC에서 Firestore 사용
class ChatVC: MessagesViewController {
...
let chatFirestoreStream = ChatFirestoreStream()
...
private func listenToMessages() {
guard let id = channel.id else {
navigationController?.popViewController(animated: true)
return
}
chatFirestoreStream.subscribe(id: id) { [weak self] result in
switch result {
case .success(let messages):
self?.loadImageAndUpdateCells(messages)
case .failure(let error):
print(error)
}
}
}
private func loadImageAndUpdateCells(_ messages: [Message]) {
messages.forEach { message in
var message = message
if let url = message.downloadURL {
FirebaseStorageManager.downloadImage(url: url) { [weak self] image in
guard let image = image else { return }
message.image = image
self?.insertNewMessage(message)
}
} else {
insertNewMessage(message)
}
}
}
...
}
- inputBar 입력 완료 델리게이트에서 firestore에 데이터 업데이트
extension ChatVC: InputBarAccessoryViewDelegate {
func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
let message = Message(user: user, content: text)
chatFirestoreStream.save(message) { [weak self] error in
if let error = error {
print(error)
return
}
self?.messagesCollectionView.scrollToLastItem()
}
inputBar.inputTextView.text.removeAll()
}
}
- ImagePicker를 통해 이미지 선택 시 firebase의 storage에 저장
extension ChatVC: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
picker.dismiss(animated: true)
if let asset = info[.phAsset] as? PHAsset {
let imageSize = CGSize(width: 500, height: 500)
PHImageManager.default().requestImage(for: asset,
targetSize: imageSize,
contentMode: .aspectFit,
options: nil) { image, _ in
guard let image = image else { return }
self.sendPhoto(image)
}
} else if let image = info[.originalImage] as? UIImage {
sendPhoto(image)
}
}
private func sendPhoto(_ image: UIImage) {
isSendingPhoto = true
FirebaseStorageManager.uploadImage(image: image, channel: channel) { [weak self] url in
self?.isSendingPhoto = false
guard let user = self?.user, let url = url else { return }
var message = Message(user: user, image: image)
message.downloadURL = url
self?.chatFirestoreStream.save(message)
self?.messagesCollectionView.scrollToLastItem()
}
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
* 전체 소스 코드: https://github.com/JK0369/ExChatWithRealTime/tree/Implement-Firebase
* 참고
https://firebase.google.com/docs/ios/swift-package-manager?hl=ko
https://www.raywenderlich.com/22067733-firebase-tutorial-real-time-chat
https://github.com/MessageKit/MessageKit/blob/master/Documentation/QuickStart.md
'iOS 응용 (swift)' 카테고리의 다른 글
Comments