[SwiftUI] CloudKit: Utility Class

Junyoung Park·2022년 8월 25일
0

SwiftUI

목록 보기
53/136
post-thumbnail

Creating a reusable utility class for CloudKit code | Advanced Learning #25

CloudKit: Utility Class

구현 목표

  • 확장성이 있는 코드 작성 → 재활용 가능한 코드 필요
  • 프로토콜, 제네릭, 퓨처 사용
  • 이스케이핑 클로저(컴플리션 핸들러)를 파라미터로 하는 함수 재사용을 통한 코드 리팩토링

구현 태스크

  1. CloudKitUserBootCamp, CloudKitCRUDBootCamp 등 뷰 모델이 사용하고 있던 함수 기능 추출
  2. CloudKitUtility 클래스 전환 → 데이터 서비스 클래스. 클래스 내 저장 프로퍼티가 없기 때문에 함수 자체에서 값 리턴만 필요, static 메소드로 구현, 싱글턴 패턴 필요 X
  3. 이스케이핑 클로저 및 Combine 통해 각 뷰 모델이 필요한 리턴 값 Result 반환 가능 → Future로 반환

핵심 코드

static func fetch<T:CloudKitableProtocol>(predicate: NSPredicate,
                      recordType: CKRecord.RecordType,
                      sortDescriptors: [NSSortDescriptor]? = nil,
                      resultsLimit: Int? = nil) -> Future<[T], Error> {
        Future { promise in
            fetch(predicate: predicate, recordType: recordType) { (items:[T]) in
                promise(.success(items))
            }
        }
    }
    
    static private func fetch<T:CloudKitableProtocol>(predicate: NSPredicate,
                      recordType: CKRecord.RecordType,
                      sortDescriptors: [NSSortDescriptor]? = nil,
                      resultsLimit: Int? = nil,
                      completionHandler: @escaping (_ items: [T]) -> ()) {
        // Execute operation
        let operation = createOperation(predicate: predicate, recordType: recordType, sortDescriptors: sortDescriptors, resultsLimit: resultsLimit)
        // Get items in query
        var returnedItems = [T]()
        addRecordMatchedBlock(queryOperation: operation) { item in
            returnedItems.append(item)
        }
        // Query completion
        addQueryResultBlock(queryOperation: operation) { finished in
            completionHandler(returnedItems)
        }
        add(operation: operation)
    }
  • 클라우드 데이터베이스 사용 가능한 구조체는 CloudKitableProtocol을 받고 있는 제네릭 타입
  • 옵션 등 메소드에 필요한 파라미터의 커스텀 및 디폴트 값 통한 확장성
  • 컴플리션 핸들러를 사용한 함수를 Future로 다시 받아 리턴 → 뷰 모델에서 해당 서비스를 사용하는 함수에서는 Combine으로 추출 가능
    func fetchItems() {
        let predicate = NSPredicate(value: true)
        
        CloudKitUtility.fetch(predicate: predicate, recordType: "Fruits")
            .receive(on: DispatchQueue.main)
            .sink { _ in
                
            } receiveValue: { [weak self] (returnedItems:[FruitModel]) in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.fruits = returnedItems
                }
            }
            .store(in: &cancellables)
    }
  • sink를 통해 Future를 받는 메소드
  • CloudKit 모듈이 임포트되어 있지 않아도 CloudKiyUtility 클래스를 통해 서비스 사용 가능

소스 코드

import CloudKit

protocol CloudKitableProtocol {
    init?(record: CKRecord)
    var record: CKRecord { get }
}

struct FruitModel: Identifiable, CloudKitableProtocol {
    let id = UUID().uuidString
    let name: String
    let imageURL: URL?
    let record: CKRecord
    
    init?(name: String, imageURL: URL?) {
        let record = CKRecord(recordType: "Fruits")
        record["name"] = name
        if let imageURL = imageURL {
            let asset = CKAsset(fileURL: imageURL)
            record["image"] = asset
        }
        self.init(record: record)
    }
    
    init?(record: CKRecord) {
        guard let name = record["name"] as? String else { return nil }
        self.name = name
        let imageAsset = record["image"] as? CKAsset
        let imageURL = imageAsset?.fileURL
        self.imageURL = imageURL
        self.record = record
    }
    
    func update(newName: String) -> FruitModel? {
        let record = record
        record["name"] = newName
        guard let fruit = FruitModel(record: record) else { return nil }
        return fruit
    }
}
  • CKRecord를 사용한 이니셜라이즈 함수를 추가 → 실패 가능한 초기화
  • update 메소드를 통해 현재 상태의 이름을 바꾼 새로운 데이터 모델 리턴
  • CloudKitableProtocol을 준수함으로써 다른 구조체와 바꿔 끼울 수 있는 확장성
