MaCocoa 016

Capitolo 016 - Codifica ed Archiviazione

Il passo successivo del progetto di catalogo è di capire come fare a salvare il contenuto della finestra in un file, e come successivamente recuperare le informazioni salvate per inserirle nuovamente all'interno della finestra.

Scopo di questo capitolo è quindi di capire le funzionalità di salvataggio e recupero dati su file. Si parte ancora una volta dall'esempio sviluppato nel capitolo precedente, e lo si modifica per quanto riguarda l'interfaccia utente e le classi interessate.

Sorgenti: Dalla documentazione Apple.

Primo inserimento: 26 Dicembre 2001

Archiviazione

Praticamente ogni applicazione ha bisogno di rendere persistente una serie di dati relativi alle proprie funzionalità. Nel caso del catalogo, è necessario salvare all'interno di un file tutte le informazioni relative ai file che l'utente ha deciso di inserire all'interno della finestra. Se infatti si esce dall'applicazione, al successivo lancio ogni informazione è stata persa.

Cocoa fornisce un meccanismo chiamato Coding e Archiving per congelare all'interno di un file una rete di oggetti tra loro interagenti, senza per questo perdere le relazioni reciproche. La codifica e l'archiviazione dei dati sono quindi alla base di ogni applicazione che abbia bisogno di immagazzinare da qualche parte i dati di lavoro o altre informazioni necessarie alla propria esecuzione.

Codifica ed archiviazione non sono solo utilizzate nei processi di salvataggio su disco, ma anche, ad esempio, nella trasmissione su rete o su linee seriali in generale (in Unix, ogni cosa è un file: la rete, le linea seriale, eccetera).

La codifica di un oggetto non è altro che la trasformazione di questo oggetto in una rappresentazione seriale, adatta cioè ad essere salvata su file o inviata su un canale seriale. Nell'operazione di codifica sono mantenute le strutture dati, le relazioni con altri oggetti e le informazioni relative alla classe dell'oggetto. L'archiviazione di un oggetto non è altro che la codifica applicata ad un mezzo che possa mantenere nel tempo le informazioni codifica, come ad esempio un file su di un disco rigido.

In Cocoa, si parla di due classi, NSCoder, che realizza le operazioni di codifica, e NSArchiver, sottoclasse di NSCoder, che esegue le operazioni di archiviazione.

Ad esempio, NSCoder possiede il metodo encoderRect: con il quale è possibile codificare un oggetto della classe Rect. In altre parole, si manda ad un oggetto NSCoder un messaggio dicendogli: eccoti un rettangolo, vedi di infilarlo nella struttura dati codificati. Ovviamente, NSCoder deve eseguire operazioni reversibili, cioè deve possedere anche il metodo opposto decodeRect: attraverso il quale è possibile estrarre dalla struttura dati codificata un rettangolo bello e pronto.

Come si fa a codificare ed archiviare un oggetto di una classe definita dal programmatore? La classe deve aderire al protocollo NSCoding.

E per spiegare quest'ultima frase, apro una parentesi.

Protocolli

Ho a suo tempo discusso come funziona la gerarchia di classi, come si generano nuove classi ereditando variabili e metodi di una superclasse, cose del genere; in particolare, se si vuole aggiungere un metodo ad una classe di cui non si è proprietari, occorre definire una sottoclasse.

I protocolli, formali o informali, servono a dichiarare metodi aggiuntivi ad una classe, metodi che però non sono tipici della classe, ma sono condivisi tra diverse classi.

Un protocollo non è altro che una serie di metodi che una classe deve definire per poter aderire a tale protocollo. Ho già utilizzato, senza dichiararlo esplicitamente, un meccanismo del genere. L'elenco dei metodi che una classe deve fornire perché possa funzionare come sorgente di dati per una NSOutlineView è in effetti un protocollo.

NSCoding

Il protocollo NSCoding deve essere adattato da tutte quelle classi che intendono avvalersi dei servizi automatici di codifica ed archiviazione. In altre parole, se si vogliono utilizzare i servizi standard di salvataggio degli oggetti su file, ogni oggetto che deve essere salvato deve realizzare due metodi specifici:

- (void)encodeWithCoder:(NSCoder *)encoder ;
- (id)initWithCoder:(NSCoder *)decoder ;

Il primo metodo serve per codificare ed archiviare l'oggetto (scriverlo su una base di dati serializzata), il secondo per decodificare l'oggetto (crearlo in base ai dati estratti).

Quando si esegue encodeWithCoder:, l'oggetto deve salvare le proprie variabili d'istanza, codificandole attraverso l'oggetto encoder della classe NSCoder. Pigliamo ad esempio un oggetto LSFileInfo; la definizione del metodo è la seguente:

- (void)encodeWithCoder:(NSCoder *)encoder
{
    [ encoder encodeObject: [ self fileFullPath ] ] ;
    [ encoder encodeObject: [ self fileName ] ] ;
    [ encoder encodeObject: [ self modDate ] ] ;
    [ encoder encodeObject: [ self fgoan ] ] ;
    [ encoder encodeObject: [ self foan ] ] ;
    [ encoder encodeObject: [ self fileType ] ] ;
    [ encoder encodeValueOfObjCType: @encode(long) at: & fileSize];
    [ encoder encodeValueOfObjCType: @encode(long) at: & filePosixPerm];
    [ encoder encodeValueOfObjCType: @encode(long) at: & creatorCode];
    [ encoder encodeValueOfObjCType: @encode(long) at: & typeCode];
}

