摘自《SwiftUI和Combine编程》---《SwiftUI中的Combine》
对于通过 Action 改变的状态,如果我们想要执行网络请求这样的副作用,可以通过同时返回合适的 AppCommand 完成。但是对于通过绑定来更新的状态,由于不会经过 Store 的 reduce 方法来返回 Command,需要我们自驱动。
场景
检测邮箱有效性
用户输入是否有效
是否已被注册(注册时需校验)
思路:
- 1> 在需要观察的属性前面加上 @Published:
accountBehavior email- @Published 需要在内部生成并持有存储,因此只能针对定义在 class 里的变量添加 @Published
- 如果属性是在 struct 中,就要将其提取到 class
struct Settings {
//...
class AccountChecker {
@Published var accountBehavior = AccountBehavior.login
@published var email = ""
}
// 声明变量持有这个 class
var checker = AccountChecker()
// ...
}
- 2> 将 accountBehavior 和 email 两个状态合并
var emailCheckPublisher: AnyPublisher<Bool, Never> {
// 每当email有变化
let emailPublisher: AnyPublisher<String, Never> = $email
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates().eraseToAnyPublisher()
// 每当behavior有变化
let behaviorPublisher: AnyPublisher<Bool, Never> = $accountBehavior.map { $0 == .login }
.eraseToAnyPublisher()
// 信号合并
let remoteVerify: AnyPublisher<Bool, Never> = Publishers.CombineLatest(emailPublisher, behaviorPublisher)
.flatMap { email, isLogin -> AnyPublisher<Bool, Never> in
let isRegular = email.isValidEmailAddress
let isLogin = isLogin
switch (isRegular, isLogin) {
case (false, _): // 输入不合规直接返回false
return Just(false).eraseToAnyPublisher()
case (true, false): // 注册行为,输入合规,api判断账号是否已存在
return EmailCheckRequest(email: email)
.publisher.eraseToAnyPublisher()
case (true, true): // 登录行为,输入合规返回true
return Just(true).eraseToAnyPublisher()
}
}.eraseToAnyPublisher()
// 短路作用
let localVaild = $email.map { $0.isValidEmailAddress }
let behaviorValid = $accountBehavior.map { $0 == .login }
return Publishers.CombineLatest3(localVaild, behaviorValid, remoteVerify)
.map { $0 && ($1 || $2) }
.eraseToAnyPublisher()
}
- 3> 非Action触发,由 Store 自身监听,驱动相关 UI
class Store: ObservableObject {
init() {
setupObserver()
}
func setupObserver() {
appState.settings.checker.emailCheckPublisher.sink {
isValid in
self.dispatch(.emailCheck(valid: isValid))
}.store(in: &disposeBag)
}
}
TextField("电子邮箱", text: settingsBinding.checker.email)
.foregroundColor(settings.isEmailVaild ? .gray : .red)
本地验证密码
检查 password 和 verifyPassword 不为空,而且两者的值相等。
var passwordVerifyPublisher: AnyPublisher<Bool, Never> {
let canSkip = $accountBehavior.map { $0 == .login }
return Publishers.CombineLatest3(canSkip, $password, $verifyPassword)
.flatMap { canSkip, pwd1, pwd2 -> AnyPublisher<Bool, Never> in
let isRegular = (pwd1.count > 0) && (pwd1 == pwd2)
switch(canSkip, isRegular) {
case (true, _):
return Just(true).eraseToAnyPublisher()
case (false, _):
return Just(isRegular).eraseToAnyPublisher()
}
}.eraseToAnyPublisher()
}
多重请求的处理
species 请求依赖于 pokemon 请求,请求1~30号数据
struct LoadPokemonRequest {
let id: Int
private var base = "https://pokeapi.co/api/v2/pokemon/"
private func pokemonPublisher(_ id: Int) -> AnyPublisher<Pokemon, Error> {
URLSession.shared
.dataTaskPublisher(for: URL(string: "\(base)\(id)")!)
.map { $0.data }
.decode(type: Pokemon.self, decoder: appDecoder)
.eraseToAnyPublisher()
}
private func speciesPublisher(_ pokemon: Pokemon) -> AnyPublisher<(Pokemon, PokemonSpecies), Error> {
URLSession.shared
.dataTaskPublisher(for: pokemon.species.url)
.map { $0.data }
.decode(type: PokemonSpecies.self, decoder: appDecoder)
.map { (pokemon, $0) }
.eraseToAnyPublisher()
}
// 向外仅提供 publisher,内部将多重请求处理好
var publisher: AnyPublisher<PokemonViewModel, AppError> {
pokemonPublisher(id)
.flatMap { speciesPublisher($0) }
.map { PokemonViewModel(pokemon: $0, species: $1) }
.mapError { AppError.networkError($0) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// 将 publisher 打包成一个,所有信号都发布后才会收到结果
static var all: AnyPublisher<[PokemonViewModel], AppError> {
(1...30).map { LoadPokemonRequest(id: $0).publisher }.zipAll
}
}
extension Array where Element: Publisher {
var zipAll: AnyPublisher<[Element.Output], Element.Failure> {
let initial = Just([Element.Output]())
.setFailureType(to: Element.Failure.self)
.eraseToAnyPublisher()
return reduce(initial) { result, publisher in
result .zip(publisher) { $0 + [$1] }.eraseToAnyPublisher()
}
}
}
数据来源不同
abilities 可能一部分已请求过存于本地,一部分需要发起请求
需要在 command 中处理数据源
struct LoadAbilityCommand: AppCommand {
let pokemon: Pokemon
// 处理 execute 要处理的 publisher
func load(pokemonAbility: Pokemon.AbilityEntry, in store: Store) -> AnyPublisher<AbilityViewModel, AppError> {
let id = pokemonAbility.ability.url.extractedID!
// 如果本地存在
if let value = store.appState.pokemonList.abilities?[id] {
return Just(value)
.setFailureType(to: AppError.self)
.eraseToAnyPublisher()
} else {
// 本地不存在
return LoadAbilityRequest(pokemonAbility: pokemonAbility).publisher
}
}
func execute(in store: Store) {
let token = SubscriptionToken()
pokemon.abilities
.map { load(pokemonAbility: $0, in: store) }
.zipAll
.sink(receiveCompletion: {
if case .failure(let error) = $0 {
store.dispatch(.loadAbilitiesDone(result: .failure(error)))
}
token.unseal()
}, receiveValue: {
store.dispatch(.loadAbilitiesDone(result: .success($0)))
}).seal(in: token)
}
}