Write unit tests for ObservableObject ViewModels with Published results - swift

Today again one combine problem I currently run in and I hope that someone of you can help. How can normal unit tests be written for ObservableObjects classes which contain #Published attributes? How can I subscribe in my test to them to get the result object which I can assert?
The injected mock for the web service works correctly, loadProducts() function set exactly the same elements from the mock in the fetchedProducts array.
But I don't know currently how to access this array in my test after it is filled by the function because it seems that I cannot work with expectations here, loadProducts() has no completion block.
The code looks like this:
class ProductsListViewModel: ObservableObject {
let getRequests: GetRequests
let urlService: ApiUrls
private let networkUtils: NetworkRequestUtils
let productsWillChange = ObservableObjectPublisher()
#Published var fetchedProducts = [ProductDTO]()
#Published var errorCodeLoadProducts: Int?
init(getRequestsHelper: GetRequests, urlServiceClass: ApiUrls = ApiUrls(), utilsNetwork: NetworkRequestUtils = NetworkRequestUtils()) {
getRequests = getRequestsHelper
urlService = urlServiceClass
networkUtils = utilsNetwork
}
// nor completion block in the function used
func loadProducts() {
let urlForRequest = urlService.loadProductsUrl()
getRequests.getJsonData(url: urlForRequest) { [weak self] (result: Result<[ProductDTO], Error>) in
self?.isLoading = false
switch result {
case .success(let productsArray):
// the products filled async here
self?.fetchedProducts = productsArray
self?.errorCodeLoadProducts = nil
case .failure(let error):
let errorCode = self?.networkUtils.errorCodeFrom(error: error)
self?.errorCodeLoadProducts = errorCode
print("error: \(error)")
}
}
}
}
The test I try to write looks like this at the moment:
import XCTest
#testable import MyProject
class ProductsListViewModelTest: XCTestCase {
var getRequestMock: GetRequests!
let requestManagerMock = RequestManagerMockLoadProducts()
var productListViewModel: ProductsListViewModel!
override func setUp() {
super.setUp()
getRequestMock = GetRequests(networkHelper: requestManagerMock)
productListViewModel = ProductsListViewModel(getRequestsHelper: getRequestMock)
}
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
productListViewModel.loadProducts()
// TODO access the fetchedProducts here somehow and assert them
}
}
The Mock looks like this:
class RequestManagerMockLoadProducts: NetworkRequestManagerProtocol {
var isSuccess = true
func makeNetworkRequest<T>(urlRequestObject: URLRequest, completion: #escaping (Result<T, Error>) -> Void) where T : Decodable {
if isSuccess {
let successResultDto = returnedProductedArray() as! T
completion(.success(successResultDto))
} else {
let errorString = "Cannot create request object here"
let error = NSError(domain: ErrorDomainDescription.networkRequestDomain.rawValue, code: ErrorDomainCode.unexpectedResponseFromAPI.rawValue, userInfo: [NSLocalizedDescriptionKey: errorString])
completion(.failure(error))
}
}
func returnedProductedArray() -> [ProductDTO] {
let product1 = ProductDTO(idFromBackend: "product-1", name: "product-1", description: "product-description", price: 3.55, photo: nil)
let product2 = ProductDTO(idFromBackend: "product-2", name: "product-2", description: "product-description-2", price: 5.55, photo: nil)
let product3 = ProductDTO(idFromBackend: "product-3", name: "product-3", description: "product-description-3", price: 8.55, photo: nil)
return [product1, product2, product3]
}
}

