Table of Contents13 sections
1. Vì sao cần Swift Concurrency?
MVVM giải quyết bài toán tách UI khỏi logic. Swift Concurrency giải quyết bài toán xử lý các task vụ đúng thread mà không callback hell. Ghép lại, ta có một kiến trúc mà:
View chỉ hiển thị state và gửi intent — không biết gì về network/DB.
ViewModel biến intent thành state, điều phối việc bất đồng bộ, và luôn expose state trên main thread.
Repository / Service làm việc nặng (I/O) ở background, được cô lập bằng
actor.
Nguyên tắc vàng: UI-facing state luôn ở @MainActor, việc nặng luôn await xuống background, và kết quả tự động hop trở lại main thread. Bạn không tự DispatchQueue.main.async nữa — compiler lo việc đó.
2. Trách nhiệm từng layer (Layer responsibilities)
View
Chỉ render state và phát intent (tap, refresh, submit...).
Không giữ business logic, không gọi network trực tiếp.
Trong SwiftUI, View vốn đã
@MainActorsẵn. Trong UIKit,UIViewControllercũng được đánh dấu@MainActortừ Swift 5.5+.
ViewModel
Nơi biến intent → state. Giữ toàn bộ presentation logic.
Sở hữu state qua
@Published(Combine) hoặc@Observable(Observation, iOS 17+).Điều phối các tác vụ async, quản lý
Taskvà cancellation.Nên là
@MainActor— sẽ giải thích kỹ ở mục 4.
Repository / Service
Trừu tượng hoá nguồn dữ liệu (API, DB, cache). ViewModel không cần biết data đến từ đâu.
Là nơi thực sự chạy việc nặng. Cô lập trạng thái mutable bằng
actor.Trả về domain model đã map sạch, không rò rỉ DTO/JSON lên ViewModel.
Một quy tắc kiểm tra nhanh: nếu xoá hết code UI, ViewModel và Repository vẫn phải compile và test được. Nếu không, tức là logic đang bị rò lên View.
3. Luồng dữ liệu

Một request đi theo vòng:
View phát intent → gọi một
asyncmethod trên ViewModel.ViewModel
awaitRepository → điểm này thread rời MainActor, nhảy xuống background zone.Repository (actor) gọi API service / local store, map dữ liệu.
Khi
awaitxong, execution tự động hop trở lại MainActor.ViewModel gán kết quả vào
@Publishedstate → View re-render.
4. @MainActor & threading correctness
Tại sao ViewModel nên là @MainActor?
@MainActor
final class BrandListViewModel: ObservableObject {
@Published private(set) var state: State = .idle
private let repository: BrandRepository
init(repository: BrandRepository) {
self.repository = repository
}
func loadBrands() async {
state = .loading
do {
let brands = try await repository.fetchBrands()
state = .loaded(brands) // an toàn — vẫn trên MainActor
} catch {
state = .failed(error)
}
}
}
Đánh dấu cả class @MainActor mang lại 3 lợi ích:
Mọi gán vào
@Publishedđều tự động trên main thread. Không còn cảnh "modifying UI from background thread".Compiler kiểm chứng giúp bạn. Nếu bạn lỡ đọc
statetừ một context khác, Swift 6 sẽ báo lỗi lúc compile, không phải crash lúc runtime.Sau mỗi
await, execution quay lại MainActor mà bạn không cần viết gì thêm — thấy rõ ởstate = .loaded(brands)ngay sauawait.
Task vụ nặng đi đâu?
@MainActor trên ViewModel không làm nghẽn main thread, vì việc nặng nằm trong Repository — một actor riêng:
actor BrandRepository {
private let api: BrandAPIService
private var cache: [Brand] = []
init(api: BrandAPIService) { self.api = api }
func fetchBrands() async throws -> [Brand] {
if !cache.isEmpty { return cache }
let dtos = try await api.getBrands() // chạy off-main
let brands = dtos.map(Brand.init) // map trên actor's executor
cache = brands // mutable state được actor bảo vệ
return brands
}
}
Khi ViewModel await repository.fetchBrands(), thread rời MainActor, chạy trên executor của actor. cache được actor serialize truy cập — không cần lock thủ công, không data race.
Bẫy phổ biến: nonisolated cho việc thuần CPU
Nếu một hàm chỉ tính toán, không đụng state của actor, hãy đánh dấu nonisolated để nó không phải hop vào actor:
extension BrandRepository {
nonisolated func parse(_ raw: Data) throws -> [BrandDTO] {
try JSONDecoder().decode([BrandDTO].self, from: raw)
}
}
@Published vs PassthroughSubject trong ViewModel @MainActor
@Published: phù hợp cho state — luôn có giá trị hiện tại, View bind trực tiếp. Đây là lựa chọn mặc định.PassthroughSubject: phù hợp cho one-shot event không có "giá trị hiện tại" (ví dụ:didTapCheckout,showToast). Đừng lạm dụng để giữ state.
Khi class đã @MainActor, cả hai đều emit trên main thread, nên View subscribe an toàn mà không cần .receive(on: DispatchQueue.main).
5. Quản lý Task và cancellation
State trên UI thường gắn với vòng đời của màn hình. Lưu Task để có thể huỷ:
@MainActor
final class BrandListViewModel: ObservableObject {
@Published private(set) var state: State = .idle
private var loadTask: Task<Void, Never>?
func fetch() {
loadTask = Task { await loadBrands() }
}
deinit {
loadTask?.cancel()
}
private func loadBrands() async {
state = .loading
do {
let brands = try await repository.fetchBrands()
state = .loaded(brands)
} catch is CancellationError {
// task bị huỷ — thường không cần đổi state
} catch {
state = .failed(error)
}
}
}
Trong Repository, tôn trọng cancellation ở các điểm phù hợp:
func fetchBrands() async throws -> [Brand] {
try Task.checkCancellation()
let dtos = try await api.getBrands()
return dtos.map(Brand.init)
}
Với SwiftUI, .task { await vm.loadBrands() } tự huỷ khi View biến mất — nên ưu tiên dùng nếu có thể.
Kết luận
MVVM thiết lập ranh giới trách nhiệm rõ ràng giữa các layer, đảm bảo mỗi thành phần chỉ làm đúng phần việc của mình. Swift Concurrency, thông qua @MainActor và actor, nâng tính đúng đắn về thread từ một quy ước dễ vi phạm lúc runtime thành một ràng buộc được compiler kiểm chứng ngay tại thời điểm biên dịch.
