Kết hợp
merge, combineLatest và zip — và vì sao lựa chọn không hiển nhiên
Ba toán tử nhận nhiều luồng và tạo ra một luồng. Chúng dễ bị lẫn với nhau và hành xử hoàn toàn khác nhau ngay khi một nguồn chậm hơn những nguồn còn lại.
merge đan xen. Mọi đầu vào phải cùng kiểu; mọi giá trị đi thẳng qua ngay khi nó đến.
let allEvents = pushNotifications.merge(with: localNotifications)
combineLatest phát ra mỗi khi bất kỳ đầu vào nào phát, ghép nó với giá trị mới nhất của từng
đầu vào còn lại. Các kiểu có thể khác nhau. Nó không phát gì cho tới khi mọi đầu vào đã tạo ra ít
nhất một giá trị.
let canSubmit = Publishers.CombineLatest3($email, $password, $agreedToTerms)
.map { email, password, agreed in
email.contains("@") && password.count >= 8 && agreed
}
zip ghép cặp theo chỉ số — cái đầu với cái đầu, cái thứ hai với cái thứ hai — và chờ mọi đầu
vào cung cấp một giá trị.
let pairs = names.zip(avatars) // chờ cả hai, phát ra (name, avatar)
Chọn giữa chúng
Câu cần hỏi: khi một nguồn phát, có nên dùng lại giá trị cũ của những nguồn kia không?
combineLatest nói có — đó chính xác là việc của nó, và là câu trả lời đúng cho việc kiểm tra biểu
mẫu, một thanh bộ lọc, hay bất cứ thứ gì bạn muốn “trạng thái hiện tại của mọi đầu vào”. Ba ô nhập
chữ cộng một công tắc, kết hợp lại, cho bạn một canSubmit đúng sau mỗi phím gõ.
zip nói không — nó chờ một giá trị mới từ mỗi nguồn. Đúng khi các giá trị thật sự thành cặp: một
danh sách id ghép với kết quả tải chúng về, hai request song song mà kết quả thuộc về nhau.
merge nói câu hỏi đó không áp dụng, vì các luồng là những nguồn thay thế nhau của cùng một loại sự
kiện chứ không phải các thành phần của một trạng thái.
Cảnh báo
zip trên những nguồn có nhịp khác nhau là một cú rò rỉ bộ nhớ kèm thêm mấy bước. Một publisher
nhanh ghép zip với một publisher chậm sẽ dồn bộ đệm mọi giá trị chưa ghép được từ phía nhanh,
mãi mãi. Hãy zip một đồng hồ mỗi giây với một đồng hồ mỗi phút, và bộ đệm phình thêm sáu mươi
giá trị mỗi phút mà chẳng có gì để ghép cặp cùng.
Cái bẫy khởi động của combineLatest
Không gì được phát ra cho tới khi mọi đầu vào đã tạo ra một giá trị. Một CombineLatest3 mà một
đầu vào là PassthroughSubject chưa bắn lần nào thì chẳng phát ra gì cả — không trạng thái ban đầu,
không kiểm tra hợp lệ, một nút gửi cứ mờ đi và không có lỗi nào giải thích vì sao.
Các cách chữa, theo thứ tự ưu tiên:
// 1. Dùng CurrentValueSubject hoặc @Published, những thứ luôn có sẵn một giá trị
let filter = CurrentValueSubject<Filter, Never>(.all)
// 2. Cho luồng một giá trị khởi đầu
subject.prepend(Filter.all)
// 3. Làm cho nó optional rồi xử lý nil
subject.map(Optional.some).prepend(nil)
Cách đầu gần như luôn đúng. Nếu một giá trị đại diện cho trạng thái luôn tồn tại, hãy mô hình hóa nó bằng một kiểu luôn có sẵn một giá trị.
Các toán tử lấy giá trị đầu
Cho một câu trả lời một lần thay vì một luồng liên tục:
publisher.first() // giá trị đầu tiên, rồi kết thúc
publisher.first(where: { $0 > 10 }) // giá trị đầu tiên thỏa điều kiện
publisher.prefix(3) // ba cái đầu, rồi kết thúc
publisher.prefix(while: { $0 < 100 }) // cho tới khi điều kiện sai
first() là cách một luồng trở thành một request một lần, và là cặp đôi tự nhiên với
.values.first(where:) khi bắc cầu sang async/await.
prefix(untilOutputFrom:) là cái đáng nhớ: lấy giá trị cho tới khi một publisher khác phát ra. Đó
là “cứ chạy tiếp cho tới khi người dùng hủy” viết gọn thành một toán tử.
progressUpdates
.prefix(untilOutputFrom: cancelTapped)
.sink { … }
Thứ tự, và cái giá của combineLatest
combineLatest phát ra một lần cho mỗi thay đổi đầu vào, nghĩa là một biểu mẫu năm trường sẽ phát ra
năm lần trong lúc người dùng dán giá trị vào từng trường. Nếu thao tác phía dưới tốn kém — một lượt
kiểm tra hợp lệ qua mạng, một phép tính lại lớn — thì đó là năm lần như thế.
Hai toán tử chữa được. debounce chờ cho cơn dồn dập lắng xuống; removeDuplicates() bỏ đi những
lần phát mà kết quả kết hợp thật ra không đổi. Cặp này là cách viết thành ngữ sau bất kỳ
combineLatest rộng nào:
Publishers.CombineLatest3($a, $b, $c)
.map(Query.init)
.removeDuplicates()
.debounce(for: .milliseconds(200), scheduler: RunLoop.main)
.sink { runExpensiveSearch($0) }
Hãy để ý thứ tự: khử trùng lặp trước rồi mới debounce, để những giá trị giống nhau bị gộp lại mà không đặt lại đồng hồ debounce.