user1019042 user1019042 - 6 months ago 94
Swift Question

Push Notification from CloudKit doesn't synchronize properly

I am trying to create a simple chat in IOS/Swift with iCloudKit. I'm modeling after this example: Create an App like Twitter: Push Notifications with CloudKit but changing it to become chat instead of Sweets.
The code's banner and badge work well to some degree and the pushing of data to CloudDashboard is fine and fast.
But the synchronization from the cloudKit to the devices doesn't work most of the time. Sometimes one device sees more than the other, sometimes less, just not too reliable. I am using the DEVELOPMENT environment in CloudKit.
What is the problem? Here is my code of implemented methods in appDelegate and the viewController:

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
let notificationSettings = UIUserNotificationSettings(forTypes: [.Alert, .Badge, .Sound], categories: nil)
UIApplication.sharedApplication().registerUserNotificationSettings(notificationSettings)
UIApplication.sharedApplication().registerForRemoteNotifications()
return true
}

func application(application: UIApplication, didReceiveRemoteNotification userInfo: [NSObject : AnyObject]) {
let cloudKitNotification = CKNotification(fromRemoteNotificationDictionary: userInfo as! [String:NSObject])

if cloudKitNotification.notificationType == CKNotificationType.Query {
dispatch_async(dispatch_get_main_queue(), { () -> Void in
NSNotificationCenter.defaultCenter().postNotificationName("performReload", object: nil)
})
}
}

func resetBadge () {
let badgeReset = CKModifyBadgeOperation(badgeValue: 0)
badgeReset.modifyBadgeCompletionBlock = { (error) -> Void in
if error == nil {
UIApplication.sharedApplication().applicationIconBadgeNumber = 0
}
}
CKContainer.defaultContainer().addOperation(badgeReset)
}
func applicationWillResignActive(application: UIApplication) {

}

func applicationDidEnterBackground(application: UIApplication) {
resetBadge()
}

func applicationWillEnterForeground(application: UIApplication) {
dispatch_async(dispatch_get_main_queue(), { () -> Void in
NSNotificationCenter.defaultCenter().postNotificationName("performReload", object: nil)
})

}

func applicationDidBecomeActive(application: UIApplication) {
resetBadge()
}


and this is the viewController

import UIKit
import CloudKit

class ChatViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate {

@IBOutlet weak var dockViewHeightConstraint: NSLayoutConstraint!
@IBOutlet weak var messageTextField: UITextField!
@IBOutlet weak var sendButton: UIButton!
@IBOutlet weak var messageTableView: UITableView!

var chatMessagesArray = [CKRecord]()
var messagesArray: [String] = [String]()

override func viewDidLoad() {
super.viewDidLoad()

// Do any additional setup after loading the view.
self.messageTableView.delegate = self
self.messageTableView.dataSource = self
// set self as the delegate for the textfield
self.messageTextField.delegate = self

// add a tap gesture recognizer to the tableview
let tapGesture:UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ChatViewController.tableViewTapped))
self.messageTableView.addGestureRecognizer(tapGesture)

setupCloudKitSubscription()

dispatch_async(dispatch_get_main_queue(), { () -> Void in
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ChatViewController.retrieveMessages), name: "performReload", object: nil)
})

// retrieve messages form iCloud
self.retrieveMessages()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

@IBAction func sendButtonTapped(sender: UIButton) {

// Call the end editing method for the text field
self.messageTextField.endEditing(true)

// Disable the send button and textfield
self.messageTextField.enabled = false
self.sendButton.enabled = false

// create a cloud object
//var newMessageObject
// set the text key to the text of the messageTextField

// save the object
if messageTextField.text != "" {
let newChat = CKRecord(recordType: "Chat")
newChat["content"] = messageTextField.text
newChat["user1"] = "john"
newChat["user2"] = "mark"

let publicData = CKContainer.defaultContainer().publicCloudDatabase
//TODO investigate if we want to do public or private

publicData.saveRecord(newChat, completionHandler: { (record:CKRecord?, error:NSError?) in
if error == nil {
dispatch_async(dispatch_get_main_queue(), {() -> Void in
print("chat saved")
self.retrieveMessages()
})
}
})
}

dispatch_async(dispatch_get_main_queue()) {
// Enable the send button and textfield
self.messageTextField.enabled = true
self.sendButton.enabled = true
self.messageTextField.text = ""
}
}

func retrieveMessages() {
print("inside reterieve messages")
// create a new cloud query
let publicData = CKContainer.defaultContainer().publicCloudDatabase

// TODO: we should use this
let predicate = NSPredicate(format: "user1 in %@ AND user2 in %@", ["john", "mark"], ["john", "mark"])
let query = CKQuery(recordType: "Chat", predicate: predicate)

//let query = CKQuery(recordType: "Chat", predicate: NSPredicate(format: "TRUEPREDICATE", argumentArray: nil))

query.sortDescriptors = [NSSortDescriptor(key:"creationDate", ascending: true)]
publicData.performQuery(query, inZoneWithID: nil) { (results: [CKRecord]?, error:NSError?) in
if let chats = results {
dispatch_async(dispatch_get_main_queue(), {() -> Void in
self.chatMessagesArray = chats
print("count is: \(self.chatMessagesArray.count)")
self.messageTableView.reloadData()
})
}
}
}

