Condividere dati tra WatchKit e la tua app con Realm
Condividere il database della tua app iOS con la sua estensione WatchKit usando Realm e gli app groups. Una piccola app TODO open source come scusa per mettere insieme i pezzi.
Versione italiana dell’articolo originale del 29 marzo 2015; traduzione a cura del team.
È stato un anno intenso per gli sviluppatori iOS. Abbiamo avuto a disposizione un’enormità di nuovi giocattoli: una nuova versione di iOS, nuovi framework, un nuovo linguaggio, nuove dimensioni di schermo e dell’hardware nuovo da metterci al polso — proprio quando pensavamo che gli orologi fossero anacronistici. La tecnologia nuova è sempre entusiasmante, ma stare al passo è un compito tosto quando le deadline incalzano. Ultimamente mi sono preso un po’ di tempo per approfondire questi temi e tirar fuori una piccola app (che pensiamo di rilasciare open source). Mentre scrivo l’app è ancora in attesa di review, quindi ci concentriamo su una cosa che ho imparato lungo la strada e che può essere utile a molti altri dev iOS: come condividere dati tra la tua app e la sua estensione WatchKit, usando Realm.
Perché Realm
Realm è un’ottima alternativa sia a SQLite che a Core Data, e offre un modo rapido e indolore per persistere i dati nella tua app iOS (e Android). La cosa che lo rende così figo è la facilità d’uso: riduce il boilerplate quasi a zero (ti guardo, Core Data), mantenendo le cose a un buon livello di astrazione (ti guardo, SQLite) e… è veloce. È davvero un gran pezzo di software, e ti consiglio di dare un’occhiata alla documentazione ufficiale se ti serve un framework di persistenza per la tua app.
L’app di esempio
Impareremo a condividere dati tra un’app e la sua estensione WatchKit, e quale modo migliore se non con un’app di esempio? Non sono un fan delle demo di app TODO, mi sembrano sempre… senza fantasia… ma devo ammettere che sono perfette per un task come questo. Quindi abbi pazienza: ti presento un’altra TODO app di esempio: Done!, una semplice lista in cui aggiungere item e marcarli come fatti dal nostro Apple Watch.
Setup di Realm
Le estensioni WatchKit sono semplicemente un altro target all’interno della tua app, esattamente come le Today extension: vengono impacchettate insieme all’app ma hanno vita propria, e soprattutto un proprio bundle identifier. Questo significa che i dati salvati dall’app non sono visibili all’estensione e viceversa. Apple offre un modo per risolvere il problema con gli app group.
Gli app group, come i bundle identifier, sono definiti da un reverse URI e hanno il prefisso group.. Permettono di condividere dati tra app e sono strettamente legati al provisioning profile (per fortuna Xcode gestisce la loro creazione nel developer portal con facilità).
Possiamo sfruttarli per creare il nostro database Realm in uno spazio condiviso, visibile (e scrivibile) sia dall’app che dall’estensione WatchKit. Iniziamo creando il group nella sezione capabilities del progetto:

Tolto questo, possiamo dire a Realm di salvare il database dentro il group.
Lo facciamo nella application(application: didFinishLaunchingWithOptions launchOptions:) del nostro AppDelegate:
let directory: NSURL = NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier("group.it.fancypixel.Done")!
let realmPath = directory.path!.stringByAppendingPathComponent("db.realm")
RLMRealm.setDefaultRealmPath(realmPath)
Ed ecco: Realm è pronto, dobbiamo solo definire un model da persistere:
// Entry.swift
import Realm
class Entry: RLMObject {
dynamic var title = ""
dynamic var completed = false
}
ViewController
Il ViewController che gestisce la TODO list sarà piuttosto semplice: una UITableView con un header custom che presenta la UITextField per l’input dell’utente. Nulla di sofisticato:
// ViewController.swift
import Realm
import UIKit
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate {
@IBOutlet var tableView: UITableView!
var dataSource: RLMResults!
override func viewDidLoad() {
super.viewDidLoad()
reloadEntries()
}
func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let container = UIView(frame: CGRectMake(0, 0, self.view.frame.size.width, 60))
let textField = UITextField(frame: CGRectMake(10, 10, self.view.frame.size.width - 20, 40))
textField.delegate = self
textField.textColor = UIColor.whiteColor()
let placeholer = NSAttributedString(string: "Add an item", attributes: [NSForegroundColorAttributeName: UIColor.lightGrayColor()])
textField.attributedPlaceholder = placeholer
container.addSubview(textField)
return container
}
func reloadEntries() {
dataSource = Entry.allObjects()
self.tableView.reloadData()
}
func textFieldShouldReturn(textField: UITextField) -> Bool {
textField.resignFirstResponder()
let realm = RLMRealm.defaultRealm()
realm.beginWriteTransaction()
let entry = Entry()
entry.title = textField.text
entry.completed = false
realm.addObject(entry)
realm.commitWriteTransaction()
reloadEntries()
return true
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return Int(dataSource.count)
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCellWithIdentifier("Cell") as UITableViewCell
let entry = dataSource[UInt(indexPath.row)] as Entry
cell.textLabel!.text = entry.title
cell.accessoryType = entry.completed ? .Checkmark : .None
return cell
}
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let entry = dataSource[UInt(indexPath.row)] as Entry
let realm = RLMRealm.defaultRealm()
realm.beginWriteTransaction()
entry.completed = !entry.completed
realm.commitWriteTransaction()
reloadEntries()
}
}
Come vedi, recuperiamo gli item della table view da Realm con una semplice chiamata:
Entry.allObjects()
e salviamo le modifiche con un paio di righe:
realm.beginWriteTransaction()
let entry = Entry
entry.title = textField.text
entry.completed = false
realm.addObject(entry)
realm.commitWriteTransaction()
Se hai lavorato con Core Data in passato, ora dovresti capire meglio cosa intendo con “meno boilerplate”.
WatchKit
Ok, nuova tecnologia, nuovo framework, c’è tanto da imparare, vero? A quanto pare, Apple ci ha pensato: per quel che ho visto, sviluppare un’app per Watch sembrerà familiare a molti dev Cocoa e Cocoa Touch, anche se con alcuni cambiamenti filosofici significativi. Per prima cosa creiamo un nuovo target per l’app Watch:

Poi il layout. Non useremo Autolayout, ma qualcosa che ricorda una versione semplificata dei linear e relative layout di Android (senza dover toccare grossi file XML però: gli storyboard sono ancora qui). Il layout sarà una WKInterfaceTable con un TableRowController custom, che conterrà una WKInterfaceImage e una WKInterfaceLabel:

Il TableRowController custom è una semplice classe con un paio di property, partiamo da lì:
class EntryTableRowController: NSObject {
@IBOutlet var imageCheck: WKInterfaceImage!
@IBOutlet var textLabel: WKInterfaceLabel!
}
Lineare: pensalo come l’equivalente di una UITableViewCell custom.
Vediamo come popolare la table:
override func awakeWithContext(context: AnyObject?) {
super.awakeWithContext(context)
let directory: NSURL = NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier("group.it.fancypixel.Done")!
let realmPath = directory.path!.stringByAppendingPathComponent("db.realm")
RLMRealm.setDefaultRealmPath(realmPath)
reloadTableData()
}
func reloadTableData() {
let realm = RLMRealm.defaultRealm()
let dataSource = Entry.allObjects()
watchTable.setNumberOfRows(Int(dataSource.count), withRowType: "EntryRow")
for index in 0..<Int(dataSource.count) {
let entry = dataSource[UInt(index)] as Entry
if let row = watchTable.rowControllerAtIndex(index) as? EntryTableRowController {
row.textLabel.setText(entry.title)
let imageName = entry.completed ? "check-completed" : "check-empty"
row.imageCheck.setImageNamed(imageName)
}
}
}
Un po’ diverso da quello a cui siamo abituati con UIKit. Niente delegate né dataSource: in WatchKit definiamo esplicitamente ogni row prima di mostrare la table.
Inoltre, come vedi nella awakeWithContext, stiamo settando Realm come avevamo fatto nell’app principale. Nota a margine: i pezzi di codice condivisi tra i due target (nel nostro caso tutto ciò che riguarda il model) andrebbero spostati in un framework custom per evitare duplicazione. Per questo sample ho preferito duplicare l’inizializzazione di Realm e condividere il model tra i due target, ma in un’app più strutturata sceglierei sicuramente la strada del framework.
Se buildiamo e lanciamo, possiamo aggiungere un item sul telefono e, dopo aver ricaricato l’app, lo vedremo anche nell’estensione watch.
Non si sente molto responsive, però, vero? Lo sistemiamo a breve, ma prima aggiungiamo la possibilità di marcare un item come done dal watch:
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) {
let dataSource = Entry.allObjects()
let entry = dataSource[UInt(rowIndex)] as Entry
let realm = RLMRealm.defaultRealm()
realm.beginWriteTransaction()
entry.completed = !entry.completed
realm.commitWriteTransaction()
reloadTableData()
}
Più o meno è tutto. Ora è ancora più evidente che, anche se le due app comunicano, manca un vero senso di interazione: dobbiamo ricaricare l’app per vedere i cambiamenti.
Sincronizzare i dati
Dobbiamo in qualche modo segnalare che la nostra watch app o l’app principale hanno fatto qualcosa di significativo. Ci sono modi ufficiali per farlo, e puoi leggerne di più in questo post di Natasha The Robot, ma in questo caso voglio mostrarti come una brillante libreria chiamata MMWormHole possa astrarre questo compito per noi.
MMWormHole
MMWormhole crea un ponte tra un’app e le sue estensioni. L’API è chiara e concisa, e il nome è deliziosamente nerd. Funziona come un sistema pub/sub: un’estremità registra interesse per un certo tipo di messaggio (identificato da una stringa), e l’altra può fare broadcast di un nuovo evento. Quando l’evento viene generato, l’handler del subscriber si attiva. Sembra qualcosa che possiamo sfruttare nella nostra app. Partiamo registrando interesse per gli aggiornamenti watch sull’app principale:
self.wormhole.listenForMessageWithIdentifier("watchUpdate", listener: { (_) -> Void in
self.reloadEntries()
})
e nella watch app ascolteremo gli aggiornamenti main:
self.wormhole.listenForMessageWithIdentifier("mainUpdate", listener: { (_) -> Void in
self.reloadTableData()
})
Tutto qui. Quando qualcosa cambia nel nostro model, basta chiamare queste funzioni:
// From the watch
self.wormhole.passMessageObject("update", identifier: "watchUpdate")
// From the main app
self.wormhole.passMessageObject(someObject, identifier: "mainUpdate")
Realm notifications
Update
Una volta che il post è andato live, Tim Anglade di Realm mi ha contattato e ha avuto la gentilezza di farmi notare che Realm ha un sistema di notification built-in, quindi in questo caso MMWormHole non è davvero necessario. L’API è facilissima da usare e non richiede configurazione, funziona così:
realmToken = RLMRealm.defaultRealm().addNotificationBlock { note, realm in
self.reloadEntries()
}
Finché manteniamo un riferimento strong al token, il sistema invoca il notification block non appena viene committata una write transaction. Trovi il codice aggiornato nell’app di esempio.
Demo
Ecco la demo app in tutto il suo splendore:

Come vedi è davvero facile mettere in piedi una piccola app per WatchKit, e grazie a un paio di ottime librerie open source aggiungere persistenza e interattività è uno sforzo indolore. Un grosso grazie sia al team di Realm sia a Mutual Mobile per l’ottimo lavoro su MMWormHole.
Come al solito, trovi il codice sorgente della demo sulla nostra pagina GitHub. Sentiti libero di lasciare un commento, ci farebbe piacere sentire il tuo feedback.