Pigeon 0.1.16

Pigeon 0.1.16

Fernando Ortiz 维护。



Pigeon 0.1.16

Pigeon🐦

CI Status Version License Platform Slack

简介

Pigeon 是一个基于 SwiftUI 和 UIKit 的库,它依赖于 Combine 来处理异步数据。它深受 React Query 启发。

简而言之

使用 Pigeon,您可以

  • 获取服务器端 API。
  • 使用可交换和可配置的缓存提供者缓存服务器响应。
  • 在同一应用的多个未连接组件之间共享服务器数据。
  • 修改服务器端资源。
  • 无效化缓存并重新获取数据。
  • 管理分页数据源
  • Pigeon 对您用于获取数据的工具是中立的。

所有这些都通过一个简单易用的接口实现,该接口使用方便的 Combine 协议 ObservableObject

Pigeon 是什么?

鸽子框架主要关于查询和变更。查询对象负责从服务器获取数据,变更对象负责修改服务器数据。查询和变更都遵守ObservableObject协议,这意味着它们与 SwiftUI 完全兼容,并且它们的状态可观察。

查询通过QueryKey进行标记。鸽子使用QueryKey对象来缓存查询结果,将它们进行内部链接,并在需要重新获取时使查询失效。

在鸽子中有一点非常重要,那就是你可以使用任何你想要的工具从任何你需要的地方获取数据。鸽子不会强迫你必须使用AlamofireURLSessionGraphQL,甚至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: ())
        })
    }
}
  1. 我们首先定义一个Codable结构,它将存储我们服务器端的数据。这虽然不是直接与Pigeon相关,但对于示例仍然需要。
  2. 我们定义一个将存储我们的User数组的QueryQuery接受两个泛型参数:`Request`(在本例中为Void,因为获取操作不会接收任何参数)和`Response`,它是我们数据的类型(在本例中为[User])。
  3. 数据在鸽子中默认被缓存。`QueryKey`是围绕简单包装String的简单包装,它标识我们的状态片段。
  4. `Query`还会接收一个`fetcher`,这是我们必须定义的一个函数。`fetcher`接受一个`Request`,并返回一个持有`Response`的 Combine Publisher。注意,我们可以在`fetcher`中放置任何自定义逻辑。在这种情况下,我们使用`URLSession`从一个 API 获取User数组。
  5. `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感兴趣。请继续滚动!

  1. 在这个例子中,我们通过使用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适配器在QueryPaginatedQuery中都可用,是可选的。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类型:PaginatedQueryPaginatedQuery针对三者使用泛型:

  • 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的例子。这里有几个重要的事情需要注意

  • keyQuery类型中的工作方式与正常的一样。
  • firstPage应该接收您的获取器可获取的第一个可能页。
  • fetcherQuery中的工作方式完全相同,但它还接收要获取的页号。

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
}

目前,该项目中包含两个缓存提供者:InMemoryQueryCacheUserDefaultsQueryCache,但您可以创建自己的缓存,只需在自定义类型中实现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 来改变 QueryCacheTypeQueryCacheConfig 全局数据。

最佳实践

您不必将网络逻辑与视图混合。您始终可以定义外部查询并将它们作为依赖项注入。您甚至可以在您的视图模型或 ObservableObject 实例中嵌入查询和突变。QueryConsumerPaginatedQuery 有三个有趣的属性

var state: QueryState<Response> { get }
var statePublisher: AnyPublisher<QueryState<Response>, Never> { get }
var valuePublisher: AnyPublisher<Response, Never>

您可以观察 statePublishervaluePublisher,从而可以抽象您的视图从 QueryType 对象中,甚至创建依赖查询。您可以通过监听它们状态的更改或成功值来链式查询。

示例

要运行示例项目,克隆仓库,然后首先从 Example 目录运行 pod install

要求

Pigeon 可以配合 SwiftUI 和 UIKit 使用。由于其依赖于 Combine,因此它需要至少 iOS 13.0 的最低版本。

安装

Pigeon 通过 CocoaPods 提供。要安装它,只需将以下行添加到您的 Podfile

pod 'Pigeon'

作者

fmo91, [email protected]

许可

Pigeon 可在 CocoaPods 上获得。要安装它,只需将以下行添加到您的 Podfile