How to properly prevent Data loss for analytics data? - ios

I am currently writing an analytics system.
Currently, it caches Events in RAM.
It writes to the Filesystem via NSUserDefaults (iOS) and SharedPreferences (Android) when the App closes, as JSON.
This Data is read when the app opens.
It also sends every N seconds or when the amount of Events reaches 20.
When the sending was successful, it deletes all events that were send from the RAM.
This has some obvious flaws: When the app crashes, all data from N seconds is lost. When the server cannot be reached (because Server is down for example) and the app crashes, even more data are lost.
My question here is: How can I improve the "safety" of my data and prevent massive data loss when the server is down or not reachable?
Here is my current code (unimportant parts removed)
import Foundation
class BackendTrackingHandler : TrackingHandler {
static let KEY_CACHE_EVENT = "TrackingCache"
private static let SEND_INTERVAL:TimeInterval = 10
var cachedEvents: [TrackingEvent] = []
var temporaryCachedEvents: [TrackingEvent] = []
var prefix: String
var endpoint: String
var timer : Timer?
//whether we currently wait for a response
var isSending: Bool = false
override init() {
//init
readCachedEventsFromDisk()
timer = Timer.scheduledTimer(timeInterval: BackendTrackingHandler.SEND_INTERVAL, target: self, selector: #selector(send), userInfo: nil, repeats: true)
}
override func trackEvent(_ event: TrackingEvent) {
cachedEvents.append(event)
if((cachedEvents.count) >= 20) {
send()
}
}
#objc func send() {
if((cachedEvents.count) < 1) {
return
}
if(isSending) {
return
}
isSending = true
let enc = JSONEncoder()
enc.outputFormatting = .prettyPrinted
let data = try! enc.encode(cachedEvents)
// Constructring Request here
let session = URLSession.shared
//while the request is on the way, we can trigger new events. Make a temporary copy
temporaryCachedEvents = cachedEvents
let taksID = UIApplication.shared.beginBackgroundTask()
let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) -> Void in
if(error != nil)
{
self.isSending = false
UIApplication.shared.endBackgroundTask(taksID)
}else {
let httpResponse = response as! HTTPURLResponse
if(httpResponse.statusCode >= 200 && httpResponse.statusCode <= 299) {
//success, Data was sent so we can create a new cached event
//remove all events we already sent
self.cachedEvents = self.cachedEvents.filter{!self.temporaryCachedEvents.contains($0)}
self.isSending = false
UIApplication.shared.endBackgroundTask(taksID)
}else {
self.isSending = false
UIApplication.shared.endBackgroundTask(taksID)
}
}
}
task.resume()
}
func readCachedEventsFromDisk() {
let dec = JSONDecoder()
guard let data = UserDefaults.standard.data(forKey: BackendTrackingHandler.KEY_CACHE_EVENT) else {
cachedEvents = []
return
}
do {
cachedEvents = try dec.decode([TrackingEvent].self, from: data)
} catch {
cachedEvents = []
}
}
func writeCachedEventsToDisk() {
let enc = JSONEncoder()
let data = try! enc.encode(cachedEvents)
UserDefaults.standard.set(data, forKey: BackendTrackingHandler.KEY_CACHE_EVENT)
}
override func onApplicationBecomeActive() {
}
override func onApplicationBecomeInactive() {
let taskID = UIApplication.shared.beginBackgroundTask()
writeCachedEventsToDisk()
UIApplication.shared.endBackgroundTask(taskID)
}
}
€dit:
TrackingEvent is a struct that is shared among multiple TrackingHandlers. There is an additional FirebaseTrackingHandler, which is meant to be operated side-by-side our own analytics system.

I think the easiest way is writing "Property Wrapper" for cachedEvents so it would directly access to UserDefaults, it seems the operation is not so huge to bother.
Second way - you could simply save cache to UserDefaults every N seconds/minutes or so if you care about performance a lot. Though, it wouldn't made your system bulletproof

Related

iOS - CoreData CloudKit sharing - participants adding children

