MaCocoa 021

Capitolo 021 - Preferirei di no

Scopo del capitolo è di costruire un meccanismo (finestra e messaggistica) per l'impostazione di un sistema di preferenze.

Sorgenti: Documentazione ed esempi di Apple.

Primo inserimento: 2 settembre 2002

Il codice di questo capitolo sarà stravolto successivamente.

Le Preferenze

Il problema lasciato in sospeso il capitolo precedente era di poter determinare il comportamento della NSOutlineView quando si tratta di espandere i bundle e di visualizzare i dotFiles. Piuttosto che determinare tale comportamento direttamente all'interno del programma, preferisco fare in modo che sia l'utente stesso a decidere, utilizzando una finestra di Preferenze.

In primo luogo, scopro che le Preferenze, nel MacOSX, sono memorizzate in un file di banale testo. I documenti che raccolgono le preferenze, diversi per ogni utente, sono raccolti nella directory ~/Library/Preferences (ricordo che la tilde rappresenta di directory principale dell'utente, nel mio caso /Users/djzero00). Tali documenti si presentano sotto forma di documento XML (di cui ignoro al momento natura, sintassi e semantica). Tuttavia, pragmaticamente, scopro che esiste un programma, il Property List Editor, presente in qualità di tool di sviluppo (nella directory /Developer/Applications) che fa proprio al caso mio.

A partire da un certo punto in poi (che mi sono perso, ma potrebbe essere Tiger), i file delel preferenze non sono più sotto forma testuale, ma sono diventati file binari. L'applicazione Property List Editor continua a leggerli tranquillamente, ma non è più possibile aprirli con un normale editor di testo (o meglio, si possono aprire, ma non ci si capisce alcunché).

figura 02

figura 02

Aprendo a caso un file di preferenze (Calcoaltrice, ad esempio), noto che il testo prima pieno di tag, codici ed altri numeri bizzarri, risulta adesso molto più strutturato (ed ancora abbastanza incomprensibile, ma essendo la prima volta che vedo un file di preferenze la cosa non mi rattrista). Una cosa da notare è il nome del file delle preferenze, che ricorda un po' un indirizzo internet a rovescio (nel caso com.apple.calculator.plist).

Per gestire il meccanismo delle preferenze, Cocoa mette a disposizione una classe, NSUserDefaults, che svolge la maggior parte del lavoro. Andando a vedere la documentazione relativa, esiste una lunga spiegazione di quali sono i valori di default, come sono cercati nei vari database, eccetera.

Alla prova dei fatti, le cose sono in realtà piuttosto semplici. Basta una istruzione:

NSUserDefaults     *defaults = [NSUserDefaults standardUserDefaults];

Con questa, costruisco un oggetto della classe NSUserDefaults che gestisce il meccanismo. Da quest'oggetto, attraverso metodi appositi, sono in grado di leggere, scrivere e gestire in generale le varie preferenze presenti. Ora, una preferenza può essere un oggetto di una di queste classi: NSData, NSString, NSNumber, NSDate, NSArray, or NSDictionary; ogni oggetto è identificato da una chiave (una specie di dizionario, insomma). Di più, ci sono metodi di convenienza per leggere e scrivere numeri interi, floating point, booleani, vettori, per cancellare un elemento, eccetera. Tutto molto semplice; la cosa più complicata è gestire l'intera faccenda all'interno del programma.

Infatti, all'interno del programma esistono diversi insiemi concettualmente differenti di Preferenze (ho definito degli oggetti della classe NSDictionary). Il primo insieme di preferenze, che chierò defFile, è quello stabilito dal file delle preferenze; c'è poi l'insieme defCode, ovvero i valori stabiliti direttamente dal codice (quelli attivi in assenza del file di preferenze, o la prima volta che è eseguito il programma da un nuovo utente...). I valori defCode sono ovviamente sovrascritti da defFile, se all'interno delle Preferenze sono appunto presenti nuovi valori.

Poi abbiamo due insiemi più volatili: l'insieme defCurr contiene i valori correnti delle Preferenze; l'insieme defDisp i valori delle Preferenze in corso di manipolazione dall'utente.

All'inizio dei tempi, all'insieme defCurr sono assegnati i valori di defCode; poi utilizzo il meccanismo fornito da NSUserDefaults per ricavare i valori di defFile e sovrascrivere i corrispondenti campi di defCurr. Ad un certo punto, l'utente aprirà la finestra per la gestione delle preferenze. In quel momento assegno a defDisp i valori di defCurr, e con defDisp mostro la finestra all'utente. Quando costui gioca con la finestra, modifica i valori di defDisp, fino a che non decide cosa fare. La scelta è ridotta a tre possibilità:

1. l'utente decide che i valori impostati vanno bene e da l'ok alla finestra; in tal caso i valori di defDisp sovrascrivono defCurr e contemporaneamente sono aggiornati i valori di defFile.

2. l'utente decide di aver pasticciato abbastanza e vuole tornare ai valori di default precedenti: su defDisp sono nuovamente copiati i valori di defCurr (e la finestra delle preferenze aggiornata di conseguenza).

3. l'utente ha pasticciato abbastanza e decide di lasciar perdere, chiudendo la finestra senza apportare modifiche. I valori di defDisp sono buttati via, e quelli di defCurr non hanno subito mutamenti.

La finestra e la classe

figura 03

figura 03

Detto questo, passo a descrivere la finestra delle preferenze: ci sono due pulsanti, uno per selezionare l'espansione dei bundle e l'altro per la visualizzazione del dotFiles. Per mia istruzione, ho aggiunto uno slider senza ragione pratica, ma giusto per aggiungere qualcosa ad una finestra altrimenti piuttosto spoglia ed un valore non booleano al sistema di preferenze.

Tutto ciò giustifica questo primo pezzo di codice, in cui sono presenti le variabili d'istanza ed alcuni metodi interessanti della classe Preferences:

@interface Preferences : NSObject
{
    IBOutlet NSButton    *expandBundleButton;
    IBOutlet NSButton    *dummy1Button;
    IBOutlet NSSlider    *dummy2Slider;

    // Current, confirmed values for the preferences: def-curr
    NSDictionary         *defCurrValues;
    // Values read from preferences at startup: def-code + def-file
    NSDictionary         *defFileValues;        
    // Values displayed in the UI: def-disp
    NSMutableDictionary     *defDispValues;    
}


- (void) userSelectUpdate:(id)sender;
- (void) userSelectRestore:(id)sender;
- (void) userSelectCancel:(id)sender;
+ (Preferences *)sharedInstance;
- (void)showPanel:(id)sender;

I tre outlet servono per accedere ai vari elementi dell'interfaccia, per impostare e leggere il valore; i primi tre metodi sono le tre Action corrispondenti ai tre pulsanti 'ok', 'Restore defaults' e 'cancel'.

Per comodità ho poi definito poi tre chiavi di accesso alle preferenze

#define     keyExpandBundle     @"ExpandBundle"
#define     keyShowDotFiles     @"ShowDotFiles"
#define     keyDummy02         @"Dummy02"

Tuttavia, la prima cosa da fare, è aprire la finestra. In Interface Builder ho quindi aperto il file principale MainMenu.nib e mi sono posto il problema di come collegare la voce Preferences del menu dell'applicazione. Al contrario della palette, che può essere aperta solo in presenza di una finestra di catalogo (il che spiega il collegamento al First Responder della voce), qui ho fatto una cosa diversa (non ho inventato nulla, ho guardato l'esempio TextEdit presente nella cartella /Developer/Examples): ho aggiunto un'istanza della classe Preferences direttamente in IB. In questo modo ho collegato la voce di menu direttamente all'istanza di Preferences invocando l'action ShowPanel. In sede di inizializzazione, infatti, faccio in modo che sia costruita ma non visualizzata la finestra, che poi appare e scompare come comandato dall'utente. La presenza di una istanza unica condivisa dall'applicazione, e l'uso di metodi (falsamente) di classe fanno in modo che la finestra sia unica.

