Skip to content

Repository pattern support library for Swift with Concurrency.

License

Notifications You must be signed in to change notification settings

KazaKago/StoreFlowable.swift

Repository files navigation

StoreFlowable.swift

GitHub tag (latest by date) Test License Swift Version Compatibility Platform Compatibility

Repository pattern support library for Swift with Concurrency.
Available for iOS or any Swift projects.

Related projects

Overview

This library provides remote and local cache abstraction and observation with Swift Concurrency.
Created according to the following 5 policies.

  • Repository pattern
    • Abstract remote and local data acquisition.
  • Single Source of Truth
    • Looks like a single source from the user side.
  • Observer pattern
    • Observing data with Combine Framework.
  • Return value as soon as possible
  • Representing the state of data

The following is the class structure of Repository pattern using this library.

README-SWIFT-1 (1)

The following is an example of screen display using LoadingState.

https://user-images.githubusercontent.com/7742104/125714730-381eee65-4126-4ee8-991a-7fc64dfb325c.jpg

Install

Install as Swift Package Manager exchanging *.*.* for the latest tag. GitHub tag (latest by date)

dependencies: [
    .package(url: "https://github.com/KazaKago/StoreFlowable.swift.git", from: "*.*.*"),
],

Get started

There are only 2 things you have to implement:

  • Create a class to manage the in-app cache.
  • Create a class to get data from origin server.

1. Create a class to manage the in-app cache

First, create a class that inherits Cacher<PARAM: Hashable, DATA>.
Put the type you want to use as a param in <PARAM: Hashable>. If you don't need the param, put in the UnitHash.

class UserCacher: Cacher<UserId, UserData> {
    static let shared = UserCacher()
    private override init() {}
}

Cacher<PARAM: Hashable, DATA> needs to be used in Singleton pattern.

2. Create a class to get data from origin server

Next, create a class that implements Fetcher.
Put the type you want to use as a Data in DATA associatedtype.

An example is shown below.

struct UserFetcher : Fetcher {

    typealias PARAM = UserId
    typealias DATA = UserData

    private let userApi = UserApi()

    func fetch(param: UserId) async throws -> UserData {
        userApi.fetch(userId: param)
    }
}

You need to prepare the API access class.
In this case, UserApi classe.

3. Build StoreFlowable from Cacher & Fetcher class

After that, you can get the StoreFlowable<DATA> class from the AnyStoreFlowable.from() method.
Be sure to go through the created StoreFlowable<DATA> class when getting / updating data.

let userFlowable: AnyStoreFlowable<UserData> = AnyStoreFlowable.from(cacher: userCacher, fetcher: userFetcher, param: userId)
let userStateSequence: LoadingStateSequence<UserData> = userFlowable.publish()

You can get the data in the form of LoadingStateSequence<DATA> (Same as AsyncSequence<LoadingState<DATA>>) by using the publish() method.
LoadingState is a enum that holds raw data.

4. Subscribe FlowLoadingState<DATA>

You can observe the data by for-in AsyncSequence.
and branch the data state with doAction() method or switch statement.

for await userState in userStateSequence {
    userState.doAction(
        onLoading: { (content: UserData?) in
            ...
        },
        onCompleted: { (content: UserData, _, _) in
            ...
        },
        onError: { (error: Error) in
            ...
        }
    )
}

Example

Refer to the example module for details. This module works as an Android app.
See GithubMetaCacher + GithubMetaFetcher or GithubUserCacher + GithubUserFetcher.

This example accesses the Github API.

Other usage of StoreFlowable class

Get data without LoadingState enum

If you don't need value flow and LoadingState enum, you can use requireData() or getData().
requireData() throws an Error if there is no valid cache and fails to get new data.
getData() returns nil instead of Error.

public extension StoreFlowable {
    func getData(from: GettingFrom = .both) async -> DATA?
    func requireData(from: GettingFrom = .both) async throws -> DATA
}

GettingFrom parameter specifies where to get the data.

public enum GettingFrom {
    // Gets a combination of valid cache and remote. (Default behavior)
    case both
    // Gets only remotely.
    case origin
    // Gets only locally.
    case cache
}

However, use requireData() or getData() only for one-shot data acquisition, and consider using publish() if possible.

Refresh data

If you want to ignore the cache and get new data, add forceRefresh parameter to publish().

public extension StoreFlowable {
    func publish(forceRefresh: Bool = false) -> LoadingStateSequence<DATA>
}

Or you can use refresh() if you are already observing the Publisher.

public protocol StoreFlowable {
    func refresh() async
}

Validate cache data

Use validate() if you want to verify that the local cache is valid.
If invalid, get new data remotely.

public protocol StoreFlowable {
    func validate() async
}

Update cache data

