MapKit (10)
너무나 오래간만에 업로드 그래도 강의를 끝내야 하기에 계속해서 글을 작성한다.
Update Check-In Status
이번엔 LocationDetailView를 조금 더 보완한다.
우선 열거형인 enum을 통해 checkin/out에 관한걸 만들어준다.
1
2
3
4
// LocationDetailViewModel
enum CheckInStatus {
case checkedIn, checkedOut
}
이제 ViewModel에 Status를 업데이트하는 함수를 만들어준다.
1
2
3
4
5
func updateCheckInStatus(to checkinStatus: CheckInStatus) {
// 1. Retrieve the DDGProfile
// 2. Create a reference to the location
// 3. Save the updated profile to CloudKit
}
주석을 보면 알겠지만 3단계에 걸쳐서 업데이트를 하게된다.
CloudKitManager에서 변수를 하나 만들어준다. var profileRecordID: CKRecord.ID?
옵셔널인 이유는 프로필이 없는 경우도 있기 때문
이전에 유저레코드를 가져오는걸 했었다.
유저레코드가 존재한다면 굳이 새로 호출할 필요없이 여기서 바로 profileRecordID에 값을 부여하는 식으로 한다.
1
2
3
4
5
6
7
8
9
func getUserRecord() {
CKContainer.default().fetchUserRecordID { recordID, error in
// 생략
if let profileReference = userRecord["userProfile"] as? CKRecord.Reference { // Added
self.profileRecordID = profileReference.recordID
}
}
}
유져가 프로필을 새로 만들었을 경우엔 RecordID가 없으므로
ProfileViewModel에서 내용을 추가해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ProfileViewModel
func createProfile() {
//생략
showLoadingView()
CloudKitManager.shared.batchSave(records: [userRecord, profileRecord]) { result in
DispatchQueue.main.async { [self] in
hideLoadingView()
switch result {
case .success(let records):
for record in records where record.recordType == RecordType.profile {
existingProfileRecord = record
CloudKitManager.shared.profileRecordID = record.recordID // Added
}
alertItem = AlertContext.createProfileSuccess
case .failure(_):
// 생략
}
}
}
}
이제 LocationDetailViewModel로 가서 updateCheckInStatus 함수에 내용을 추가해주자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func updateCheckInStatus(to checkinStatus: CheckInStatus) {
// 1. Retrieve the DDGProfile
guard let profileRecordID = CloudKitManager.shared.profileRecordID else {
// show an alert
return
}
CloudKitManager.shared.fetchRecord(with: profileRecordID) { [self] result in
switch result {
case .success(let record):
// 2. Create a reference to the location
switch checkinStatus {
case .checkedIn:
record[DDGProfile.kIsCheckedIn] = CKRecord.Reference(recordID: location.id, action: .none)
case .checkedOut:
record[DDGProfile.kIsCheckedIn] = nil
}
// 3. Save the updated profile to CloudKit
CloudKitManager.shared.save(record: record) { result in
switch result {
case .success(_):
// update our checkedInProfile array
print("✅ checked In/Out Successfully")
case .failure(_):
print("❌ Error saving record")
}
}
case .failure(_):
print("❌ Error fetching record")
}
}
}
우선은 프린트를 통해 제대로 되는지 안되는지를 확인하고, 이후에 Alert로 바꾸면 된다.
그리고
1
2
3
Button {
viewModel.updateCheckInStatus(to: .checkedOut) // new
}
LocationDetailView에 해당 버튼을 눌렀을때 작동하게끔 이제 코드를 추가해준다.
Show Checked In Profiles
1. CloudKitManager
CloudKitManager에서 다음과 같이 함수를 만들어 준다.
해당함수는 체크인한 프로필들을 가져오는 역할을 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func getCheckedInProfiles(for locationID: CKRecord.ID, completed: @escaping (Result<[DDGProfile], Error>) -> Void) {
let reference = CKRecord.Reference(recordID: locationID, action: .none)
let predicate = NSPredicate(format: "isCheckedIn == %@", reference)
let query = CKQuery(recordType: RecordType.profile, predicate: predicate)
CKContainer.default().publicCloudDatabase.perform(query, inZoneWith: nil) { records, error in
guard let records = records, error == nil else {
completed(.failure(error!))
return
}
let profiles = records.map { $0.convertToDDGProfile() }
completed(.success(profiles))
}
}
해당코드에 대해서 설명을 간단히 해보자면
우선 레퍼런스를 만들어 준다. Reference Docs
여기서 레퍼런스란 데이터베이스 내의 두 레코드 사이에 N:1(Many-to-One) 관계를 설정하는 객체이다.
그리고 predicate를 만들어 준다. (이때의 포맷은 isCheckedIn 으로 해준다.)
이제 쿼리를 만들어주는데, profile에서 해당 predicate를 충족 하는 query이다.
그래서 container의 db에서 해당 query를 실행하게 하는데, profiles에 query를 충족하는 profile 들만 담도록 하는것이다.
이때 CKRecord를 그대로 사용할 수 없기에 convertToDDGProfile를 사용해주었다.
2. LocationViewModel
이제 LocationVM으로 가서
새롭게 함수를 만들어준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Published var checkedInProfiles: [DDGProfile] = []
func getCheckedInProfiles() {
CloudKitManager.shared.getCheckedInProfiles(for: location.id) { [self] result in
DispatchQueue.main.async {
switch result {
case .success(let profiles):
checkedInProfiles = profiles
case .failure(_):
print("Error fetching checkedIn Profiles")
}
}
}
}
새롭게 만든 checkedInProfiles에 담아주는 것이다. 이때 profiles 에 대한 update는 UI가 바뀜을 의미하므로 DispatchQueue를 사용하여 Background 단위에서 실행하도록 해준다.
3. LocationDetailView
ScrollView의 LazyVgrid 부분에 Foreach를 넣어준다
1
2
3
4
5
6
7
8
9
10
11
12
13
ScrollView {
LazyVGrid(columns: viewModel.columns) {
ForEach(viewModel.checkedInProfiles) { profile in
FirstNameAvatarView(
image: PlaceholderImage.avatar,
firstName: "Sean"
)
.onTapGesture {
viewModel.isShowingProfileModal = true
}
}
}
}
이때 DDGProfile에 Identifiable 프로토콜을 채택해준다.
ForEach가 받는 파라미터중 ID가 있는데 이 ID를 사용하기위해선 Identifiable 프로토콜이 필수 이기 때문이다.
ForEach Docs 참고.
- Identifiable 채택이 필수인 이유
ForEach가 받는 파라미터 중에는 데이터를 구분하기 위한id가 필요하다.
- 기술적 인과관계: SwiftUI는 상태가 변할 때 어떤 데이터가 추가, 삭제, 혹은 수정되었는지 알아야 화면을 효율적으로 다시 그린다(Rendering). 이때 각 데이터를 고유하게 식별해주는
id가 없으면 SwiftUI는 어떤 뷰를 업데이트해야 할지 판단할 수 없다.
- 구현 방법
- 모델
A에Identifiable프로토콜을 채택하면, 반드시id라는 프로퍼티를 가져야 한다. - struct A: Identifiable 구현 예시: UUID 등을 활용한 id 선언
1 2 3 4 5 6 7
struct A: Identifiable { let id = UUID() // 고유 식별자 생성 // or let id: CKRecord.ID // 현재 DDGProfile에 대한 방식 var name: String var description: String }
- 모델
- 만약 Identifiable을 채택하지 않는다면?
- 프로토콜을 채택하지 않고도
ForEach를 쓸 수는 있지만, 호출부에서 명시적으로 경로를 지정해줘야 한다.
- 예:
ForEach(data, id: \.self)또는ForEach(data, id: \.customID) - 하지만 모델 설계 단계에서
Identifiable을 미리 채택해두는 것이 코드를 훨씬 간결하고 안전하게 만든다.
- 프로토콜을 채택하지 않고도
그리고 ckRecordID에서 id로 변수명을 바꿔준다.
1
2
3
4
5
6
7
8
9
10
11
12
struct DDGProfile: Identifiable {
// 생략
let id: CKRecord.ID
//생략
init(record: CKRecord) {
id = record.recordID
// 생략
}
}
현재 ScrollView의 부분엔 사실 하드코딩이 되어있는 상태이다. 이미 작동테스트는 끝났기에 이제 이부분도 수정을 해본다.
우선 FirstNameAvatarView 의 부분을 먼저 수정해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct FirstNameAvatarView: View {
var profile: DDGProfile
var body: some View {
VStack {
AvatarView(image: profile.createAvatarImage(), size: 64)
Text(profile.firstName)
// 생략
}
}
}
사실 뭐 크게 어려운 부분은 없다.
기존 image, firstName 부분에 profile만 추가해주는것 밖에 없다.
그리고 Foreach를 수정해준다.
1
2
3
4
5
ForEach(viewModel.checkedInProfiles) { profile in
FirstNameAvatarView(profile: profile
)
// 생략
}
다시 LocationViewModel로 돌아가서 updateCheckInStatus 부분을 수정한다.
이제는 체크인이 되었으면 업데이트가 되어야하기 때문에, 기존에 print를 통해 작동 테스트를 했다면 이젠 업데이트가 되도록 코드를 추가해주도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func updateCheckInStatus(to checkinStatus: CheckInStatus) {
// 생략
CloudKitManager.shared.fetchRecord(with: profileRecordID) { [self] result in
switch result {
case .success(let record):
// 생략
// Save the updated profile to CloudKit
CloudKitManager.shared.save(record: record) { result in
switch result { // modified
case .success(let record):
// update our checkedInProfile array
let profile = DDGProfile(record: record)
switch checkinStatus {
case .checkedIn:
checkedInProfiles.append(profile)
case .checkedOut:
checkedInProfiles.removeAll(where: {$0.id == profile.id})
}
print("✅ checked In/Out Successfully")
case .failure(_):
print("❌ Error saving record")
}
}
case .failure(_):
print("❌ Error fetching record")
}
}
}
save 부분을 수정했다.
딱히 언급할 부분은 없어보인다.
다시 View로 돌아가서
1
2
3
Button {
viewModel.updateCheckInStatus(to: .checkedIn)
}
기존에 체크아웃을 해둔걸 체크인으로 하고 테스트를 해보도록 한다.
현재는 Who’s Here?에 아무도 없는 상태
누르면 이렇게 등록한 profile의 이미지와 이름이 나오는걸 볼 수 있다.
이젠 checkedOut으로 바꿔서 테스트 하기전 코드를 추가해야할 부분이 있는데.
우리는 view가 로드되기전에 데이터를 가져오는 코드를 추가하지 않았다. 그래서 다시시작해도 checkedin 쪽 ui는 아무것도 없을것이므로 이부분을 보완해야한다.
1
2
3
.onAppear(perform: {
viewModel.getCheckedInProfiles()
})
이렇게 추가만 해주면 된다.
다시 실행해서 테스트를 해보면
작동이 잘 되는걸 알 수 있다.
4. 문제 해결
하지만 하나의 문제가 있는데 바로
1
2
Publishing changes from background threads is not allowed;
make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
이런 경고가 뜬다. 이부분을 해결해보도록 한다.
1
2
3
4
5
DispatchQueue.main.async {
switch result {
// 생략
}
}
DispatchQueue 를 사용해서 기존에 switch 구문을 감싸주면 된다.
경고가 왜 발생하는걸까?
- 기술적 배경: CloudKit의 데이터 요청 및 저장(
save,fetch)은 네트워크 통신이므로 시스템의 백그라운드 스레드(Background Thread)에서 비동기로 실행된다.- 인과관계: 하지만
@Published나@State같은 UI 관련 프로퍼티는 화면을 다시 그리는 역할을 하므로 반드시 메인 스레드(Main Thread)에서만 업데이트되어야 한다. 백그라운드에서 UI를 건드리려 하면 위와 같은 경고가 뜨는 것이다.
5. CheckedStatus에 따라 버튼 유동적으로 기능 바꾸기
지금은 CheckedIn, CheckedOut 부분을 일일이 고쳐서 작동 테스트를 했는데,
이젠 상태에 따라 자동적으로 In/Out을 바꾸도록 해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Published var isCheckedIn = false
CloudKitManager.shared.save(record: record) { result in
DispatchQueue.main.async {
switch result {
case .success(let record):
// 생략
isCheckedIn = checkinStatus == .checkedIn
case .failure(_):
print("❌ Error saving record")
}
}
}
isCheckedIn = checkinStatus == .checkedIn 의 경우 체크인 상태라면 true, 아니라면 false를 리턴하게 한다.
물론 isCheckedIn = !isCheckedIn 도 가능하다. 강의에서는 그냥 위의 방법을 사용했을 뿐 하지만 강의에서의 방식이 안정성이 있다.
이유는 네트워크 통신은 항상 성공한다는 보장이 없다. 만약 단순하게 상태만 반전시키면 이런 문제가 생길 수 있다.
- 상태가 꼬이는 경우: 내 폰에서는 체크인 버튼을 눌러서 화면이 ‘체크인 완료’로 바뀌었는데, 실제로는 인터넷이 끊겨서 서버 저장에 실패했다면? 내 화면과 실제 데이터가 서로 달라지는 상황이 발생한다.
해결책: 그래서 나는 서버에 데이터를 보내고 나서, 서버가 돌려준 확실한 결과값을 보고 다시 한번 비교해서 화면을 바꾼다.
- 결론: “서버에서 처리가 잘 되었는지 확인하고 그 결과에 내 화면을 일치시키는 것”이 훨씬 안전하다.
이제 다시 View로 가서
1
2
3
Button {
viewModel.updateCheckInStatus(to: viewModel.isCheckedIn ? .checkedOut : .checkedIn)
}
체크인한 상태 라면 checkedOut을 활성화, 체크인 하지 않은 상태라면 checkedIn을 활성화 하도록 한다.
작동 테스트를 해보면
잘 되는걸 알 수 있다.
Github: Dub-Dub-Grub Repository