import SwiftUI
import CloudKit
import Combine
import UserNotifications

class CloudKitUtility {
    enum CloudKitError: String, LocalizedError {
        case iCloudAccountUnavailable
        case iCloudAccountNotFound
        case iCloudAccountNotDetermined
        case iCloudAccountRestricted
        case iCloudAccountUnknown
        case iCloudAppicationPermissionNotGranted
        case iCloudCouldNotFetchUserRecordIC
        case iCloudCouldNotDiscoverUser
    }
}

// MARK: USER FUNCTIONS
extension CloudKitUtility {
    static private func getiCloudStatus(completionHandler: @escaping (Result<Bool, Error>) -> Void) {
        CKContainer.default().accountStatus { status, error in
            DispatchQueue.main.async {
                switch status {
                case .couldNotDetermine:
                    completionHandler(.failure(CloudKitError.iCloudAccountNotDetermined))
                case .available:
                    completionHandler(.success(true))
                case .restricted:
                    completionHandler(.failure(CloudKitError.iCloudAccountRestricted))
                case .noAccount:
                    completionHandler(.failure(CloudKitError.iCloudAccountNotFound))
                    break
                case .temporarilyUnavailable:
                    completionHandler(.failure(CloudKitError.iCloudAccountUnavailable))
                @unknown default:
                    completionHandler(.failure(CloudKitError.iCloudAccountUnknown))
                }
            }
        }
    }
    
    static func getiCloudStatus() -> Future<Bool, Error> {
        Future { promise in
            CloudKitUtility.getiCloudStatus { result in
                promise(result)
            }
        }
    }
    
    static private func requestAplicationPermission(completionHandler: @escaping((Result<Bool, Error>) -> ())) {
        CKContainer.default().requestApplicationPermission([.userDiscoverability]) { returnedStatus, returnedError in
            if returnedStatus == .granted {
                completionHandler(.success(true))
            } else {
                completionHandler(.failure(CloudKitError.iCloudAppicationPermissionNotGranted))
            }
        }
    }
    
    static func getApplicationPermission() -> Future<Bool, Error> {
        Future { promise in
            requestAplicationPermission { result in
                promise(result)
            }
        }
    }
    
    static private func fetchUserRecordId(completionHandler: (@escaping ((Result<CKRecord.ID, Error>) -> ()))) {
        CKContainer.default().fetchUserRecordID { returnedId, returnedError in
            if let id = returnedId {
                completionHandler(.success(id))
            } else {
                completionHandler(.failure(CloudKitError.iCloudCouldNotFetchUserRecordIC))
            }
        }
    }
    
    static private func discoverUserIdentity(id: CKRecord.ID, completionHandler: (@escaping ((Result<String, Error>) -> ()))) {
        CKContainer.default().discoverUserIdentity(withUserRecordID: id) { returnedIdentity, returnedError in
            DispatchQueue.main.async {
                if let name = returnedIdentity?.nameComponents?.givenName {
                    completionHandler(.success(name))
                } else {
                    completionHandler(.failure(CloudKitError.iCloudCouldNotDiscoverUser))
                }
            }
        }
    }
    