Normalmente, ogni oggetto comincia il metodo invocando encodeWithCoder: per la superclasse. Qui non accade perché LSFileInfo è un discendente diretto di NSObject, e quindi non ci sono altre variabili d'istanza da salvare. Il metodo poi procede a codificare (in un certo ordine, arbitrario ma determinato) tutte le variabili d'istanza (almeno, quello che sono necessarie alla piena funzionalità dell'oggetto). Poiché molte delle variabili d'istanza sono a loro volta degli oggetti, le prime sei righe codificano direttamente questi oggetti invocando il metodo encodeObject:; il polimorfismo degli oggetti stessi fa poi in modo che per ogni oggetto sia utilizzato il codificatore più appropriato per la classe.

Le ultime quattro righe sono invece un accorgimento per salvare in maniera uniforme anche le variabili d'istanza che oggetti non sono. Allo scopo si usa il metodo encodeValueOfObjCType:at:. Il primo argomento del metodo deve specificare il tipo del dato che si intende codificare. Utilizzo la direttiva al compilatore @encode(.) perché si preoccupi lui stesso di produrre la rappresentazione appropriata. Il secondo argomento è un puntatore al dato che interessa codificare.

Questo metodo deve andare di pari passo con il metodo estrattore. Anzi, se i due metodi non sono uno lo specchio dell'altro, il meccanismo non funziona. Qui è importante che l'ordine di estrazione dei dati sia lo stesso utilizzato per la codifica:

- (id)initWithCoder:(NSCoder *)decoder
{
    [ self setFileFullPath: [ decoder decodeObject ] ];
    [ self setFileName: [ decoder decodeObject ] ];
    [ self setModDate: [ decoder decodeObject ] ];
    [ self setFgoan: [ decoder decodeObject ] ];
    [ self setFoan: [ decoder decodeObject ] ];
    [ self setFileType: [ decoder decodeObject ] ];
    [ decoder decodeValueOfObjCType: @encode(long) at: & fileSize];
    [ decoder decodeValueOfObjCType: @encode(long) at: & filePosixPerm];
    [ decoder decodeValueOfObjCType: @encode(long) at: & creatorCode];
    [ decoder decodeValueOfObjCType: @encode(long) at: & typeCode];
    return self ;
}

Come si può vedere, i due metodi sono molto simili, esattamente speculari.

In realtà, gli oggetti da archiviare all'interno del catalogo non sono di tipo LSFileInfo, ma di tipo FileStruct. Ecco quindi la realizzazione del protocollo per questa classe:

- (void)encodeWithCoder:(NSCoder *)encoder
{
    int dim ;
    // salvo la variabili ereditate
    [ super encodeWithCoder: encoder ];
    // vedo quanti file ci sono
    dim = [ self numOfFiles] ;
    // metto da parte questo numero
    [ encoder encodeValueOfObjCType: @encode(int) at: & dim];
    // se ci sono file dipendenti, li salvo tutti
    if ( dim > 0 )
        [ encoder encodeObject: [ self fileList ]];
}

- (id)initWithCoder:(NSCoder *)decoder
{
    int dim ;
    // recupero le variabili ereditate
    [ super initWithCoder: decoder ];
    // vedo se ci sono file dipendenti
    [ decoder decodeValueOfObjCType: @encode(int) at: & dim];
    if ( dim <= 0 )
        [ self setFileList: nil ]; // non ce ne sono
        // recupero le info di tutti i file contenuti
    else [ self setFileList: [ decoder decodeObject ]];
    return self ;
}

Qui c'è un problema aggiuntivo, dovuto al fatto che un oggetto FileStruct contiene al suo interno un vettore (fileList) di altri oggetti FileStruct. Ora, la variabile fileList è un oggetto della classe NSMutableArray; basta dare un'occhiata alla documentazione relativa per notare che è conforme al protocollo NSCoding. Questo significa che non devo essere io direttamente a preoccuparmi di esaminare tutti gli oggetti presenti nel vettore e dire a ciascuno di archiviarsi, ma è sufficiente invocare il messaggio encodeObject: con argomento il vettore stesso. Poiché la classe aderisce al protocollo, sia occupa lei stessa di salvare il contenuto del vettore, in maniera molto semplice ed efficace per il programmatore. Ho in ogni caso evitato di codificare il vettore se il vettore in realtà non è presente; è questo il motivo del calcolo della dimensione del vettore stesso prima di effettuarne la codifica (perché tutto ciò funzioni, ho dovuto modificare la classe FileStruct, ma ne parlerò dopo...).

Punto di partenza

Rimane da discutere come scatenare tutto il meccanismo di salvataggio/ripristino dei dati. NSCoder è una classe astratta; per fare qualcosa di serio, occorre rivolgersi ad una sua sottoclasse. Per quanto riguarda i file su disco, le classi che mi interessano si chiamano NSArchiver e NSUnarchiver.

L'idea di base è che si archivia un solo oggetto (root) per file. Tuttavia, oggetti dipendenti da questo oggetto root (perché immagazzinati come variabili d'istanza) sono ugualmente salvati all'interno del file. In questo modo, è possibile salvare in un file un intero grafo di oggetti interconnessi. Nel caso del catalogo, la cosa è semplice: l'oggetto root che mi interessa è un NSMutableArray, (è la variabile startPoint dell'oggetto LSDataSource), che, per sua natura, da luogo a tanti alberi di file quanti sono i punti di partenza inserito dall'utente.

Questo fatto mi risparmia un problema: non sempre nel grafo degli oggetti interconnessi gli oggetti sono presenti una volta sola; ad esempio, potrebbero esserci oggetti riferiti in altri oggetti, che a loro volta tornano indietro...cose del genere (in effetti, i più accorti di voi si saranno accorti che nel codice sopra presentato è scomparsa la variabile d'istanza parentDir della classe FileStruct, che puntava proprio all'indietro...ma anche di questo ne parlo poi). La cosa è risolta direttamente da NSArchiver: questa classe tiene conto di tutti gli oggetti incontrati nel processo di attraversamento del grafo di oggetti. La prima volta che un oggetto viene incontrato, è codificato normalmente; incontri successivi con lo stesso oggetto portano non ad una nuova codifica, ma all'inserimento di un riferimento alla codifica precedente.

A questo punto, per salvare un grafo basta archiviare un oggetto root:

[NSArchiver archiveRootObject: mioOggetto toFile: miofile];

e per recuperarlo dal file basta assegnare un oggetto root:

mioNuovoOggetto = [ NSUnarchiver unarchiveObjectWithFile: miofile];

Prima di attaccare queste istruzioni nel punto giusto dell'applicazione, bisogna modificare l'interfaccia.

L'interfaccia

figura 01

figura 01

Ho modificato l'interfaccia dell'applicazione per tenere conto delle nuove possibilità. Via il pulsantone per aggiungere un file, ho deciso per tre pulsanti quadrati, uno per aggiungere file al catalogo, uno per caricare il catalogo salvato su disco, uno per salvare il catalogo su disco. Devo aggiungere una coppia di target/action, ovvero un paio di metodi per la classe controllore, una per effettuare il salvataggio e l'altra per il recupero. Semplifico ulteriormente la NSOutlineView riducendo ancora una volta il numero delle colonne. Da apprezzare il fatto che non cambia la classe LSDataSource.

Salvataggio e recupero

Diventa così possibile concludere la realizzazione delle procedure di salvataggio e di recupero da file dei dati. Per salvare su file, in risposta al clic sul pulsante Save il metodo è il seguente:

- (IBAction)save2File:(id)sender
{
    int        risposta ;
    NSSavePanel    *sPanel = [[ NSSavePanel savePanel ]autorelease ];
    // personalizzo il pannello di salvataggio
    [ sPanel setTitle:@"Salva Catalogo" ];
    [ sPanel setPrompt:@"Determina un file di catalogo" ];
    // i file salvati avranno l'estensione lscat
    [ sPanel setRequiredFileType: @"lscat" ];
    
    risposta = [ sPanel runModal ] ;
    if ( risposta == NSOKButton )
    {
        // recupero il nome del file
        NSString *aFile = [ sPanel filename ] ;
        // archivio l'intera struttura dati
        [NSArchiver archiveRootObject: [dataSource startPoint] toFile: aFile ];
    }
}

Qui, oltre alla chiamata finale a NSArchiver, c'è l'apertura di un dialogo modale di salvataggio file. C'è poca differenza rispetto a quanto visto nei capitoli precedenti relativi all'apertura di un file. Da notare il metodo setRequiredFileType: che gestisce l'estensione dei file prodotti. Se l'estensione non è presente o è diversa da lscat, aggiunge .lscat al nome del file. Inoltre, poiché c'è un solo nome di file possibile come risposta (quello immesso dall'utente), il metodo utilizzato è filename, e restituisce direttamente il path del file.

