Migrare a WatchOS 2
WatchOS 2 sposta l'extension dall'iPhone all'Apple Watch e cambia tutto. Come abbiamo migrato Gulps usando WatchConnectivity e come abbiamo aggiunto le prime complication.
Versione italiana dell’articolo originale del 24 novembre 2015; traduzione a cura del team.
Un po’ di tempo fa abbiamo rilasciato Gulps sull’AppStore. Gulps è una semplice app per tracciare il consumo d’acqua che ti ricorda anche di bere durante la giornata. È stata un’app di “prime volte” per me: prima app in Swift, prima app interamente open source, prima app WatchKit, prima app HealthKit, prima implementazione di 3D Touch. È la mia app di riferimento quando devo sperimentare cose nuove. La serie continua con WatchOS 2. In questo post vedremo come condividere dati tra l’app principale e la watch extension con il framework WatchConnectivity, e come creare un piccolo set di complication per il watch face. In aggiunta lo faremo con un’app completamente open source, viva sull’AppStore. Andiamo.
Gulps
La versione precedente di Gulps si appoggiava agli App group Apple per condividere dati tra l’app e la watch extension. I dati erano salvati su Realm e, grazie al suo sistema di notification, l’app watch era sempre allineata quando avveniva un cambiamento. Tutto questo era possibile perché la watch extension stava sul telefono insieme all’app principale. Era un sistema brillante, e facilissimo da implementare… mi è caduto il cuore quando Apple ha annunciato che spostavano la watch extension sul watch stesso. Sapevo che stava arrivando una tempesta di refactoring. Prima di aprire questo vaso di Pandora, godiamoci un attimo i bei vecchi tempi. Questo era il setup per un’app WatchOS 1:
realmToken = RLMRealm.defaultRealm().addNotificationBlock { note, realm in
self.reloadAndUpdateUI()
}
Una volta avvenuto un cambiamento sul telefono o sull’app Watch, partiva una broadcast notification che invocava una closure che aggiornava la UI. Se vuoi saperne di più su Realm e app grouping, dai un’occhiata a questo articolo del sottoscritto. È ancora il modo migliore per condividere dati tra l’app principale e un’estensione (es. un today widget).

WatchOS 2
So che ci ho messo un po’ a salire sul treno di WatchOS 2, ma dato che Gulps è stata accolta e recensita bene, volevo essere extra cauto nel gestire la transizione al nuovo OS. Giusto per ricordartelo: WatchOS 2 cambia drasticamente come funziona un’app watch, spostando l’extension dall’app iPhone all’Apple Watch stesso.