(Il meccanismo di gestione della finestra sarà profondamente modificato in un capitolo successivo).

Lettura e scrittura delle preferenze

Ho quindi il metodo di inizializzazione:

// metodo di inizializzazione (che legge i defaults)
- (id) init
{
    // mantengo unica la finestra: se l'ho gia' creata, faccio nulla
    if (sharedInstance)
    {
        [self dealloc];
    }
    else
    {
        // costruisco l'istanza delle preferenze
        [super init];
        // carico le preferenze dal file
        defCurrValues = [[[self class] preferencesFromDefaults] copyWithZone:[self zone]];
        // sono sia def-curr che def-file
        defFileValues = [defCurrValues retain];
        // assegno gli stessi valori anche a def-disp
        [self discardDisplayedValues];
        sharedInstance = self;
        // inizializzo queste due variabili che mi servono poi
        lsPrefsYes = [[NSNumber alloc] initWithBool:YES];
        lsPrefsNo = [[NSNumber alloc] initWithBool:NO];
    }
    return sharedInstance;
}

La parte interessante è il caricamento dei defaults dal file apposito. Il metodo è di classe, giusto per mantenere unico il punto di accesso al file delle preferenze:

+ (NSDictionary *)
preferencesFromDefaults
{
    // pesco il file dei defaults e costruisco un dizionario
    NSUserDefaults     *defaults = [NSUserDefaults standardUserDefaults];
    NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithCapacity:10];
    // inserisco il booleano che dice se espandere o meno i bundle
    readBoolDefault( dict, defaults, keyExpandBundle );
    // booleano per mostrare o meno i dotFiles
    readBoolDefault( dict, defaults, keyShowDotFiles );
    // un dummy intero
    readIntDefault( dict, defaults, keyDummy02 );

    return dict;
}