    static func discoverUserIdentity(completion: @escaping (Result<String, Error>) -> ()) {
        fetchUserRecordId { fetchCompletion in
            switch fetchCompletion {
            case .success(let recordID):
                discoverUserIdentity(id: recordID, completionHandler: completion)
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
    
    static func discoverUserIdentity() -> Future<String, Error> {
        Future { promise in
            discoverUserIdentity { result in
                promise(result)
            }
        }
    }
}
  • 뷰 모델에서 사용할 함수 접근 가능 및 다른 함수 private 선언
  • 컴플리션 핸들러 클로저 사용 메소드 → Future 값 리턴 → 뷰 모델이 sink를 통해 리턴받는 값
  • Result<Success, Failure>을 그대로 Promise가 가질 수 있음
import SwiftUI
import Combine

class CloudKitUserBootCampViewModel: ObservableObject {
    @Published var permissionStatus: Bool = false
    @Published var isSignedIntoiCloud: Bool = false
    @Published var error: String = ""
    @Published var userName: String = ""
    var cancellables = Set<AnyCancellable>()
    
    init() {
        getiCloudStatus()
        requestPermission()
        getCurrentUserName()
    }
    
    private func getiCloudStatus() {
        CloudKitUtility.getiCloudStatus()
            .sink { [weak self] completion in
                switch completion {
                case .finished: break
                case .failure(let error):
                    guard let self = self else { return }
                    self.error = error.localizedDescription
                    print(error.localizedDescription)
                }
            } receiveValue: { [weak self] bool in
                DispatchQueue.main.async {
                    guard let self = self else { return }
                    self.isSignedIntoiCloud = true
                }
            }
            .store(in: &cancellables)
    }
    
    func requestPermission() {
        CloudKitUtility.getApplicationPermission()
            .sink { [weak self] completion in
                guard let self = self else { return }
                switch completion {
                case .finished: break
                case .failure(let error):
                    self.error = error.localizedDescription
                }
            } receiveValue: { [weak self] bool in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.permissionStatus = true
                }
            }
            .store(in: &cancellables)
    }
    
    func getCurrentUserName() {
        CloudKitUtility.discoverUserIdentity()
            .receive(on: DispatchQueue.main)
            .sink { _ in
            } receiveValue: { [weak self] returnedName in
                guard let self = self else { return }
                self.userName = returnedName
            }
            .store(in: &cancellables)
    }
}
  • 서비스 클래스 구독 → store을 통해 저장
  • Combine을 통해 받은 값을 바탕으로 UI 업데이트 시 weak self, 메인 스레드 주의(또는 receive를 통해 명시적 선언)
// MARK: CRUD FUNCTIONS
extension CloudKitUtility {
    
    static func createOperation(predicate: NSPredicate,
                                recordType: CKRecord.RecordType,
                                sortDescriptors: [NSSortDescriptor]? = nil,
                                resultsLimit: Int? = nil) -> CKQueryOperation {
        let query = CKQuery(recordType: recordType, predicate: predicate)
        query.sortDescriptors = sortDescriptors
        let queryOperation = CKQueryOperation(query: query)
        if let resultsLimit = resultsLimit {
            queryOperation.resultsLimit = resultsLimit
        }
        return queryOperation
    }
    
    static func fetch<T:CloudKitableProtocol>(predicate: NSPredicate,
                      recordType: CKRecord.RecordType,
                      sortDescriptors: [NSSortDescriptor]? = nil,
                      resultsLimit: Int? = nil) -> Future<[T], Error> {
        Future { promise in
            fetch(predicate: predicate, recordType: recordType) { (items:[T]) in
                promise(.success(items))
            }
        }
    }
    
    static private func fetch<T:CloudKitableProtocol>(predicate: NSPredicate,
                      recordType: CKRecord.RecordType,
                      sortDescriptors: [NSSortDescriptor]? = nil,
                      resultsLimit: Int? = nil,
                      completionHandler: @escaping (_ items: [T]) -> ()) {
        // Execute operation
        let operation = createOperation(predicate: predicate, recordType: recordType, sortDescriptors: sortDescriptors, resultsLimit: resultsLimit)
        // Get items in query
        var returnedItems = [T]()
        addRecordMatchedBlock(queryOperation: operation) { item in
            returnedItems.append(item)
        }
        // Query completion
        addQueryResultBlock(queryOperation: operation) { finished in
            completionHandler(returnedItems)
        }
        add(operation: operation)
    }
    
    static private func addRecordMatchedBlock<T:CloudKitableProtocol>(queryOperation: CKQueryOperation, completionHandelr: @escaping (_ item: T) -> ()) {
        if #available(iOS 15, *) {
            queryOperation.recordMatchedBlock = { (returnedRecordId, returnedResult) in
                switch returnedResult {
                case .success(let record):
                    guard let item = T(record: record) else { return }
                    completionHandelr(item)
                    break
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                }
            }
        } else {
            queryOperation.recordFetchedBlock = { returnedRecord in
                guard let item = T(record: returnedRecord) else { return }
                completionHandelr(item)
            }
        }
    }
    
    static private func addQueryResultBlock(queryOperation: CKQueryOperation, completionHandler: (@escaping (_ finished: Bool) -> ())) {
        if #available(iOS 15, *) {
            queryOperation.queryResultBlock = { returnedResult in
                completionHandler(true)
            }
        } else {
            queryOperation.queryCompletionBlock = { (returnedCursor, returnedError) in
                completionHandler(true)
            }
        }
    }
    
    static private func add(operation: CKDatabaseOperation) {
        CKContainer.default().publicCloudDatabase.add(operation)
    }
    
