Optimizing UI Performance in an Apple Watch App with Salesforce Smartsync

The introduction of Wearable technology, specifically smart watches, allows you to take advantage of smaller windows of time that can introduce new design challenges. Performance becomes critical since the user is unlikely to keep looking at their watch while a network operation completes. Additionally, the amount of screen real estate is even smaller. How do we present the minimal information necessary for the job?

In a previous Apple Watch article, I wrote about how we can use App Groups to share credentials data between the parent App and the Watchkit Extension. In that model, the Watchkit extension was responsible for the data-level interactions (i.e. REST API calls) with Salesforce. There was one key drawback to that initial approach. The performance of the solution was heavily influenced by network latency, which might not provide a great user experience. Since that time, there have been improvements that allow you to create a better user experience:


  • In the subsequent beta versions of the Watchkit (from Version 2 onwards), Apple has provided a mechanism to be able to communicate with the parent app (openParentApplication).
  • The Salesforce Mobile SDK has been updated to include Smartsync capability for native iOS apps.


Offline/Online Pattern

Apple2.png


We’ll outline a design pattern using the above two capabilities to provide an offline/online solution for a user. In this particular example, we’ll describe how this pattern can be used in a simple Salesforce Events Watch App. Here are the key design considerations:


  • The user needs to be able to see the events immediately upon launching the app. This means having to use offline storage. However, we need to inform the user of the currency of the information, i.e. when was it last synced. This is so the user can determine for themselves how much they can rely on that information.
  • When the user is presented with cached data that was old, we want to issue a sync request for that information and present the updated information when it becomes available.



Apple1.png


The above sequence diagram shows this pattern in action:


  • The Parent App establishes the OAUTH flow upon initial login and syncs the records (in this example from the Event object) using SmartSync.
  • When the Watch App launches, it invokes a function in the Parent App using the openParentApplication method call. The Parent App immediately responds with the records in its local store and the time when the last sync was performed. If the last sync time was greater than 60 seconds ago (interval can be configured based on use case), the Parent App also initiates another sync.
  • Because the current Watchkit SDK doesn’t provide an ability for the Parent App to invoke methods in the Watchkit Extension, the Watchkit Extension must periodically poll back to determine if the sync has completed. In this example, the extension polls every 3 seconds. It stops polling after a maximum of three attempts.


Sample App Implementation

The sample app uses the SmartSyncExplorer app bundled with the Salesforce Mobile SDK (iOS) as a base.

We need to be able to persist the sync time in the app for each object so we can let the user know that information. The SObjectDataManager class was modified to persist this information in the user’s NSUserDefaults. The code added to the existing class is shown below.



- (void)refreshRemoteData {
 
………………..
        
        if ([sync isDone]) {
            //Store last sync time for object in NSUserDefaults
            NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.Salesforce.MattY.SFWatchDemo3"];
            NSDate *currentTime = [NSDate date];
            NSString *objectKey = [[NSString alloc] initWithFormat:@"%@-%@", self.dataSpec.objectType, @"syncTime"];
            [defaults setObject:currentTime forKey:objectKey];
            [defaults synchronize];
            
            
            NSLog(@"Sync time stored in NSUserDefaults for object Type %@", self.dataSpec.objectType);
        }
    }];
    
    
    
 …………………..   
    
    
    
    
}


The App Delegate in the main app needs to implement a handleWatchKitExtensionRequest method to handle the request from the Watchkit extension for stored offline data. Some key points to note:


  • The response needs to include the requested record data and information about the last sync time.
  • The method should initiate a sync from remote if requested by the caller. However, given the sync is performed by a background thread, and given that the Parent app has no way of notifying the Watchkit Extension when that sync is complete, the Watchkit Extension must call back to get the latest results.



#pragma mark - WatchKit Extension Call Methods

- (void)application:(UIApplication *)application handleWatchKitExtensionRequest:(NSDictionary *)userInfo reply:(void (^)(NSDictionary *))reply{
    
    NSLog(@"Received message from watch");
    
    
    //Retrieve data from local Smartstore
    self.dataMgr = [[SObjectDataManager alloc] initWithViewController:nil dataSpec:[EventSObjectData dataSpec]];
    
    
    [self.dataMgr refreshLocalData];
    
    //Refresh from remote if required
    if ([[userInfo objectForKey:@"request"] isEqualToString:@"getEventsRemote"]){
        [self.dataMgr refreshRemoteData];
    }
    
    
    NSMutableArray *events = [[NSMutableArray alloc] initWithCapacity:10];
    
    for (NSInteger i=0 ; i<[self.dataMgr.dataRows count]; i++) {
        EventSObjectData *obj = [self.dataMgr.dataRows objectAtIndex:i];
        NSDate *eventDate = [SFDateUtil SOQLDateTimeStringToDate:obj.startDateTime];
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        [formatter setDateFormat:@"hh:mm a"];
        
        
        NSString *stringFromDate = [formatter stringFromDate:eventDate];
        NSDictionary *anEvent = @{@"subject": obj.subject, @"startTime": stringFromDate};
        [events addObject:anEvent];
    }
    
    //Retrieve the sync time for Events from NSUserDefaults
    NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.Salesforce.MattY.SFWatchDemo3"];
    NSDate *syncDate = [defaults objectForKey:@"Event-syncTime"];
    
    //How long ago did the sync occur
    NSTimeInterval diff = [[NSDate date] timeIntervalSinceDate:syncDate];
    
    //Refresh from remote if required - this is done in background thread. Data may not be available prior to posting reply.
    //Watch needs to poll back for latest
    if (diff > 60) {
        [self.dataMgr refreshRemoteData];
    }
    
    NSString *syncTimeGap = @"";
    
    if (diff<60) {
        syncTimeGap = @"Synced less than a minute ago";
    }else if(diff < 3600){
        NSInteger minutes = floor(diff/60);
        syncTimeGap = [NSString stringWithFormat:@"Synced about %li minutes ago", (long)minutes];
    }else if(diff < 86400){ //less than a day
        NSInteger hours = floor(diff/3600);
        syncTimeGap =[NSString stringWithFormat:@"Synced about %li hours ago", (long)hours];
    }else{
        syncTimeGap = @"Synced more than a day ago";
    }
    
    NSDictionary *response = @{@"events" : events, @"syncGap": syncTimeGap, @"diff": [NSNumber numberWithFloat:diff]};
    
    reply(response);
    
}