dove sono utilizzate le due funzioni accessorie seguenti:

void
readBoolDefault ( NSMutableDictionary * locDict, NSUserDefaults * locDef, id key )
{
    id    obj ;
    // vedo se nei defualts c'e' un valore per la chiave
    obj = [ locDef objectForKey: key] ;
    // se c'e'
    if ( obj )
    {
        // recupero dal dizionario il booleano, poi lo converto in un NSNumber
        // per reinfilarlo nel dizionario
        [locDict setObject: [NSNumber numberWithBool:[locDef boolForKey:key]] forKey:key] ;
    }
    else
    {
        // recupero dal dizionario def-code il valore e lo metto del dzionario
        [locDict setObject: [defaultValues() objectForKey:key] forKey:key] ;
    }
}
void
readIntDefault ( NSMutableDictionary * locDict, NSUserDefaults * locDef, id key )
{
    id    obj ;
    // vedo se nei defaults c'e' un valore per la chiave
    obj = [ locDef objectForKey: key] ;
    // se c'e'
    if ( obj )
    {
        // recupero dal dizionario il booleano, poi lo converto in un NSNumber
        // per reinfilarlo nel dizionario
        [locDict setObject: [NSNumber numberWithInt:[locDef integerForKey:key]] forKey:key] ;
    }
    else
    {
        // recupero dal dizionario def-code il valore e lo metto del dzionario
        [locDict setObject: [defaultValues() objectForKey:key] forKey:key] ;
    }
}

Da notare il codice per la lettura del default (vorrei che apprezzaste il fatto che stavolta il codice si dilunga, costruisce variabili intermedie, è commentato, insomma è più leggibile, anche per me): se all'interno di defFile c'è l'oggetto corrispondente ad una data chiave, viene utilizzato tale valore; altrimenti, si pesca da un dizionario inserito direttamente nel codice:

static NSDictionary *defaultValues() {
    static NSDictionary *dict = nil;
    if (!dict) {
    dict = [[NSDictionary alloc] initWithObjectsAndKeys:
        [NSNumber numberWithBool:NO], keyExpandBundle,
        [NSNumber numberWithBool:NO], keyShowDotFiles,
        [NSNumber numberWithInt:50], keyDummy02,
        nil];
    }
    return dict;
}

Totalmente simmetrici sono i metodi e le funzioni per la scrittura di questi default:

+ (void)
savePreferencesToDefaults:(NSDictionary *)dict
{
    // recupero il file dei defaults
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    // inserisco ordinatamente i vari elementi
    writeBoolDefault( [[self sharedInstance] getPreferences], defaults, keyExpandBundle );
    writeBoolDefault( [[self sharedInstance] getPreferences], defaults, keyShowDotFiles );
    writeIntDefault( [[self sharedInstance] getPreferences], defaults, keyDummy02 );
}

void
writeBoolDefault( NSDictionary * locDict, NSUserDefaults * locDef, id key )
{
    // se il valore def-code e' lo stesso del valore def-file
    if ( [ [defaultValues() objectForKey: key] isEqual: [locDict objectForKey: key] ] )
    {
        // tolgo addirittura il valore dal file;
        // in questo modo lo tengo pulito e conciso
        [locDef removeObjectForKey: key];
    }
    else
    {
        // altrimenti, lo scrivo nel file
        [locDef setBool:[[locDict objectForKey: key] boolValue] forKey: key];
    }
}

