Combine và async/await
Bắc cầu theo cả hai chiều, và quyết định chuyển đổi cái gì
Hai hệ thống liên thông với nhau tốt, nghĩa là việc chuyển đổi có thể làm dần chứ không cần viết lại từ đầu. Chương này là phần thực dụng của lập trường đã nêu ở phần giới thiệu.
Từ Combine sang async
Mọi publisher đều phơi ra .values, một AsyncSequence:
for await state in model.state.values {
render(state)
}
Với một publisher có thể hỏng, chuỗi đó ném lỗi:
do {
for try await item in api.fetchItems().values {
process(item)
}
} catch {
show(error)
}
Còn với một publisher chạy một lần, .first() rút nó về một giá trị duy nhất:
let profile = try await api.fetchProfile().values.first(where: { _ in true })
Cách viết đó vụng về, và đây là câu trả lời thành thật — Combine không có thuộc tính .firstValue.
Phần lớn codebase tự thêm một cái:
extension Publisher {
var firstValue: Output {
get async throws {
for try await value in values { return value }
throw CancellationError()
}
}
}
Cảnh báo
Duyệt .values trên một publisher vô hạn thì không bao giờ trả về, và cái Task bao quanh sẽ
sống cho tới khi bị hủy. Bên trong một modifier .task thì như vậy là đúng và tự động; bên trong
một Task tách rời thì đó là một chỗ rò. Hãy chặn nó bằng prefix, hoặc bảo đảm có thứ gì đó
hủy nó.
Từ async sang Combine
Không có cây cầu dựng sẵn theo chiều này. Future cộng với một Task là hình dạng tiêu chuẩn:
extension Future where Failure == Error {
convenience init(operation: @escaping () async throws -> Output) {
self.init { promise in
Task {
do {
promise(.success(try await operation()))
} catch {
promise(.failure(error))
}
}
}
}
}
// dùng
Future { try await api.fetchProfile() }
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { … }, receiveValue: { … })
Hai điểm cần lưu ý. Future chạy ngay khi được tạo, nên hãy bọc nó trong Deferred nếu bạn muốn
công việc bắt đầu lúc có người đăng ký. Và hủy publisher kết quả không hủy cái Task — công
việc async vẫn chạy tiếp mà chẳng ai nghe. Nếu việc hủy là quan trọng, hãy giữ lấy cái task rồi hủy
nó trong handleEvents(receiveCancel:).
Deferred {
let task = Task { try await api.fetchProfile() }
return Future { promise in
Task { promise(await task.result) }
}
.handleEvents(receiveCancel: { task.cancel() })
}
Đến nước này thì cũng đáng hỏi liệu cái lớp bọc Combine kia có còn xứng với công sức không.
So sánh, một cách thành thật
| Công việc | Combine | Concurrency có cấu trúc |
|---|---|---|
| Một request | Future, vụng về |
try await — rõ ràng tốt hơn |
| Một chuỗi giá trị | Publisher |
AsyncSequence — ngang nhau |
| Debounce | .debounce — một dòng |
Tự viết, hoặc AsyncAlgorithms |
| Ghép mới nhất của 3 nguồn | .combineLatest — một dòng |
Thật sự vụng về |
| Thử lại có độ lùi | catch đệ quy — xấu |
Vòng for với Task.sleep — rõ ràng tốt hơn |
| Hủy | AnyCancellable thủ công |
Có cấu trúc, tự động |
| Request song song | flatMap(maxPublishers:) |
withTaskGroup — rõ ràng tốt hơn |
| Quan sát trạng thái giao diện | @Published |
@Observable — rõ ràng tốt hơn |
| Xử lý lỗi | Kết liễu cả luồng | try/catch — rõ ràng tốt hơn |
Cái quy luật trong bảng đó chính là toàn bộ lập luận. Concurrency có cấu trúc thắng ở bất cứ đâu mà
công việc có điểm đầu và điểm cuối, vì đó là thứ nó được thiết kế để làm và vì việc hủy cùng việc
lan truyền lỗi đến miễn phí. Combine trụ lại ở chỗ công việc là một luồng sự kiện liên tục đang
được ghép lại — debounce, combineLatest, throttle — và đó đúng là những trường hợp mà
AsyncAlgorithms sinh ra để rốt cuộc sẽ thay thế.
Chuyển đổi cái gì, và theo thứ tự nào
Không phải tất cả cùng lúc, và không theo một quy tắc cứng. Thứ tự giữ cho codebase vẫn chạy được:
Chuyển trước: các request một lần. Một Future hay một publisher một giá trị bọc quanh một lời
gọi mạng sẽ thành try await với ít code hơn, có việc hủy thật sự, và một nhánh lỗi không giết chết
thứ gì khác. Đây là lãi ròng.
Chuyển thứ hai: từ ObservableObject sang @Observable. Việc này dẹp @Published, thứ dẹp luôn
đám publisher Combine đông đảo nhất trong hầu hết ứng dụng, và cho bạn các lần cập nhật view mịn hơn
như một hệ quả kèm theo.
Tạm để lại: những pipeline mà giá trị nằm ở các toán tử. Một ô tìm kiếm dựng trên debounce +
removeDuplicates + switchToLatest là bốn dòng đúng đắn. Viết lại nó bằng tay là ba mươi dòng kèm
lỗi mới, đổi lại chẳng có lợi ích nào người dùng thấy được.
Để yên: bất cứ thứ gì đang chạy tốt, phức tạp và không có test. Một pipeline đồng bộ bốn trăm dòng đã chạy đúng trong môi trường thật suốt ba năm thì không nợ bạn một cuộc chuyển đổi nào. Hãy viết lại nó khi bạn cần sửa nó, đừng làm trước.
Một cuộc chuyển đổi đáng làm ngay hôm nay
Thay đổi có giá trị cao nhất trong phần lớn codebase Combine là thay những pipeline kết thúc lặng lẽ
khi có lỗi bằng một trong hai: Failure == Never cộng với trạng-thái-dưới-dạng-giá-trị, hoặc bằng
async/await nơi try khiến lỗi không thể bị phớt lờ. Cái lớp lỗi đó — tính năng lặng lẽ chết sau
lần lỗi mạng đầu tiên — là kiểu hỏng đắt đỏ nhất của framework này, và cả hai cách chữa đều dẹp nó
vĩnh viễn.