apple/Docs, iOS, Swift

CloudKit 정리 코드 예제 #3

lgvv 2024. 8. 18. 16:45

CloudKit 정리 코드 예제 #3

CloudKit 정리 환경설정 #1

https://rldd.tistory.com/607
CloudKit 정리 이론 #2
https://rldd.tistory.com/619
CloudKit 정리 코드 예제 #3

https://rldd.tistory.com/631

목차

  • 아이클라우드 권한 체크
  • CloudKit 코드 기본 설정
    • 데이터 저장
    • 데이터 수정
    • 데이터 삭제
  • 데이터 불러오기
    • 서버 변경 사항이 있는지 토큰값을 기반으로 변경사항 체크
    • 데이터 로드


아이클라우드 권한 체크

아이클라우드 권한 체크하는 코드

   func checkiCloudAccountStatus(completion: @escaping (CKAccountStatus) -> Void) {
        CKContainer.default().accountStatus { accountStatus, error in
            if let error = error {
                print("Error fetching iCloud account status: \(error)")
                return
            }
            
            switch accountStatus {
            case .available:
                completion(.available)
            case .noAccount:
                print("No iCloud account found. Please log in to iCloud.")
            case .restricted:
                print("iCloud account is restricted.")
            case .couldNotDetermine:
                print("Could not determine iCloud account status.")
            @unknown default:
                print("Unknown iCloud account status.")
            }
        }
    }

 

 

CloudKit 코드 기본 설정

 

  • 컨테이너 아이디: 프로젝트에 설정한 컨테이너 아이디
  • 존: CloudKit 콘솔에서 만든 존
  • 레코드 타입: 레코드 타입
  • 컨테이너: 컨테이너 이름
  • 데이터베이스: public, shared, private 데이터베이스
  • 토큰: 마지막으로 바뀌었는지 토큰 값.
    • 타겟이 iOS 17 이상이라면 CKSyncEngine을 통해 적절한 타이밍에 노티를 받아서 처리하는 방식으로 처리 가능.

 

    /// 아이클라우드 앱 컨테이너 아이디(프로젝트에 설정한 아이디)
    private let containerID = "iCloud.com.dailytodo.app"
    /// 아이클라우드 존
    private let zone = CKRecordZone(zoneName: "dailytodo")
    /// 아이클라우드 레코드 타입
    private let recordType = "Memo"
    /// 컨테이너
    private var container: CKContainer {
        CKContainer(identifier: containerID)
    }
    /// 아이클라우드 프라이빗 데이터 베이스
    private var privateDatabase: CKDatabase {
        container.privateCloudDatabase
    }
    /// 아이클라우드 원격 데이터로부터 마지막 체인지 토큰
    private var lastChangeToken: CKServerChangeToken?

 

 

데이터 저장

  • 데이터 저장의 경우 심플함.
  • save의 클로저를 통해 에러 및 record를 받아 에러 핸들리 가능.
  • 주의할 점
    • save의 경우 중복 값이 존재하더라도 다른 다른 RecordName이 할당되어서 다른 값으로 인식하고 저장.
  • 스킬
    • modify를 활용하면 save를 보다 안전하게 처리할 수 있음. 아래 modify 쪽 참고

다른 레코드 네임 (id는 내가 만든 커스텀 필드)

   func save() {
        // 레코드 타입 주기
        let record = CKRecord(recordType: "Notice")
        
        // 저장한 값을 dictionary 형태로 생성
        record.setValuesForKeys([
            "title": "\(Date.now.description)",
            "viewership": 10
        ])
        
        // 값 저장
        privateCloudDatabase.save(record) { record, error in }
    }

 

데이터 수정

수정은 다양하게 활용할 수 있음. 특히 옵션에 따라 데이터 처리되는 부분이 다르므로 반드시 유의

 

 

매우 간단한 형태

 

단순히 레코드의 값을 변경함.

    /// 간단한 형태의 수정
    func modify(ckRecord: CKRecord) {
        var ckRecord = ckRecord
        
        ckRecord.setValue(1000, forKey: "viewership")
        
        let modifyRecordsOperation = CKModifyRecordsOperation(
            recordsToSave: [ckRecord],
            recordIDsToDelete: nil
        )
    }

 

 

 

