鸽子 🐦

CI Status Version License Platform Slack

简介

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

简而言之

使用 Pigeon 你可以

所有这些都通过一个非常简单的接口工作,该接口使用非常方便的 ObservableObject Combine 协议。

什么是 Pigeon?

Pigeon 主要围绕 Queries(查询)和 Mutations(变更)。Queries 是负责获取服务器数据的对象,而 Mutations 是负责修改服务器数据的对象。Queries 和 Mutations 都符合 ObservableObject 协议,这意味着它们都与 SwiftUI 完全兼容,并且它们的状态是可观察的。

Queries 由 QueryKey 标识。Pigeon 使用 QueryKey 对象来缓存查询结果,在内部链接它们,并在需要重新获取查询时使查询失效。

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

  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)
        }
    }
}

Queries 和 Query Consumers

除了 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。

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)
}

便捷的 Key

你还可以通过扩展 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)
        }
    }
}

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 为此用例提供了一种特殊类型的 QueryPaginatedQueryPaginatedQuery 在三种类型上是泛型的

让我们看一个例子

@ObservedObject private var users = PaginatedQuery<Void, LimitOffsetPaginatedQueryKey, [User]>(
    key: QueryKey(value: "users"),
    firstPage: LimitOffsetPaginatedQueryKey(
        limit: 20,
        offset: 0
    ),
    fetcher: { (request, page) in
        // ...
    }
)

这是一个 PaginatedQuery 的示例。 这里有几个重要的事情需要注意

除了 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 类型中的一个重要限制是 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
}

目前,该项目中包含两个缓存提供程序:InMemoryQueryCacheUserDefaultsQueryCache,但你可以通过在自定义类型中实现 QueryCacheType 来创建自己的缓存。

Query Renderers

如果你在快速入门部分看到了状态渲染

// ...
    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 全局数据。

最佳实践

你不必将网络逻辑与视图混合。 你总是可以在外部定义你的查询并将它们作为依赖项注入。 你甚至可以将 Queries 和 Mutations 嵌入到你自己的视图模型或 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。

安装

使用 Cocoapods

Pigeon 可以通过 CocoaPods 获得。 要安装它,只需将以下行添加到你的 Podfile 中

pod 'Pigeon'

使用 Swift Package Manager

Pigeon 也可以通过 Swift Package Manager 获得。 要安装它

  1. 在 Xcode 中,打开 File > Swift Packages > Add Package Dependency...
  2. 在打开的窗口中,将 https://github.com/fmo91/Pigeon.git 粘贴到包存储库 URL 文本字段中。
  3. 单击下一步并接受默认值。

作者

fmo91, ortizfernandomartin@gmail.com

许可证

Pigeon 在 MIT 许可证下可用。 有关更多信息,请参见 LICENSE 文件。