Per recuperare i dati dal file, bisogna prima richiedere il file, e poi procedere all'operazione:

- (IBAction)loadFromFile:(id)sender
{
    NSOpenPanel    *oPanel = [ NSOpenPanel openPanel ] ;
    int        risposta ;
    // imposto un po' di caratteristiche del dialogo
    [ oPanel setTitle:@"Apri Catalogo" ];
    [ oPanel setPrompt:@"Seleziona un file di catalogo" ];
    [ oPanel setCanChooseDirectories:NO ];
    [ oPanel setCanChooseFiles:YES ];
    [ oPanel setAllowsMultipleSelection:NO ];
    [ oPanel setResolvesAliases:YES ];
    // posso selezionare solo i file che hanno estensione lscat
    risposta = [ oPanel runModalForTypes: [ NSArray arrayWithObjects: @"lscat" ] ];

    if ( risposta == NSOKButton )
    {
        // recupero il nome del file selezionato
        NSArray *filesToOpen = [ oPanel filenames ];
        NSString *aFile = [ filesToOpen objectAtIndex: 0 ];
        // recupero l'intera struttura dati dal file    
        NSMutableArray * data = [NSUnarchiver unarchiveObjectWithFile: aFile ];
        // assegno il tutto alla sorgente di dati
        [ dataSource setStartPoint: data ] ;
        // dico che sono cambiate le cose
        [ outlineView reloadData ];
    }
}

Oltre ad un po' di istruzioni per la personalizzazione del dialogo, da notare come adesso accetto di aprire solo i file aventi l'estensione lscat. Alla fine poi, dopo aver recuperato i dati con NSUnarchiver, assegno i dati come startPoint dell'oggetto dataSource, senza dimenticare di inviare l'invito di rinfrescare i dati a NSOutlineView.

Modifiche e cambiamenti