Maybe this article can help you
Testing your Combine Publishers
To solve your issue I will use code from my article
typealias CompetionResult = (expectation: XCTestExpectation,
cancellable: AnyCancellable)
func expectValue<T: Publisher>(of publisher: T,
timeout: TimeInterval = 2,
file: StaticString = #file,
line: UInt = #line,
equals: [(T.Output) -> Bool])
-> CompetionResult {
let exp = expectation(description: "Correct values of " + String(describing: publisher))
var mutableEquals = equals
let cancellable = publisher
.sink(receiveCompletion: { _ in },
receiveValue: { value in
if mutableEquals.first?(value) ?? false {
_ = mutableEquals.remove(at: 0)
if mutableEquals.isEmpty {
exp.fulfill()
}
}
})
return (exp, cancellable)
}
your test needs to use this function
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
/// The expectation here can be extended as needed
let exp = expectValue(of: productListViewModel .$fetchedProducts.eraseToAnyPublisher(), equals: [{ $0[0].idFromBackend == "product-1" }])
productListViewModel.loadProducts()
wait(for: [exp.expectation], timeout: 1)
}

The easy and clearest way for me is simply to test #published var after X seconds. An example bellow :
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
productListViewModel.loadProducts()
// TODO access the fetchedProducts here somehow and assert them
let expectation = XCTestExpectation()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
XCTAssertEqual(self.productListViewModel.fetchedProducts, ["Awaited values"])
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
}
I hope that helps !

Related

Unit Testing Combine

I'm having difficulties testing Combine. I'm following:
https://www.swiftbysundell.com/articles/unit-testing-combine-based-swift-code/
Which tests:
final class ViewModel {
#Published private(set) var tokens = [String]()
#Published var string = ""
private let tokenizer = Tokenizer()
init () {
$string
.flatMap(tokenizer.tokenize)
.replaceError(with: [])
.assign(to: &$tokens)
}
}
struct Tokenizer {
func tokenize(_ string: String) -> AnyPublisher<[String], Error> {
let strs = string.components(separatedBy: " ")
return Just(strs)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
with the following:
func testTokenizingMultipleStrings() throws {
let viewModel = ViewModel()
let tokenPublisher = viewModel.$tokens
.dropFirst()
.collect(2)
.first()
viewModel.string = "Hello #john"
viewModel.string = "Check out #swift"
let tokenArrays = try awaitPublisher(tokenPublisher)
XCTAssertEqual(tokenArrays.count, 2)
XCTAssertEqual(tokenArrays.first, ["Hello", "john"])
XCTAssertEqual(tokenArrays.last, ["Check out", "swift"])
}
And the following helper function:
extension XCTestCase {
func awaitPublisher<T: Publisher>(
_ publisher: T,
timeout: TimeInterval = 10,
file: StaticString = #file,
line: UInt = #line
) throws -> T.Output {
var result: Result<T.Output, Error>?
let expectation = self.expectation(description: "Awaiting publisher")
let cancellable = publisher.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
result = .failure(error)
case .finished:
break
}
expectation.fulfill()
},
receiveValue: { value in
result = .success(value)
}
)
waitForExpectations(timeout: timeout)
cancellable.cancel()
let unwrappedResult = try XCTUnwrap(
result,
"Awaited publisher did not produce any output",
file: file,
line: line
)
return try unwrappedResult.get()
}
}
Here receiveValue is never called so the test doesn't complete.
How can I get this test to pass?
I ran into the same issue and eventually realized that for the test to pass, we need to update the view-model between setting up the subscription and waiting for the expectation. Since both currently happen inside the awaitPublisher helper, I added a closure parameter to that function:
func awaitPublisher<T: Publisher>(
_ publisher: T,
timeout: TimeInterval = 10,
file: StaticString = #file,
line: UInt = #line,
closure: () -> Void
) throws -> T.Output {
...
let expectation = ...
let cancellation = ...
closure()
waitForExpectations(timeout: timeout)
...
}
Note the exact position of the closure – it won’t work if it’s called too early or too late.
You can then call the helper in your test like so:
let tokenArrays = try awaitPublisher(publisher) {
viewModel.string = "Hello #john"
viewModel.string = "Check out #swift"
}
let viewModel = ViewModel()
let tokenPublisher = viewModel.$tokens
.dropFirst()
.collect(2)
.first()
viewModel.string = "Hello #john"
viewModel.string = "Check out #swift"
let tokenArrays = try awaitPublisher(tokenPublisher)
Your tokenPublisher won't do anything until you subscribe to it. In this code you create the publisher, do some actions that would have pushed values through the Publisher if anyone was subscribed to it, then you call awaitPublisher (the thing that does the subscription). You need to reverse those:
let viewModel = ViewModel()
let tokenPublisher = viewModel.$tokens
.dropFirst()
.collect(2)
.first()
let tokenArrays = try awaitPublisher(tokenPublisher)
viewModel.string = "Hello #john"
viewModel.string = "Check out #swift"