Questo ha grossi vantaggi: meno spinner della noia, l’app è molto più snappier e può fare molto meno affidamento sul telefono (es. facendo network request indipendenti). Tutto bello, ma ha un costo… condividere i dati come prima è impossibile. Apple però ha fornito un nuovo framework: WatchConnectivity.
Il framework offre diversi modi per abilitare la comunicazione tra telefono e watch, ma può essere parecchio insidioso far funzionare tutto in modo affidabile. Possiamo dire che WatchOS 2 ha aggiunto un po’ di complessità e qualche complication 😬. Le complication sono piccoli moduli usati per personalizzare i watch face e mostrare piccoli bocconi di informazione interessante quando l’utente dà un’occhiata all’ora. Ora possiamo finalmente aggiungere moduli nostri: avere la percentuale del nostro obiettivo idrico giornaliero suona piuttosto bene (così bene che gli utenti hanno iniziato a richiederla il giorno dopo il rilascio di WatchOS 2).
Condivisione
WatchConnectivity introduce quattro modi principali per comunicare tra telefono e watch:
- Application Context, un dictionary con le informazioni più aggiornate. Viene trasferito dal sistema in background, e ogni nuovo elemento sovrascrive il precedente. Ottimo per condividere lo stato corrente dell’applicazione.
- Use info, simile all’application context, ma i trasferimenti vengono accodati e consegnati in ordine, senza sovrascritture.
- File transfer per condividere file.
- Interactive messages, usati quando sia l’app sia il telefono sono attivi e in range. È un modo più interattivo per condividere dati, ideale quando l’utente deve vedere il feedback su entrambi gli schermi.
Per Gulps ho scelto la condivisione Application Context, dato che lo stato corrente del progresso quotidiano è quello che conta davvero, e l’utente raramente usa entrambe le app contemporaneamente (anche se, dato che il trasferimento è veloce, quando lo fa il feedback resta snappy).
Tutto il codice è disponibile su GitHub; per la condivisione faremo riferimento a WatchConnectivityHelper e WatchEntryHelper: qui e qui.
Setup della session
Prima di comunicare avanti e indietro dobbiamo abilitare la watch session sia sul telefono che sul watch. Lo facciamo usando il singleton WCSession di WatchConnectivity.
La session va inizializzata il prima possibile, quindi l’AppDelegate sembra un posto ragionevole per farlo lato telefono:
public func setupWatchConnectivity(delegate delegate: WCSessionDelegate) {
guard WCSession.isSupported() else {
return
}
let session = WCSession.defaultSession()
session.delegate = delegate
session.activateSession()
}
Il protocollo WCSessionDelegate dichiara tutte le funzioni che possono essere triggerate dalla session. Dobbiamo implementare solo questa se ci interessa l’application context:
func session(session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject])
Come suggerisce la signature, questo viene chiamato quando c’è un aggiornamento nell’application context, e il secondo parametro è il context stesso, rappresentato come dictionary. Ne parleremo dopo.
Una volta sistemato il telefono, dobbiamo fare più o meno la stessa cosa sul watch. Un buon posto dove inizializzare la session sul watch è il watch extension delegate:
func applicationDidFinishLaunching() {
setupWatchConnectivity()
}
// MARK: - Watch Connectivity
private func setupWatchConnectivity() {
guard WCSession.isSupported() else {
return
}
let session = WCSession.defaultSession()
session.delegate = self
session.activateSession()
}
Tutto pronto, è il momento di pensare a come comunicare.
Dal telefono al watch
Tutte le funzioni di utility per inviare e leggere dati dal watch vivono in una comoda struct: WatchConnectivityHelper, e un’istanza di questa struct è tenuta dall’AppDelegate. Ci serve un modo per essere notificati quando avviene un cambiamento sul database, così da poter triggerare un aggiornamento dell’application context. Come ho già detto, Realm rende tutto facile grazie alle Realm Notification. Subito dopo l’attivazione della session viene creata una nuova Realm notification (il token va tenuto strongly):
realmNotification = watchConnectivityHelper.setupWatchUpdates()
L’implementazione di setupWatchUpdates è lineare:
/**
Updates data on WatchOS, and listens for changes
- Returns: RLMNotificationToken that needs to be retained
*/
public func setupWatchUpdates() -> RLMNotificationToken {
// Send the current data right away
sendWatchData()
return EntryHandler.sharedHandler.realm.addNotificationBlock { note, realm in
// Once a change in Realm is triggered, refresh the watch data
self.sendWatchData()
}
}
Questo invia opportunisticamente lo stato corrente subito, e crea un nuovo token che, quando viene triggerato un cambiamento, richiama il metodo che invia l’application context.
È un buon momento per pensare a cosa ci serve davvero sul watch e modellare l’application context di conseguenza. Vogliamo tenere il data transfer al minimo per evitare tempi di trasferimento lunghi, mantenendo a portata di mano tutto ciò che ci serve. Per i miei scopi mi serve solo lo stato attuale dell’utente, il suo obiettivo giornaliero e le sue portion size (potrebbero cambiare nel tempo, quindi vanno refreshate spesso).
public class func watchData(current current: Double) -> [String: Double] {
let userDefaults = NSUserDefaults.groupUserDefaults()
return [
Constants.Gulp.Goal.key(): userDefaults.doubleForKey(Constants.Gulp.Goal.key()),
Constants.WatchContext.Current.key(): current,
Constants.Gulp.Small.key(): userDefaults.doubleForKey(Constants.Gulp.Small.key()),
Constants.Gulp.Big.key(): userDefaults.doubleForKey(Constants.Gulp.Big.key())]
}
Una volta pronto il pacchetto di dati, possiamo spararlo al watch:
/**
Sends the current data to WatchOS
*/
public func sendWatchData() {
guard WCSession.isSupported() else {
return
}
let watchData = Settings.watchData(current: EntryHandler.sharedHandler.currentEntry().quantity)
let session = WCSession.defaultSession()
session.activateSession()
if session.watchAppInstalled {
do {
try session.updateApplicationContext(watchData)
} catch {
print("Unable to send application context: \(error)")
}
}
}
Per inviare il nuovo context dobbiamo assicurarci che l’app watch sia effettivamente installata, poi chiamiamo updateApplicationContext(_:), funzione che può fare throw, quindi va chiamata con try.
Lato watch dobbiamo aspettare una call del WCSessionDelegate e salvare la roba nuova:
func session(session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject]) {
if let goal = applicationContext[Constants.Gulp.Goal.key()] as? Double,
let current = applicationContext[Constants.WatchContext.Current.key()] as? Double,
let small = applicationContext[Constants.Gulp.Small.key()] as? Double,
let big = applicationContext[Constants.Gulp.Big.key()] as? Double {
WatchEntryHelper.sharedHelper.saveSettings(goal: goal, current: current, small: small, big: big)
NSNotificationCenter.defaultCenter().postNotificationName(NotificationContextReceived, object: nil)
self.reloadComplications()
}
}
Dato che stiamo implementando tutto nel delegate dell’Extension, per aggiornare l’interfaccia (nel caso improbabile che l’utente stia fissando sia telefono sia watch con entrambe le app in esecuzione) facciamo passare una notification tramite NSNotificationCenter, lasciando che l’interface controller la gestisca con un’animazione. Un altro approccio sarebbe forzare un reload dell’interface controller, ma impedirebbe questa animazione.
Per fortuna la comunicazione tra watch e telefono funziona in modo praticamente identico.
Noterai che, quando l’application context si aggiorna, faccio anche reload delle complication: parliamone.
La vita non è poi così complicated
Le complication sono elementi che vivono sul watch face e possono essere personalizzati dall’utente. Esistono diversi tipi di complication con diverse dimensioni, a seconda dello spazio in cui vengono mostrate. Queste sono le possibili famiglie di complication che possiamo implementare:

Le complication sono fornite da un singolo oggetto che implementa il protocollo CLKComplicationDataSource. L’oggetto viene dichiarato direttamente nel file Info.plist dell’app watch, e caricato automaticamente dal sistema.
Una singola complication può offrire uno snapshot statico della situazione corrente (la temperatura attuale, i tuoi dati di attività, o nel caso di Gulps la percentuale di completamento del goal giornaliero), e può anche offrire feature di time travel, permettendo all’utente di ruotare la digital crown per avere uno snapshot passato o futuro. Time travel è ottimo per app che già conoscono il futuro, ma non si addice a Gulps, quindi non vedrai l’implementazione qui. Se vuoi saperne di più, salta in fondo al post per un po’ di reference.
CLKComplicationDataSource
Nel nostro caso i metodi del datasource che hanno bisogno di un’implementazione sono questi due:
func getPlaceholderTemplateForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTemplate?) -> Void)
func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTimelineEntry?) -> Void)
La prima funzione richiede un template per un dato tipo di complication. Un template è un semplice placeholder statico che viene mostrato all’utente mentre configura il watch face. La seconda funzione richiede la complication corrente che va caricata. Entrambe richiedono di chiamare un completion handler con l’oggetto corretto per ogni complication che supporti. Il supporto a un certo tipo di complication si dichiara nelle settings del progetto.