I have implemented sharing of items from my Core Data store, using the NSPersistentCloudKitContainer api, with limited success. The objects I share (a TripsLog) have child objects (Trip), which appear on participants' devices. I've implemented more or less a direct copy of Apple's example code, except using my own core data entities.
The problem is that any new trips I create on participants' devices don't appear in the owner's database, whereas any I create on the owner's device appear for all participants, updating their displays pretty much instantly. If I edit any of the original trips on a participant's device, the changes are successfully shared, it's just the new ones which are problematic.
I'm getting a lot of noise in the console -
"Attempt to map database failed: permission was denied. This attempt will not be retried"
"Permission Failure" (10/2007); server message = "Invalid bundle ID for container"
etc
which really aren't helpful. The bundle ID & containers are set up properly and it all works fine for syncing across a single user's devices.
Although my code is more-or-less just the same as Apple's, except for the container identifier & the Core Data entities, here's most of the relevant stuff -
// sharing protocol
protocol CloudKitSharable: NSManagedObject {
static var entityName: String { get }
var identifier: String { get }
var sharedTitle: String { get }
var sharedSubject: String? { get }
var thumbnailImage: UIImage? { get }
}
class PersistenceController: NSObject {
enum CoreDataError: Error {
case modelURLNotFound(forResourceName: String)
case modelLoadingFailed(forURL: URL)
}
static let shared = PersistenceController()
private static let containerIdentifier = "iCloud.com.containerIdentifier"
private var _privatePersistentStore: NSPersistentStore?
var privatePersistentStore: NSPersistentStore {
return _privatePersistentStore!
}
private var _sharedPersistentStore: NSPersistentStore?
var sharedPersistentStore: NSPersistentStore {
return _sharedPersistentStore!
}
lazy var cloudKitContainer: CKContainer = {
return CKContainer(identifier: Self.containerIdentifier)
}()
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container: NSPersistentCloudKitContainer = try! mainDatabaseContainer()
guard let localDatabaseURL = localDatabaseURL, let cloudDatabaseURL = cloudDatabaseURL else {
fatalError("#\(#function): Failed to get local database URLs")
}
// Set up the database which will sync over the cloud
let cloudStoreDescription = NSPersistentStoreDescription(url: cloudDatabaseURL)
cloudStoreDescription.configuration = "Cloud"
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
setupStoreDescription(cloudStoreDescription)
let cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: Self.containerIdentifier)
cloudKitContainerOptions.databaseScope = .private
cloudStoreDescription.cloudKitContainerOptions = cloudKitContainerOptions
// Setting up for sharing
let sharedStoreDescription = cloudStoreDescription.copy() as! NSPersistentStoreDescription
sharedStoreDescription.url = sharedDatabaseURL
let sharedStoreOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: Self.containerIdentifier)
sharedStoreOptions.databaseScope = .shared
sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions
// Set up the stuff which we don't want to sync to the cloud
let localStoreDescription = NSPersistentStoreDescription(url: localDatabaseURL)
localStoreDescription.configuration = "Local"
setupStoreDescription(localStoreDescription)
// finish setting up the container
container.persistentStoreDescriptions = [
cloudStoreDescription,
localStoreDescription,
sharedStoreDescription
]
loadPersistentStores(for: container)
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.transactionAuthor = TransactionAuthor.app
/**
Automatically merge the changes from other contexts.
*/
container.viewContext.automaticallyMergesChangesFromParent = true
/**
Pin the viewContext to the current generation token and set it to keep itself up-to-date with local changes.
*/
do {
try container.viewContext.setQueryGenerationFrom(.current)
} catch {
fatalError("#\(#function): Failed to pin viewContext to the current generation:\(error)")
}
/**
Observe the following notifications:
- The remote change notifications from container.persistentStoreCoordinator.
- The .NSManagedObjectContextDidSave notifications from any context.
- The event change notifications from the container.
*/
NotificationCenter.default.addObserver(self, selector: #selector(storeRemoteChange(_:)),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator)
NotificationCenter.default.addObserver(self, selector: #selector(containerEventChanged(_:)),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: container)
return container
}()
func mergeTransactions(_ transactions: [NSPersistentHistoryTransaction], to context: NSManagedObjectContext) {
context.perform {
for transaction in transactions {
context.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
}
}
}
private var cloudDatabaseURL: URL? {
return UIApplication.applicationSupportDirectory?.appendingPathComponent("CloudDatabase.sqlite")
}
private var localDatabaseURL: URL? {
return UIApplication.applicationSupportDirectory?.appendingPathComponent("Database.sqlite")
}
private var sharedDatabaseURL: URL? {
return UIApplication.applicationSupportDirectory?.appendingPathComponent("Shared.sqlite")
}
private func setupStoreDescription(_ description: NSPersistentStoreDescription) {
description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.shouldInferMappingModelAutomatically = true
}
private func model(name: String) throws -> NSManagedObjectModel {
return try loadModel(name: name, bundle: Bundle.main)
}
private func loadModel(name: String, bundle: Bundle) throws -> NSManagedObjectModel {
guard let modelURL = bundle.url(forResource: name, withExtension: "momd") else {
throw CoreDataError.modelURLNotFound(forResourceName: name)
}
guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
throw CoreDataError.modelLoadingFailed(forURL: modelURL)
}
return model
}
private func mainDatabaseContainer() throws -> NSPersistentCloudKitContainer {
return NSPersistentCloudKitContainer(name: "Database", managedObjectModel: try model(name: "Database"))
}
private func locationsContainer() throws -> NSPersistentContainer {
return NSPersistentContainer(name: "Locations", managedObjectModel: try model(name: "Locations"))
}
private func loadPersistentStores(for container: NSPersistentContainer) {
container.loadPersistentStores { [unowned self] storeDescription, error in
if let error = error {
fatalError("#\(#function): Failed to load persistent stores:\(error)")
} else {
print("Database store ok: ", storeDescription)
if let containerOptions = storeDescription.cloudKitContainerOptions, let url = self.sharedDatabaseURL {
if containerOptions.databaseScope == .shared {
let sharedStore = container.persistentStoreCoordinator.persistentStore(for: url)
self._sharedPersistentStore = sharedStore
}
} else if let url = self.cloudDatabaseURL {
let privateStore = container.persistentStoreCoordinator.persistentStore(for: url)
self._privatePersistentStore = privateStore
}
}
}
}
/**
An operation queue for handling history-processing tasks: watching changes, deduplicating tags, and triggering UI updates, if needed.
*/
lazy var historyQueue: OperationQueue = {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
return queue
}()
}
// Sharing extensions
extension PersistenceController {
func presentCloudSharingController<T: CloudKitSharable>(for item: T) {
/**
Grab the share if the item is already shared.
*/
var itemToShare: CKShare?
if let shareSet = try? persistentContainer.fetchShares(matching: [item.objectID]), let (_, share) = shareSet.first {
itemToShare = share
}
let sharingController: UICloudSharingController
if let itemToShare = itemToShare {
sharingController = UICloudSharingController(share: itemToShare, container: cloudKitContainer)
} else {
sharingController = newSharingController(for: item)
}
sharingController.delegate = self
/**
Setting the presentation style to .formSheet so there's no need to specify sourceView, sourceItem, or sourceRect.
*/
if let viewController = rootViewController {
sharingController.modalPresentationStyle = .formSheet
viewController.present(sharingController, animated: true)
}
}
func presentCloudSharingController(share: CKShare) {
let sharingController = UICloudSharingController(share: share, container: cloudKitContainer)
sharingController.delegate = self
/**
Setting the presentation style to .formSheet so there's no need to specify sourceView, sourceItem, or sourceRect.
*/
if let viewController = rootViewController {
sharingController.modalPresentationStyle = .formSheet
viewController.present(sharingController, animated: true)
}
}
private func newSharingController<T: CloudKitSharable>(for unsharedItem: T) -> UICloudSharingController {
return UICloudSharingController { (_, completion: #escaping (CKShare?, CKContainer?, Error?) -> Void) in
/**
The app doesn't specify a share intentionally, so Core Data creates a new share (zone).
CloudKit has a limit on how many zones a database can have, so this app provides an option for users to use an existing share.
If the share's publicPermission is CKShareParticipantPermissionNone, only private participants can accept the share.
Private participants mean the participants an app adds to a share by calling CKShare.addParticipant.
If the share is more permissive, and is, therefore, a public share, anyone with the shareURL can accept it,
or self-add themselves to it.
The default value of publicPermission is CKShare.ParticipantPermission.none.
*/
self.persistentContainer.share([unsharedItem], to: nil) { objectIDs, share, container, error in
if let share = share {
self.configure(share: share, with: unsharedItem)
}
completion(share, container, error)
}
}
}
private var rootViewController: UIViewController? {
for scene in UIApplication.shared.connectedScenes {
if scene.activationState == .foregroundActive,
let sceneDelegate = (scene as? UIWindowScene)?.delegate as? UIWindowSceneDelegate,
let window = sceneDelegate.window {
return window?.rootViewController
}
}
print("\(#function): Failed to retrieve the window's root view controller.")
return nil
}
}
extension PersistenceController: UICloudSharingControllerDelegate {
/**
CloudKit triggers the delegate method in two cases:
- An owner stops sharing a share.
- A participant removes themselves from a share by tapping the Remove Me button in UICloudSharingController.
After stopping the sharing, purge the zone or just wait for an import to update the local store.
This sample chooses to purge the zone to avoid stale UI. That triggers a "zone not found" error because UICloudSharingController
deletes the zone, but the error doesn't really matter in this context.
Purging the zone has a caveat:
- When sharing an object from the owner side, Core Data moves the object to the shared zone.
- When calling purgeObjectsAndRecordsInZone, Core Data removes all the objects and records in the zone.
To keep the objects, deep copy the object graph you want to keep and make sure no object in the new graph is associated with any share.
The purge API posts an NSPersistentStoreRemoteChange notification after finishing its job, so observe the notification to update
the UI, if necessary.
*/
func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) {
if let share = csc.share {
purgeObjectsAndRecords(with: share)
}
}
func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) {
if let share = csc.share, let persistentStore = share.persistentStore {
persistentContainer.persistUpdatedShare(share, in: persistentStore) { (share, error) in
if let error = error {
print("\(#function): Failed to persist updated share: \(error)")
}
}
}
}
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
print("\(#function): Failed to save a share: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
return csc.share?.title ?? "Airframe Logbook"
}
}
extension PersistenceController {
func shareObject<T: CloudKitSharable>(_ unsharedObject: T, to existingShare: CKShare?, completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)? = nil) {
persistentContainer.share([unsharedObject], to: existingShare) { (objectIDs, share, container, error) in
guard error == nil, let share = share else {
print("\(#function): Failed to share an object: \(error!))")
completionHandler?(share, error)
return
}
/**
Deduplicate tags, if necessary, because adding a photo to an existing share moves the whole object graph to the associated
record zone, which can lead to duplicated tags.
*/
if existingShare != nil {
/*
if let tagObjectIDs = objectIDs?.filter({ $0.entity.name == "Tag" }), !tagObjectIDs.isEmpty {
self.deduplicateAndWait(tagObjectIDs: Array(tagObjectIDs))
}
*/
} else {
self.configure(share: share, with: unsharedObject)
}
/**
Synchronize the changes on the share to the private persistent store.
*/
self.persistentContainer.persistUpdatedShare(share, in: self.privatePersistentStore) { (share, error) in
if let error = error {
print("\(#function): Failed to persist updated share: \(error)")
}
completionHandler?(share, error)
}
}
}
/**
Delete the Core Data objects and the records in the CloudKit record zone associated with the share.
*/
func purgeObjectsAndRecords(with share: CKShare, in persistentStore: NSPersistentStore? = nil) {
guard let store = (persistentStore ?? share.persistentStore) else {
print("\(#function): Failed to find the persistent store for share. \(share))")
return
}
persistentContainer.purgeObjectsAndRecordsInZone(with: share.recordID.zoneID, in: store) { (zoneID, error) in
if let error = error {
print("\(#function): Failed to purge objects and records: \(error)")
}
}
}
func existingShare(for item: NSManagedObject) -> CKShare? {
if let shareSet = try? persistentContainer.fetchShares(matching: [item.objectID]),
let (_, share) = shareSet.first {
return share
}
return nil
}
func share(with title: String) -> CKShare? {
let stores = [privatePersistentStore, sharedPersistentStore]
let shares = try? persistentContainer.fetchShares(in: stores)
let share = shares?.first(where: { $0.title == title })
return share
}
func shareTitles() -> [String] {
let stores = [privatePersistentStore, sharedPersistentStore]
let shares = try? persistentContainer.fetchShares(in: stores)
return shares?.map { $0.title } ?? []
}
private func configure<T: CloudKitSharable>(share: CKShare, with item: T) {
share[CKShare.SystemFieldKey.title] = item.sharedTitle
}
}
extension PersistenceController {
func addParticipant(emailAddress: String, permission: CKShare.ParticipantPermission = .readWrite, share: CKShare,
completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)?) {
/**
Use the email address to look up the participant from the private store. Return if the participant doesn't exist.
Use privatePersistentStore directly because only the owner may add participants to a share.
*/
let lookupInfo = CKUserIdentity.LookupInfo(emailAddress: emailAddress)
let persistentStore = privatePersistentStore //share.persistentStore!
persistentContainer.fetchParticipants(matching: [lookupInfo], into: persistentStore) { (results, error) in
guard let participants = results, let participant = participants.first, error == nil else {
completionHandler?(share, error)
return
}
participant.permission = permission
participant.role = .privateUser
share.addParticipant(participant)
self.persistentContainer.persistUpdatedShare(share, in: persistentStore) { (share, error) in
if let error = error {
print("\(#function): Failed to persist updated share: \(error)")
}
completionHandler?(share, error)
}
}
}
func deleteParticipant(_ participants: [CKShare.Participant], share: CKShare,
completionHandler: ((_ share: CKShare?, _ error: Error?) -> Void)?) {
for participant in participants {
share.removeParticipant(participant)
}
/**
Use privatePersistentStore directly because only the owner may delete participants to a share.
*/
persistentContainer.persistUpdatedShare(share, in: privatePersistentStore) { (share, error) in
if let error = error {
print("\(#function): Failed to persist updated share: \(error)")
}
completionHandler?(share, error)
}
}
}
// Core Data models
extension TripsLog {
#NSManaged var name: String
#NSManaged var identifier: String
#NSManaged var entries: NSSet
}
extension Trip {
#NSManaged var identifier: String
#NSManaged var name: String
#NSManaged var date: Date
#NSManaged var comments: String?
#NSManaged var leaderName: String
#NSManaged var images: NSSet?
}
If anyone is able to shed any light on this I'd really appreciate it, as Apple's own documentation is somewhat lacking. Many thanks!