Alamofire request not being executed after adding one parameter

I have this function. Whenever I comment the "v": 2 parameter it works like a charm, then I add it and when trying to call the function from my VC it doesn't get fired off. I think the problem is somewhere at the top. Like I said, when commenting the "v:2" parameter, everything goes smooth. I've checked my postman and the URL Request adding that parameter does give me the right response, but my function is not being called in my VC. Any ideas? Please I'm desperate ):
public func getBusinessBy(location loc: String, type: Int, loading: Bool, OnSuccess success: #escaping (_ businesses: [NSDictionary]) -> Void, OnFailed failed: #escaping (_ error: String) -> Void) {
if loading {
GlobalLoader.show()
}
let bUrl = HttpUtils.getBusinessesBy(type: type)
let params: Parameters = [
"location": loc,
"type": type,
"v": 2,
"params": Config.ENABLE_SEARCH ? Config.SEARCH_BUSINESS_PARAMS : Config.COMMON_BUSINESS_PARAMS,
]
Alamofire.request(bUrl, method: .get, parameters: params, encoding: URLEncoding.queryString, headers: [:]).responseJSON() { response in
switch response.result {
case .success(let val):
print("yaaaaay!")
if let res = val as? NSDictionary {
if let businesses = res.results() {
let allBusiesses = businesses.sorted(by: { (d1, d2) -> Bool in
(d1.object(forKey: "open") as! Bool) && !(d2.object(forKey: "open") as! Bool)
})
// Storing All Businesses to global
Session.sharedInstance.setAllBusiness(allBusiesses)
var tmpCat = [NSDictionary]()
var tmpPro = [NSDictionary]()
var tmpPMotion = [NSDictionary]()
var tmpPBusiness = [NSDictionary]()
for busin in allBusiesses {
guard let categories = busin["categories"] as? [NSDictionary] else {
return
}
tmpCat.append(contentsOf: categories)
var flag = false
for cat in categories {
if let prod = cat["products"] as? [NSDictionary] {
for p in prod {
var pClone = NSMutableDictionary()
pClone = p.mutableCopy() as! NSMutableDictionary
let pivot = NSMutableDictionary()
pivot["business_id"] = busin.getId()
pivot["business_name"] = busin.getName()
pivot["business_description"] = busin.getDescription()
pivot["business_enabled"] = busin.isOpened()
pivot["category_name"] = cat.getName()
pivot["category_description"] = cat.getDescription()
pivot["category_enabled"] = cat.isEnabled()
pClone["pivot"] = pivot
tmpPro.append(pClone)
if p.isFeatured() {
tmpPMotion.append(pClone)
if !flag {
tmpPBusiness.append(busin)
flag = true
}
}
}
}
}
}
Session.sharedInstance.setAllProduct(tmpPro)
Session.sharedInstance.setPromotions(tmpPMotion)
Session.sharedInstance.setPromBusinesses(tmpPBusiness)
Session.sharedInstance.setAllCategory(tmpCat)
// }
success(businesses)
}
} else {
failed(val as? String ?? "Passing error!")
}
if loading {
// SVProgressHUD.dismiss()
GlobalLoader.hide()
}
break
case .failure(let error):
print("naaaaay!")
failed(error.localizedDescription)
if loading {
// SVProgressHUD.dismiss()
GlobalLoader.hide()
}
break
}
}
}