Diamo un’occhiata ai template:
func getPlaceholderTemplateForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTemplate?) -> Void) {
if complication.family == .UtilitarianSmall {
let smallFlat = CLKComplicationTemplateUtilitarianSmallFlat()
smallFlat.textProvider = CLKSimpleTextProvider(text: "42%")
smallFlat.imageProvider = CLKImageProvider(onePieceImage: UIImage(named: "complication")!)
smallFlat.tintColor = .mainColor()
handler(smallFlat)
}
// ...
}
Codice piuttosto banale: controlliamo quale family di complication è stata richiesta e ne forniamo una. Le complication hanno molte impostazioni e tipi predefiniti, quindi ti incoraggio a controllare la reference guide di Apple.

Il setup finale della complication non differisce troppo dal template, a parte l’ovvio valore corrente che sostituisce il placeholder pre-cotto:
func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTimelineEntry?) -> Void) {
let percentage = WatchEntryHelper.sharedHelper.percentage() ?? 0
if complication.family == .UtilitarianSmall {
let smallFlat = CLKComplicationTemplateUtilitarianSmallFlat()
smallFlat.textProvider = CLKSimpleTextProvider(text: "\(percentage)%")
smallFlat.imageProvider = CLKImageProvider(onePieceImage: UIImage(named: "complication")!)
smallFlat.tintColor = .mainColor()
handler(CLKComplicationTimelineEntry(date: NSDate(), complicationTemplate: smallFlat))
}
// ...
}
Dai un’occhiata al source completo qui.
Reloading delle complication
Quindi: l’utente riempie il suo progress, va a dormire, prende il watch al mattino e… eccola lì… la percentuale precedente ancora visualizzata. Trovare un modo per resettare la percentuale non è stato così immediato, dato che le complication le refresho solo quando si aggiunge una nuova porzione.
A quanto pare però il protocollo del data source contiene una funzione piuttosto utile:
/** This method will be called when you are woken due to a requested update. If your complication data has changed you can
then call -reloadTimelineForComplication: or -extendTimelineForComplication: to trigger an update. */
optional public func requestedUpdateDidBegin()
A-ha! Ogni tanto WatchOS chiede alle tue complication come si sentono… così gentile. Possiamo sfruttare questa occasione per resettare le complication:
func requestedUpdateDidBegin() {
let server = CLKComplicationServer.sharedInstance()
server.activeComplications.forEach { server.reloadTimelineForComplication($0) }
}
Dato che il model contiene la data dell’ultimo aggiornamento, è facile controllare quando una percentuale è ormai stale:
/**
Returns the current quantity
It also checks if the data is stale, resetting the quantity if needed
- Returns: Double the current quantity
*/
func quantity() -> Double {
let quantity = userDefaults.doubleForKey(Constants.WatchContext.Current.key())
if let date = userDefaults.objectForKey(Constants.WatchContext.Date.key()) as? NSDate {
if let tomorrow = date.startOfTomorrow where NSDate().compare(tomorrow) != NSComparisonResult.OrderedAscending {
// Data is stale, reset the counter
return 0
}
}
return quantity
}

Puoi andare sul repo GitHub di Gulps e compilarla, o ancora meglio scaricarla dallo store: noterai che l’app WatchOS è molto più snappier e il tempo di comunicazione tra i due device è piuttosto veloce.
Sono abbastanza soddisfatto del risultato, ma ha richiesto una buona dose di pazienza. WatchConnectivity funziona alla grande quando lo impari con una semplice demo app, ma quando ci infili un’app appena un po’ più complessa ti aspettano sorprese. Alla fine si tratta di capire cosa serve trasferire e quando. Non aiuta che compilare l’app su un device reale richieda parecchio tempo. Vedrai lo spinner di caricamento UN SACCO di volte, tieni duro.
Detto questo, alla fine l’app gira molto più liscia, quindi ne vale la pena.
Reference
Puoi leggere di più sul framework WatchConnectivity in questo articolo di Natasha Murashev (che ha anche avuto la gentilezza di contribuire al progetto), questo di Kristina Thai o quello di Raywenderlich scritto da Mic Pringle. Anche “watchOS 2 by Tutorials” di Raywenderlich è stato di grande aiuto: prendilo in considerazione se vuoi approfondire lo sviluppo WatchOS.