Trang chủ

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.