    static func add<T:CloudKitableProtocol>(item: T, completionHandler: @escaping(Result<Bool, Error>) -> ()) {
        let record = item.record
        // Save to CloudKit
        save(record: record, completionHandler: completionHandler)
    }
    
    static func update<T:CloudKitableProtocol>(item: T, completionHandler: @escaping(Result<Bool, Error>) -> ()) {
        add(item: item, completionHandler: completionHandler)
    }
    
    static func save(record: CKRecord, completionHandler: @escaping (Result<Bool, Error>) -> ()) {
        CKContainer.default().publicCloudDatabase.save(record) { (returnedRecord, returnedError) in
            if let returnedError = returnedError {
                completionHandler(.failure(returnedError))
            } else {
                completionHandler(.success(true))
            }
            
        }
    }
    
    static func delete<T:CloudKitableProtocol>(item: T) -> Future<Bool, Error> {
        Future { promise in
            CloudKitUtility.delete(item: item, completionHandler: promise)
        }
    }
    
    static func delete<T:CloudKitableProtocol>(item: T, completionHandler: @escaping (Result<Bool, Error>) -> ()) {
        delete(record: item.record, completionHandler: completionHandler)
    }
    
    static private func delete(record: CKRecord, completionHandler: @escaping (Result<Bool, Error>) -> ()) {
        CKContainer.default().publicCloudDatabase.delete(withRecordID: record.recordID) { returnedID, returnedError in
            if let returnedError = returnedError {
                completionHandler(.failure(returnedError))
            } else {
                completionHandler(.success(true))
            }
        }
    }
}
  • predicate, recordType, sortDescriptors 등 데이터베이스 쿼리문을 작성하는 데 필요한 요소의 파라미터화
  • 함수 디폴트 값을 통해 효율적 구현 가능
  • 제네릭 타입 선언을 통한 해당 프로토콜을 준수하는 구조체를 모두 핸들링 가능
import SwiftUI
import Combine

class CloudKitCRUDBootCampViewModel: ObservableObject {
    @Published var text: String = ""
    @Published var fruits: [FruitModel] = []
    @Published var isUpdatingItem: Bool = false
    var selectedFruit: FruitModel? = nil
    var placeholder: String {
        isUpdatingItem ? "Update \(selectedFruit?.name ?? "Fruit") name with..." : "Add fruit name here..."
    }
    var cancellables = Set<AnyCancellable>()
    
    init() {
        fetchItems()
    }
    
    func addButtonPressed() {
        guard !text.isEmpty else { return }
        addItem(name: text)
    }
    
    func fetchItems() {
        let predicate = NSPredicate(value: true)
        
        CloudKitUtility.fetch(predicate: predicate, recordType: "Fruits")
            .receive(on: DispatchQueue.main)
            .sink { _ in
                
            } receiveValue: { [weak self] (returnedItems:[FruitModel]) in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    self.fruits = returnedItems
                }
            }
            .store(in: &cancellables)
    }
    
    private func addItem(name: String) {
        guard
            let image = UIImage(named: "peach"),
            let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("peach.png"),
            let data = image.pngData() else { return }
        do {
            try data.write(to: url)
            guard let newFruit = FruitModel(name: name, imageURL: url) else { return }
            CloudKitUtility.add(item: newFruit) { [weak self] result in
                guard let self = self else { return }
                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                    self.fetchItems()
                    self.text = ""
                }
            }
        } catch {
            print(error.localizedDescription)
        }
    }
    
    func updateItem() {
        guard !text.isEmpty && isUpdatingItem, let fruit = selectedFruit, let newFruit = fruit.update(newName: text) else { return }
        isUpdatingItem = false
        CloudKitUtility.update(item: newFruit) { [weak self] result in
            guard let self = self else { return }
            DispatchQueue.main.async {
                self.text = ""
                self.fetchItems()
            }
        }
    }
    
    func deleteItem(indexSet: IndexSet) {
        guard let index = indexSet.first else { return }
        let fruit = fruits[index]
        CloudKitUtility.delete(item: fruit)
            .receive(on: DispatchQueue.main)
            .sink { _ in
            } receiveValue: { [weak self] success in
                print("DELETE IS: \(success)")
                guard let self = self else { return }
                self.fruits.remove(at: index)
            }
            .store(in: &cancellables)
    }
}
  • CloudKit을 더 이상 모듈에 임포트하지 않은 뷰 모델 구조
