• Concurrent Data models

    • 샘플 데이터 모델
    struct SpacePhoto {
    	var title: String
    	var description: String
    	var date: Date
    	var url: URL
    	...
    }
    
    struct SpacePhoto: Codable {}
    struct SpacePhoto: Identifiable {}
    
    class Photos: ObservableObject {
    	@Published var items: [SpacePhoto]
    	
    	func updateItems() {
    		// TO-DO:
    	}
    }
    
    • 샘플 뷰
    struct CatalogView: View {
    	@StateObject private var photos = Photos()
    
    	var body: some View {
    		NavigationView {
    			List {
    				ForEach(photos.items) { item in
    					PhotoView(photo: item)
    						.listRowSeparator(.hidden)
    				}
    			}
    			.navigationTitle("Catalog")
    			.listStyle(.plain)
    		}
    	}
    }
    
    struct PhotoView: View {
    	var photo: SpacePhoto
    
    	var body: some View {
    		ZStack(alignment: .bottom) {
    			HStack {
    				Text(photo.title)
    				Spacer()
    			}
    			.padding()
    			.background(.thinMaterial)
    		}
    		.background(.thickMaterial)
    		.mask(RoundedRectangle(cornerRadius: 16))
    		.padding(.bottom, 8)
    	}
    }
    
    • 5.5부터 메인 런루프는 메인 액터에서 동작한다.
    • updateItems 구현
    func updateItems() {
    		let fetched = /* 새로운 아이템 가져옴 */
    		items = fetched // objectWillChange 이벤트 발생. 이때 snapshot이 만들어지고
    		// 이 스냅샷을 가지고 기존 데이터와 diff를 수행해서 업데이트 
    }
    
    • 이때 메인 액터에서 너무 많은 일을 하면 업데이트가 느려지고, 사용자 이벤트를 받지 못하게 된다.

      • 업데이트 틱을 놓치게 되고, 사용자에게는 hitch로 보이게 된다.

      • 그렇다고 업데이트를 메인 액터가 아닌곳에서 하면, 런루프 틱이 발생할 때, 상태 변화가 없을 수 있다.

        func updateItems() {
        	let fetched = fetchPhotos()
        	items = fetched 
        }
        
      • objectWillChange, 상태 업데이트, 런루프 틱은 항상 이 순서대로 일어나야만 한다.

      • 메인 액터에서 이를 수행하면, 이를 보장할 수 있다. 그래서 DispatchQueue.main을 사용했다.

    • 이제는 await를 쓰면 된다! 작업을 메인 액터에 yield 시킨다.

      func updateItems() async {
      		let fetched = await fetchPhotos()
      		items = fetched 
      }
      
      // task group을 쓰면 더 효율적이지만 여기서는 샘플이니까
      func fetchPhotos() await -> [SpacePhoto] {
      	var downloaded: [SpacePhoto] = []
      	for date in randomPhotoDates() {
      		let url = SpacePhoto.requestFor(date: date)
      		if let photo = await fetchPhoto(from: url) {
      			downloaded.append(photo)
      		}
      	}
      	return downloaded
      }
      
      func fetchPhoto(from url: URL) async -> SpacePhoto {
      	do {
      		let (data, _) = try await URLSession.shared.data(from: url)
      		return try SpacePhoto(data: data)
      	} catch {
      		return nil
      	}
      }
      
    • 클래스에 @MainActor를 명시하면 해당 클래스의 모든 메소드들은 MainActor에서 돌아간다.

      @MainActor
      class Photos: ObservableObject {
      
    • View에서는 .task modifier로 호출한다. → onAppear에서 하던 비동기 호출을 여기서 하면 된다.

      • task는 뷰의 생명주기와 동일하게 간다
      struct CatalogView: View {
      	@StateObject private var photos = Photos()
      
      	var body: some View {
      		NavigationView {
      			List {
      				ForEach(photos.items) { item in
      					PhotoView(photo: item)
      						.listRowSeparator(.hidden)
      				}
      			}
      			.navigationTitle("Catalog")
      			.listStyle(.plain)
      		}
      		.task {
      			await photos.updateItems()
      		}
      	}
      }
      
    • 이미지는 AsyncImage로 호출한다.

    struct PhotoView: View {
    	var photo: SpacePhoto
    
    	var body: some View {
    		ZStack(alignment: .bottom) {
    			AsyncImage(url: photo.url) { image in
    				image
    					.resizable()
    					.aspectRatio(contentMode: .fill) 
    			} placeholder: {
    				progressView()
    			}.frame(minWidth: 0, minHeight: 400)
    
    			HStack {
    				Text(photo.title)
    				Spacer()
    				SavePhotoButton(photo: photo)
    			}
    			.padding()
    			.background(.thinMaterial)
    		}
    		.background(.thickMaterial)
    		.mask(RoundedRectangle(cornerRadius: 16))
    		.padding(.bottom, 8)
    	}
    }
    
  • 저장 버튼 구현

    struct SavePhotoButton: View {
    	var photo: SpacePhoto
    	@State private var isSaving = false
    
    	var body: some View {
    		Button {
    			async {
    				isSaving = true
    				await photo.save()
    				isSaving = false
    			}
    		} label: {
    			Text("Save")
    				.opacity(isSaving ? 0 : 1)
    				.overlay { // localization에 따라서 뷰 크기가 유동적으로 조절되도록
    					if isSaving {
    						ProgressView()
    					}
    				}
    		}
    		.disabled(isSaving)
    		.buttonStyle(.bordered)
    	}
    }
    
  • 리프레시 기능 구현

    struct CatalogView: View {
    	@StateObject private var photos = Photos()
    
    	var body: some View {
    		NavigationView {
    			List {
    				ForEach(photos.items) { item in
    					PhotoView(photo: item)
    						.listRowSeparator(.hidden)
    				}
    			}
    			.navigationTitle("Catalog")
    			.listStyle(.plain)
    			.refreshable {
    					await photos.updateItems()
    			}
    		}
    		.task {
    			await photos.updateItems()
    		}
    	}
    }
    
    • 이러한 툴들을 통해서 동시성은 tricky한 게 아니라 managed하게 되었다.