Async Future Promise not working in Swift 5

I'm trying to implement an async call to a function loadUserFromFirebase and populate and return an array based on user attributes that I then use to write to a firebase collection.
I'm trying to use the Combine framework from Swift using future and promises but for some reason, the receiveCompletion and receiveValue don't get called and thus I get an empty array from my async function call.
Here is my code:
var cancellable = Set<AnyCancellable>()
func loadUserFromFirebase(groupUserIds: [String], handler: #escaping ([groupMate]) -> Void) {
var groupmateID = 0
var groupMates : [groupMate] = []
print("groupUserIds: \(groupUserIds)" )
for groupUserUID in groupUserIds {
print("groupUserUID: \(groupUserUID)" )
let future = Future<groupMate, Never> { promise in
self.ref.collection("Users").document(groupUserUID).getDocument(){
(friendDocument, err) in
if let err = err {
print("Error getting documents \(err)")
} else {
// print("friendDocument: \(String(describing: friendDocument?.data()))" )
let groupUsername = (friendDocument?.data()?["username"]) as! String
let groupUID = (friendDocument?.data()?["uid"]) as! String
let groupName = (friendDocument?.data()?["name"]) as! String
let groupPic = (friendDocument?.data()?["imageurl"]) as! String
promise(.success(groupMate(id: groupmateID, uid: groupUID , name: groupName , username: groupUsername, pic: groupPic)))
}
groupmateID += 1
}
}
print("in receiveCompletion")
future.sink(receiveCompletion: { completion in
print("in receiveCompletion")
print(1, completion)
switch completion {
case .failure(let error):
print(3, error)
handler([])
return
case .finished:
break
}
},
receiveValue: {
print("in receiveValue")
groupMates.append($0)
print(groupMates)
handler(groupMates)
}).store(in: &cancellable)
}
}
func creategroup(groupName: String){
addedTogroupUsers.append(self.uid)
print("here111")
loadUserFromFirebase(groupUserIds: addedTogroupUsers) { groupMates in
print("here222")
let groupData: [String: Any] = [
"groupName": "\(groupName)",
"groupmates": groupMates
]
print("here333 \(groupData)")
print("groupMates are \(self.groupMates)")
var groupref: DocumentReference? = nil
groupref = self.ref.collection("groups").addDocument(data: groupData) { err in
if let err = err {
print("Error adding document: \(err)")
} else {
print("Document added with ID: \(groupref!.documentID)")
for addedgroupUser in self.addedTogroupUsers {
self.ref.collection("Users").document(addedgroupUser).updateData([
"groups": FieldValue.arrayUnion([groupref!.documentID])
])
}
}
}
print("groupName is \(groupName) and addedTogroup are \(self.addedTogroupUsers)")
}
}
I'm trying to see if AnyCancellable is the way to go but since I'm using a chained array of future promises, I'm not sure how to implement it. Please let me know how you'd solve this problem so that the array does get populated since the documents do exist and the print inside the method call work but the groupMates array in the createGroup function prints an empty array afterwards. Thanks!
Edit: Added AnyCancallable to Code along with completion handler as suggested
dealing with async functions can be tricky. You are getting an empty array, because you are returning too early, in loadUserFromFirebase. Try this approach (untested) using the old style closure:
func loadUserFromFirebase(groupUserIds: [String], handler: #escaping ([groupMate]) -> Void) { // <-- here
var groupmateID = 0
var groupMates : [String] = []
print("groupUserIds: \(groupUserIds)" )
for groupUserUID in groupUserIds {
print("groupUserUID: \(groupUserUID)" )
let future = Future<groupMate, Never> { promise in
self.ref.collection("Users").document(groupUserUID).getDocument(){ (friendDocument, err) in
if let err = err {
print("Error getting documents \(err)")
} else {
print("friendDocument: \(String(describing: friendDocument?.data()))" )
let groupUsername = (friendDocument?.data()?["username"]) as! String
let groupUID = (friendDocument?.data()?["uid"]) as! String
let groupName = (friendDocument?.data()?["name"]) as! String
let groupPic = (friendDocument?.data()?["imageurl"]) as! String
promise(.success(groupMate(id: groupmateID, uid: groupUID , name: groupName , username: groupUsername, pic: groupPic)))
}
groupmateID += 1
}
}
future.sink(receiveCompletion: { completion in
print("in receiveCompletion")
print(1, completion)
switch completion {
case .failure(let error):
print(3, error)
handler([]) // <-- here
return // <-- here
case .finished:
break
}
},
receiveValue: {
print("in receiveValue")
groupMates.append($0)
print(groupMates)
handler(groupMates) // <-- here
})
}
// <-- do not return here
}
func creategroup(groupName: String) {
addedTogroupUsers.append(self.uid)
// -- here wait until you get the data
loadUserFromFirebase(groupUserIds: addedTogroupUsers) { groupMates in
let groupData: [String: Any] = [
"groupName": "\(groupName)",
"groupmates": groupMates // <-- here
]
print("groupMates are \(self.groupMates)")
var groupref: DocumentReference? = nil
groupref = ref.collection("groups").addDocument(data: groupData) { err in
if let err = err {
print("Error adding document: \(err)")
} else {
print("Document added with ID: \(groupref!.documentID)")
for addedgroupUser in self.addedTogroupUsers {
self.ref.collection("Users").document(addedgroupUser).updateData([
"groups": FieldValue.arrayUnion([groupref!.documentID])
])
}
}
}
print("groupName is \(groupName) and addedTogroup are \(addedTogroupUsers)")
}
}
Note, if you are targeting ios 15, macos 12, you are far better served if you use the swift 5.5 async/await/task features. They really work well.
EDIT: trying to return all results
func loadUserFromFirebase(groupUserIds: [String], handler: #escaping ([groupMate]) -> Void) {
var groupmateID = 0
var groupMates : [String] = []
print("groupUserIds: \(groupUserIds)" )
var arr: [Future<groupMate, Never>] = [Future<groupMate, Never>]()
var cancellable = Set<AnyCancellable>()
for groupUserUID in groupUserIds {
print("groupUserUID: \(groupUserUID)" )
let future = Future<groupMate, Never> { promise in
self.ref.collection("Users").document(groupUserUID).getDocument(){ (friendDocument, err) in
if let err = err {
print("Error getting documents \(err)")
} else {
print("friendDocument: \(String(describing: friendDocument?.data()))" )
let groupUsername = (friendDocument?.data()?["username"]) as! String
let groupUID = (friendDocument?.data()?["uid"]) as! String
let groupName = (friendDocument?.data()?["name"]) as! String
let groupPic = (friendDocument?.data()?["imageurl"]) as! String
promise(.success(groupMate(id: groupmateID, uid: groupUID , name: groupName , username: groupUsername, pic: groupPic)))
}
groupmateID += 1
}
}
arr.append(future)
}
Publishers.MergeMany(arr)
.collect()
.sink { _ in
print("-----> merging ")
} receiveValue: { value in
print("-----> value: \(value)")
groupMates = value // <--- maybe append?
print(groupMates)
handler(groupMates)
}
.store(in: &cancellable)
}

