Thời gian
debounce, throttle, và cái scheduler mà cả hai đều cần
Mọi toán tử thời gian đều nhận một scheduler, và scheduler không phải thủ tục hình thức — nó quyết định cả việc đồng hồ của toán tử chạy lúc nào lẫn việc giá trị đến trên luồng nào.
debounce so với throttle
Hai cái này bị lẫn với nhau liên tục, và chúng giải hai bài toán ngược nhau.
debounce chờ sự im lặng. Nó chỉ phát ra một giá trị sau khi nguồn đã im trong khoảng thời gian
cho trước. Giá trị đến trong lúc chờ sẽ đặt lại đồng hồ.
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { search($0) }
Gõ liên tục trong mười giây thì chẳng có gì được phát ra cho tới khi bạn ngừng tay. Đó đúng là điều bạn muốn với một ô tìm kiếm — bạn cần truy vấn mà người dùng gõ xong, không phải những truy vấn họ đi ngang qua.
throttle lấy mẫu theo nhịp. Nó phát ra tối đa một giá trị mỗi khoảng, bất kể có bao nhiêu giá
trị đến.
scrollOffset
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
.sink { updateHeader($0) }
Giá trị vẫn tiếp tục đi ra trong suốt một luồng liên tục, ở nhịp đều đặn. Đúng cho vị trí cuộn, số đo cảm biến, cập nhật tiến độ — bất cứ thứ gì bạn muốn cập nhật đều đặn nhưng không cần tất cả.
Câu hỏi phân biệt: trong một cơn dồn dập liên tục, bạn có muốn có đầu ra không? Throttle thì có, debounce thì không.
Tham số latest: của throttle chọn giá trị nào trong mỗi khoảng sẽ sống sót — true cho cái mới
nhất (thường là thứ bạn muốn với một vị trí), false cho cái đầu tiên (tốt hơn khi giá trị đầu trong
một cơn dồn dập mới là cái có nghĩa, như một cú chạm nút).
Cảnh báo
debounce trên một luồng không bao giờ im lặng thì chẳng phát ra gì, mãi mãi. Nếu nguồn là một
đồng hồ hay một cảm biến, debounce là toán tử sai, và kiểu hỏng là sự im lặng chứ không phải một
lỗi.
Các toán tử thời gian còn lại
publisher.delay(for: .seconds(1), scheduler: DispatchQueue.main)
publisher.timeout(.seconds(10), scheduler: DispatchQueue.main, customError: { .timedOut })
publisher.collect(.byTime(DispatchQueue.main, .seconds(1)))
publisher.measureInterval(using: DispatchQueue.main)
timeout đáng được gọi tên vì hành vi mặc định của nó gây bất ngờ: không có customError:, một lần
hết giờ sẽ kết thúc luồng một cách thành công chứ không làm nó hỏng. Một pipeline lặng lẽ kết
thúc thay vì báo một request bị kẹt thì còn tệ hơn một pipeline báo lỗi, nên hãy truyền cái lỗi vào.
collect(.byTime(_:_:)) gom thành lô — hữu ích để biến một luồng các sự kiện phân tích riêng lẻ
thành một request mỗi giây, và đó là khác biệt giữa một API hợp lý và một lệnh chặn vì vượt hạn mức.
Chọn scheduler nào
Lựa chọn này đổi cả hành vi chứ không chỉ đổi luồng thực thi:
| Scheduler | Ghi chú |
|---|---|
DispatchQueue.main |
Giao trên hàng đợi chính. Tiêu chuẩn cho giao diện |
RunLoop.main |
Run loop chính. Tạm dừng trong lúc theo dõi cuộn |
DispatchQueue.global() |
Công việc chạy nền |
ImmediateScheduler.shared |
Không trễ chút nào — dùng để kiểm thử |
RunLoop.main và DispatchQueue.main không thay thế được cho nhau, và điều này hay bẫy người ta.
Trong lúc người dùng đang kéo một khung cuộn, run loop chính ở chế độ theo dõi và một đồng hồ được
lên lịch trên RunLoop.main sẽ không nổ. Một debounce(scheduler: RunLoop.main) trên ô tìm kiếm
nằm trong một danh sách đang cuộn sẽ trông như bị đơ cho tới khi nhấc ngón tay lên.
Với bất cứ thứ gì hướng tới người dùng, hãy ưu tiên DispatchQueue.main.
receive(on:) so với subscribe(on:)
Hai toán tử khác nhau giải hai bài toán khác nhau, và lẫn lộn chúng là lỗi luồng phổ biến nhất trong Combine.
receive(on:) đổi nơi giá trị được giao — mọi thứ nằm dưới nó chạy trên scheduler đó.
subscribe(on:) đổi nơi việc đăng ký diễn ra — nơi công việc của chính publisher bắt đầu, tức
là ở phía trên.
expensivePublisher
.subscribe(on: DispatchQueue.global(qos: .userInitiated)) // công việc bắt đầu ở đây
.map { transform($0) } // vẫn trên hàng đợi global
.receive(on: DispatchQueue.main) // việc giao chuyển về main
.sink { updateUI($0) } // main
Vị trí quan trọng với cả hai. receive(on:) chỉ ảnh hưởng tới những gì đứng sau nó, nên đặt nó ở đầu
chuỗi rồi làm công việc map tốn kém bên dưới sẽ chuyển công việc ấy lên hàng đợi chính — đúng ngược
với ý định.
subscribe(on:) cần đến ít hơn người ta tưởng. URLSession.dataTaskPublisher vốn đã làm việc ngoài
luồng chính; thêm subscribe(on:) vào nó chẳng đạt được gì. Hãy với tới nó khi publisher làm việc
đồng bộ lúc đăng ký — đọc một file, một Sequence.publisher lớn, một Deferred tốn kém — và hãy
với tới receive(on:) trong gần như mọi pipeline kết thúc ở giao diện.
Mẹo
Một receive(on: DispatchQueue.main) đặt ngay trước sink là mặc định đúng. Hãy đặt nó muộn
nhất có thể để mọi thứ phía trên nó ở lại ngoài hàng đợi chính.