没有写过完整SwiftUI项目的同学,应该没怎么使用过Combine,可以这么说,**Combine就是专门用于处理数据的利器,**如果你学会了这些知识,那么你写SwiftUI程序的效率绝对会成倍的增加。
前边已经写了很多篇文章详细介绍了Combine中的Publisher,Operator,Subscriber,相信大家已经对Combine有了一个基本的了解,今天就带领大家一起研究一下Combine的实际应用。
大家可以在这里找到SwiiftUI和Combine的合集:FuckingSwiftUI
本文演示demo下载地址:CombineDemoTest
模拟网络搜索
上图演示了一个开发中最常见的场景,实时地根据用户的输入进行搜索,这样一个功能表面上看起来非常简单,其实内部逻辑细节很多:
- 需要为用户输入设置一个网络请求的间隔时间,比如当用户停止输入0.5秒后才发送请求,避免浪费不必要的网络资源
- 去重
- 显示loading状态
先看一下首页的代码:
struct ContentView: View {
@StateObject private var dataModel = MyViewModel()
@State private var showLogin = false;
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 0) {
ZStack {
HStack(spacing: 10) {
Group {
if dataModel.loading {
ActivityIndicator()
} else {
Image(systemName: "magnifyingglass")
}
}
.frame(width: 30, height: 30)
TextField("请输入要搜索的repository", text: $dataModel.inputText)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button("登录") {
self.showLogin.toggle()
}
}
.padding(.vertical, 10)
.padding(.horizontal, 15)
}
.frame(width: geometry.size.width, height: 44)
.background(Color.orange)
List(dataModel.repositories) { res in
GithubListCell(repository: res)
}
}
}
.sheet(isPresented: $showLogin) {
LoginView()
}
}
}
上边代码非常简单,没有任何数据相关的处理逻辑,这些处理数据的逻辑全都在MyViewModel
中进行,妙的地方在于,如果View中依赖了MyViewModel
后,那么当MyViewModel
数据改编后,View自动刷新。
- 我们使用
@StateObject
初始化dataModel
,让View管理其生命周期 - 使用
GeometryReader
可以获取到父View的frame GithubListCell
是每个仓库cell的封装,代码就不贴上来了,可以下载代码查看
重点来了,我们看看MyViewModel
中的内容:
final class MyViewModel: ObservableObject {
@Published var inputText: String = ""
@Published var repositories = [GithubRepository]()
@Published var loading = false
var cancellable: AnyCancellable?
var cancellable1: AnyCancellable?
let myBackgroundQueue = DispatchQueue(label: "myBackgroundQueue")
init() {
cancellable = $inputText
// .debounce(for: 1.0, scheduler: myBackgroundQueue)
.throttle(for: 1.0, scheduler: myBackgroundQueue, latest: true)
.removeDuplicates()
.print("Github input")
.map { input -> AnyPublisher<[GithubRepository], Never> in
let originalString = "https://api.github.com/search/repositories?q=\(input)"
let escapedString = originalString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: escapedString)!
return GithubAPI.fetch(url: url)
.decode(type: GithubRepositoryResponse.self, decoder: JSONDecoder())
.map {
$0.items
}
.replaceError(with: [])
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: RunLoop.main)
.assign(to: \.repositories, on: self)
cancellable1 = GithubAPI.networkActivityPublisher
.receive(on: RunLoop.main)
.assign(to: \.loading, on: self)
}
}
在这里,我会大概讲解主要代码的用途,不会太过详细,因为这些内容在之前的文章中已经详细讲过了。
$inputText
:当我们在用@Published
装饰过的属性前边加一个$
符号后,就能获取一个Publisher.debounce(for: 1.0, scheduler: myBackgroundQueue)
: 当有输入时,debounce就会开启一个1秒的时间窗口,如果在1秒内收到了新的数据,则再开启一个新的1秒的时间窗口,之前的窗口作废,直到1秒内没有新的数据,然后发送最后收到的数据,它的核心思想是可以控制频繁的数据发送问题.throttle(for: 1.0, scheduler: myBackgroundQueue, latest: true)
: throttle会开启一系列连续的1秒的时间窗口,每次达到1秒的临界点就发送最近的一个数据,注意,当收到第一个数据时,会立刻发送。.removeDuplicates()
可以去重,比如,当最近收到的两个数据都是swift时,第二个就会被忽略.print("Github input")
可以打印pipline的过程,可以给输出信息加上前缀.map
: 上边map的逻辑是把输入的字符串映射成一个新的Publisher,这个新的Publisher会请求网络,最终输出我们封装好的数据模型GithubRepositoryResponse.self
.decode
用于解析数据.replaceError(with: [])
用于替换错误,如果网络请求出错,则发送一个空的数组.switchToLatest()
用于输出Publisher的数据,如果map返回的是Publisher,就要使用switchToLatest
切换输出.receive(on: RunLoop.main)
用于切换线程.assign(to: \.repositories, on: self)
: assign可以直接使用KeyPath的形式为属性复制,它是一个Subscriber
大家看到了吗? 在一个完整的处理过程中,用到了很多Operators,通过组合使用这些Operator,几乎能实现任何需求。
我们再看看 GithubAPI的封装:
enum GithubAPIError: Error, LocalizedError {
case unknown
case apiError(reason: String)
case networkError(from: URLError)
var errorDescription: String? {
switch self {
case .unknown:
return "Unknown error"
case .apiError(let reason):
return reason
case .networkError(let from):
return from.localizedDescription
}
}
}
struct GithubAPI {
/// 加载
static let networkActivityPublisher = PassthroughSubject<Bool, Never>()
/// 请求数据
static func fetch(url: URL) -> AnyPublisher<Data, GithubAPIError> {
return URLSession.shared.dataTaskPublisher(for: url)
.handleEvents(receiveCompletion: { _ in
networkActivityPublisher.send(false)
}, receiveCancel: {
networkActivityPublisher.send(false)
}, receiveRequest: { _ in
networkActivityPublisher.send(true)
})
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw GithubAPIError.unknown
}
switch httpResponse.statusCode {
case 401:
throw GithubAPIError.apiError(reason: "Unauthorized")
case 403:
throw GithubAPIError.apiError(reason: "Resource forbidden")
case 404:
throw GithubAPIError.apiError(reason: "Resource not found")
case 405..<500:
throw GithubAPIError.apiError(reason: "client error")
case 500..<600:
throw GithubAPIError.apiError(reason: "server error")
default: break
}
return data
}
.mapError { error in
if let err = error as? GithubAPIError {
return err
}
if let err = error as? URLError {
return GithubAPIError.networkError(from: err)
}
return GithubAPIError.unknown
}
.eraseToAnyPublisher()
}
}
GithubAPIError
是对各种Error的一个封装,有兴趣可以看看Alamofirez中的AFErrornetworkActivityPublisher
是一个Subject,本质上也是一个Publisher,用于发送网络加载的通知事件,大家可以看上边视频左上角的loading,就是用networkActivityPublisher
实现的URLSession.shared.dataTaskPublisher(for: url)
是最常见的网络请求Publisher.handleEvents
可以监听pipline中的事件.tryMap
是一种特殊的Operator,它主要用于数据映射,但允许throw异常.mapError
用于处理错误信息,,在上边的代码中,我们做了错误映射的逻辑,错误映射的核心思想是把各种各样的错误映射成自定义的错误类型.eraseToAnyPublisher()
用于磨平Publisher的类型,这个就不多做介绍了
总结一下,很多同学可能无法立刻体会到上边代码的精妙之处,响应式编程的妙处就在于我们提前铺设好数据管道,数据就会自动在管道中流动,实在是秒啊。
模拟登录
如果说网络请求是对异步数据的处理,那么模拟登录就是对多个数据流的处理,让我们先简单看一下UI代码:
struct LoginView: View {
@StateObject private var dataModel = LoginDataModel()
@State private var showAlert = false
var body: some View {
VStack {
TextField("请输入用户名", text: $dataModel.userName)
.textFieldStyle(RoundedBorderTextFieldStyle())
if dataModel.showUserNameError {
Text("用户名不能少于3位!!!")
.foregroundColor(Color.red)
}
SecureField("请输入密码", text: $dataModel.password)
.textFieldStyle(RoundedBorderTextFieldStyle())
if dataModel.showPasswordError {
Text("密码不能少于6位!!!")
.foregroundColor(Color.red)
}
GeometryReader { geometry in
Button(action: {
self.showAlert.toggle()
}) {
Text("登录")
.foregroundColor(dataModel.buttonEnable ? Color.white : Color.white.opacity(0.3))
.frame(width: geometry.size.width, height: 35)
.background(dataModel.buttonEnable ? Color.blue : Color.gray)
.clipShape(Capsule())
}
.disabled(!dataModel.buttonEnable)
}
.frame(height: 35)
}
.padding()
.border(Color.green)
.padding()
.animation(.easeInOut)
.alert(isPresented: $showAlert) {
Alert(title: Text("登录成功"),
message: Text("\(dataModel.userName) \n \(dataModel.password)"),
dismissButton: nil)
}
.onDisappear {
dataModel.clear()
}
}
}
具体涉及到SwiftUI的知识就不再复述了,套路都是相同的,在上边的UI代码中,我们直接拿LoginDataModel
来使用,所有的业务逻辑都封装在LoginDataModel
之中。
class LoginDataModel: ObservableObject {
@Published var userName: String = ""
@Published var password: String = ""
@Published var buttonEnable = false
@Published var showUserNameError = false
@Published var showPasswordError = false
var cancellables = Set<AnyCancellable>()
var userNamePublisher: AnyPublisher<String, Never> {
return $userName
.receive(on: RunLoop.main)
.map { value in
guard value.count > 2 else {
self.showUserNameError = value.count > 0
return ""
}
self.showUserNameError = false
return value
}
.eraseToAnyPublisher()
}
var passwordPublisher: AnyPublisher<String, Never> {
return $password
.receive(on: RunLoop.main)
.map { value in
guard value.count > 5 else {
self.showPasswordError = value.count > 0
return ""
}
self.showPasswordError = false
return value
}
.eraseToAnyPublisher()
}
init() {
Publishers
.CombineLatest(userNamePublisher, passwordPublisher)
.map { v1, v2 in
!v1.isEmpty && !v2.isEmpty
}
.receive(on: RunLoop.main)
.assign(to: \.buttonEnable, on: self)
.store(in: &cancellables)
}
func clear() {
cancellables.removeAll()
}
deinit {
}
}
仔细观察上边的代码,它是声明式的,对各个数据的处理是如此的清晰:
- 我们使用
userNamePublisher
来处理用户名的逻辑 - 我们使用
passwordPublisher
来处理密码的逻辑 - 我们使用
CombineLatest
来合并用户名和密码的数据,用于控制登录按钮的状态
它确实是声明式的,如果从上往下看,它很像一份说明书,而不是一堆变量的计算。
在此,我也懒得写非Combine的对照代码了,大家可以仔细理解代码,细细品味其中韵味。
总结
本文写的不算复杂,也不算全面,并非一个完整的实战内容,只是让大家看一下Combine在真实开发场景的例子。本教程后续还有3篇文章,分别讲解如何自定义Publisher,Operator和Subscriber,算是进阶内容,大家拭目以待吧。