func tableViewTapped () {
// Force the textfied to end editing
self.messageTextField.endEditing(true)
}

// MARK: TextField Delegate Methods
func textFieldDidBeginEditing(textField: UITextField) {
// perform an animation to grow the dockview
self.view.layoutIfNeeded()
UIView.animateWithDuration(0.5, animations: {
self.dockViewHeightConstraint.constant = 350
self.view.layoutIfNeeded()
}, completion: nil)
}

func textFieldDidEndEditing(textField: UITextField) {

// perform an animation to grow the dockview
self.view.layoutIfNeeded()
UIView.animateWithDuration(0.5, animations: {
self.dockViewHeightConstraint.constant = 60
self.view.layoutIfNeeded()
}, completion: nil)
}

// MARK: TableView Delegate Methods

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

// Create a table cell
let cell = self.messageTableView.dequeueReusableCellWithIdentifier("MessageCell")! as UITableViewCell

// customize the cell
let chat = self.chatMessagesArray[indexPath.row]
if let chatContent = chat["content"] as? String {
let dateFormat = NSDateFormatter()
dateFormat.dateFormat = "MM/dd/yyyy"
let dateString = dateFormat.stringFromDate(chat.creationDate!)
cell.textLabel?.text = chatContent
//cell.detailTextLabel?.text = dateString
}
//cell.textLabel?.text = self.messagesArray[indexPath.row]

// return the cell
return cell
}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
//print(tableView.frame.size)
//print("count: \(self.chatMessagesArray.count)")
return self.chatMessagesArray.count
}
/*
// MARK: - Navigation

// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
// Get the new view controller using segue.destinationViewController.
// Pass the selected object to the new view controller.
}
*/

// MARK: Push Notifications

func setupCloudKitSubscription() {
let userDefaults = NSUserDefaults.standardUserDefaults()
print("the value of the bool is: ")
print(userDefaults.boolForKey("subscribed"))
print("print is above")
if userDefaults.boolForKey("subscribed") == false { // TODO: maybe here we do multiple types of subscriptions

let predicate = NSPredicate(format: "user1 in %@ AND user2 in %@", ["john", "mark"], ["john", "mark"])
//let predicate = NSPredicate(format: "TRUEPREDICATE", argumentArray: nil)
let subscription = CKSubscription(recordType: "Chat", predicate: predicate, options: CKSubscriptionOptions.FiresOnRecordCreation)
let notificationInfo = CKNotificationInfo()
notificationInfo.alertLocalizationKey = "New Chat"
notificationInfo.shouldBadge = true

subscription.notificationInfo = notificationInfo

let publicData = CKContainer.defaultContainer().publicCloudDatabase
publicData.saveSubscription(subscription) { (subscription: CKSubscription?, error: NSError?) in
if error != nil {
print(error?.localizedDescription)
} else {
userDefaults.setBool(true, forKey: "subscribed")
userDefaults.synchronize()
}
}
}

}
}

Answer

I see that you are using the push notification as a signal to reload all data. CloudKit does use a cashing mechanism (details of that are unknown) for a specific predicate. In your case you are executing the same predicate over and over. Because of this cashing you could miss records. Try doing a manual refresh after a minute or so and you will see that then suddenly your records will appear.

You should handle push notifications differently. When you receive a notification you should also query the notification messages (You could get 1 push notification while there are multiple notifications. This can happen when you have a lot of notifications)

But first you should handle the current notification. Start with a check if the notification is for a query using:

if cloudKitNotification.notificationType == CKNotificationType.Query {

Then cast it to a query notification using:

if let queryNotification = cloudNotification as? CKQueryNotification

Get the recordID

if let recordID = queryNotification.recordID {

Then depending on what has happened change your local (in app) data. You can check that using:

if queryNotification.queryNotificationReason == .RecordCreated

Of course it could also be . RecordDeleted or .RecordUpdated

If it's a .RecordCreated or .RecordUpdated you should fetch that record using the recordID

Then when that is processed, you have to fetch other not processed notifications. For that you have to create a CKFetchNotificationChangesOperation You do have to be aware that you have to pass it a change token. If you send it a nil you will get all notifications that were ever created for your subscriptions. When the operations finishes it will send you a new change token. You should save that into your userDefaults so that you can use that the next time when you start processing notifications.

The code for that query will look something like:

let operation = CKFetchNotificationChangesOperation(previousServerChangeToken: self.previousChangeToken)
operation.notificationChangedBlock = { notification in
...
operation.fetchNotificationChangesCompletionBlock = { changetoken, error in
...
operation.start()

Then for that notification you should execute the same logic as above for the initial notification. And the changetoken should be saved.

One other benefit of this mechanism is that your records come in one by one and you could create a nice animation that updates your tableview.