Swift - Combine subscription not being called

Recently, I tried to use freshOS/Networking swift package.
And I read the README file several times and I couldn't make it work with me. I'm trying to get a list of countries using public API services and here's what I did:
Model
import Foundation
import Networking
struct CountryModel: Codable {
let error: Bool
let msg: String
let data: [Country]
}
struct Country: Codable {
let name: String
let Iso3: String
}
extension Country: NetworkingJSONDecodable {}
extension CountryModel: NetworkingJSONDecodable {}
/*
Output
{
"error":false,
"msg":"countries and ISO codes retrieved",
"data":[
{
"name":"Afghanistan",
"Iso2":"AF",
"Iso3":"AFG"
}
]
}
*/
View Controller + print(data) in callAPI() function does not print
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
callAPI()
}
fileprivate func configureUI() {
title = "Choose Country"
view.addSubview(tableView)
tableView.delegate = self
tableView.dataSource = self
tableView.frame = view.bounds
}
fileprivate func callAPI() {
let countriesService = CountriesApi()
var cancellable = Set<AnyCancellable>()
countriesService.countries().sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("finished") // not printing
break
case .failure(let error):
print(error.localizedDescription)
}
}) { data in
print(data) // not printing
self.countriesData = data
}.store(in: &cancellable)
}
CountriesAPI()
struct CountriesApi: NetworkingService {
let network = NetworkingClient(baseURL: "https://countriesnow.space/api/v0.1")
// Create
func create(country c: Country) -> AnyPublisher<Country, Error> {
post("/countries/create", params: ["name" : c.name, "Iso3" : c.Iso3])
}
// Read
func fetch(country c: Country) -> AnyPublisher<Country, Error> {
get("/countries/\(c.Iso3)")
}
// Update
func update(country c: Country) -> AnyPublisher<Country, Error> {
put("/countries/\(c.Iso3)", params: ["name" : c.name, "Iso3" : c.Iso3])
}
// Delete
func delete(country c: Country) -> AnyPublisher<Void, Error> {
delete("/countries/\(c.Iso3)")
}
func countries() -> AnyPublisher<[CountryModel], Error> {
get("/countries/iso")
}
}
I hope someone can help with what I'm missing.
The problem lies in your callAPI() function, if you change your code to this:
fileprivate func callAPI() {
let countriesService = CountriesApi()
var cancellable = Set<AnyCancellable>()
countriesService.countries()
.print("debugging")
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("finished") // not printing
break
case .failure(let error):
print(error.localizedDescription)
}
}) { data in
print(data) // not printing
self.countriesData = data
}.store(in: &cancellable)
}
Notice I just added the line print("debugging").
If you run that, you can see in your console that your subscription gets cancelled immediately.
debugging: receive cancel
Why? Because your "cancellable" or "subscription" only lives in the scope of your function, thus, it is deallocated immediately.
What you can do is add the cancellables set as a property in your ViewController, like this:
final class ViewController: UIViewController {
private var cancellable = Set<AnyCancellable>()
fileprivate func callAPI() {
// the code you had without the set
let countriesService = CountriesApi()
countriesService.countries().sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("finished") // not printing
break
case .failure(let error):
print(error.localizedDescription)
}
}) { data in
print(data) // not printing
self.countriesData = data
}.store(in: &cancellable)
}
}