// MARK: PUSH NOTIFICATION FUNCTIONS
extension CloudKitUtility {
    static private func requestNotificationPermission(options: UNAuthorizationOptions, completionHandler: (@escaping (Result<Bool, Error>) -> ())) {
        UNUserNotificationCenter.current().requestAuthorization(options: options) { bool, error in
            if let error = error {
                completionHandler(.failure(error))
            } else {
                completionHandler(.success(true))
            }
        }
    }
    
    static func requestNotificationPermission(options: UNAuthorizationOptions = [.alert, .sound, .badge]) -> Future<Bool, Error> {
        Future { promise in
            CloudKitUtility.requestNotificationPermission(options: options) { result in
                promise(result)
            }
        }
    }
    
    static private func subscribeToNotification(recordType: String, predicate: NSPredicate, options: CKQuerySubscription.Options, subscriptionID: String, title: String, alertBody: String, soundName: String, completionHandler: (@escaping (Result<Bool, Error>) -> ())) {
        let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: subscriptionID, options: options)
        let notification = CKSubscription.NotificationInfo()
        notification.title = title
        notification.alertBody = alertBody
        notification.soundName = soundName
        subscription.notificationInfo = notification
        CKContainer.default().publicCloudDatabase.save(subscription) { returnedSubscription, returnedError in
            if let returnedError = returnedError {
                completionHandler(.failure(returnedError))
            } else {
                completionHandler(.success(true))
            }
        }
    }
    
    static func subscribeToNotification (recordType: String, predicate: NSPredicate = NSPredicate(value: true), options: CKQuerySubscription.Options = [.firesOnRecordCreation], subscriptionID: String, title: String = "There's a new fruit here!", alertBody: String = "Open the app and check it out", soundName: String = "defaut") -> Future<Bool, Error> {
        Future { promise in
            subscribeToNotification(recordType: recordType, predicate: predicate, options: options, subscriptionID: subscriptionID, title: title, alertBody: alertBody, soundName: soundName) { result in
                promise(result)
            }
        }
    }
    
    static private func unsubsribeToNotification(subscriptionID: String, completionHandler: (@escaping (Result<Bool, Error>) -> ())) {
        CKContainer.default().publicCloudDatabase.delete(withSubscriptionID: subscriptionID) { returnedID, returnedError in
            if let returnedError = returnedError {
                completionHandler(.failure(returnedError))
            } else {
                completionHandler(.success(true))
            }
        }
    }
    
    static func unsubsribeToNotification(subscriptionID: String) -> Future<Bool, Error> {
        Future { promise in
            unsubsribeToNotification(subscriptionID: subscriptionID) { result in
                promise(result)
            }
        }
    }
}
  • 컴플리션 핸들러 → Future 리턴 반복
import SwiftUI
import Combine

class CloudKitPushNotificationBootCampViewModel: ObservableObject {
    let subscriptionID = "fruit_added_to_database"
    let recordType = "Fruits"
    var cancellables = Set<AnyCancellable>()
    
    func requestNotificationPermission() {
        CloudKitUtility.requestNotificationPermission()
            .receive(on: DispatchQueue.main)
            .sink { _ in
            } receiveValue: { returnedData in
            }
            .store(in: &cancellables)
    }
    
    func subscribeToNotifications() {
        CloudKitUtility
            .subscribeToNotification(recordType: recordType, subscriptionID: subscriptionID)
            .receive(on: DispatchQueue.main)
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                    break
                case .finished:
                    break
                }
            } receiveValue: { returnedData in
                print("SUCCESSFULLY SUBSCRIBE NOTIFICATION")
            }
            .store(in: &cancellables)
    }
    
    func unsubscribeToNotification() {
        CloudKitUtility
            .unsubsribeToNotification(subscriptionID: subscriptionID)
            .receive(on: DispatchQueue.main)
            .sink { completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                case .finished:
                    break
                }
            } receiveValue: { returnedData in
                print("SUCCESSFULLY UNSUBSCRIBE NOTIFICATION")
            }
            .store(in: &cancellables)
    }
}
  • CloudKit 모듈 사용이 아니라 Combine을 통한 값 리턴
  • 뷰 모델 역할에 충실한 리팩토링

구현 화면

뷰 모델은 뷰 모델의, 서비스 클래스는 서비스 클래스의 역할을 분리하는 게 핵심! 동일한 메소드를 작성하더라도 private func, func를 통해 뷰 모델과 같은 외부에서 접근 가능한 가이드라인을 줄 수 있다. 외부에서 사용할 메소드는 최대한 간단하게 리팩토링하자!

profile
JUST DO IT

0개의 댓글