Apple WatchConnectivity

While learning Swift, one of the new features I wanted to add to the Nutrition GPA app was an Apple Watch app/extension.  Getting into WatchKit was surprisingly easy, in part because development follows a similar paradigm to UIKit interfaces but the watch user interface features are somewhat limited.

To share data between devices, Apple makes it relatively easy to pass data back and forth from the host app (on the iPhone) to the watch app.  I started with reading some framework documentation and looking at some sample code.

For my purposes, I wanted users to be able to take the Nutrition GPA daily quiz on their watch.  At first, I thought I could define “Shared Groups” to write to a common file location, but that didn’t work.  Not needing bi-directional communication, I decided to send the quiz “answers” to the phone – which would then store them to the local database.

WCSessionDelegate

The first step is defining a class to handle the watch connectivity session messages.  This class will handle various state changes and posting notifications it received data from either the watch or the phone to the rest of your app.  You’ll instantiate an instance of this class in both your AppDelegate and ExtensionDelegate classes.

class WatchSessionDelegate: NSObject, WCSessionDelegate
{
   // Unpack the data and post a notification
   func session(_ session: WCSession, didReceiveMessageData messageData: Data)
   {
      var cmd = MessagePackage(.sendMessageData, .received)
      cmd.dataPackage = EncodedData(messageData)

      DispatchQueue.main.async { NotificationCenter.default.post(.didReceiveData, cmd) }
   }

   // Unpack the data, use callback for response
   func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping(Data) -> Void)
}

There are a handful of session state methods that you may want to implement specifically for your application.   They provide hooks for connectivity, reachability,  and session state.  Refer to the Apple Docs.

AppDelegate

Configuring the app delegate simply requires creating a private WatchSessionDelegate instance and setting the WCSession properties appropriately.

private var sessionDelegate = WatchSessionDelegate()

