Trang chủ

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.