Search code examples
swiftcombine

Publisher's sink is triggered twice


I was wondering is it the expected behaviour for a subscriber to have its sink triggered twice when in this scenario?

func testExample() throws {
    let service = MockAPIService(isSuccessful: true)
    let sut = ContentViewModel(apiService: service)
    let fetchImagesExpectation = expectation(description: "Fetching images")
    
    sut.$images
        .sink { value in
            print("## count: ", value?.count)
            // fetchImagesExpectation.fulfill()
            // XCTAssertEqual(value?.count ?? 0, 5)
        }
        .store(in: &cancellables)
    sut.fetch()
    wait(for: [fetchImagesExpectation], timeout: 1)
}

Console output:

## count:  nil
## count:  Optional(5)

Here is ContentViewModel:


final class ContentViewModel: ObservableObject {
    @Published private (set) var images: [ImageModel]?
    private let apiService: NetworkingService
    private var cancellable: Set<AnyCancellable>
    
    init(apiService: NetworkingService = APIService()) {
        self.cancellable = Set<AnyCancellable>()
        self.apiService = apiService
    }
    
    func fetch() {
        apiService.fetchImages()
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { result in
                if case let .failure(error) = result {
                    Logger.logError(error)
                }
            }, receiveValue: { [weak self] response in
                self?.handleRespose(response)
            })
            .store(in: &cancellable)
    }
    
    private func handleRespose(_ response: SampleImagesResponse) {
        guard let responseImages = response.sample else {
            return
        }
        
        self.images = responseImages.compactMap { item in
            if let urlString = item.imageURL,
               let url = URL(string: urlString),
               let imageID = item.id,
               let title = item.description {
                return ImageModel(imageID: imageID, title: title, url: url)
            }
            return nil
        }
    }
}

When sut.fetch() is commented console is still printing ## count: nil.


Solution

  • it the expected behaviour for a subscriber to have its sink triggered twice when in this scenario?

    Yes. The nil value arises from merely connecting the sink to the $images in the first place, as you can readily discover by means of this much simpler scenario (no test, no ObservableObject, no fetch, no ImageModel, no nothing):

    import UIKit
    import Combine
    
    final class ContentViewModel {
        @Published private (set) var images: [Int]?
    }
    
    class ViewController: UIViewController {
        var cancellables = Set<AnyCancellable>()
        override func viewDidLoad() {
            super.viewDidLoad()
            ContentViewModel().$images.sink { value in print(value) }.store(in: &cancellables)
        }
    }
    

    That prints nil. This behavior is simply a feature of @Published, as I explain in my online book:

    the values emitted will be the initial value at subscription time followed by new values when the property changes [emphasis mine]

    If you don't want to receive that value, insert a dropFirst() operator before the sink.