Can't get weight samples using anchored query

I'm working on a watchOS App as my first Swift/iOS project ever. I want to fetch the latest body weight sample and use it for some calculation. The result is presented to the user. As soon as a new sample is added, I want to update my UI as well. It works in a completely fresh simulator installation. As soon as I add a sample in the iOS simulator, the app updates its UI in the watchOS simulator. However, it doesn't work on my real device or after resetting the watchOS simulator. And I just don't know why. The HKAnchoredObjectQuery just returns 0 samples but I definitely have some samples stored in health. I can even see them under Settings > Health on my watch. I can't imagine this is related to my code, but here it is:
class WeightProvider: ObservableObject {
private static let weightSampleType = HKSampleType.quantityType(forIdentifier: .bodyMass)!
private static let healthStore: HKHealthStore = .init()
private var previousAnchor: HKQueryAnchor?
private var runningQuery: HKAnchoredObjectQuery?
#Published var bodyWeight: Measurement<UnitMass>?
func getBodyWeight(longRunning: Bool = false) {
let query = HKAnchoredObjectQuery(type: Self.weightSampleType, predicate: nil, anchor: previousAnchor, limit: longRunning ? HKObjectQueryNoLimit : 1, resultsHandler: processQueryResult)
if longRunning {
query.updateHandler = processQueryResult
runningQuery = query
}
Self.healthStore.execute(query)
}
func stopLongRunningQuery() {
if let runningQuery = runningQuery {
Self.healthStore.stop(runningQuery)
self.runningQuery = nil
}
}
private func processQueryResult(_: HKAnchoredObjectQuery, samples: [HKSample]?, _: [HKDeletedObject]?, newAnchor: HKQueryAnchor?, error: Error?) {
guard let samples = samples as? [HKQuantitySample], error == nil else {
fatalError(error?.localizedDescription ?? "Failed to cast [HKSample] to [HKQuantitySample]")
}
previousAnchor = newAnchor
guard let sample = samples.last else {
return
}
DispatchQueue.main.async {
if Locale.current.usesMetricSystem {
let weight = sample.quantity.doubleValue(for: .gramUnit(with: .kilo))
self.bodyWeight = .init(value: weight, unit: UnitMass.kilograms)
} else {
let weight = sample.quantity.doubleValue(for: .pound())
self.bodyWeight = .init(value: weight, unit: UnitMass.pounds)
}
}
}
}
// MARK: - HealthKit Authorization
extension WeightProvider {
private static let typesToRead: Set<HKObjectType> = [
weightSampleType,
]
func authorize(completion: #escaping (Bool, Error?) -> Swift.Void) {
Self.healthStore.requestAuthorization(toShare: nil, read: Self.typesToRead) { success, error in
completion(success, error)
}
}
}
In my Views onAppear I call this function:
private func authorizeHealthKit() {
guard firstRun else {
return
}
firstRun = false
weightProvider.authorize { success, error in
guard success, error == nil else {
return
}
weightProvider.getBodyWeight(longRunning: true)
}
}
HealthKit is properly authorized as I can see in the Settings of my Watch. Any ideas? Any tips for my code in general?
Wow, after all this time I found the issue: The line previousAnchor = newAnchor needs to be after the guard statement. That's it.

