Trang chủ

`.task` và thứ thật sự hủy nó

.task là modifier của SwiftUI dùng để khởi động công việc bất đồng bộ gắn với vòng đời của một view. Nó đã thay cho một khuôn mẫu gồm onAppear, một Task được lưu lại, onDisappear và việc hủy thủ công — khoảng mười lăm dòng sổ sách rất dễ làm sai một cách tinh vi.

struct ProfileView: View {
    let userID: User.ID
    @State private var profile: Profile?

    var body: some View {
        ProfileContent(profile: profile)
            .task {
                profile = try? await api.profile(for: userID)
            }
    }
}

Quy tắc về vòng đời

Tác vụ khởi động khi view xuất hiện và bị hủy tự động khi danh tính của view mất đi. Không phải khi nó cuộn ra khỏi màn hình — mà khi danh tính của nó rời khỏi cây.

Sự phân biệt đó quan trọng. Trong một List, một dòng cuộn ra khỏi tầm nhìn có thể bị gỡ mà cũng có thể không, nên một .task đặt trên một dòng không phải một cơ chế “hủy khi ra khỏi màn hình” đáng tin. Với một luồng vô hạn — một dòng dữ liệu vị trí, một websocket — hãy buộc nó vào một view container mà bạn hiểu rõ vòng đời thay vì buộc vào một dòng.

Tham số id: mới là cái quan trọng

Đây là thứ đã chữa một con bug tôi sống chung suốt nhiều tháng:

.task(id: userID) {
    profile = try? await api.profile(for: userID)
}

Không có id:, tác vụ chạy một lần cho danh tính view đó. Đi từ user A sang user B sẽ dùng lại đúng cái view ấy, nên tác vụ không chạy lại, và màn hình hiện hồ sơ của A với tiêu đề điều hướng của B.

Có id:, việc userID thay đổi sẽ hủy tác vụ đang bay rồi khởi động một cái mới. Đó chính xác là hành vi bạn muốn và nó rất phiền để viết tay — hủy cái trước, khởi động cái sau, kiểm tra việc hủy sau lần await để một phản hồi chậm của A không ghi đè lên B.

Mẹo

Mọi giá trị mà công việc của tác vụ phụ thuộc vào đều thuộc về id:. Nếu closure đọc userID, filter và sortOrder thì id: nên là cả ba — hãy gộp chúng vào một struct Hashable nhỏ thay vì chọn lấy một cái.

Việc hủy vẫn mang tính hợp tác

.task hủy cái Task, thứ chỉ đặt một lá cờ. Công việc không bao giờ kiểm tra lá cờ đó vẫn chạy tiếp:

.task(id: query) {
    let results = await search(query)      // nếu `search` có kiểm tra việc hủy thì ổn
    guard !Task.isCancelled else { return }   // kiểm tra lại trước khi ghi
    self.results = results
}

Lần kiểm tra thứ hai đó mới quan trọng. Closure của một tác vụ đã bị hủy vẫn có thể đang thực thi sau khi await trả về, và ghi ra kết quả cũ chính là con bug mà id: đáng lẽ phải chữa.

Phần lớn các lời gọi URLSession và Task.sleep hợp tác một cách tự động. Một vòng lặp đồng bộ thì không.

Độ ưu tiên

.task(priority: .background) cho công việc mà người dùng không chờ đợi. Mặc định là .userInitiated, đúng cho mọi thứ đang trên màn hình và sai cho việc tải trước hay gửi dữ liệu phân tích.

.task(priority: .background) {
    await prefetchNextPage()
}

Sai theo hướng lãng phí — để mọi thứ ở .userInitiated — gây bùng nổ luồng và, trên một thiết bị có nhân tiết kiệm điện, nó chủ động ngăn bộ lập lịch làm đúng việc của mình.

Trường hợp nó không nổ

.task chạy khi view xuất hiện. Trong một TabView, các view ở những tab chưa được chọn có thể được dựng ra mà không xuất hiện, và trong một NavigationStack thì một màn hình đích không được tạo cho tới khi nó được đẩy vào.

Điều đó thường là đúng và thỉnh thoảng gây bất ngờ — câu “sao dữ liệu của tôi không nạp” với một tab mà người dùng chưa mở là hành vi đúng như dự kiến, không phải một con bug.

Cái bẫy họ hàng: .task đặt trên một view bên trong một ScrollView có LazyVStack sẽ nổ khi các dòng được hiện thực hóa, và đó là cách bạn vô tình khởi động hai trăm request mạng chỉ bằng cách cuộn nhanh. Nếu mỗi dòng nạp một thứ gì đó thì lượt nạp ấy thuộc về một mô hình có cache, không thuộc về một .task trên từng dòng.

Cái gì thay cho cái gì

Cũ Mới
onAppear + Task { } .task { }
onAppear + Task + onDisappear + cancel() .task { }
onChange(of:) + hủy + khởi động lại .task(id:) { }
onReceive(publisher) .task { for await value in stream { … } }

Dòng cuối là dòng đáng biết đến. Một AsyncSequence được tiêu thụ trong một .task cho bạn đúng thứ mà onReceive từng cho, kèm việc hủy tự động và không cần AnyCancellable nào:

.task {
    for await location in locationManager.updates {
        self.location = location
    }
}

Khi view ra đi, vòng lặp bị hủy và lượt duyệt kết thúc. Đó là toàn bộ vòng đời, trong ba dòng, không có gì phải lưu lại và không có gì phải nhớ đi dọn dẹp.