SwiftUI app freezing when using multiple product identifiers in StoreKit

I'm currently learning Swift and following some tutorials but I'm stuck on a StoreKit issue.
The code works when I provide a single productIdentifier, but when I provide more than 1 in the Set, the entire app hangs on loading. This is in the iOS Simulator, and on a device. I've got 2 identifiers in the set, and both of these work individually, but not at the same time. My code looks the same as the original tutorial (video) so I don't know where I'm going long.
Entire Store.swift file below. Problem appears to be in the fetchProducts function, but I'm not sure. Can anyone point me in the right direction?
import StoreKit
typealias FetchCompletionHandler = (([SKProduct]) -> Void)
typealias PurchaseCompletionHandler = ((SKPaymentTransaction?) -> Void)
class Store: NSObject, ObservableObject {
#Published var allRecipes = [Recipe]() {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
for index in self.allRecipes.indices {
self.allRecipes[index].isLocked = !self.completedPurchases.contains(self.allRecipes[index].id)
}
}
}
}
private let allProductIdentifiers = Set(["com.myname.ReceipeStore.test", "com.myname.ReceipeStore.test2"])
private var completedPurchases = [String]()
private var productsRequest: SKProductsRequest?
private var fetchedProducts = [SKProduct]()
private var fetchCompletionHandler: FetchCompletionHandler?
private var purchaseCompletionHandler: PurchaseCompletionHandler?
override init() {
super.init()
startObservingPaymentQueue()
fetchProducts { products in
self.allRecipes = products.map { Recipe(product: $0) }
}
}
private func startObservingPaymentQueue() {
SKPaymentQueue.default().add(self)
}
private func fetchProducts(_ completion: #escaping FetchCompletionHandler) {
guard self.productsRequest == nil else { return }
fetchCompletionHandler = completion
productsRequest = SKProductsRequest(productIdentifiers: allProductIdentifiers)
productsRequest!.delegate = self
productsRequest!.start()
}
private func buy(_ product: SKProduct, competion: #escaping PurchaseCompletionHandler) {
purchaseCompletionHandler = competion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
}
extension Store {
func product(for identififier: String) -> SKProduct? {
return fetchedProducts.first(where: { $0.productIdentifier == identififier })
}
func purchaseProduct(_ product: SKProduct) {
buy(product) { _ in }
}
}
extension Store: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
var shouldFinishTransactions = false
switch transaction.transactionState {
case .purchased, .restored:
completedPurchases.append(transaction.payment.productIdentifier)
shouldFinishTransactions = true
case .failed:
shouldFinishTransactions = true
case .deferred, .purchasing:
break
#unknown default:
break
}
if shouldFinishTransactions {
SKPaymentQueue.default().finishTransaction(transaction)
DispatchQueue.main.async {
self.purchaseCompletionHandler?(transaction)
self.purchaseCompletionHandler = nil
}
}
}
}
}
// loading products from the store
extension Store: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
let loadedProducts = response.products
let invalidProducts = response.invalidProductIdentifiers
guard !loadedProducts.isEmpty else {
print("Could not load the products!")
if !invalidProducts.isEmpty {
print("Invalid products found: \(invalidProducts)")
}
productsRequest = nil
return
}
// cache the feteched products
fetchedProducts = loadedProducts
// notify anyone waiting on the product load (swift UI view)
DispatchQueue.main.async {
self.fetchCompletionHandler?(loadedProducts)
self.fetchCompletionHandler = nil
self.productsRequest = nil
}
}
}```
It looks like you're running all of your requests on the main DispatchQueue, this will block other main queue work until completed. You should consider handling some of these tasks with a custom concurrent queue. This bit of sample code should get the ball rolling.
func requestProducts(_ productIdentifiers: Set<ProductIdentifier>, handler: #escaping ProductRequestHandler) {
// Set request handler
productRequest?.cancel()
productRequestHandler = handler
// Request
productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest?.delegate = self
productRequest?.start()
}
func requestPrices() {
// Retry interval, 5 seconds, set this to your liking
let retryTimeOut = 5.0
var local1: String? = nil
var local2: String? = nil
let bundleIdentifier = Bundle.main.bundleIdentifier!
let queue = DispatchQueue(label: bundleIdentifier + ".IAPQueue", attributes: .concurrent)
// Request price
queue.async {
var trying = true
while(trying) {
let semaphore = DispatchSemaphore(value: 0)
requestProducts(Set(arrayLiteral: SettingsViewController.pID_1000Credits, SettingsViewController.pID_2000Credits)) { (response, error) in
local1 = response?.products[0].localizedPrice
local2 = response?.products[1].localizedPrice
semaphore.signal()
}
// We will keep checking on this thread until completed
_ = semaphore.wait(timeout: .now() + retryTimeOut)
if(local2 != nil) { trying = false }
}
// Update with main thread once request is completed
DispatchQueue.main.async {
self.price1 = local1 ?? "$0.99"
self.price2 = local2 ?? "$1.99"
}
}
}
extension SKProduct {
// Helper function, not needed for this example
public var localizedPrice: String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = self.priceLocale
return formatter.string(from: self.price)
}