🐦
Pigeon
简介
Pigeon 是一个基于 SwiftUI 和 UIKit 的库,它依赖于 Combine 来处理异步数据。它深受 React Query 启发。
简而言之
使用 Pigeon,您可以
- 获取服务器端 API。
- 使用可交换和可配置的缓存提供者缓存服务器响应。
- 在同一应用的多个未连接组件之间共享服务器数据。
- 修改服务器端资源。
- 无效化缓存并重新获取数据。
- 管理分页数据源
- Pigeon 对您用于获取数据的工具是中立的。
所有这些都通过一个简单易用的接口实现,该接口使用方便的 Combine 协议 ObservableObject
。
Pigeon 是什么?
鸽子框架主要关于查询和变更。查询对象负责从服务器获取数据,变更对象负责修改服务器数据。查询和变更都遵守ObservableObject
协议,这意味着它们与 SwiftUI 完全兼容,并且它们的状态可观察。
查询通过QueryKey
进行标记。鸽子使用QueryKey
对象来缓存查询结果,将它们进行内部链接,并在需要重新获取时使查询失效。
在鸽子中有一点非常重要,那就是你可以使用任何你想要的工具从任何你需要的地方获取数据。鸽子不会强迫你必须使用Alamofire
或URLSession
或GraphQL
,甚至CoreData
。你可以使用最恰当的工具从所需的地方获取数据。你唯一需要使用的是Combine
发布者。
最后我想说明的是,鸽子可以选择性地缓存你的响应:你可以让鸽子存储你的获取响应,并以几乎无配置的方式用数据填充你的应用程序。
快速开始
在鸽子的核心是Query
可观察对象。让我们来看看鸽子的“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
相关,但对于示例仍然需要。 - 我们定义一个将存储我们的
User
数组的Query
。Query
接受两个泛型参数:`Request`(在本例中为Void
,因为获取操作不会接收任何参数)和`Response`,它是我们数据的类型(在本例中为[User]
)。 - 数据在鸽子中默认被缓存。`QueryKey`是围绕简单包装
String
的简单包装,它标识我们的状态片段。 - `Query`还会接收一个`fetcher`,这是我们必须定义的一个函数。`fetcher`接受一个`Request`,并返回一个持有`Response`的 Combine
Publisher
。注意,我们可以在`fetcher`中放置任何自定义逻辑。在这种情况下,我们使用`URLSession`从一个 API 获取User
数组。 - `Query`包含一个状态,该状态可以是
idle
(如果它刚刚开始)、loading
(如果获取器正在运行)、failed
(包含一个`Error`)或succeed
(包含响应)。`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)
}
}
}
查询和查询消费者
除了查询之外,Pigeon还有另一种类型,即消费者(Consumer)
,它并不提供任何获取能力,仅提供了消耗、并对同一个它订阅的QueryKey
所对应的查询变化做出反应的能力。请注意,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提供了一种每N秒使用fetcher获取数据的方式。这是通过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。
mutations
除了允许查询外,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)
}
方便的keys
您还可以通过扩展QueryKey
来定义更多方便的keys,如下所示
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)
}
}
}
Key适配器
有时候,您可能需要根据请求的参数来缓存与您的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
针对三者使用泛型:
- Request:用于执行获取所需的数据类型。
- PageIdentifier:一个符合
PaginatedQueryKey
的类型,用于标识当前页。默认情况下,Pigeon提供了两种PaginatedQueryKey
替代方案:NumericPaginatedQueryKey
(第1页,第2页,...)和LimitOffsetPaginatedQueryKey
(限制:20,偏移:40,例如)。如果这些不符合您的需求,则可以创建一个实现PaginatedQueryKey
的新类型,并自定义其行为。 - Response:响应类型。此类型需要符合
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
在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
不能被缓存。
对Codable的依赖
Pigeon Query
类型的限制之一是响应必须为Codable
。这是因为服务器端数据的缓存性质。数据可以被缓存,而要被缓存,我们需要它是Codable
的。
缓存
缓存与Pigeon机制深度集成。由于Pigeon中的所有数据都是可编码的,因此可以缓存所有数据,并在下一次应用启动时用于状态复用水。
让我们看一个例子
@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
全局数据。
最佳实践
您不必将网络逻辑与视图混合。您始终可以定义外部查询并将它们作为依赖项注入。您甚至可以在您的视图模型或 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'
作者
fmo91, [email protected]
许可
Pigeon 可在 CocoaPods 上获得。要安装它,只需将以下行添加到您的 Podfile