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
목차
- 아이클라우드 권한 체크
- 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 쪽 참고
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:
- 이 정책은 클라이언트의 레코드 전체를 서버 레코드에 덮어씀. 서버 레코드의 모든 필드는 클라이언트가 보낸 필드로 대체. 이 정책을 사용할 경우, 서버의 모든 기존 데이터가 클라이언트의 데이터로 교체.
- 예를 들어, 사용자가 폼을 제출할 때, 모든 입력 필드를 업데이트하는 애플리케이션에서 사용될 수 있.
- ifServerRecordUnchanged:
/// 메모 저장 및 수정
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
- 한번에 가졍로 수 있는 레코드의 최대 갯수
- 레코드의 갯수가 많은 경우 페이징해 가졍로 수 있음.
- 아래 예제에서는 시스템이 허용하는 최대치
- inZoneWith
/// 모든 데이터 불러옴
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)))
}
}
}
}