Search code examples
iosdelaycombineflatmap

iOS Combine: using .delay in .flatMap results in data lost


I am learning Combine on iOS. This is my code:

struct ContentView: View {
let aiRepsonse = "View the latest news and breaking news today for U.S., world, weather, entertainment, politics and health at abc.com."

@State private var cancellableSet = Set<AnyCancellable>()
@State private var content = ""

func test() {
    content = ""
    
    aiRepsonse.publisher
        .flatMap{
            return Just($0).delay(for: 0.01, scheduler: RunLoop.main)}
        .sink { _ in
            if content.count != aiRepsonse.count{
                print("error! lost data! content.count is \(content.count),  aiRepsonse.count is \( aiRepsonse.count)")
                print("content    is : \(content)")
                print("aiRepsonse is : \(aiRepsonse)")
            }

        } receiveValue: { value in
            content += String(value)
        }
        .store(in: &cancellableSet)

}

var body: some View {
    VStack {
        Text(content)
            .frame(height: 200)
        Button(action: {
            test()
        }, label: {
            Text("Test")
        })
    }
}
}

If you press Test button several times in a short time, the error log will appear. For example,

error! lost data! content.count is 104,  aiRepsonse.count is 117
content    is : View the latest news and breaking news today for U., wol, weather     eertainmet, politicsnd heathat ac.com.
aiRepsonse is : View the latest news and breaking news today for U.S., world,     weather, entertainment, politics and health at abc.com.

I don't understand why some [Just($0).delay()] publishers do not emit character correctly. Is this a back-pressure problem? Thanks a lot.


Solution

  • Some data is lost because you are using a RunLoop as the schedular. From this post,

    RunLoop.main runs callbacks only when the main run loop is running in the .default mode, which is not the mode used when tracking touch and mouse events. If you use RunLoop.main as a Scheduler, your events will not be delivered while the user is in the middle of a touch or drag.

    If you use DispatchQueue.main, no data will be lost.

    Another problem is that test might be called when the publisher created by the previous call to test has not completed. This is not much of a problem when the delay is 0.01, but will become a problem when the delay is larger. You will end up having two sinks appending to content at the same time, and content would be longer than expected. This can be solved by cancelling everything in cancellableSet first, before starting a new publisher.


    In practice, it might be more convenient to implement this with async-await instead of Combine. Presumably you are sending prompts to some AI and getting its responses. You can put that code in a task(id: prompt) { ... } view modifier. Here is a sketch:

    @State private var content = ""
    @State private var prompt = ""
    @State private var text = ""
    
    var body: some View {
        VStack {
            Text(content)
    
            TextField("Prompt", text: $text)
            Button("Send") {
                prompt = text
            }
        }
        .task(id: prompt) {
            guard !prompt.isEmpty else { return }
            await showResponse(forPrompt: prompt)
        }
    }
    
    func showResponse(forPrompt prompt: String) async {
        content = ""
    
        // assuming fetchAIResponse returns a Publisher
        let values = fetchAIResponse(prompt: prompt)
            .flatMap(maxPublishers: .max(1)) {
                return Just($0).delay(for: 0.01, scheduler: DispatchQueue.main)
            }.values
        for await character in values {
            content += String(character)
        }
    
        // if you can, I would recommend not using a Publisher at all, and have fetchAIResponse return a AsyncSequence directly.
    }