Creating a reusable utility class for CloudKit code | Advanced Learning #25
CloudKitUserBootCamp
, CloudKitCRUDBootCamp
등 뷰 모델이 사용하고 있던 함수 기능 추출 CloudKitUtility
클래스 전환 → 데이터 서비스 클래스. 클래스 내 저장 프로퍼티가 없기 때문에 함수 자체에서 값 리턴만 필요, static
메소드로 구현, 싱글턴 패턴 필요 XCombine
통해 각 뷰 모델이 필요한 리턴 값 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
를 통해 뷰 모델과 같은 외부에서 접근 가능한 가이드라인을 줄 수 있다. 외부에서 사용할 메소드는 최대한 간단하게 리팩토링하자!