Using a completion handler to return an array of items from a NASA API

This is my working code to fetch one item from NASA API I used the completion handler as presented on Apple Programming book.
class PhotoInfoController {
func fetchPhotoInfo(completion: #escaping (PhotoInfo?) -> Void) {
let baseURL = URL(string: "https://api.nasa.gov/planetary/apod")!
let query: [String:String] = [
"api_key" : "DEMO_KEY"
]
let url = baseURL.withQueries(query)!
let task = URLSession.shared.dataTask(with: url) {
(data, response, error) in
let jsonDecoder = JSONDecoder()
if let data = data,
let photoInfo = try? jsonDecoder.decode(PhotoInfo.self, from: data) {
completion(photoInfo)
} else {
print("Not found or data is not sanitazed.")
completion(nil)
}
}
task.resume()
}
}
The problem I having a hard time to figuring it out is how you can return an array go items (PhotoInfo) via a completion handler. This is my code so far:
class PhotoInfoController {
func fetchPhotoInfo(completion: #escaping ([PhotoInfo]?) -> Void) {
let baseURL = URL(string: "https://api.nasa.gov/planetary/apod")!
let currentDate = Date()
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-d"
var photoInfoCollection: [PhotoInfo] = []
for i in 0 ... 1 {
let modifiedDate = Calendar.current.date(byAdding: .day,value: -i ,to: currentDate)!
let stringDate = formatter.string(from: modifiedDate)
let query: [String:String] = [
"api_key" : "DEMO_KEY",
"date" : stringDate
]
let url = baseURL.withQueries(query)!
let task = URLSession.shared.dataTask(with: url) { (data,
response,
error) in
let jsonDecoder = JSONDecoder()
if let data = data,
let photoInfo = try? jsonDecoder.decode(PhotoInfo.self, from: data) {
photoInfoCollection.append(photoInfo)
} else {
print("Data was not returned")
}
}
task.resume()
}
completion(photoInfoCollection)
}
}
Any ideas or guide will greatly appreciated Thanks!
Code Implemented after suggestions:
class PhotoInfoController {
private let baseURL = URL(string: "https://api.nasa.gov/planetary/apod")!
private let currentDate = Date()
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-d"
return formatter
}()
private let jsonDecoder = JSONDecoder()
func fethPhotoInfo(itemsToFetch: Int, completion: #escaping ([PhotoInfo]?) -> Void) {
var count = 0
var photoInfoCollection: [PhotoInfo] = []
for i in 0 ... itemsToFetch {
let modifiedDate = Calendar.current.date(byAdding: .day, value: -i, to: currentDate)!
let query: [String : String] = [
"api_key" : "DEMO_KEY",
"date" : dateFormatter.string(from: modifiedDate)
]
let url = baseURL.withQueries(query)!
let task = URLSession.shared.dataTask(with: url) {
(data, response, error) in
if let data = data,
let photoInfo = try? self.jsonDecoder.decode(PhotoInfo.self, from: data) {
photoInfoCollection.append(photoInfo)
count += 1
if count == itemsToFetch {
completion(photoInfoCollection)
}
} else {
print("Data for \(self.dateFormatter.string(from: modifiedDate)) not made.")
}
}
task.resume()
}
}
}
Your code won't work as written.
You use a for loop to start 2 URLSession dataTask objects. Those tasks are async; the code that invokes the network request returns right away, before the request has even been sent.
Then outside your for loop you invoke your completion handler, before your network requests have even had a chance to be sent out. You will need a mechanism to keep track of the number of pending requests and invoke the completion handler when both requests have finished.
Consider this function that simulates what you are doing:
func createAsyncArray(itemCount: Int, completion: #escaping ([Int]) -> Void) {
var count = 0; //Keep track of the number of items we have created
var array = [Int]() //Create an empty results array
//Loop itemCount times.
for _ in 1...itemCount {
let delay = Double.random(in: 0.5...1.0)
//Delay a random time before creating a random number (to simulate an async network response)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
//Add a random number 1...10 to the array
array.append(Int.random(in: 1...10))
//Increment the number of results we have added to the array
count += 1
print("In loop, count = \(count)")
//If we have have added enough items to the array, invoke the completion handler.
if count == itemCount {
completion(array)
}
}
}
print("at this point in the code, count = \(count)")
}
You might call that code like this:
let itemCount = Int.random(in: 2...10)
print("\(itemCount) items")
createAsyncArray(itemCount: itemCount) { array in
for i in 0..<itemCount {
print("array[\(i)] = \(array[i])")
}
}
Sample output from that function might look like this:
9 items
at this point in the code, count = 0
In loop, count = 1
In loop, count = 2
In loop, count = 3
In loop, count = 4
In loop, count = 5
In loop, count = 6
In loop, count = 7
In loop, count = 8
In loop, count = 9
array[0] = 8
array[1] = 6
array[2] = 5
array[3] = 4
array[4] = 7
array[5] = 10
array[6] = 2
array[7] = 4
array[8] = 7
Note that the output displays "at this point in the code, count = 0" before any of the entries have been added to the array. That's because each call to DispatchQueue.main.asyncAfter() returns immediately, before the code inside the closure has been executed.
The function above uses a local variable count to keep track of how many items have been added to the array. Once the count reaches the desired number, the function invokes the completion handler.
You should use an approach like that in your code.
Edit:
You should be aware that your network requests may complete out of order. Your code submits itemsToFetch+1 different requests. You have no idea what order those requests will finish in, and it is very unlikely that the requests will complete in the order they are submitted. If your second request completes faster than the first, its closure will execute first.
You're complicating it for yourself with trying to do everything in one method. Imagine you have the fetchPhotoInfo function working (it actually works, so, good job so far):
struct PhotoInfo: Codable {
let copyright: String
}
class PhotoInfoController {
private let base = "https://api.nasa.gov/planetary/apod"
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-MM-d"
return formatter
}()
func fetchPhotoInfo(forDate date: Date, completion: #escaping (PhotoInfo?) -> Void) {
guard var components = URLComponents(string: base) else {
completion(nil)
return
}
components.queryItems = [
URLQueryItem(name: "api_key", value: "DEMO_KEY"),
URLQueryItem(name: "date", value: dateFormatter.string(from: date))
]
guard let url = components.url else {
completion(nil)
return
}
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else {
completion(nil)
return
}
let photoInfo = try? JSONDecoder().decode(PhotoInfo.self, from:data)
completion(photoInfo)
}
task.resume()
}
}
Your next goal is to fetch multiple photo info. Keep your class, keep your method and add another one which leverages what you already have. One way to achieve this is to use DispatchGroup:
func fetchPhotoInfo(forDates dates: [Date], completion: #escaping ([PhotoInfo]) -> Void) {
// Group of tasks
let taskGroup = DispatchGroup()
// Result of photos
var result: [PhotoInfo] = []
// For each date ...
dates.forEach {
// ... enter the group
taskGroup.enter()
// Fetch photo info
fetchPhotoInfo(forDate: $0) { photoInfo in
defer {
// Whenever the fetchPhotoInfo completion closure ends, leave
// the task group, but no sooner.
taskGroup.leave()
}
// If we've got the photo ...
if let photoInfo = photoInfo {
// ... add it to the result. We can safely add it here, because
// the fetchPhotoInfo completion block is called on the
// URLSession.shared.delegateQueue which has maxConcurrentOperationCount
// set to 1 by default. But you should be aware of this,
// do not rely on it and introduce some kind of synchronization.
result.append(photoInfo)
}
}
}
// At this point, we told the URLSession (via fetchPhotoInfo) that we'd like to
// execute data tasks. These tasks already started (or not, we don't know), but
// they didn't finish yet (most likely not, but we don't know either). That's
// the reason for our DispatchGroup. We have to wait for completion of all
// our asynchronous tasks.
taskGroup.notify(queue: .main) {
completion(result)
}
}
You can use it in this way:
let pic = PhotoInfoController()
pic.fetchPhotoInfo(forDate: Date()) { info in
print(String(describing: info))
}
pic.fetchPhotoInfo(forDates: [Date(), Date().addingTimeInterval(-24*60*60)]) { infos in
print(infos)
}
And the output is:
Optional(NASA.PhotoInfo(copyright: "Stephane Guisard"))
[NASA.PhotoInfo(copyright: "Stephane Guisard"), NASA.PhotoInfo(copyright: "Zixuan LinBeijing Normal U.")]
There's no error handling, you have to add it yourself.
Even if I did provide an answer to your question, I'm going to mark it as a duplicate of Wait until swift for loop with asynchronous network requests finishes executing. Your code to fetch single photo info works and you are just struggling to understand how to wait for multiple asynchronous tasks.