The Watchkit Extension method to retrieve the data is fairly straightforward. After receiving the response (containing data from the local store) from the Parent App, the Watchkit Extension will start a polling process using NSTimer to call back and retrieve updated information from the remote sync operation. The polling process is cancelled when updated information is received (last polling occurred within 60 seconds in this example) or when the maximum number of polling events have occurred (set to 3 in this example).



// Invoke Parent app to get latest Events data from the local store
    NSDictionary *request = @{@"request":@"getEventsLocal"}; //set up request dictionary
    
    [InterfaceController openParentApplication:request reply:^(NSDictionary *replyInfo, NSError *error) {
        
        if (error) {
            NSLog(@"%@", error);
        } else {
            NSLog(@"Sync Gap: %@", [replyInfo valueForKey:@"syncGap"]);
            self.events = [replyInfo valueForKey:@"events"];
            [self.refreshTimeLabel setText:[replyInfo valueForKey:@"syncGap"]];
            [self refreshTableData]; //refresh the table with returned data
            NSLog(@"diff val: %@", [replyInfo valueForKey:@"diff"]);
            //if the last sync time was greater than 60 seconds ago, poll again in 3 secs to check if a newer synced dataset is available
            if ([[replyInfo valueForKey:@"diff"] floatValue]> 60) {
                
                self.timer = [NSTimer scheduledTimerWithTimeInterval:3.0 target:self selector:@selector(RefreshFromParent) userInfo:nil repeats:YES];
                
            }
        }
        
    }];


- (void)RefreshFromParent {
    
    if (self.pollCount < 3){
        
        NSLog(@"RefreshFromParent");
        
        NSDictionary *request = @{@"request":@"getEventsRemote"}; //set up request dictionary
        BOOL parentSuccess =  [InterfaceController openParentApplication:request reply:^(NSDictionary *replyInfo, NSError *error) {
            self.pollCount = self.pollCount+1;
            NSLog(@"Poll Count: %i", self.pollCount);

            if (error) {
                NSLog(@"%@", error);
            } else {
                NSLog(@"Response Received");
                NSLog(@"diff val: %@", [replyInfo valueForKey:@"diff"]);
                self.events = [replyInfo valueForKey:@"events"];
                
                [self.refreshTimeLabel setText:[replyInfo valueForKey:@"syncGap"]];
                [self refreshTableData]; //refresh the table with returned data
                
                //if the last sync time was less than 60 seconds ago, cancel the polling
                if ([[replyInfo valueForKey:@"diff"] floatValue]< 60) {
                    NSLog(@"Cancellig Timer. Poll Count: %i", (int)self.pollCount);
                    if(self.timer)
                    {
                        [self.timer invalidate];
                        self.timer = nil;
                        NSLog(@"Cancelled Timer");
                    }
                }
            }
            
        }];
        
        NSLog(@"Request sent?: %i", parentSuccess);
        
    }else{ //Cancel timer if we've tried to refresh more than three times (likely to be an issue / no network connection)
        if(self.timer)
        {
            [self.timer invalidate];
            self.timer = nil;
            NSLog(@"Cancelled Timer");
        }
    }
}


The code for the sample app can be found in github.


Future Considerations

While this pattern works well for read-only scenarios, any update/create scenarios will need additional consideration. If there are updates involved, one should be careful to design use cases that won’t result in conflicts or errors (e.g. validation error failures in Salesforce). It’ll be difficult to present those errors on the watch for the user to correct. While the parent application can be designed to present the users with such errors or conflicts, it’s likely to be a cumbersome user experience.

Finally, I wanted to mention the Salesforce Wear Developer Pack for Apple Watch, which provides a sample app that uses WatchKit and the Salesforce Mobile SDK for iOS to build connected apps. The app, which uses Salesforce approval requests, demonstrates typical patterns developers will face when building these connected apps and is intended as a starter project to accelerate the creation of more fully featured apps using the benefits of Apple Watch and the Salesforce1 Platform.