Trang chủ

Subject

Cây cầu từ code mệnh lệnh, và phần hay bị lạm dụng

Subject là một publisher mà bạn đẩy giá trị vào được bằng tay. Nó là cánh cửa giữa code gọi hàm và code ghép luồng dữ liệu.

let taps = PassthroughSubject<Void, Never>()

@IBAction func buttonTapped() {
    taps.send(())
}

Có hai loại, khác nhau ở đúng một điểm: một subscriber mới có nhận được gì ngay lập tức hay không.

PassthroughSubject không giữ gì cả. Đăng ký sau khi ba giá trị đã được gửi thì bạn đã lỡ cả ba. Đúng cho các sự kiện — một cú chạm, một thông báo, “người dùng vừa yêu cầu làm mới”.

CurrentValueSubject giữ giá trị mới nhất và phát lại nó cho mọi subscriber mới. Đúng cho trạng thái — người dùng hiện tại, việc đồng bộ có đang bật không, bộ lọc đang chọn. Thuộc tính .value của nó cũng đọc và ghi trực tiếp được, khiến nó thành cây cầu theo cả hai chiều.

let isOnline = CurrentValueSubject<Bool, Never>(true)

isOnline.value = false                  // đồng thời gửi tới các subscriber
print(isOnline.value)                   // đọc mà không cần đăng ký

Quy tắc quyết định: nếu một subscriber đến muộn cần biết tình hình hiện tại thì đó là trạng thái và bạn muốn CurrentValueSubject. Nếu đến muộn nghĩa là khoảnh khắc đã trôi qua thì đó là sự kiện và bạn muốn PassthroughSubject.

Kết thúc là vĩnh viễn

Gửi .finished hoặc một lỗi thì subject coi như xong. Mọi send(_:) sau đó đều bị lờ đi lặng lẽ, và mọi subscriber mới nhận ngay phần kết thúc.

subject.send(completion: .finished)
subject.send(42)                        // bị lờ đi, không cảnh báo, không sập

Chuyện này gây ra một lỗi rất cụ thể và gây rối trí: một subject nối vào nhánh lỗi sẽ kết thúc ngay ở lỗi đầu tiên, và tính năng đó ngừng hoạt động mà không có thêm lỗi nào nữa, vì cái pipeline đáng ra phải báo lỗi thì đã đóng. Nếu một subject đại diện cho một nguồn còn tiếp diễn, hoặc đừng bao giờ kết thúc nó, hoặc hãy mô hình hóa lỗi thành giá trị.

enum LoadState { case loading, loaded([Item]), failed(String) }

let state = CurrentValueSubject<LoadState, Never>(.loading)

Failure == Never với lỗi được mang theo dưới dạng một case là hình dạng sống sót được khi va vào một ứng dụng thật, và đó là hình dạng mà phía hướng ra SwiftUI của phần lớn pipeline nên có.

Chỗ subject bị lạm dụng

Một subject nằm giữa pipeline thường là dấu hiệu một chuỗi đã bị bẻ ra rồi khâu lại bằng tay:

// mùi lạ: đăng ký một luồng chỉ để đẩy vào một luồng khác
upstream
    .sink { [weak self] value in
        self?.relay.send(value * 2)
    }
    .store(in: &cancellables)

Đó là .map { $0 * 2 } kèm thêm mấy bước thừa, cộng thêm một nhánh lỗi bị mất, cộng thêm một vòng đời mà giờ bạn phải tự quản. Bất cứ khi nào một sink tồn tại chỉ để send vào một subject, hai nửa ấy vốn thuộc về cùng một chuỗi.

Cảnh báo

Subject cũng là chỗ tính an toàn luồng lặng lẽ biến mất. Gọi send(_:) từ hai hàng đợi cùng lúc là không an toàn, và kiểu hỏng là một subscription bị sai lệch chứ không phải một cú sập rõ ràng. Nếu một subject được nạp từ nhiều hơn một chỗ, hãy chốt lấy một hàng đợi và dùng receive(on:) trước nó, hoặc giữ nó sau một actor.

Phơi bày một subject một cách an toàn

Một subject để public hay internal thì ai cũng gửi vào được — và kết thúc được. Hãy giữ subject ở chế độ private rồi công bố một khung nhìn chỉ đọc:

final class SessionStore {
    private let stateSubject = CurrentValueSubject<SessionState, Never>(.signedOut)

    var state: AnyPublisher<SessionState, Never> {
        stateSubject.eraseToAnyPublisher()
    }

    var currentState: SessionState { stateSubject.value }

    func signIn(_ user: User) {
        stateSubject.send(.signedIn(user))
    }
}

Bên gọi quan sát và đọc được; chỉ SessionStore mới đổi hay kết thúc được nó. Đây là hình dạng đáng lấy làm mặc định — một subject bị phơi ra là trạng thái dùng chung có thể sửa được khoác một cái tên đẹp hơn, và rồi nó sẽ bị kết thúc bởi một đoạn code mà bạn không ngờ là có quyền đó.

Lối tắt @Published

@Published thực chất là một CurrentValueSubject được sinh ra sẵn cho bạn, chiếu ra bằng $:

final class Model: ObservableObject {
    @Published var query = ""
}

model.$query
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .sink { … }

Một điểm gài đáng biết: $query bắn trong willSet, nên các subscriber thấy thay đổi trước khi thuộc tính có giá trị mới. Đọc model.query bên trong cái sink đó cho bạn giá trị cũ. Hãy dùng giá trị mà closure được trao, đừng dùng thuộc tính.