Pagine

2014-09-01

NSManagedObject, Undo e dealloc

L'allegra compagnia definita nel titolo di questo post normalmente funziona felicemente assieme: anche se non si comprende bene bene perché, tutto va a posto e non c'è n'è dobbiamo preoccupare (per dirla alla Apple: it just works).
Ma la legge di Murphy ci dice che se qualcosa potrebbe non funzionare, certamente lo farà nel momento peggiore... Infatti...
Supponiamo di avere una sottoclasse di un NSManagedObject che ha bisogno di reagire al cambio di una delle sue proprietà; supponiamo di avere un attribute posizione che ad un certo punto viene modificato esternamente e del quale dobbiamo avvisare un'altra parte della nostra app, p.es. per aggiornare alcuni conteggi, tramite una notifica.
Questo sembra essere un compito perfetto per un accessor personalizzato, evitando l'inserimento di un observer dell'oggetto su una proprietà di se stesso:
- (void)setOrdine:(NSNumber *)ordine
{
    [self willChangeValueForKey:@"ordine";
    _ordine = ordine;
    [self didChangeValueForKey:@"ordine"];
    [[NSNotificationCenter defaultCenter] postNotificationName:@"myNotification" object:nil];
}
Ogni volta che il valore di ordine viene modificato, la notifica viene spedita; il tutto senza observer.
Se però eliminiamo l'oggetto dal NSManagedObjectContext (l'interfaccia si aggiorna di conseguenza) e in seguito chiamiamo la voce di Undo del menu Edit, Core Data si premura di rifar comparire l'oggetto stesso, ma l'interfaccia non cambia! Dobbiamo esplicitamente fare azioni per aggiornare lo stato dei conteggi, quali p.es. forzare il ridisegno, tramite una chiamata... che però non sappiamo dove fare (l'Undo non mi avvisa di aver fatto qualcosa e la fa in modo asincrono).

Allora occorre cambiare strategia: introduciamo un observer sulla property e dovremo inserirlo sia in -awakeFromInsert che in -awakeFromFetch. Questi infatti sono i metodi chiamati rispettivamente alla creazione di un NSManagedObject e al suo recupero dallo store:

- (void)awakeFromInsert
{
    [super awakeFromInsert];
    [self setPrimitiveValue:... forKey:@"..."];
    [self addObserver:self forKeyPath:@"posizione" options:0 content:nil];
}

- (void)awakeFromFetch
{
    [super awakeFromFetch];
    [self addObserver:self forKeyPath:@"posizione" options:0 content:nil];
}

- (void)didTurnIntoFault
{
    [self removeObserver:self forKeyPath:@"posizione"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ( [keyPath isEqualToString:@"posizione"] ) {
        [[NSNotificationCenter defaultCenter] postNotificationName:@"myNotification" object:nil];
    }
}

Notiamo che abbiamo dovuto togliere l'observer nel metodo -didTurnIntoFault: Apple infatti ci dice che è questo il metodo da usare con i NSManagedObject e che non dobbiamo lasciare in giro degli observer quando l'oggetto non c'è più, poiché potrebbero persino essere attaccati ad altri oggetti. In questo modo, ogni oggetto, che sia creato ex-novo o che sia caricato da disco, avrà un observer sulla property posizione e quando viene cancellato, l'observer viene tolto.
Bene: lanciamo, osserviamo che la notifica viene effettivamente spedita quando la property cambia; proviamo a cancellare l'oggetto: ok. Facciamo un Undo: voilà! L'aggiornamento non sembra funzionare più! Peggio: cancelliamo nuovamente l'oggetto (senza utilizzare il Redo) ed otteniamo una bellissima exception: "ho cercato di togliere un observer ma questo non c'era"!!!

Siamo arrivati al punto del problema: quando abbiamo cancellato l'oggetto la prima volta, è stato chiamato -didTurnIntoFault, che ha giustamente tolto l'observer. Quando però abbiamo fatto l'Undo, il nostro ManagedObject è stato riportato in vita ...artificialmente! Cioè, senza passare dall'-awakeFromFetch! Quindi non ha avuto occasione di rimettere a posto l'observer sulla property e questo spiega perché i conteggi non vengano eseguiti; quando poi cancelliamo di nuovo l'oggetto, il -didTurnIntoFault viene nuovamente chiamato, ma l'observer non esiste ed il codice si lamenta giustamente!
In modo un po' empirico, potremmo pensare di eliminare il -didTurnIntoFault, ma dopo aver cancellato un oggetto, all'uscita dell'applicazione otterremo un alto lamento del codice, che ci dice che abbiamo lasciato un observer vagante e che ciò non è cosa da farsi! Siamo in una strada senza uscita: se lo togliamo si arrabbia, se non lo togliamo si arrabbia lo stesso!

Intanto, dobbiamo capire il perché succeda una cosa simile; dopo averci ragionato un po', eccoci al punto! Quando un oggetto viene cancellato, è in effetti tolto dalla vista, ma viene messo nella coda di Undo e non viene distrutto; quando l'Undo viene chiamato, non fa altro che rimettere l'oggetto al suo posto, senza passare da nessuno dei metodi precedenti. Quindi è corretto non togliere l'observer alla cancellazione: l'oggetto non è cancellato, ma solo messo da parte. Quando l'Undo lo ripresenta, l'oggetto è pronto per continuare a funzionare.
Ma allora dove possiamo togliere l'observer? Quando l'oggetto verrà definitivamente distrutto e anche l'Undo non lo manterrà più! Ok, ma in pratica, dove??? Proprio nel metodo sconsigliato da Apple per i ManagedObject:
- (void)dealloc
{
    [self removeObserver:self forKeyPath:@"posizione"];
}
Lo so, lo so: Apple dice di non usare questo metodo con i ManagedObject, perché probabilmente non verrebbe mai chiamato. Ma questo è proprio uno dei casi in cui è certamente chiamato: noi andiamo a togliere l'observer proprio quando l'oggetto sta per essere distrutto e non quando viene solo tolto dalla vista, con possibilità di essere reinserito.

In questo modo non lasciamo observer vaganti, non cerchiamo di toglierli quando non ci sono e, soprattutto, le notifiche verranno lanciate ogni volta che la property dell'oggetto cambia, proprio come volevamo.

2014-06-12

NSOutlineView, NSTreeController e la gestione della memoria

Quando vogliamo costruire una NSOutlineView cell-based tramite un NSTreeController, anche se le cose non sono così chiare, sappiamo bene cosa fare! Oltre ad implementare il protocollo del delegate dell'Outline, dobbiamo anche usare la classe di convenienza NSTreeNode, che in pratica rappresentano i nodi; questi oggetti, anche se decidiamo di dimenticarli, vengono comunque creati.
Allora, visto che ci sono, tanto vale usarli; spesso è utile farne una sottoclasse per aggiungere variabili a noi interessanti (p.es. per legarli via binding alla cella).

La parte importante è il representedObject, che utilizziamo per riporvi l'oggetto che verrà rappresentato dal nodo. Giusto per non lasciare le cose troppo astratte, ogni nodo viene creato con il suo representedObject e poi aggiunto al NSTreeController. Per la radice dell'OutlineView (indexPath=0); il representedObject è un NSDictionary con le grandezze che ci servono:

NSDictionary *theRootObject = @{kOutlineTitle: kRootTitlekOutlineCode: @(kCodeHeader)};
ISNode *radice = [ISNode treeNodeWithRepresentedObject:theRootObject];
[self.listController insertObject:radice atArrangedObjectIndexPath:[NSIndexPath indexPathWithIndex:0]];

dove ISNode è la nostra sottoclasse di NSTreeNode e listController è il NSTreeController. Mentre per un qualunque altro nodo, l'unica differenza è l'IndexPath, che ovviamente avrà un indice in più (cioè saranno tutti figli del nodo radice).
Fin qui tutto a posto. Ma supponiamo di cancellare un oggetto mostrato nell'OutlineView... La prima cosa che ci viene in mente è di trovare l'indexPath dell'oggetto e poi usare i metodi del TreeController:

[self.listController removeObjectAtArrangedObjectIndexPath:path];

e in effetti abbiamo ragione! Il problema è che se proviamo ad aggiungere e togliere oggetti, dopo poco ci troveremo un numero interessante di NSTreeNode vaganti per la RAM, che non se ne vogliono andare! Possiamo vederlo facilmente tramite Instruments!

Non mi sono dato la pena di andare a fondo e scoprire il perché: infatti, una volta che un oggetto viene eliminato da un Controller dovrebbe essere libero e quindi deallocato da ARC alla prima occasione. La mia opinione, non dimostrata, è che in qualche modo sia colpa del representedObject (anche se in realtà dovrebbe essere il TreeNode che trattiene il representedObject e non viceversa). Se qualcuno ha un'idea al proposito... si faccia avanti!

In ogni caso, la soluzione è stata semplice: prima di togliere l'oggetto dal TreeController, mi sono registrato il TreeNode che stava per essere eliminato e, subito dopo il metodo -removeObjectAtArrangedObjectIndexPath, non ho fatto altro che imporlo a nil.

ISNode *selezione = [self.listController selectedObjects][0];
[self.listController removeObjectAtArrangedObjectIndexPath:path];
selezione = nil;

Così facendo, Instrument mostra che tutti i TreeNode eliminati scompaiono dalla RAM.

2014-03-24

Creazione e utilizzo dei file package

Può capitare che nella nostra applicazione, che sia basata su NSDocument o meno, ci sia la necessità di registrare dati in file separati, nascondendoli alla vista del normale utente, in modo che nessuno possa modificarli indipendentemente dall'applicazione.
Per questa funzionalità esistono i package! Nel Finder compaiono come normali singoli file, che possono essere copiati, spostati e cancellati normalmente. In realtà sono cartelle, che il Finder presenta come file perché sa che così deve essere! Esempi? Tutta la suite di iWork salva in questo modo. Selezionando uno di questi file, il tasto destro mostra la voce "mostra contenuto pacchetto".

Per prima cosa, il documento deve essere previsto nel progetto Xcode: se la nostra app è document-based non c'è bisogno di fare nulla, ma se non lo è dobbiamo cliccare sull'icona del progetto e scegliere il tab Info: ci viene mostrata la rappresentazione semplificata del file info.plist della nostra applicazione. Subito sotto, clicchiamo su Document Types e aggiungiamo un documento: è necessario inserire almeno il nome (di nostra scelta) e l'estensione (anche qui siamo liberi, ma non deve essere un'estensione già nota al sistema - l'estensione può essere di qualunque lunghezza - di solito è qualcosa che ricorda il nome del nostro software). Dobbiamo poi scegliere come ruolo (role) Editor e mettiamo la spunta su 'Document is distributed as bundle', che è proprio quello che vogliamo.

Questo è tutto: Xcode porterà nell'Info.plist dell'applicazione questi dati e, dopo averla lanciata la prima volta, le cartelle con questa estensione verranno trattate dal Finder come file singoli. Nella figura sono riportate le impostazioni per la libreria esportata da InerziaThings.

Scrittura

Ora però dobbiamo costruirli. Il tutto viene fatto attraverso l'oggetto NSFileWrapper.
Come esempio, supponiamo di voler inserire in un package alcune immagini e informazioni collegate: l'idea è di scrivere le informazioni in un file plist (già costruito e di nome xmlDoc) e le relative immagini in una cartella, il tutto all'interno del package.
Per prima cosa, trasformiamo il plist in un NSData:

NSData *xmlData = [xmlDoc XMLDataWithOptions:NSXMLNodePrettyPrint];
NSFileWrapper *mainDirectory = [[NSFileWrapper alloc] initDirectoryWithFileWrappers:nil];
[mainDirectory addRegularFileWithContents:xmlData preferredFilename:nomeFile];

Il preferredFileName è proprio il nome con cui comparirà il file plist all'interno del package, a meno che il nome non esista già: ma dato che siamo noi a crearlo, sappiamo cosa ci stiamo mettendo!)
Notiamo che al momento stiamo solo costruendo una struttura in memoria, nulla finora è stato scritto realmente sul disco.
Ora creiamo la cartella che conterrà le immagini con lo stesso metodo di prima, definiamo il nome di questa cartella (imageFolderName) e la aggiungiamo alla struttura del package:

NSFileWrapper *imageDirectory = [[NSFileWrapper alloc] initDirectoryWithFileWrappers:nil];
[imageDirectory setPreferredFilename:imageFolderName];
[mainDirectory addFileWrapper:imageDirectory];

Supponiamo di avere i riferimenti (URL) alle varie immagini in un array imageURLs; creiamo quindi un loop che legga i singoli URL e li aggiunga all'interno della cartella:

for (NSURL *theImage in imageURLs)
{
    NSFileWrapper *wrapImage = [[NSFileWrapper alloc] initWithURL:theImage options:0 error:nil];
  [imageDirectory addFileWrapper:wrapImage];
}

Si è trattato di creare un NSFileWrapper per ogni immagine, partendo dall'URL e aggiungere ognuno di essi al wrapper che rappresenta la cartella.
Ora non ci resta che scrivere effettivamente la struttura su disco e questo viene fatto utilizzando il metodo writeToURL di NSFileWrapper:

NSError *error;
BOOL finito = [mainDirectory writeToURL:exportURL options:0 originalContentsURL:nil error:&error];

dove exportURL rappresenta la locazione sul file system dove andremo a scrivere e sarà stato ottenuto in precedenza tramite un NSSavePanel. È buona norma controllare che non ci siano stati errori, guardando il valore del booleano finito ed eventualmente andando a leggere la descrizione dell'errore contenuta in error.

Lettura

Quando si tratta di leggere un package, dovremo fare lo stesso cammino ma a ritroso: dovremo perciò inizializzare un NSFileWrapper che punti all'URL richiesto (ottenuto da un NSOpenPanel) e chiedere allo stesso oggetto i file wrapper contenuti, tramite il metodo fileWrappers, che fornisce un NSDictionary contenente i riferimenti:

NSError *error;
NSFileWrapper *mainWrapper = [[NSFileWrapper alloc] initWithURL:theURL options:NSFileWrapperReadingImmediate error:&error];
NSDictionary *rootWrappers = [mainWrapper fileWrappers];

Alla fine, dentro rootWrappers avremo i dati necessari per ricostruire la struttura; per prima cosa otteniamo il wrapper della cartella con le immagini, utilizzando come chiave nel NSDictionary quella che avevamo usato durante la sua creazione (cioè il nome) e da questi i wrapper relativi alle immagini; poi da rootWrappers ricaviamo il nome del file plist in cui sono riportati i dati delle immagini:

NSFileWrapper *imagesDir = [rootWrappers objectForKey:imageFolderName];
NSDictionary *allImages = [imagesDir fileWrappers];
NSFileWrapper *imageListWrapper = [rootWrappers objectForKey:nomeFile];
NSData *theData = [imageListWrapper regularFileContents];
NSError *error;
NSXMLDocument *xmlDocument = [[NSXMLDocument alloc] initWithData:theData options:NSXMLDocumentTidyXML error:&error];


//loop sui nodi XML per ottenere i dati delle immagini
//poi per ogni immagine, questa viene costruita leggendo il wrapper:
//nomeImmagine: il nome originale; theURL: la posizione in cui salvare l'immagine
    NSFileWrapper *theImageWrapper = [dict objectForKey:nomeImmagine];
    NSData *theImageData = [theImageWrapper regularFileContents];
    [theImageData writeToURL:theURL atomically:NO];

In questo spezzone abbiamo ottenuto l'oggetto NSData utilizzando il metodo regularFileContents, da cui si ricava il documento XML. Da questo si potranno leggere i dati in esso memorizzati ed utilizzarli per ricreare le immagini originali con i loro dati. Infatti, con le ultime tre righe (che sono all'interno di un loop), ricaviamo il wrapper per ogni immagine, da questo otteniamo i dati come oggetto NSData, che verrà scritto su disco nella posizione voluta tramite l'ultima riga.

A questo punto abbiamo ricostruito tutta la struttura ed ottenuto i dati di partenza.

2014-01-01

La NSWindow che non voleva ilFrame...

Quanti di voi, amici sviluppatori, cambiano posizione e dimensioni ad una NSWindow usando il suo bravo metodo

[mainWindow setFrame:presentWindowRect display:YES];

oppure il metodo esteso con l'animazione

[mainWindow setFrame:presentWindowRect display:YES animate:YES];

per esempio per aggiungere in modo elegante una NSView per determinati scopi? Siete in molti? Bene.
E quanti di voi pensano ai propri utenti e salvano in automatico la posizione delle finestre, inserendo un nome a piacere nel campo Autosave di Interface Builder? Sempre molti, direi.
Ancora una domanda: quanti cercano di mantenere la compatibilità con Lion? Suppongo una buona parte.
Ok, ora che abbiamo selezionato l'uditorio, partiamo!

In questi giorni, sto mettendo a punto una nuova versione di InerziaTimer, passando ad una interfaccia più moderna e semplice, magari utilizzando una finestra NSBorderlessWindow costruita ad hoc ed ovviamente sviluppando con SDK 10.9 (ma la compatibilità deve andare indietro fino al 10.6).
Decido di mostrare in ogni momento solo gli elementi necessari alla funzione selezionata: quindi la solita trafila della NSView caricata da uno xib aggiuntivo tramite un NSViewController, con la finestra principale che si allunga per far posto alla nuova vista.
Dato che OS X calcola le coordinate dello schermo partendo dal basso, mentre l’utente si aspetta che la finestra mantenga fissa la parte superiore, bisogna fare un po’ di calcoli; in pratica:

NSRect presentWindowRect = [mainWindow frame]; //frame della NSWindow
NSRect viewRect = [accessoryView frame]; //frame della nuova vista da aggiungere
presentWindowRect.size.height = presentWindowRect.size.height + viewRect.size.height;
presentWindowRect.origin.y = presentWindowRect.origin.y - viewRect.size.height;
[mainWindow setFrame:presentWindowRect display:YES animate:YES];
[[mainWindow contentView] addSubview:accessoryView];

Questo codice funziona già per la finestra delle preferenze, dove ogni pannello ha altezze diverse e la finestra aggiusta la propria in conseguenza.

La prima implementazione aveva un problema: la finestra principale viene caricata all'inizio senza vista, mentre alla chiusura dell’app laNSView era presente. Per cui la posizione salvata all'uscita dall'app era relativa ad una finestra allungata, non a quella senza la NSView aggiuntiva che veniva caricata nella sessione successiva; il risultato era che ad ogni chiusura e riapertura, la finestra principale si posizionava sempre più in basso sullo schermo, fino ad uscirne! Ok, colpa mia (è sempre così); prima di chiudere l’app bisogna togliere la NSView, in modo che venga salvata la posizione della NSWindow da sola. È semplice: nel metodo -applicationShouldTerminate basta togliere la NSView e accorciare la finestra, sempre tenendo conto che l’origine del frame è in basso a sinistra:

NSRect presentWindowRect = [mainWindow frame]; //frame attuale della finestra con vista
CGFloat altezzaVista = [accessoryView frame].size.height;  //altezza della NSView
presentWindowRect.origin.y += altezzaVista;            //spostiamo la NSWindow in su..
presentWindowRect.size.height -= altezzaVista;            //e la accorciamo di conseguenza
[mainWindow setFrame:presentWindowRect display:NO];        //nessun display tanto la stiamo chiudendo

Il risultato è che la parte superiore resta fissa, si accorcia l’altezza e la finestra si presenta pronta per la chiusura.
Bene. Effettuo la modifica e lancio il tutto: perfetto, come aspettavo! Poi, dopo un altro po’ di sviluppo, provo il funzionamento di una nuova caratteristica sul 10.7 e... scopro che ad ogni riavvio la finestra si sposta in alto, fin quasi a scomparire! Penso si tratti di un baco: lo correggo su Lion, torno su Mavericks e.. qui la finestra si sposta ogni volta verso il basso!
Ok, riassumo: se lo correggo per Lion, non va su Mavericks e viceversa! Non potevo credere di dover fare un IF per poter mantenere una finestra allo stesso posto...
Allora ho cominciato a fare delle prove: inserimento di NSLog un po’ dovunque ed ho scoperto che su Lion il mio codice veniva seguito alla lettera ma, prima di presentarla a video, la finestra ritornava alla posizione iniziale, come se il mio codice non fosse mai stato eseguito! Ho provato ad inserire observer un po’ ovunque per catturare il momento in cui veniva rimodificato il frame, ma senza risultato: la finestra andava a posizionarsi dove voleva lei!
Anche San Google non sembrava aiutarmi, finché alla fine non trovo un problema simile sul solito StackOverflow: anche lì la finestra ribelle non voleva onorare il setFrame su Lion.
Il consiglio era di togliere il check alla caratteristica Restorable in Interface Builder; dato che la mia finestra è costruita da codice in una sottoclasse di NSWindow, ho inserito nel codice della sottoclasse:

- (BOOL)isRestorable
{
  return NO;
}

che da Lion in poi rappresenta un override, mentre per il 10.6 Snow Leopard sarà un semplice metodo aggiuntivo non utilizzato.
Compilato e lanciato: perfetto su Lion e perfetto su Mavericks!

Ho poi consultato la documentazione Apple sulla User Interface Preservation. Si tratta del meccanismo secondo il quale le finestre di una app sono riportate, ad una successiva riapertura, nello stato in cui erano alla chiusura, sempre che nelle Preferenze di Sistema l’utente abbia così scelto. Il risultato è stato:
  1. non c’è scritto da nessuna parte che il comportamento sia cambiato in qualche momento tra Lion e Mavericks (non ho provato su Mountain Lion, ma non mi aspetto differenze).
  2. il Restore richiede di definire un oggetto che faccia questo lavoro tramite un completionHandler; non conoscendo questa funzionalità, non avevo fatto nulla per implementarla.
  3. su entrambi i Mac utilizzati, questa funzione era disabilitata.
  4. la documentazione della classe NSWindow afferma che il metodo standard ritorna YES solo se lo styleMask della NSWindow comprende NSTitledWindow, mentre nel mio caso si trattava di una NSBorderlessWindow.
Risultato: ho risolto il problema attuale; ma forse qualcosa mi sfugge ancora nel generale...