void
writeIntDefault( NSDictionary * locDict, NSUserDefaults * locDef, id key )
{
    // se il valore def-code e' lo stesso del valore def-file
    if ( [ [defaultValues() objectForKey: key] isEqual: [locDict objectForKey: key] ] )
    {
        // tolgo addirittura il valore dal file;
        // in questo modo lo tengo pulito e conciso
        [locDef removeObjectForKey: key];
    }
    else
    {
        // altrimenti, lo scrivo nel file
        [locDef setInteger:[[locDict objectForKey: key] boolValue] forKey: key];
    }
}

Qui ho aggiunto la complicazione inutile di eliminare una voce dal defFile nel caso in cui coincida con il valore stabilito direttamente dal codice. In questo modo il file delle preferenze si mantiene più piccolo e conciso, e solamente con i valori differenti da quelli stabiliti dal programmatore con coscienza.

Le azioni

Adesso sono comprensibili i tre metodi 'action' in corrispondenza della scelta dell'utente:

- (void) userSelectUpdate:(id)sender
{
    // devo recuperare i valori dalla finestra
    [defDispValues setObject:
        ([expandBundleButton state] ? lsPrefsYes : lsPrefsNo)
        forKey:keyExpandBundle];
    [defDispValues setObject:
        ([dummy1Button state] ? lsPrefsYes : lsPrefsNo)
        forKey:keyShowDotFiles];
    [defDispValues setObject:
        ([NSNumber numberWithInt:[ dummy2Slider intValue ]])
         forKey:keyDummy02];
    // recuperati i valori; li assegno ai correnti
    [ self commitDisplayedValues ] ;
    // salvo le preference su file
    [ Preferences saveDefaults ];
    // e per finire nascondo la finestra
    [[expandBundleButton window] setIsVisible: FALSE ];
}

Qui leggo lo stato dei tre controlli all'interno della finestra, uso commitDisplayedValues per aggiornare i valori, salvo i valori nel file e nascondo la finestra.

- (void)commitDisplayedValues
{
    if (defCurrValues != defDispValues)
    {
    [defCurrValues release];
    defCurrValues = [defDispValues copyWithZone:[self zone]];
    }
}

Più concisa la situazione in caso di restore:

// scelta restore sulla finestra: rileggo le prefs
- (void) userSelectRestore:(id)sender
{
    [ self discardDisplayedValues ] ;
}

dove tutto il lavoro è svolto dal metodo discardDisplayedValues :

- (void) discardDisplayedValues
{
    if (defCurrValues != defDispValues)
    {
        [defDispValues release];
        defDispValues = [defCurrValues mutableCopyWithZone:[self zone]];
        [self updatePrefWindow];
    }
}

Infine il metodo associato al pulsante Cancel semplicemente butta via i valori eventualmente aggiornati dall'utente (e poi chiude la finestra):

- (void) userSelectCancel:(id)sender
{
    if (defCurrValues != defDispValues)
    {
        [defDispValues release];
        defDispValues = [defCurrValues mutableCopyWithZone:[self zone]];
    }
    [[expandBundleButton window] setIsVisible: FALSE ];
}

I due metodi che interagiscono direttamente con la finestra sono i seguenti:

- (void)
updatePrefWindow
{
    // il solito trucco per vedere se la finestra c'e'
    if (!expandBundleButton) return ;
    // recupero il valore, ed imposto il bottone di conseguenza
    [expandBundleButton setState:[[defDispValues objectForKey:keyExpandBundle] boolValue] ? 1 : 0];
    [dummy1Button setState:[ [defDispValues objectForKey:keyShowDotFiles] boolValue] ? 1 : 0 ] ;
    [dummy2Slider setIntValue:[ [defDispValues objectForKey:keyDummy02] intValue ] ];
}

Per aggiornare la finestra, piglio i valori di defDisp (quelli da visualizzare!) ed imposto lo stato dei controlli di conseguenza.

Invece, per mostrare la finestra, distinguo se si tratta della prima volta o meno:

- (void)
showPanel:(id)sender
{
    // infame trucco: se la finestra non e' stata creata, l'outlet
    // non e' stato assegnato...
    if (!expandBundleButton)
    {
        // carico la finestra usando il nome del nib-file
        if (![NSBundle loadNibNamed:@"prefsPane" owner:self])
        {
            // sono in errore... forse dovrei fare uqalcosa di meglio...
            NSLog(@"Failed to load Preferences.nib");
            NSBeep();
            return;
        }
        // non voglio che compaia nella lista delle finestre
        [[expandBundleButton window] setExcludedFromWindowsMenu:YES];
        // questo, lo ignoro
        [[expandBundleButton window] setMenu:nil];
        // inserisco i def-curr nella finestra
        [self updatePrefWindow];
        // la piazzo al centro dello schermo
        [[expandBundleButton window] center];
    }
    // porto la finestra davanti a tutti
    [[expandBundleButton window] makeKeyAndOrderFront:nil];
}

Mancano un po' di metodi aggiuntivi di servizio, che vi lascio esaminare con comodo.

L'uso delle Preferenze

A questo punto, il meccanismo delle preferenze è attivo e funzionante; occorre semplicemente utilizzarlo. Per comodità, c'è il seguente metodo (fintamente) di classe

+ (id)
objectForKey:(id)key
{
    return [[[self sharedInstance] getPreferences] objectForKey:key];
}

che utilizza questo metodo accessorio:

- (NSDictionary *)
getPreferences
{
    return defCurrValues;
}

In questo modo, per accedere ad un valore di preferenze, devo semplicemente scrivere qualcosa del tipo:

[Preferences objectForKey:keyDummy02] intValue]

per avere bello pronto il valore richiesto.

Devo solo riscrivere alcuni metodi della classe LSDataSource, con un codice che si spiega da sé:

- (int)    
outlineView:            (NSOutlineView *)outlineView
    numberOfChildrenOfItem:    (id)item
{

    // se l'item e' nil, stiamo parlando della radice
    if (item == nil)
        // dico quindi che ci sono tanti elementi quanti presenti nel vettore
        return( [ [self startPoint] count ]);
    // se arrivo qui, mi si chiede quali figli ha un file
    // tratto subito i file normali, che non hanno figli
    if ( [item numOfFiles] == 0 )
        return ( 0 ) ;
    // se arrivo qui, ho una directory
    // se devo mostrare anche i dotfiles, conto tutto
    if ( [[Preferences objectForKey:keyShowDotFiles] boolValue] )
        return ( [item numOfFiles] ) ;
    // se arrivo qui, devo eliminare dal computo i dotFiles
    // ed alora, mi tocca esaminare tutti i file e contare quelli
    // che mi interessano
    return ( countNormalFiles( item) ) ;
}


- (BOOL)
outlineView:            (NSOutlineView *)outlineView
    isItemExpandable:    (id)item
{    
    if (item == nil)
        return ( YES ) ;
    // se non ha figli, e' un file, non si espande
    if ( [item numOfFiles] == 0 )
        return ( NO ) ;
    // se arrivo qui, l'item ha figli
    // se devo espandere anche i bundle, espando tutto
    if ( [[Preferences objectForKey:keyExpandBundle] boolValue] )
        return ( YES ) ;
    // se arrivo qui, non devo espandere i bundle
    // espando allora i non bundle
    return (! checkIfBundleDirectory([ item fileName ]) ) ;
}


- (id)
outlineView:        (NSOutlineView *) outlineView
    child:        (int) index
    ofItem:        (id) item
{
    if (item == nil)
        return( [ [self startPoint] objectAtIndex: index ]);
    // se visualizzo anche i dotFiles, non c'e' problema
    if ( [[Preferences objectForKey:keyShowDotFiles] boolValue] )
        return ([item getFileAtIndex:index]);
    // altrimenti, e' un bel pasticcio, devo saltare i dotfiles
    return ( getNormalFile ( item, index ) );
}

figura 04

figura 04

Rimane solo da identificare correttamente il file delle preferente. Per farlo, uso Project Builder, Tab principale Target, tab secondario Application Setting, campo Identifier, al quale ho attribuito il valore it.djzero00.mc020. Così facendo, lanciando l'applicazione, compare come per magia il file dallo stesso nome all'interno della directory delle preferenze utente.

I file sono tutti più o meno nuovi, nel senso che ci sono state ampie modifiche ai vecchi file ereditati dalle precedenti puntate. Ho preferito infatti rimodellare, ricommentare e ripulire i file con l'idea di rinfrescarmi la memoria. Non ci sono grandi e sostanziali modifiche; tuttavia, vi consiglio di ripartire anche voi da questi file, sia di codice che di interfaccia (nib).

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).