Talking to remote servers in a data center somewhere is an important task for any app, but sometimes on the watch it seems redundant. Why talk to a server on another continent when there’s a perfectly good iPhone just a Bluetooth connection away? That’s where the WatchConnectivity framework comes in. Often, we just want to send small bits of data back and forth between the two devices—preferences, small images, or user documents, for instance. WatchConnectivity serves as a bridge between the two apps—in fact, you’ll use the framework on both iOS and watchOS. There are several ways to transfer data back and forth using WatchConnectivity. As we go through them, you’ll implement TapALap’s iPhone app to handle the more advanced features you want to include.
To communicate with the watch app, the iOS app needs an object that implements the WCSessionDelegate protocol. This object will be responsible for all communication with the watch, so you’ll want to create it as early as possible in your iPhone app’s lifecycle. In Xcode, select File → New → File… and select Source under iOS on the left-hand side. Select the Cocoa Touch Class template and click Next. For the next screen, name the class SessionDelegate and make it a subclass of NSObject. Keep the language as Swift, and then click Next. Make sure that on the next screen the TapALap target is selected, not the WatchKit App or WatchKit Extension target. Click Create, and you have your new class. All you need to do is import WatchConnectivity and add conformance to WCSessionDelegate in its declaration:
| import WatchConnectivity |
| |
| class SessionDelegate: NSObject, WCSessionDelegate { |
Even though we’re writing this app in Swift, we’ll occasionally need to pay our respects to Objective-C, the language that predated it for iOS and OS X development. Since the WCSessionDelegate protocol that we’re going to conform to was written in Objective-C, it specified that the protocol also conforms to the NSObject protocol. Here’s the declaration in Objective-C:
| @protocol WCSessionDelegate <NSObject> |
In Swift, this gets translated as such:
| public protocol WCSessionDelegate : NSObjectProtocol |
If we wanted to keep our objects as “pure” Swift objects, we could simply conform to NSObjectProtocol ourselves, but then we’d have to implement a whole host of boilerplate methods that we don’t really need. By subclassing NSObject instead, we get those methods for free and can focus on the ones that matter for this app.
You’ll add more functionality to the session delegate as you go, but before it can do anything, you need to tell the iOS app to start it at launch. Open AppDelegate.swift and add some setup code to application(_:didFinishLaunchingWithOptions:) to kick off your session delegate:
| import WatchConnectivity |
| |
| @UIApplicationMain |
| class AppDelegate: UIResponder, UIApplicationDelegate { |
| |
| var window: UIWindow? |
| |
| lazy var sessionDelegate = SessionDelegate() |
| |
| func application(application: UIApplication, |
| didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) |
| -> Bool { |
| if WCSession.isSupported() { |
| let session = WCSession.defaultSession() |
| |
| session.delegate = sessionDelegate |
| session.activateSession() |
| } |
| |
| return true |
| } |
| |
| } |
When the app starts up, you’ll see if WCSession is supported—this will return false on devices that can’t pair with Apple Watch, like iPads and iPod Touches. If it’s supported, you’ll call activateSession on the session after setting up a delegate for it. This opens up a channel of communication between the watch and the phone, which you’ll use as you send data back and forth. First, you’ll send the list of tracks back and forth using the application context.
Think of the application context like NSUserDefaults; it’s a Dictionary that you can treat as an arbitrary key-value store. The application context is a great way to provide data to the counterpart app without interrupting what the user is doing—the data is sent in the background. To update the application context, simply call updateApplicationContext(_:) on your WCSession, passing in the new context in its entirety. The counterpart app can access it with the session’s receivedApplicationContext property, as well as through the WCSessionDelegate method session(_:didReceiveApplicationContext:). You will use the application context for the list of tracks.
The first step in this app will be to send the tracks to iOS from watchOS whenever they’re updated. Open Track.swift to make the necessary modifications. First, you need to import WatchConnectivity:
| import WatchConnectivity |
Next, you modify save to update the application context:
| func save() { |
| var tracks = Track.savedTracks() |
| |
| guard !tracks.contains(self) else { return } |
| |
| tracks.append(self) |
| |
| let trackDictionaries: [[String: AnyObject]] = tracks.map { |
| $0.dictionaryRepresentation() |
| } |
| |
| do { |
| let newContext = ["Tracks": trackDictionaries] |
| |
| try WCSession.defaultSession().updateApplicationContext(newContext) |
| } |
| catch let error { |
| NSLog("Error saving tracks to application context: (error).") |
| } |
| |
| NSUserDefaults.standardUserDefaults().setObject(trackDictionaries, |
| forKey: "SavedTracks") |
| } |
Build and run, and then save a new track. You’ll see an error appear in the application’s console in Xcode that looks something like this:
| TapALap WatchKit Extension Error saving tracks to application context: |
| Error Domain=WCErrorDomain Code=7004 "WatchConnectivity session has not |
| been activated." UserInfo={NSLocalizedRecoverySuggestion=Activate the |
| WatchConnectivity session., NSLocalizedDescription=WatchConnectivity session |
| has not been activated., NSLocalizedFailureReason=Function activateSession |
| has not been called.}. |
The important thing to take from this error is “Function activateSession has not been called.” Just as you need to start the session on iOS, you need to start it on watchOS, too. You’ll use your ExtensionDelegate to create the session. Open up ExtensionDelegate.swift and import WatchConnectivity. Then, add another method to applicationDidFinishLaunching to start the session:
| func applicationDidFinishLaunching() { |
| startSession() |
| returnToInProgressRun() |
| } |
| |
| lazy var sessionDelegate = SessionDelegate() |
| |
| func startSession() { |
| WCSession.defaultSession().delegate = sessionDelegate |
| WCSession.defaultSession().activateSession() |
| } |
You may notice that this code fails to compile. The session needs a delegate to be set before you can call activateSession on it, and so you’ve included your SessionDelegate class. Problem is, since you added that class to the iOS App target and not the WatchKit Extension target, it’s not available from the latter. To fix that, open SessionDelegate.swift and open Xcode’s Utilities pane to the File Inspector (or press ⌘⌥1). Under Target Membership, ensure both targets are checked, like so:
Now, your SessionDelegate class will build for both iOS and watchOS. Build and run again and save a track, and you won’t see any errors in the console. Now let’s head over to the iOS app and read this data in.
Your iOS app will begin as simply as it can: a single table view. The Xcode template we used to create TapALap included a view controller, but you’re not going to use it. Go ahead and delete ViewController.swift, and then open Main.storyboard (be sure to open the iOS storyboard and not the watchOS storyboard named Interface.storyboard). Delete the existing view controller, and you have a fresh slate to start with. Add a table view controller to the storyboard; this will be your main user interface. Select the table view controller; then open the Attributes Inspector (⌘⌥4) and check the box next to Is Initial View Controller. Next, select the table view itself. There’s a prototype cell in there that you don’t need; set Prototype Cells to 0 to remove it. Finally, select the view controller, and in Xcode select Editor → Embed In → Navigation Controller. This will add a navigation controller to your UI, which will prevent the table view from appearing behind the status bar. Double-click the title area of the navigation bar to give the view controller the title Tracks. With that, your UI is complete! It should look like the following image:
Now you need to get data out of the application context. Create a new Cocoa Touch class for the iOS app, make it a subclass of UITableViewController, and name it TrackListTableViewController. Be sure to open the storyboard and set the class of your new view controller to this new class! Then, in TrackListTableViewController.swift, let’s get your list of tracks going! First, you need to import WatchConnectivity:
| import WatchConnectivity |
Next, you need a way to get tracks out of the application context’s data:
| var tracks: [Track] { |
| guard let trackDictionaries = WCSession.defaultSession() |
| .receivedApplicationContext["Tracks"] as? [[String: AnyObject]] |
| else { return [] } |
| |
| return trackDictionaries.flatMap(Track.init).sort { $0.name < $1.name } |
This code will get the Dictionary representations of tracks you saved to the application context and convert them to Track objects. To use them, implement the UITableView data source protocol:
| override func tableView(tableView: UITableView, |
| numberOfRowsInSection section: Int) -> Int { |
| return tracks.count |
| } |
| |
| override func tableView(tableView: UITableView, |
| cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { |
| let reuseIdentifier = "TrackCell" |
| |
| let cell: UITableViewCell |
| |
| if let reusedCell = tableView.dequeueReusableCellWithIdentifier( |
| reuseIdentifier) { |
| cell = reusedCell |
| } |
| else { |
| cell = UITableViewCell(style: .Value1, |
| reuseIdentifier: reuseIdentifier) |
| } |
| |
| if tracks.count > indexPath.row { |
| let track = tracks[indexPath.row] |
| |
| cell.textLabel?.text = track.name |
| |
| cell.detailTextLabel?.text = NSLengthFormatter() |
| .stringFromMeters(track.lapDistance) |
| } |
| |
| return cell |
| } |
First, you’ll return the number of tracks when asked how many rows there are. Next, you’ll return a cell for each Track, using the name and the lap distance in the cell. You used a built-in table view cell style, so you don’t even need to add any UI! Before you can build this, however, there’s a problem—you don’t have access to the Track class! As with SessionDelegate.swift, you’ll need to open Track.swift and add it to both the WatchKit Extension target and the iOS App target.
Handling Common Code Between Targets | |
---|---|
So far, you’ve been sharing code between targets by including the source files in each. While this works in this case, since there are only two such files, it doesn’t really scale to large projects with hundreds of files. In real code, you could use a framework target to share this code, keep it organized, and build it for multiple platforms. For more information on creating your own frameworks, consult the Xcode documentation. |
At this point, if you build and run the app, you’ll see your tracks listed, as you can see here!
When the application context changes while the iPhone app’s session is active, you should update this table view. The easiest way to do that is to use NSNotificationCenter to post a notification whenever it changes. You’ll listen for these notifications in viewDidLoad so that whenever a change occurs, your table view is updated:
| override func viewDidLoad() { |
| super.viewDidLoad() |
| } |
| |
| func registerForTrackNotifications() { |
| NSNotificationCenter.defaultCenter().addObserver(self, |
| selector: "applicationContextChanged", |
| name: "ApplicationContextChanged", |
| object: nil) |
| } |
| |
| func applicationContextChanged() { |
| NSOperationQueue.mainQueue().addOperationWithBlock { |
| self.tableView.reloadData() |
| } |
| } |
In this code, you simply register for a notification and then call reloadData on your table view when one comes in. You use NSOperationQueue to make sure that the update happens on the main queue, which is required for UI updates like this. WatchConnectivity doesn’t guarantee that the context will be received on the main queue, so this is an important step.
Next, you need to implement the session delegate’s side of things. Open SessionDelegate.swift and implement session(_:didReceiveApplicationContext:) to post the notification:
| func session(session: WCSession, |
| didReceiveApplicationContext applicationContext: [String : AnyObject]) { |
| NSNotificationCenter.defaultCenter().postNotificationName( |
| "ApplicationContextChanged", object: nil) |
| } |
Build and run again. While the iOS app is open, open the watch app and add a track. You’ll see the table view update with the new track. Huzzah!
As you can see, the application context is a great way to synchronize data between the iPhone and Apple Watch. Everything you’ve done so far applies equally in reverse; the iPhone can send a context to the watch for processing in just the same way. Next, let’s look at another way to send data back and forth: user info transfers.
If the application context is meant as a shared key-value store to use between the apps, the concept of transferring user info between them is the opposite. This API allows you to send a Dictionary of data from one device to other completely asynchronously. Simply use WCSession’s transferUserInfo(_:) method, passing in a Dictionary with whatever data you want to send. These user info dictionaries are queued on the sending device, so even if the user quits the watch app before it finishes, watchOS will ensure that the transfer happens when possible. You can inspect the session’s outstandingUserInfoTransfers property to see if the transfer has finished, but typically you won’t need to—just set it and forget it. On the receiving end, the session’s delegate will receive the session(_:didReceiveUserInfo:) method on a background thread with the user info dictionary you passed in. This is especially helpful when the watch and phone aren’t connected but then become connected later—whatever data was queued for transmission is sent and handled at the system’s convenience. You’ll use that in TapALap to handle deleting tracks on the phone.
To handle deleting tracks, you’ll use the standard iOS swipe-to-delete and Edit button approach to deleting table view cells. First, let’s enable the Edit button. Open up TrackListTableViewController.swift and add it to viewDidLoad:
| override func viewDidLoad() { |
| super.viewDidLoad() |
| registerForTrackNotifications() |
| |
| navigationItem.rightBarButtonItem = self.editButtonItem() |
| } |
This will add the Edit button to the top right of the view controller. When the user taps it, the table view cells will reveal a Delete button. Next, to enable swipe-to-delete, you’ll implement tableView(_:canEditRowAtIndexPath:) to tell the system that the rows are editable.
| override func tableView(tableView: UITableView, |
| canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool { |
| return true |
| } |
Finally, you need to actually delete the tracks when the user tries to delete them. You’ll use transferUserInfo(_:) to achieve this—when the user deletes a track, you’ll send the track’s dictionary representation associated with the key TrackToDelete. You’ll add this to tableView(_:commitEditingStyle:forRowAtIndexPath:) and it’ll be called when the user taps Delete:
| override func tableView(tableView: UITableView, |
| commitEditingStyle editingStyle: UITableViewCellEditingStyle, |
| forRowAtIndexPath indexPath: NSIndexPath) { |
| if editingStyle == .Delete { |
| let track = tracks[indexPath.row] |
| |
| WCSession.defaultSession().transferUserInfo( |
| ["TrackToDelete": track.dictionaryRepresentation()]) |
| |
| tableView.deleteRowsAtIndexPaths([indexPath], |
| withRowAnimation: .Automatic) |
| } |
| } |
If you build and run and then try to delete a track, you’ll see a crash. Why? When this method finishes, it tells the table view to delete that row. The problem is, when you delete a row, UITableView expects to get a different result from calling tableView(_:numberOfRowsInSection:) on its data source. Your application context hasn’t yet changed—you just sent the info to the watch—so your number of tracks will still include the just-being-deleted one. What can you do to solve this? Simple: you will remove any of the tracks that are pending deletion from the list of tracks. Modify the tracks computed property like so to achieve this:
1: | var tracks: [Track] { |
- | guard let trackDictionaries = WCSession.defaultSession() |
- | .receivedApplicationContext["Tracks"] as? [[String: AnyObject]] |
- | else { return [] } |
5: | |
- | let pendingDeletedTracks = WCSession.defaultSession() |
- | .outstandingUserInfoTransfers |
- | .map { $0.userInfo } |
- | .flatMap { $0["TrackToDelete"] as? [String: AnyObject] } |
10: | .flatMap(Track.init) |
- | |
- | return trackDictionaries.flatMap(Track.init).sort { $0.name < $1.name } |
- | .filter { !pendingDeletedTracks.contains($0) } |
- | } |
This new logic is a little complicated, so let’s step through it line-by-line. On line 7, you get an array from the current WCSession of all outstanding user info transfers. Then, on line 8, you transform that array into an array of those transfers’ userInfo dictionaries. You know that you used the TrackToDelete key in your user info dictionary, so on line 9 you transform the array again, pulling out whatever was saved under that key as long as it’s the correct type. In this case, it’ll be the track’s dictionary representation. Finally, on line 10, you transform the array one last time into an array of Track objects. With this array, on line 13, you can filter out tracks that are pending deletion. Build and run again, and you can successfully delete tracks! If you launch the app a second time, you’ll see any tracks you deleted reappear. Let’s switch back over to the watch side so you can actually delete these tracks.
Back in your watch app, you will receive these user info transfers in your session delegate. Specifically, the method to implement is session(_:didReceiveUserInfo:). You only want to implement this on watchOS, but since your SessionDelegate class spans both OSes, you can use a conditional to compile it only for watchOS:
| #if os(watchOS) |
| func session(session: WCSession, |
| didReceiveUserInfo userInfo: [String : AnyObject]) { |
| if let trackDict = userInfo["TrackToDelete"] as? [String: AnyObject], |
| track = Track(dictionaryRepresentation: trackDict) { |
| track.delete() |
| } |
| } |
| #endif |
In this method, if you find a track to delete, you convert the dictionary representation to a Track instance and then call delete on it. As of now, that doesn’t exist, so let’s head over to Track.swift and implement it. You can reuse a lot of logic from the existing save method, so you’ll extract some logic out of it. While you’re at it, since you can modify the saved tracks only on watchOS, you’ll restrict it to watchOS. So, you’ll implement save and delete as follows:
| #if os(watchOS) |
| |
| func save() { |
| var tracks = Track.savedTracks() |
| |
| guard !tracks.contains(self) else { return } |
| |
| tracks.append(self) |
| |
| Track.saveTracks(tracks) |
| } |
| |
| func delete() { |
| var tracks = Track.savedTracks() |
| |
| guard let trackIndex = tracks.indexOf(self) else { return } |
| |
| tracks.removeAtIndex(trackIndex) |
| |
| Track.saveTracks(tracks) |
| } |
| |
| static func saveTracks(tracks: [Track]) { |
| let trackDictionaries: [[String: AnyObject]] = tracks.map { |
| $0.dictionaryRepresentation() |
| } |
| |
| do { |
| let newContext = ["Tracks": trackDictionaries] |
| |
| try WCSession.defaultSession().updateApplicationContext(newContext) |
| } |
| catch let error { |
| NSLog("Error saving tracks to application context: (error).") |
| } |
| |
| NSUserDefaults.standardUserDefaults().setObject(trackDictionaries, |
| forKey: "SavedTracks") |
| } |
| #endif |
Each of those methods simply modifies the saved tracks and calls the new saveTracks(_:) method you created. Now, if you run the watch app, then start the iPhone app and delete tracks, you’ll be able to delete tracks and have your changes persist. Wonderful!
User info transfers are an excellent choice for sending data back and forth between your apps. You can send as many as you need, and unlike the application context, a new user info dictionary doesn’t overwrite the previous one—you simply receive both. This makes it perfect to record that an event happened. Because these transfers are performed in the background, the system can choose when to send them, optimizing battery life on the watch without you even needing to write any code. Sometimes, however, you want to send a message to the counterpart app immediately and even get a response. For that, WatchConnectivity has another trick up its sleeve: sending messages.
In the early days of WatchKit, before the WatchKit extension ran on the watch itself, all processing happened on the phone. The APIs available to WatchKit extensions were limited, and networking was discouraged. Instead, developers were encouraged to send messages to the parent iOS application, which would process the messages (in the background if necessary) and send a reply. Although those methods are now deprecated in watchOS 2, there are still times when the process is necessary. Sending messages to the phone is a bit like communicating with a web server over HTTP; watchOS and iOS communicate via Dictionary objects sent back and forth. One side—either the watch or the phone—sends a dictionary to the other side, which sends back a reply containing another dictionary. It’s a simple process, but it allows you to very easily hand responsibility for a task from the watch to the phone.
When do you use these methods? If a watch app needs more processing power, it’s better to perform the computation on the user’s iPhone, which has a relatively beefy processor and battery when compared to the minuscule watch components. Even some tasks that are trivial computationally rely on frameworks that are unavailable on the watch and therefore must be performed on the phone. watchOS does some of this itself; since there’s no GPS on the watch, it uses the phone’s GPS location for the Maps app.
To send a message to the counterpart app, either from iOS or from watchOS, you call WCSession’s sendMessage(_:replyHandler:errorHandler:) method. The message parameter is a simple Dictionary containing whatever keys and values you want to send. The counterpart app’s session delegate receives the message in the callback method session(_:didReceiveMessage:replyHandler:). When the counterpart is finished with the message, it calls the replyHandler, a closure you provide in the first step. This closure also takes a dictionary, which can contain a reply message. If something goes wrong sending the message, your errorHandler will be called.
So, for example, let’s say you wanted to make a calculator on watchOS but do all of the actual calculations on iOS, since the phone’s processor is so much faster. On the watch, you’d send a message to the phone to add two numbers:
| func sendMessageToPhone() { |
| WCSession.defaultSession().sendMessage(["Add": [2, 2]], |
| replyHandler: { (response: [String : AnyObject]) -> Void in |
| NSLog("The result is (response["result"])") |
| }, errorHandler: { (error: NSError) -> Void in |
| NSLog("An error occurred sending the message: (error)") |
| }) |
| } |
Then, on the phone, you’d receive this message and reply:
| func session(session: WCSession, |
| didReceiveMessage message: [String : AnyObject], |
| replyHandler: ([String : AnyObject]) -> Void) { |
| if let numbersToAdd = message["Add"] as? [Int] { |
| let sumOfNumbers = numbersToAdd.reduce(0, combine: +) |
| |
| replyHandler(["result": sumOfNumbers]) |
| } |
| else { |
| replyHandler([:]) |
| } |
| } |
This code adds the numbers using reduce(_:combine:) and then executes the replyHandler with the result. It’s important to note that it calls the replyHandler no matter what—if you don’t execute it, then the system doesn’t know when you’ve finished.
When you send a message from watchOS to iOS, the parent iOS application wakes up in the background to handle the message. But because the Apple Watch is so constrained on battery life and processing power, sending a message from iOS to watchOS will succeed only if the watch app is open. For that reason, it’s much more common to send messages from watchOS to iOS. Next, let’s look at a way to send even more data at once.
As if you didn’t already have enough ways to transfer data back and forth, there’s one more in WatchConnectivity: you can transfer entire files. This may seem like overkill on the watch, which doesn’t really have the screen space for editing documents and the like, but it does have one great use case: media. Whether you’re recording audio on the watch and transferring it to the phone or transferring (hopefully short) videos from the phone to the watch, these methods allow you to perform those transfers one file at a time using a pretty simple API. From the sending device, call transferFile(_:metadata:) on the WCSession. This will add a new WCSessionFileTransfer instance to the outstandingFileTransfers of the session and initiate the transfer when appropriate. On the receiving device, assuming everything went well, the session delegate receives the session(_:didReceiveFile:) method, passing in the path to the file. Be sure to move the file out from this path, because once this method returns, the OS will delete it! Back on the sending app, either after a successful transfer or an error, the session delegate receives the session(_:didFinishFileTransfer:error:) callback method. Use this method to retry if needed. While these file-transferring methods may not be used the most often in your app, they’re the most convenient way to send large chunks of data back and forth between devices.
3.14.245.221