조금 더 유려한 형태

      • save 대신 중복값을 피하기 위해 로직을 유려하게 대응
      • savePolicy:
        • ifServerRecordUnchanged:
          • 이 정책은 서버의 레코드가 변경되지 않은 경우에만 클라이언트의 변경을 저장. 클라이언트가 보낸 변경 사항이 서버의 레코드와 일치하는 경우에만 저장이 허용.
          • 예를 들어, 은행 거래 애플리케이션에서 이 정책을 사용하여 마지막으로 확인된 잔액과 거래가 일치하는지 확인할 수 있음
        • changedKeys:
          • 이 정책은 클라이언트가 보낸 변경된 키들만 서버 레코드에 덮어씀. 서버 레코드의 나머지 키는 그대로 유지. 클라이언트는 레코드 전체를 보내지 않고, 실제로 변경된 키만 전송.
          • 예를 들어, 사용자가 프로필 사진만 변경하고 나머지 프로필 정보는 유지하는 소셜 미디어 애플리케이션에서 사용될 수 있음.
        • allKeys:
          • 이 정책은 클라이언트의 레코드 전체를 서버 레코드에 덮어씀. 서버 레코드의 모든 필드는 클라이언트가 보낸 필드로 대체. 이 정책을 사용할 경우, 서버의 모든 기존 데이터가 클라이언트의 데이터로 교체.
          • 예를 들어, 사용자가 폼을 제출할 때, 모든 입력 필드를 업데이트하는 애플리케이션에서 사용될 수 있.
    •  
  •  
 /// 메모 저장 및 수정
    func save(memo: Memo) async {
        let record = makeRecord(memo: memo)
        record.setValuesForKeys([
            "id": memo.id.uuidString,
            "created_at": memo.createdAt,
            "text": memo.text,
            "tag": memo.tag.rawValue,
            "type": memo.type.stringValue,
            "todo_phase": memo.todoPhase.rawValue
        ])

        privateDatabase.modifyRecords(
            saving: [record],
            deleting: [],
            savePolicy: .changedKeys
        ) { result in
            switch result {
            case .success(let success):
                print("🏕️ 성공, \(success)")
                
            case .failure(let failure):
                print("🏕️ 실패, \(failure)")
                
            }
        }
    }

 

데이터 삭제

  • 데이터 삭제는 CKRecord 인스턴스의 recordID를 사용해서 처리할 수 있음.
  • 주의할 점
    • 데이터 삭제의 경우 비용이 생각보다 큼. 모든 필드를 완전히 다 삭제하는 경우에는 deleteAll을 지원하지 않아서 모든 데이터를 fetch 한 후 delete를 모든 record에 대해서 수행해야 함.
    • 이보다는 새로운 zone을 만드는 것을 권장.
    /// 메모 삭제
    func delete(_ memo: Memo) async -> Result<CKRecord.ID, Error> {
        let recordID = makeRecord(memo: memo).recordID
        
        do {
            let recordID = try await privateDatabase.deleteRecord(withID: recordID)
            return .success(recordID)
        } catch {
            return .failure(error)
        }
    }

 

모든 값을 삭제하고 싶은 경우

 

이러 형태로 처리 가능하나 권장하지 않음.

private func deleteAll() async -> Void {
    await withTaskGroup(of: Void.self) { taskGroup in
        for record in records {
            taskGroup.addTask {
                await self.deleteRecord(recordID: record.recordID)
            }
        }
        // 모든 태스크가 완료될 때까지 대기
        while let _ = await taskGroup.next() {
            print("🧑🏻‍💻 모두 삭제 호출 3")
        }
    }
}

/// 단일 Record 삭제
private func deleteRecord(recordID: CKRecord.ID) async {
    await withCheckedContinuation { continuation in
        privateCloudDatabase.delete(withRecordID: recordID) { recordId, error in
            if let error = error {
                print("Error deleting record: \(error)")
            }
            continuation.resume()
        }
    }
}

 

데이터 불러오기