If you want to update the local cache, use the update() method.
Publisher observers will be notified.

public protocol StoreFlowable {
    func update(newData: DATA?) async
}

LoadingStateSequence<DATA> operators

Map LoadingStateSequence<DATA>

Use mapContent(transform) to transform content in LoadingStateSequence<DATA>.

let state: LoadingStateSequence<Int> = ...
let mappedState: LoadingStateSequence<String> = state.mapContent { value: Int in
    value.toString()
}

Combine multiple LoadingStateSequence<DATA>

Use combineState(state, transform) to combine multiple LoadingStateSequence<DATA>.

let state1: LoadingStateSequence<Int> = ...
let state2: LoadingStateSequence<Int> = ...
let combinedState: LoadingStateSequence<Int> = state1.combineState(state2) { value1: Int, value2: Int in
    value1 + value2
}

Manage Cache

Manage cache expire time

You can easily set the cache expiration time. Override expireSeconds variable in your Cacher<PARAM: Hashable, DATA> class. The default value is TimeInterval.infinity (= will NOT expire).

class UserCacher: Cacher<UserId, UserData> {
    static let shared = UserCacher()
    private override init() {}

    override var expireSeconds: TimeInterval {
        get { TimeInterval(60 * 30) } // expiration time is 30 minutes.
    }
}

Persist data

If you want to make the cached data persistent, override the method of your Cacher<PARAM: Hashable, DATA> class.

class UserCacher: Cacher<UserId, UserData> {
    static let shared = UserCacher()
    private override init() {}

    override var expireSeconds: TimeInterval {
        get { TimeInterval(60 * 30) } // expiration time is 30 minutes.
    }

    // Save the data for each parameter in any store.
    override func saveData(data: GithubMeta?, param: UnitHash) async {
        ...
    }

    // Get the data from the store for each parameter.
    override func loadData(param: UnitHash) async -> GithubMeta? {
        ...
    }

    // Save the epoch time for each parameter to manage the expiration time.
    // If there is no expiration time, no override is needed.
    override func saveDataCachedAt(epochSeconds: Double, param: UnitHash) async {
        ...
    }

    // Get the date for managing the expiration time for each parameter.
    // If there is no expiration time, no override is needed.
    override func loadDataCachedAt(param: UnitHash) async -> Double? {
        ...
    }
}

Pagination support

This library includes Pagination support.

Inherit PaginationCacher<PARAM: Hashable, DATA> & PaginationFetcher instead of Cacher<PARAM: Hashable, DATA> & Fetcher.

An example is shown below.

class UserListCacher: PaginationCacher<UnitHash, UserData> {
    static let shared = UserListCacher()
    private override init() {}
}

struct UserListFetcher : PaginationFetcher {

    typealias PARAM = UnitHash
    typealias DATA = UserData

    private let userListApi = UserListApi()

    func fetch(param: UnitHash) async throws -> PaginationFetcher.Result<UserData> {
        let fetched = userListApi.fetch(pageToken: nil)
        return PaginationFetcher.Result(data: fetched.data, nextKey: fetched.nextPageToken)
    }

    func fetchNext(nextKey: String, param: UnitHash) async throws -> PaginationFetcher.Result<UserData> {
        let fetched = userListApi.fetch(pageToken: nextKey)
        return PaginationFetcher.Result(data: fetched.data, nextKey: fetched.nextPageToken)
    }
}

You need to additionally implements fetchNext(nextKey: String, param: PARAM).

And then, You can get the state of additional loading from the next parameter of onCompleted {}.

let userFlowable = AnyStoreFlowable.from(cacher: userListCacher, fetcher: userListFetcher)
for await loadingState in userFlowable.publish() {
    loadingState.doAction(
        onLoading: { (content: UserData?) in
            // Whole (Initial) data loading.
        },
        onCompleted: { (content: UserData, next: AdditionalLoadingState, _) in
            // Whole (Initial) data loading completed.
            next.doAction(
                onFixed: { (canRequestAdditionalData: Bool) in
                    // No additional processing.
                },
                onLoading: {
                    // Additional data loading.
                },
                onError: { (error: Error) in
                    // Additional loading error.
                }
            )
        },
        onError: { (error: Error) in
            // Whole (Initial) data loading error.
        }
    )
}

To display in the UITableView, Please use the difference update function. See also UITableViewDiffableDataSource.

Request additional data

You can request additional data for paginating using the requestNextData() method.

public extension PaginationStoreFlowable {
    func requestNextData(continueWhenError: Bool = true) async
}

Pagination Example

The GithubOrgsCacher + GithubOrgsFetcher or GithubReposCacher + GithubReposFetcher classes in example module implement pagination.

License

This project is licensed under the Apache-2.0 License - see the LICENSE file for details.