SwiftUI + Combine - 发布者在第一次错误时终止

SwiftUI + Combine - Publisher terminates upon first error

我想在用户在搜索字段中键入 3 个或更多字母时执行 API 调用。

我终于让它工作了,但不幸的是,当我关闭服务器并在服务器上运行时,结果发现 Publisher 在第一个错误和用户再次在搜索字段中键入文本时终止 API 呼叫已完成。

我在 Combine 上观看了 WWDC 2019 视频并阅读了一些博客文章,但 Combine API 似乎经常变化,每个来源做的每件事都不同,当我修改它时编译器经常抛出无用的错误,如 Fix: Replace type X with type X(见截图)

PS:我知道我可以使用 filter 过滤掉短于 3 个字母的查询,但不知何故我无法让出版商和类型工作。我觉得我是在 Combine...

上遗漏了一些基本的东西

代码如下:

DictionaryService.swift

class DictionaryService {

    func searchMatchesPublisher(_ query: String,
                                inLangSymbol: String,
                                outLangSymbol: String,
                                offset: Int = 0,
                                limit: Int = 20) -> AnyPublisher<[TranslationMatch], Error> {
        
    ...
}

DictionarySearchViewModel.swift

class DictionarySearchViewModel: ObservableObject {
    @Published var inLang = "de"
    @Published var outLang = "en"
    
    @Published var translationMatches = [TranslationMatch]()
    @Published var text: String = ""
    
    private var cancellable: AnyCancellable? = nil
    
    init() {
        cancellable = $text
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .map { [self] queryText -> AnyPublisher<[TranslationMatch], Error> in
                if queryText.count < 3 {
                    return Future<[TranslationMatch], Error> { promise in
                        promise(.success([TranslationMatch]()))
                    }
                    .eraseToAnyPublisher()
                } else {
                    return DictionaryService.sharedInstance()
                        .searchMatchesPublisher(queryText, inLangSymbol: self.inLang, outLangSymbol: self.outLang)
                }
            }
            .switchToLatest()
            .eraseToAnyPublisher()
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .assign(to: \.translationMatches, on: self)
    }
}

这是预期的行为。一旦错误被发布到管道中,管道就完成了。可以作为失败完成,也可以作为单个最终值完成,如果你使用replaceError,但无论哪种方式,它都完成了。

我会用flatMap来说明,而不是map加上switchToLatest,但是原理是完全一样的。

这里的解决方案是在 flatMap 闭包 中捕获或替换错误 ,防止它从 flatMap 中渗透出来。这样完成的是flatMap内的二级管线,而不是整个外管线。

我将用你的情况的简化示意图来演示。我有一个要输入的文本字段,我的视图控制器是它的委托:

import UIKit
import Combine

enum Oops : Error { case oops }

class ViewController: UIViewController, UITextFieldDelegate {
    @IBOutlet weak var tf: UITextField!
    @Published var currentText = ""
    
    var pipeline : AnyCancellable!
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.pipeline = self.$currentText
            .debounce(for: 0.2, scheduler: DispatchQueue.main)
            .filter { [=10=].count > 3 }
            .flatMap { s -> AnyPublisher<String,Never> in
                Future<String,Error> { promise in
                    if Bool.random() {
                        promise(.success(s))
                    } else {
                        promise(.failure(Oops.oops))
                    }
                }
                .replaceError(with: "yoho")
                .eraseToAnyPublisher()
            }
            .sink(receiveCompletion: { print([=10=]) }, receiveValue: { print([=10=]) })
    }

    func textFieldDidChangeSelection(_ textField: UITextField) {
        self.currentText = textField.text ?? ""
    }

}

如您所见,我已将 .flatMap 中的 Future 配置为 随机 失败。但是因为故障被替换在inside the .flatMap,那个故障不会导致整个管道停止工作。因此,当您键入和退格等操作时,您有时会看到控制台中打印的文本字段文本,有时会看到我用来指示错误的 "yoho",但无论如何管道都会继续工作。

如果您想改用 .map.switchToLatest,那将是完全相同的代码。在上面的代码中我有 flatMap 的地方,我们将改为:

        .map { s -> AnyPublisher<String, Never> in
            Future<String,Error> { promise in
                if Bool.random() {
                    promise(.success(s))
                } else {
                    promise(.failure(Oops.oops))
                }
            }
            .replaceError(with: "yoho")
            .eraseToAnyPublisher()
        }
        .switchToLatest()

根据马特的回答,更新后的工作代码不会终止上游发布者:

init() {
    cancellable = $text
        .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
        .filter { [=10=].count >= 3 }
        .removeDuplicates()
        .map { [self] queryText -> AnyPublisher<[TranslationMatch], Never> in
            DictionaryService.sharedInstance()
                .searchMatchesPublisher(queryText, inLangSymbol: self.inLang, outLangSymbol: self.outLang)
                .replaceError(with: [TranslationMatch]())
                .eraseToAnyPublisher()
        }
        .switchToLatest()
        .eraseToAnyPublisher()
        .receive(on: DispatchQueue.main)
        .assign(to: \.translationMatches, on: self)
}