func application(_ app: UIApplication, _ launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool
{
   // Make sure WatchConnectivity is supported
   if WCSession.isSupported()
   {
      WCSession.default.delegate = sessionDelegate
      WCSession.default.activate()

      // Add a notification
      NotificationCenter.default.addObserver(
         self,
         selector: #selector(type(of: self).didReceiveData(_:)),
         name: .didReceiveData,
         object: nil)
   }
}

@objc func didReceiveData(_ notif: Notification)
{
   // unpack the notification
   guard let message = notification.object as? MessagePackage else { return }
   guard let quizData = message.dataPackage?.quizData else { return }

   // save the data
   UserData.shared().saveQuizData(quizData)
}
ExtensionDelegate

On the watch side, the ExtensionDelegate is similar.  There’s a bit more code to process and handle pending background tasks in the event connectivity disappears.

private lazy var sessionDelegate: WatchSessionDelegate = { return WatchSessionDelegate() }()

private var activeObservation: NSKeyValueObservation?

private var contentPending: NSKeyValueObservation?

private var wcBackgroundTasks = [WKWatchConnectivityRefreshBackgroundTask]()

override init()
{
   if WCSession.isSupported()
   {
      activeObservation = WCSession.default.observe(\.activationState) { _, _ in DispatchQueue.main.async {
         self.completeBackgroundTasks()
       }

      contentPending = WCSession.default.observe(\.hasContentPending) { _, _ in DispatchQueue.main.async {
         self.completeBackgroundTasks()
      }

      WCSession.default.delegate = sessionDelegate
      WCSession.default.activate()
   }
}

func completeBackgroundTasks()
{
   guard WCSession.ddefault.activationState == .activated, WCSession.default.hasContentPending == false else { return }

   wcBackgroundTasks.forEach { $0.setTaskCompletedWithSnapshot(false) }

   let date = Date(timeIntervalSinceNow: 1)
   WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: date, userInfo: nil) { error in if let error = error { 
      print("scheduledSnapshotRefresh: Error \(error)")
      }
   }
   
   wcBackgroundTasks.removeAll()
}

With the appropriate handlers added to the two application delegates, we need to consider the packaging of the shared data.   In the app delegate code (above), I’ve used a MessagePackage object without defining it.

MessagePackage & EncodedData

These two structures provide a handy transport wrapper around the actual data we want to send.  Having these classes allows us to also include some state information (sending, receiving, replied, failed) and provide some helper serialization methods through the use of dynamic properties.

In my case, I opted to use NSKeyValueEncoding/Decoding to perform the object serialization.  Key value coding is a common pattern on iOS and provides a binary Data package.

Because Swift wraps the phone and watch in unique namespaces, the class name is specific to the platform when serializing or deserializing (e.g. NutritionGPA.QuizData… and NutritionGPAExtension.QuizData…).

Early on, this was causing the data to serialize on one device and silently fail to decode on the other platform when it was received.  There are a couple ways to resolve this issue but I opted to explicitly declare each object with @objc to avoid the name changes.

@objc(QuizData) class QuizData: NSObject, NSCoding

You’ll need to do this for any custom code that the instance also serializes.

Alternatively, NSKeyedArchiver can be configured to look for specific classes and store/retrieve custom code.  It just requires setting the appropriate class name before any store/retrieve operation – which felt a little clumsy to me.  You can read about both choices on StackOverflow and decide what best works for your application.

struct MessagePackage
{
   enum Command: String {
      case sendMessageData = "SendMessageData"
   }
 
   enum Status: String {
      case sent = "Sent"
      case received = "Received"
      case replied = "Replied"
      case failed = "Failed"
   }

   var command: Command
   var status: Status
   var dataPackage: EncodedData?
   var errorMessage: String?

   init(command: Command, status: Status)
   {
      self.command = command
      self.status = status
   }
}

struct EncodedData
{
   var encodedData: Data
   var quizData: QuizData?
   {
      let optional = NSKeyedUnarchiver.unarchiveObject(with: encodedData)
      guard let quiz = optional as? QuizData else
      {
         print("Error: unable to unarchive")
         return nil
      }
      
      return quiz
   }

   init(_ encodedData: Data)
   {
      let data = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData)
      guard let dictionary = data as? [String: Any] else {
         fatalError("Failed to unarchive")
      }

      self.init(dictionary)
   }

   init(_ encodedDict: [String: Any])
   {
      guard let encodedData = encodedDict[Payload.dataKey] as? Data else 
      {
         fatalError("Error: incorrect key")
      }
      
      self.encodedData = encodedData
   }
}

Now that we have the app delegates setup and some handy wrappers around serializing our data – how do we send it?

SessionCommands & DataProvider

Both of these protocols provide some helper methods that are adopted by the classes that transmit data.

SessionCommands provides a single method that creates a MessageData object and sends it via Watch Connectivity.

DataProvider handles archiving both the raw quiz data and creating the encoded MessageData transparently to adopting classes.

Both protocols are implemented by the results view controller on the Watch app.  When the user presses “Finish”, the quiz data is encoded and sent to the phone for storage.

protocol DataProvider
{
   func messageData(_ obj: AnyObject) -> Data
}

extension DataProvider
{
   func messageData(_ obj: AnyObject) -> Data
   {
      let data = try? NSKeyedArchiver.archiveData(withRootObject: obj, requiringSecureCoding: false)
      guard let rawQuizData = data else {
         fatalError("Unable to archive")
      }
      
      let payload = try? NSKeyedArchiver.archivedData(withRootObject: [Payload.dataKey: rawQuizData], requiringSecureCoding: false)
      guard let payloadQuiz = payload else {
         fatalError("Unable to archive")
      }

      return payloadQuiz
   }
}

protocol SessionCommands
{
   func sendMessageData(_ messageData: Data)
}

extension SessionCommands
{
   func sendMessageData(_ messageData: Data)
   {
      var command = MessagePackage(command: .sendMessageData, status: .sent)
      command.dataPackage = EncodedData(messageData)

      guard WCSession.default.activationState == .activated else { return }

      WCSession.default.sendMessageData(messageData, replyHandler: { replayData in
      command.status = .replied
      command.dataPackage = EncodedData(replyData)
       }, errorHandler: { error in
         command.status = .failed
         command.errorMessage = error.localizedDescription
       })
   }
}
Debugging

There are also a number of test scenarios to consider: multiple watches paired to a single iPhone, connectivity lost (phone and watch too far apart), or handling background tasks.

Finally, it’s worth adding a bunch of unit tests around the serialization / deserialization to make sure the data can properly flow from device to device.

While it certainly helps to write unit tests to help isolate potential problems, it’s also useful to debug both the watch and phone on the simulator simultaneously.  If you’re not seeing data from one device to the other, consider debugging both app and extension at the same time in Xcode.

To do this, start debugging either the app or extension, then launch the other app on the device and select “Debug | Attach to Process” in Xcode to attach to both at the same time.

Finishing Up

I omitted some details in the code above – notably sending and handling the various notifications in both applications.

Generally, the app delegate will receive the quiz taken on the watch, then save it to the local database.  This is great – but some additional thought is required to determine how the application should respond if the user is interacting with it.  For example, if the user is on the start screen – which displays the total number of quizzes taken – the display should update to reflect new data.  Or, if the user is looking at their quiz history – the list should update to include this latest entry.

These aren’t difficult problems to solve – largely they involve having each view controller subscribe to a data notification (or redefine them in the phone’s app delegate specifically for these view controllers) and recalculating their display.

Last, there are a few areas where – now that things are working – I’d consider refactoring or a slightly more flexible approach.  Specifically, the places where “fatalError” is called create the potential for a poor end-user experience.  This code would probably be better to fail more gracefully!

Leave a Reply

Your email address will not be published. Required fields are marked *