Introducing StoreKitPlus
In this post, let’s take a look at StoreKitPlus, which adds extra functionality for working with StoreKit 2 and aims to make it much easier to use StoreKit in SwiftUI.
Background
StoreKit 2 is a huge improvement compared to the old StoreKit APIs. Gone are the many notifications, transaction states etc. that you had to listen for. The new APIs are very simple to use and behave great.
However, I have found some things missing when using this new framework. One thing is an easy way to observe store-specific state, so that store state can drive the UI in a SwiftUI application. Other things are the possibility to mock the StoreKit integration, persisting product and purchase information and to set up a local representation of the real StoreKit products etc.
As such, I’ve created a tiny layer on top of StoreKit 2. It adds observable state, an abstract store service protocol, a concrete store service implementation as well as protocols for validating transactions and specifying local product representations. StoreKitPlus is easy to start using and can be extended with your own, custom logic, should you need to.
Let’s take a look at what it contains.
Getting products
To get products from StoreKit 2, you can use the Product.products
api:
let productIds = ["com.your-app.productid"]
let products = try await Product.products(for: productIds)
However, if you need to do this in an abstract way, for instance if you need to mock the functionality in a unit test suite, extend the core functionality, etc., you can use the StoreService
protocol, which has a getProducts()
function:
let productIds = ["com.your-app.productid"]
let products = try await service.getProducts()
The StandardStoreService
implementation communicates directly with StoreKit and syncs the result to a provided, observable StoreContext
. Read more on this context further down.
Purchasing products
To purchase products with StoreKit 2, you can use the Product.purchase
api:
let result = try await product.purchase()
switch result {
case .success(let result): try await handleTransaction(result)
case .pending: break
case .userCancelled: break
@unknown default: break
}
return result
However, if you need to do this in an abstract way, as described above, the StoreService
protocol has a purchase(_:)
function:
let result = try await service.purchase(product)
If you use the StandardStoreService
implementation, it communicates directly with StoreKit and syncs the result to a provided, observable StoreContext
.
Restoring purchases
To restore purchase with StoreKit 2, you can use the Transaction.latest(for:)
api and then verify each transaction to see that it’s purchased, not expired and not revoked.
This involves a bunch of steps, which makes the operation pretty complicated. To simplify, you can use the StoreService
restorePurchases()
function:
try await service.restorePurchases()
If you use the StandardStoreService
implementation, it communicates directly with StoreKit and syncs the result to a provided, observable StoreContext
.
Syncing store data
To perform a full product and purchase information sync with StoreKit 2, you can fetch all products and transactions from StoreKit, then set your local state to reflect this information.
This involves a bunch of steps, which makes the operation pretty complicated. To simplify, you can use the StoreService
syncStoreData()
function:
try await service.syncStoreData()
If you use the StandardStoreService
implementation, it communicates directly with StoreKit and syncs the result to a provided, observable StoreContext
.
Observable state
StoreKitPlus has an observable StoreContext
that can be used to observe the store-specific state for a certain app.
let productIds = ["com.your-app.productid"]
let context = StoreContext()
let service = StandardStoreService(
productIds: productIds,
context: context
)
The context lets you keep track of available and purchased products, and will even cache the IDs of the product and purchased products, which lets you use this information even if the app is later offline.
A context instance can be injected when creating a StandardStoreService
to make the service keep track of changes as the user uses the service to communicate with StoreKit. This means that the context will be automatically kept in sync when the user uses the service in your app.
Local products
If you want to be able to provide a local representation of your StoreKit product collection, you can use the ProductRepresentable
protocol.
The protocol is just an easy way to provide identifiable product types, that can be matched with the real product IDs, for instance:
enum MyProduct: CaseIterable, String, ProductRepresentable {
case premiumMonthly = "com.myapp.products.premium.monthly"
case premiumYearly = "com.myapp.products.premium.yearly"
var id: String { rawValue }
}
You can now use this collection to initialize a standard store service:
let products = MyProduct.allCases
let context = StoreContext()
let service = StandardStoreService(
products: products,
context: context
)
You can also match any product collection with a context’s purchased product IDs:
let products = MyProduct.allCases
let context = StoreContext()
let purchased = products.purchased(in: context)
Just make sure that your local product types use the same IDs as the real products. Also note that some operations require that you provide a real StoreKit Product
.
Conclusion
As you can see, using the StoreKitPlus library is very easy and just adds a bunch of convenience utilities on top of StoreKit 2. I will add more functionality when I see the need, or when other developers request more functionality. Until then, the library will be kept intentionally tiny.
If you want to git StoreKitPlus a try, you can test it here. I’d love to hear your thoughts on this, so don’t hesitate to comment, leave feedback etc.
Discussion
If you found this post interesting and would like to share your thoughts, ideas, feedback etc. please comment in the Disqus section below or reply to this tweet.