Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - Firestore 본문

iOS 응용 (swift)

[iOS - swift] 2. 실시간 채팅 앱 구현 방법 (MessageKit, Firebase, Firestore) - Firestore

jake-kim 2021. 11. 16. 23:24

1. 실시간 채팅 앱 구현 방법 (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 생성자 추가

Firestore 저장 형태

  • 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

 

 

Comments