데이터를 로드하는 과정은 여러 옵션을 줄 수 있음.

 

서버 변경 사항이 있는지 토큰값을 기반으로 변경사항 체크

 

  • 키값을 체크할 수 있음 불러온 토큰 값을 UserDefault에 저장하는 로직
  • 존이 없을 경우 존을 새로 생성
  • moreComing 키워드를 통해 더 불러올 것이 있는지 확인
    private func loadLastChangeToken() {
        guard let data = UserDefaults.standard.data(forKey: "lastChangeToken"),
              let token = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) else {
            return
        }

        lastChangeToken = token
    }
    
    private func saveChangeToken(_ token: CKServerChangeToken) {
        let tokenData = try! NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)

        lastChangeToken = token
        UserDefaults.standard.set(tokenData, forKey: "lastChangeToken")
    }
    
    // 해당 디바이스에서 존 정보 갱신
    func createZoneIfNeeded() async {
        guard !UserDefaults.standard.bool(forKey: "isZoneCreated") else {
            return
        }

        do {
            _ = try await privateDatabase.modifyRecordZones(saving: [zone], deleting: [])
        } catch {
            print(error.localizedDescription)
        }

        UserDefaults.standard.setValue(true, forKey: "isZoneCreated")
    }
    
        
    /// 가장 최근 변화가 있는지 체크
    func fetchLatestChanges() async -> Result<([CKRecord], [CKRecord.ID]), CloudKitMemoServiceError> {
        if isLoadingFetchLatestChanges {
            return .failure(.loadingFetchLatestChanges)
        }
        
        isLoadingFetchLatestChanges = true
        defer { isLoadingFetchLatestChanges = false }
        
        var awaitingChanges = true
        
        loadLastChangeToken()
        await createZoneIfNeeded()

        var changedRecords: [CKRecord] = []
        var deletedRecordIDs: [CKRecord.ID] = []
        
        while awaitingChanges {
            do {
                let changes = try await privateDatabase.recordZoneChanges(
                    inZoneWith: zone.zoneID,
                    since: lastChangeToken
                )
                
                changedRecords = changes.modificationResultsByID.compactMap { try? $1.get().record }
                deletedRecordIDs = changes.deletions.map { $0.recordID }

                saveChangeToken(changes.changeToken)
                awaitingChanges = changes.moreComing
            } catch {
                awaitingChanges = false
            }
        }
        
        return .success((changedRecords, deletedRecordIDs))
    }

 

데이터 로드

  • NSPredicate: Query 필터링 혹은 조건 지정. true로 설정할 경우 모든 레코드를 포함하는 쿼리 생성
  • fetch 인자값
    • inZoneWith
      • 해당 구문을 실행할 존을 지정
    • desiredKeys:
      • 가져올 레코드의 특정 키 지정하는 배열. nil일 경우 모든 필드 가져옴
      • ["name", "date"]와 같이 지정하면 해당 필드만 가져옴.
    • resultsLimit
      • 한번에 가졍로 수 있는 레코드의 최대 갯수
      • 레코드의 갯수가 많은 경우 페이징해 가졍로 수 있음.
      • 아래 예제에서는 시스템이 허용하는 최대치

 

/// 모든 데이터 불러옴
    func fetchAll() async -> Result<[Memo],  CloudKitMemoServiceError> {
        let predicate = NSPredicate(value: true)
        let query = CKQuery(recordType: recordType, predicate: predicate)
        
        return await withCheckedContinuation { continuation in
            privateDatabase.fetch(
                withQuery: query,
                inZoneWith: zone.zoneID,
                desiredKeys: nil,
                resultsLimit: CKQueryOperation.maximumResults
            ) { result in
                switch result {
                case let .success(success):
                    let records = success.matchResults.compactMap { try? $0.1.get() }
                    
                    var memos: [Memo] = []
                    
                    records.forEach { record in
                        let memo = Memo.make(record: record)
                        memos.append(memo)
                    }
                    
                    continuation.resume(returning: .success(memos))
                    
                case let .failure(failure):
                    continuation.resume(returning: .failure(.message(failure.localizedDescription)))
                }
            }
        }
    }