SwiftUI CoreData nillError ObservableObject

I'm having an issue developing my app. The thing is this, when I enter a certain code, a JSON file starts downloading and got saved in an array. That is fine, the problem is ones it finishes downloading I tried to automatically save the information in CoreData, by running this code below.
var i:Int = 0
while(i < itemsDescargados.count){
let invitacionItems = Invitem(context: self.mangedObjectContext)
invitacionItems.titulo = itemsDescargados[i].titulo
invitacionItems.modo = itemsDescargados[i].modo
invitacionItems.id = itemsDescargados[i].id
invitacionItems.descripcion = itemsDescargados[i].descripcion
invitacionItems.category = itemsDescargados[i].category
invitacionItems.rojo = itemsDescargados[i].rojo
invitacionItems.verde = itemsDescargados[i].verde
invitacionItems.azul = itemsDescargados[i].azul
invitacionItems.imagen = itemsDescargados[i].imagen
do {
try self.mangedObjectContext.save()
print("Downloaded and Saved!")
}
catch{
print("errorCoreData: \(error)")
}
i = i + 1
}
When I put this code into a button and tap it myself it works perfectly, it saves the data and everything is fine, but the problem is that if I run this code into the network manager class class NetworkManagerInv: ObservableObject { after the download it's finished then when the app runs try self.mangedObjectContext.save() automatically prints in the Console log "errorCoreData:nilError" and doesn't save anything.
I did a lot of research but I didn't find anything helpful so I hope you guys can help me. Thanks in advance, sorry if I misspelled something English is not my main language.
Here the NetworkManagerCode
class NetworkManagerInv: ObservableObject {
#Environment(\.managedObjectContext) var mangedObjectContext
#FetchRequest(fetchRequest: Invitem.getAllItems()) var invitem:FetchedResults<Invitem>
var didChange = PassthroughSubject<NetworkManagerInv, Never>()
var jsoninvItem = [JsoninvItem]() {
didSet {
didChange.send(self)
}
}
init(){
let configuration = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: configuration)
print("Network")
guard let url = URL(string: "http://192.168.1.42:" + codigo + "/items.json") else { return }
session.dataTask(with: url) { (data, _, _) in
guard let data = data else { return }
let items2 = try! JSONDecoder().decode([JsoninvItem].self, from: data)
DispatchQueue.main.async {
self.jsoninvItem = items2
itemsDescargados = items2
if(imgDescargadas.count>0){
//Here i need to put the code, this runs ones it finished
}
}
print("Compleated fetching json items")
}.resume()
}

Where to store Decoded JSON array from server and how to access it globally in viewControllers?

Currently im creating application which parses JSON from my server. From server I can receive array with JSON models.
Data from this array must be populated in table View.
My question Is simple: where to store decoded array from server, if I want to access it from many viewControllers in my application?
Here is my JSON model, which coming from server.
import Foundation
struct MyModel: Codable {
var settings: Test?
var provider: [Provider]
}
extension MyModel {
struct setting: Codable {
var name: String
var time: Int
}
}
here is how I am decoding it
import Foundation
enum GetResourcesRequest<ResourceType> {
case success([ResourceType])
case failure
}
struct ResourceRequest<ResourceType> where ResourceType: Codable {
var startURL = "https://myurl/api/"
var resourceURL: URL
init(resourcePath: String) {
guard let resourceURL = URL(string: startURL) else {
fatalError()
}
self.resourceURL = resourceURL.appendingPathComponent(resourcePath)
}
func fetchData(completion: #escaping
(GetResourcesRequest<ResourceType>) -> Void ) {
URLSession.shared.dataTask(with: resourceURL) { data, _ , _ in
guard let data = data else { completion(.failure)
return }
let decoder = JSONDecoder()
if let jsonData = try? decoder.decode([ResourceType].self, from: data) {
completion(.success(jsonData))
} else {
completion(.failure)
}
}.resume()
}
}
This is an example of CategoriesProvider. It just stores categories in-memory and you can use them across the app. It is not the best way to do it and not the best architecture, but it is simple to get started.
class CategoriesProvider {
static let shared = CategoriesProvider()
private(set) var categories: [Category]?
private let categoryRequest = ResourceRequest<Category>(resourcePath: "categories")
private let dataTask: URLSessionDataTask?
private init() {}
func fetchData(completion: #escaping (([Category]?) -> Void)) {
guard categories == nil else {
completion(categories)
return
}
dataTask?.cancel()
dataTask = categoryRequest.fetchData { [weak self] categoryResult in
var fetchedCategories: [Category]?
switch categoryResult {
case .failure:
print("error")
case .success(let categories):
fetchedCategories = categories
}
DispatchQueue.main.async {
self?.categories = fetchedCategories
completion(fetchedCategories)
}
}
}
}
I suggest using URLSessionDataTask in order to cancel a previous task. It could happen when you call fetchData several times one after another. You have to modify your ResourceRequest and return value of URLSession.shared.dataTask(...)
Here more details about data task https://www.raywenderlich.com/3244963-urlsession-tutorial-getting-started#toc-anchor-004 (DataTask and DownloadTask)
Now you can fetch categories in CategoriesViewController in this way:
private func loadTableViewData() {
CategoriesProvider.shared.fetchData { [weak self] categories in
guard let self = self, let categories = categories else { return }
self.categories = categories
self.tableView.reloadData()
}
}
In the other view controllers, you can do the same but can check for the 'categories' before making a fetch.
if let categories = CategoriesProvider.shared.categories {
// do something
} else {
CategoriesProvider.shared.fetchData { [weak self] categories in
// do something
}
}
If you really want to avoid duplicate load data() calls, your simplest option would be to cache the data on disk (CoreData, Realm, File, etc.) after parsing it the first time.
Then every ViewController that needs the data, can just query your storage system.
Of course the downside of this approach is the extra code you'll have to write to manage the coherency of your data to make sure it's properly managed across your app.
make a global dictionary array outside any class to access it on every viewcontroller.

Resources