Pigeon 是一个 SwiftUI 和 UIKit 库,它依赖 Combine 来处理异步数据。 它深受 React Query 的启发。
使用 Pigeon 你可以
所有这些都通过一个非常简单的接口工作,该接口使用非常方便的 ObservableObject
Combine 协议。
Pigeon 主要围绕 Queries(查询)和 Mutations(变更)。Queries 是负责获取服务器数据的对象,而 Mutations 是负责修改服务器数据的对象。Queries 和 Mutations 都符合 ObservableObject
协议,这意味着它们都与 SwiftUI 完全兼容,并且它们的状态是可观察的。
Queries 由 QueryKey
标识。Pigeon 使用 QueryKey
对象来缓存查询结果,在内部链接它们,并在需要重新获取查询时使查询失效。
Pigeon 中非常重要的一点是,你可以使用任何你想要的方式从任何你需要的地方获取数据。 Pigeon 不会强迫你使用 Alamofire
或 URLSession
或 GraphQL
甚至 CoreData
。 你可以使用最合适的工具从你需要的地方获取数据。 唯一需要使用的是 Combine
发布者。
最后我想说明一点,然后我们就可以直接进入代码了。 Pigeon 可以选择性地缓存你的响应:你可以让 Pigeon 存储你的获取的响应,它将以几乎零配置的方式填充你的应用程序的数据。
Pigeon 的核心是 Query
ObservableObject。 让我们探索一下 Pigeon 的“Hello World”
// 1
struct User: Codable, Identifiable {
let id: Int
let name: String
}
struct UsersList: View {
// 2
@ObservedObject var users = Query<Void, [User]>(
// 3
key: QueryKey(value: "users"),
// 4
fetcher: {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com/users")!)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
)
var body: some View {
// 5
List(users.state.value ?? []) { user in
Text(user.name)
}.onAppear(perform: {
// 6
self.users.refetch(request: ())
})
}
}
Codable
结构,它将存储我们的服务器端数据。 这与 Pigeon
本身无关,但仍然是我们的示例正常工作所必需的。Query
,它将存储我们的 User
数组。 Query
接受两个泛型参数:Request
(在本例中为 Void
,因为 fetch 操作不会接收任何参数)和 Response
,它是我们数据的类型(在本例中为 [User]
)。QueryKey
是一个简单的包装器,围绕着标识我们状态的 String
。Query
还会接收一个 fetcher
,这是一个我们必须定义的函数。 fetcher
接收 Request
并返回一个包含 Response
的 Combine Publisher
。 请注意,我们可以将任何自定义逻辑放入 fetcher
中。 在这种情况下,我们使用 URLSession
从 API 获取 User
数组。Query
包含一个状态,该状态可以是:idle
(如果它刚刚开始),loading
(如果 fetcher 正在运行),failed
(也包含一个 Error
)或 succeed
(也包含 Response
)。 value
只是一个方便的属性,如果存在则返回 Response
,否则返回 nil
。// ...
var body: some View {
// 5
switch users.state {
case .idle, .loading:
return AnyView(Text("Loading..."))
case .failed:
return AnyView(Text("Oops..."))
case let .succeed(users):
return AnyView(
List(users) { user in
Text(user.name)
}
)
}
}
// ...
注意:如果你觉得这很丑陋,那么你可能对 QueryRenderer
感兴趣。 继续滚动!
refetch
手动触发我们的 Query
。 但是,我们也可以配置我们的 Query
使其立即触发,如下所示struct UsersList: View {
@ObservedObject var users = Query<Void, [User]>(
key: QueryKey(value: "users"),
// Changing the query behavior, we can tell the query to
// start fetching as soon as it initializes.
behavior: .startImmediately(()),
fetcher: {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com/users")!)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
)
var body: some View {
List(users.state.value ?? []) { user in
Text(user.name)
}
}
}
除了 Queries 之外,Pigeon 还有另一种类型 Consumer
,它不提供任何获取功能,但只提供消费的能力,并对它订阅的相同 QueryKey
的 Queries 的更改做出反应。 请注意,Query
依赖注入是在内部完成的,并且状态不会重复。
struct ContentView: View {
@ObservedObject var users = Query<Void, [User]>(
key: QueryKey(value: "users"),
behavior: .startImmediately(()),
fetcher: {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com/users/")!)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
)
var body: some View {
UsersList()
}
}
struct UsersList: View {
@ObservedObject var users = Query<Void, [User]>.Consumer(key: QueryKey(value: "users"))
var body: some View {
List(users.state.value ?? []) { user in
Text(user.name)
}
}
}
struct User: Codable, Identifiable {
let id: Int
let name: String
}
Pigeon 提供了一种使用 fetcher 每 N 秒获取数据的方法。 这是通过 Query
类中的 pollingBehavior
属性实现的。 默认值为 .noPolling
。 让我们看一个例子
@ObservedObject var users = Query<Void, [User]>(
key: QueryKey(value: "users"),
behavior: .startImmediately(()),
pollingBehavior: .pollEvery(2),
fetcher: {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com/users")!)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
)
该查询将每 2 秒触发一次其 fetcher。
除了允许查询外,Pigeon 还提供了一种更改服务器数据并强制重新获取受影响的查询的方法。
@ObservedObject var sampleMutation = Mutation<Int, User> { (number) -> AnyPublisher<User, Error> in
Just(User(id: number, name: "Pepe"))
.tryMap({ $0 })
.eraseToAnyPublisher()
}
// ...
sampleMutation.execute(with: 10) { (user: User, invalidate) in
// Invalidate triggers a new query on the "users" key
invalidate(QueryKey(value: "users"), .lastData)
}
你还可以通过扩展 QueryKey
来定义更方便的 Key,如下所示
extension QueryKey {
static let users: QueryKey = QueryKey(value: "users")
}
然后你可以这样使用它
struct UsersList: View {
@ObservedObject var users = Query<Void, [User]>.Consumer(key: .users)
var body: some View {
List(users.state.value ?? []) { user in
Text(user.name)
}
}
}
有时,你不仅需要根据你的 Query
类型缓存值,还需要根据你的请求的参数缓存值。 例如,你可能希望将 id=1 的用户的响应缓存在与 id=2 的用户不同的缓存值中。 这就是 key 适配器解决的问题。 Key 适配器在 Query
和 PaginatedQuery
中都可用,并且是可选的。 Key 适配器在构造函数的 keyAdapter
参数下发送,并且是具有 (QueryKey, Request) -> QueryKey
签名的函数。
@ObservedObject private var user = Query<Int, [User]>(
key: QueryKey(value: "users"),
keyAdapter: { key, id in
key.appending(id.description)
},
behavior: .startImmediately(1),
cache: UserDefaultsQueryCache.shared,
fetcher: { id in
// ...
}
)
获取服务器数据时,一个非常常见的场景是分页。 Pigeon 为此用例提供了一种特殊类型的 Query
:PaginatedQuery
。 PaginatedQuery
在三种类型上是泛型的
PaginatedQueryKey
的类型,用于标识当前页面。 默认情况下,Pigeon 提供两种 PaginatedQueryKey
替代方案:NumericPaginatedQueryKey
(第 1 页,第 2 页,...)和 LimitOffsetPaginatedQueryKey
(例如,限制:20,偏移量:40)。 如果这些与你的需求不符,那么你可以创建一个实现 PaginatedQueryKey
的新类型并自定义其行为。Sequence
才能适合在 PaginatedQuery
中使用。让我们看一个例子
@ObservedObject private var users = PaginatedQuery<Void, LimitOffsetPaginatedQueryKey, [User]>(
key: QueryKey(value: "users"),
firstPage: LimitOffsetPaginatedQueryKey(
limit: 20,
offset: 0
),
fetcher: { (request, page) in
// ...
}
)
这是一个 PaginatedQuery
的示例。 这里有几个重要的事情需要注意
key
的工作方式与常规 Query
类型完全相同。firstPage
应该接收你的 fetcher 的第一个可能的页面。fetcher
的工作方式与 Query
中的完全相同,但它也接收要获取的页面。除了 Query
提供的所有功能之外,PaginatedQuery
还允许你做更多的事情
// If you want to fetch the next page.
users.fetchNextPage()
// If you need to fetch the first page again (this will reset the current state for your query)
users.refetch(request /* some Request */)
需要注意的重要一点是,PaginatedQuery
目前无法缓存。
Pigeon Query
类型中的一个重要限制是 Response
必须是 Codable
。 这是因为服务器端数据的可缓存性。 数据可以被缓存,并且为了被缓存,我们需要它是 Codable
。
缓存与 Pigeon 机制深度集成。 Pigeon Query
对象中的所有数据都可以被缓存,因为它具有可编码性,然后在下一次应用程序启动时用于状态水合。
让我们看一个例子
@ObservedObject private var cards = PaginatedQuery<Void, NumericPaginatedQueryKey, [Card]>(
key: QueryKey(value: "cards"),
firstPage: NumericPaginatedQueryKey(current: 0),
behavior: .startImmediately(()),
cache: UserDefaultsQueryCache.shared,
cacheConfig: QueryCacheConfig(
invalidationPolicy: .expiresAfter(1000),
usagePolicy: .useInsteadOfFetching
),
fetcher: { request, page in
print("Fetching page no. \(page)")
return GetCardsRequest()
.execute()
.map(\.cards)
.eraseToAnyPublisher()
}
)
这来自该项目中的示例文件夹。 如果你在 cacheConfig
中看到
cacheConfig: QueryCacheConfig(
invalidationPolicy: .expiresAfter(1000),
usagePolicy: .useInsteadOfFetching
),
这几乎是不言自明的:如果可能并且其数据有效,Pigeon 将使用缓存而不是获取。 并且数据将被视为有效,直到从保存起 1000 秒。
Pigeon 提供了两种失效策略
public enum InvalidationPolicy {
case notExpires
case expiresAfter(TimeInterval)
}
和三种使用策略
public enum UsagePolicy {
case useInsteadOfFetching
case useIfFetchFails
case useAndThenFetch
}
目前,该项目中包含两个缓存提供程序:InMemoryQueryCache
和 UserDefaultsQueryCache
,但你可以通过在自定义类型中实现 QueryCacheType
来创建自己的缓存。
如果你在快速入门部分看到了状态渲染
// ...
var body: some View {
// 5
switch users.state {
case .idle, .loading:
return AnyView(Text("Loading..."))
case .failed:
return AnyView(Text("Oops..."))
case let .succeed(users):
return AnyView(
List(users) { user in
Text(user.name)
}
)
}
}
// ...
那么你可能觉得它可以做得更好。 所有的 AnyView
是什么? 奇怪...
好吧,Pigeon 提供了一种替代方法:QueryRenderer
。 这是一个包含三个要求的协议
// When Query is in loading state
var loadingView: some View { get }
// When Query is in succeed state
func successView(for response: Response) -> some View
// When Query is in failure state
func failureView(for failure: Error) -> some View
作为交换,QueryRenderer
提供了一种用于渲染 QueryState
的方法。 让我们看一个完整的例子
struct UsersList: View {
@ObservedObject private var users = Query<Void, [User]>(
key: QueryKey(value: "users"),
behavior: .startImmediately(()),
fetcher: {
URLSession.shared
.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com/users/")!)
.map(\.data)
.decode(type: [User].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
)
var body: some View {
self.view(for: users.state)
}
}
extension UsersList: QueryRenderer {
var loadingView: some View {
Text("Loading...")
}
func successView(for response: [User]) -> some View {
List(response) { user in
Text(user.name)
}
}
func failureView(for failure: Error) -> some View {
Text("It failed...")
}
}
struct User: Codable, Identifiable {
let id: Int
let name: String
}
请注意,你不必在你的 View
中实现 QueryRenderer
。 你总是可以为渲染逻辑创建一个不同的结构,并使该结构可用于不同的上下文。 查看这个完整的例子
struct CardDetailView: View {
@ObservedObject private var card = Query<String, Card>(
key: QueryKey(value: "card_detail"),
keyAdapter: { key, id in
key.appending(id)
},
cache: UserDefaultsQueryCache.shared,
cacheConfig: QueryCacheConfig(
invalidationPolicy: .expiresAfter(500),
usagePolicy: .useInsteadOfFetching
),
fetcher: { id in
CardDetailRequest(cardId: id)
.execute()
.map(\.card)
.eraseToAnyPublisher()
}
)
private let id: String
let renderer = NameRepresentableRenderer<Card>()
init(id: String) {
self.id = id
}
var body: some View {
renderer.view(for: card.state)
.navigationBarTitle("Card Detail")
}
}
protocol NameRepresentable {
var name: String { get }
}
extension Card: NameRepresentable {}
struct NameRepresentableRenderer<T: NameRepresentable>: QueryRenderer {
var loadingView: some View {
Text("Loading...")
}
func failureView(for failure: Error) -> some View {
EmptyView()
}
func successView(for response: T) -> some View {
Text(response.name)
}
}
你可以通过在任一类型上调用 setGlobal
来更改 QueryCacheType
和 QueryCacheConfig
全局数据。
你不必将网络逻辑与视图混合。 你总是可以在外部定义你的查询并将它们作为依赖项注入。 你甚至可以将 Queries 和 Mutations 嵌入到你自己的视图模型或 ObservableObject
实例中。 Query
、Consumer
和 PaginatedQuery
具有三个有趣的属性
var state: QueryState<Response> { get }
var statePublisher: AnyPublisher<QueryState<Response>, Never> { get }
var valuePublisher: AnyPublisher<Response, Never>
你可以观察 statePublisher
或 valuePublisher
,因此你可以从 QueryType
对象中抽象出你的视图,甚至创建依赖查询。 你可以通过监听其状态或成功值的更改来链接查询。
要运行示例项目,请克隆存储库,然后首先从 Example 目录运行 pod install
。
Pigeon 可以与 SwiftUI 和 UIKit 一起使用。 因为它依赖于 Combine,所以它需要最低 iOS 版本 13.0。
Pigeon 可以通过 CocoaPods 获得。 要安装它,只需将以下行添加到你的 Podfile 中
pod 'Pigeon'
Pigeon 也可以通过 Swift Package Manager 获得。 要安装它
https://github.com/fmo91/Pigeon.git
粘贴到包存储库 URL 文本字段中。fmo91, ortizfernandomartin@gmail.com
Pigeon 在 MIT 许可证下可用。 有关更多信息,请参见 LICENSE 文件。