Ho eseguito alcune modifiche sul vecchio codice, in modo da renderlo più rispondente alle nuove esigenze. La modifica più importante riguarda il codice sentinella (-1) che avevo assegnato alla variabile d'istanza fileList degli oggetti FileStruct, per indicare che il file era effettivo e non una directory. Dopo aver penato un intero pomeriggio con applicazioni che morivano allegramente con errori vari, mi sono reso conto di inviare messaggi a fileList senza controllare che la variabile fosse un effettivo oggetto e non il numero -1. Piuttosto che filtrare questa circostanza (cosa piuttosto noiosa), approfitto del fatto che si possono inviare tutti i messaggi del mondo ad un oggetto di tipo nil (cioè, assolutamente vuoto e nullo), senza che questo si arrabbi. Contestualmente, ho anche modificato il metodo initTreeFromPath: per correggere l'errore segnalato alla fine del capitolo precedente. Il problema è che la variabile fileList è inizializzata subito con una dimensione data dal numero di file contenuti nella directory; in realtà, nel ciclo for successivo, alcuni file sono esclusi da conteggio, per cui spesso il vettore non è completamente riempito. Ciò non inficia la funzionalità dell'applicazione (il metodo count continua a funzionare correttamente), ma c'è un certo spreco di spazio. Ho deciso quindi di mettere i dati in una variabile temporanea, e solo alla fine assegnare il vettore della giusta dimensione a fileList. Il codice spiega tutto:

- (id) initTreeFromPath: (NSString*) fullPath
{
    ++++tutto come prima++++
    if (expandOK && fileOK && isAdir)
    {
        // ok, e' una directory, recupero l'elenco dei file
        NSArray *dirContent = [ fileManager directoryContentsAtPath: fullPath ];
        // vedo quanti file ci sono all'interno
        int numFile = [ dirContent count ];
        // costruisco un vettore destinato a contenere i file e lo assegno
        NSMutableArray *tmpfileList = [[ NSMutableArray alloc ] initWithCapacity: numFile ];
        // adesso, per ogni elemento, lo aggiungo e costruisco l'albero
        for ( i = 0; i < numFile; i++)
        {
            NSString *myfile = [ dirContent objectAtIndex: i ] ;
            // ma solo se lo voglio veramente
            if ( myFileFilterInsert( myfile) )
                [tmpfileList addObject:[
                    [FileStruct alloc]
                        initTreeFromPath:[ fullPath stringByAppendingPathComponent:
                            myfile] ]
                ];
        }
        // adesso copio il vettore con la giusta dimensione
        fileList = [[ NSMutableArray alloc ] initWithCapacity: [ tmpfileList count] ];
        [ fileList setArray: tmpfileList ];
    }
    else
    {
        // va beh, e' un file normale
        fileList = nil ;
    }
    return ( self );
}

La modifica invece del valore sentinella da -1 a nil ha generato altre modifiche; la più importante riguarda il metodo numOfFiles, adesso molto più semplice:

- (int)numOfFiles
{
    return ( [[self fileList] count] ) ;
}

Qui, anche se fileList è nil, il metodo restituisce correttamente 0 come dimensione del vettore.

Infine, ho eliminato la variabile d'istanza parentDir. Ciò è avvenuto in un punto oscuro della storia di questo capitolo, quando non funzionava nulla. Ho pensato che i miei problemi derivassero dal fatto che archiviare l'oggetto parentDir una seconda volta fosse un problema; in realtà, come poi ho scoperto leggendo la documentazione, non c'è problema. Tuttavia, ho notato che la variabile non serviva ad alcuno scopo, ed infatti tutto funziona anche senza di essa. E questo la condanna all'eliminazione (salvo poi scoprire, come credo succederà tra poco, che servirà a qualcosa di utile e fondamentale... è la storia della mia vita, fare, disfare, rifare, mi sento un po' Penelope).

Licenza Creative Commons
Eccetto dove diversamente specificato, i contenuti di questo sito sono rilasciati sotto Licenza Creative Commons.
Pagina a cura di Livio Sandel (macocoa2012@gmail.com).