Når man programmerer apps er det selvsagt centralt, at det sker i overensstemmelse med det, brugeren forventer.

Det omfatter:
– En lynhurtig opstartstid, især hvis han har haft app’en åben lige før.
– Alt er ‘åbent og klar til brug’ – altid.
– Brugeren forventer at kunne fortsætte hvor han slap sidst
– Brugeren forventer at kunne installere hundredvis af apps
– Brugeren skal ikke generes af detaljer om hukommelsesstyring, såsom at skulle lukke programmer efter brug

For at understøtte disse forventninger vil Android-styresystemet på den ene side forsøge at holde så mange programmer i hukommelsen så længe som muligt, så de ikke behøver at blive indlæst igen, men på den anden side skal det kunne smide en app ud af hukommelsen uden varsel.

Disse vilkår giver programmørerne nogle ekstra udfordringer, for de har ingen kontrol over, hvordan deres app lukkes og kun lidt kontrol over, hvordan den startes – og samtidig kan der være meget stærkt svingende adgang til internettet.

Det siger sig selv, at brugerens oplevelse forringes væsentligt, hvis en app ikke tager højde for dette. Selvom artiklen herunder bruger en del ord som kun Android-udviklere kender, er den skrevet så andre Android-brugere også kan få et indblik i, hvad der foregår, og hvordan udviklerne burde have løst det.

Arkitekturen i Android-platformen

På grund af arkitekturen i Android – specielt at applikationerne ikke selv har kontrol over, hvornår og hvordan de lukkes ned og startes op – skal man skelne mellem den applikation, som brugeren oplever og den (Linux-)proces, som app’en kører i. Brugeren kan skifte væk fra app’en og hoppe tilbage igen og forventer at kunne tage den i brug fra det punkt, hun forlod den.

For at kunne understøtte en optimal brugeroplevelse, vil Android beholde så mange processer (med hver sin JVM) i hukommelsen, som der er plads til og så, når der er brug for mere hukommelse, fjerne (dræbe) den proces der vurderes som mindst vigtig for brugeroplevelsen.

Den dræbte proces får ikke mulighed for at gemme data eller rydde op, for oprydning ville forsinke den proces, der har brug for hukommelsen (typisk det program brugeren er i gang med). I stedet forventes det at Android-programmer rydder op og gemmer data, når de ikke mere er synlige, dvs. efter at onStop() er kørt færdigt (i Android 2.3.3 og tidligere kan det ske allerede efter onPause()).

Brugeren kan, ved at holde HJEM-knapppen nede, vælge mellem de senest brugte applikationer og ved ikke, at nogle af dem muligvis har fået dræbt deres proces. Vælger brugeren en af de apps, der er smidt ud af hukommelsen, vil Android starte en ny process (og en ny JVM) og genskabe applikationens tilstand.

Teknisk set sker det ved at Android kalder onCreate() på den forreste aktivitet (skærmbillede) i applikationen og giver den et Bundle med den gemte tilstand fra forrige gang (savedInstanceState). De parametre (intent’et), som den oprindelige aktivitet blev oprettet med, bliver også genskabt.

En måde at undgå at blive dræbt på er ved at starte en service. Da vil Android forsøge at undgå at slå processen ihjel, og, hvis f.eks. den aktive proces i forgrunden eller en af de andre processer med services kræver meget hukommelse, genstarte processen og servicen hurtigst muligt. Det siger sig selv, at det ikke giver nogen god brugeroplevelse, hvis der er for mange apps, der benytter sig af services.

Har man levende ikoner på hjemmeskærmen eller lytter efter broadcasts, bliver processen startet op fra tid til anden, men kan blive lukket lige så hurtigt igen, når broadcastet er blevet behandlet.

Og har man implementeret en content provider, kan ens process blive startet, når som helst nogen forespørger på de data, som app’en udbyder.

Et Androidprogram kan altså blive startet på en lang række måder, og den første stump programkode der køres kan derfor være meget forskellig afhængig af situationen.

Hvornår kan man risikere at starte i en frisk JVM?

Har man nogle data i hukommelsen, som man er afhængig af, skal man derfor tjekke for, om man kører på en frisk JVM, og data skal genindlæses/genskabes. Gør man det ‘lidt efter lidt’, når problemer opstår, vil man ende med et uoverskueligt program, for man kan risikere at starte op i en frisk JVM på et utal af måder.

Måderne er

  • Brugeren starter programmet fra hjemmeskærmen (eller på en anden måde)
    • Dvs. i alle de mulige start-skærmbilleder (aktiviteter med et <intent-filter>)
  • Brugeren holder HJEM nede og vælger programmet mellem de seneste brugte programmer
    • Dvs. der bør også være tjek i alle de interne aktiviteter i programmet
      (medmindre Android aldrig vil starte dem op som de første fordi man ikke kan skifte tilbage til dem – det styrer man i manifestet hvor man bl.a. kan sætte android:noHistory=”true” eller android:launchMode=”singleTask”)
  • Alle andre indgangspunkter til programmet, dvs.
    • Alle services (inkl. levende baggrunde)
    • Alle broadcast receivers (inkl. alle levende ikoner)
    • Alle content providers

Man kan derfor, medmindre man tænker sig om, ende i en uoverskuelig suppedas af kopieret initialiseringskode til at holde styr på, om data er indlæst.

Designmønstret Singleton

En Singleton er en klasse, som der må være én og kun én instans (objekt) af. Singleton betyder egentlig enkeltfødt og designmønsteret anvendes, når man vil centralisere: Når der skal være præcist én instans af en bestemt klasse, og resten af programmet altid bruger dette ene objekt.

Singletons bruges ofte til at repræsentere resurser, eller data som i deres natur kun skal eksistere én gang. Det kan for eksempel være stamdata i et program, en resurse med begrænset adgang eller en systemresurse, såsom telefoni eller wifi-manageren.

I større projekter praktiserer man forskellige trick for at sikre, at en klasse forbliver en singleton, oftest med indkapsling, en privat konstruktør og en dedikeret fabrikeringsmetode:

package eks.livscyklus;
public class SingletonFuld {
    private Programdata programdata = new Programdata();
    private static SingletonFuld instansen;

    public static SingletonFuld getInstans() {
        if (instansen==null) {
            instansen = new SingletonFuld();
        }
        return instansen;
    }
    
    public Programdata getProgramdata() {
        return programdata;
    } 
}

Instansen (og programdata) bliver oprettet første gang instansen hentes, i onCreate():

SingletonFuld.getInstans().getProgramdata()

 

I mindre projekter – herunder en middelstor Android-app med f.eks. 30-40 klasser – kan man godt forsvare at droppe indkapslingen og bare bruge en klassevariabel.

package eks.livscyklus;

public class SingletonSimpel {
  public Programdata programdata = new Programdata();
  public static SingletonSimpel instans = new SingletonSimpel();
}

En forskel her er, at instansen og programdata bliver oprettet ved indlæsning af klassen, dvs før programmet startes

SingletonSimpel.instans.programdata

 

Ofte har man under initialiseringen brug for at tilgå Android-systemet, f.eks. for at få indlæst data fra filsystemet eller resurserne. Her kommer begge metoder til kort, da de ikke har adgang til et Context-objekt.

Kunne man så ikke løse det ved, i onCreate()-metoden i hovedaktiviteten, at tjekke for at instansen er indlæst? Her har vi et Context-objekt, vi kan bruge til initialiseringen. Det gjorde vi i DR Radio på denne måde:

public class Afspilning_akt extends Activity {
 private DRData drdata;
 ...
 @Override
 public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
 ...
 try {
    drdata = DRData.tjekInstansIndlæst(this);
 } catch (Exception ex) {
    // Popop-advarsel til bruger om intern fejl 
    //og rapporter til udvikler-dialog
    Log.kritiskFejl(this, ex);
    return;
 }

Men, som vi så i forrige afsnit, så skal dette tjek ske i stort set alle aktiviteter/services/reveicers i dit program, før du kan være sikker på, at instansen er indlæst.

Trådsikkerhed og singletons

Drevne programmører vil sikkert bemærke, at SingletonFuld ikke er implementeret med trådsikkerhed for øje. Det er den ikke, fordi det simpelt hen ikke er nødvendigt i langt de fleste tilfælde: Alle programmer kører som udgangspunkt i én tråd, og der er derfor ikke behov for trådsikker programmering.

Hvis du selv opretter andre tråde, for eksempel i form af AsyncTasks, så vent at starte dem til efter at din singleton er initialiseret.

Hvis initialiseringen tager lang tid (herunder hvis den omfatter nogen form for netværkskommunikation), så bør det ske asynkront. I så tilfælde kan du f.eks. lave et statusflag, der fortæller, hvor langt initialiseringen er kommet og f.eks. sende et broadcast, når initialisering er færdig (din receivers onReceive()-metode vil blive kaldt fra GUI-tråden, selvom du sender broadcastet fra en anden tråd).

Bruge Application-objektet som singleton

Ønsker man at samle sin initialisering ét sted, stiller Android en facilitet til rådighed for dette, nemlig Application-objektet.

 

Android applikation objektet

 

 

Når processen og JVM’en startes op instantieres Application-objektet én gang og får kaldt onCreate(). Dette sker, stort set uanset på hvilken måde app’en startes op, og før nogen metoder kaldes i nogen aktiviteter, services eller broadcast receivers.

Application-objektet bliver i hukommelsen, så længe processen lever.

Herunder bruger vi Application-objektet til at gemme en instans af SharedPreferences og til at initialisere en hjælpeklasse, nemlig en cache af filer hentet over netværket (der behøves viden, hvor de cachede filer skal ligge – her vælger vi getCacheDir() som giver app’ens private cache-mappe).

package eks.livscyklus;

import android.app.Application;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.util.Log;
import eks.youtube.FilCache;

/**
 * Her kan foretages fælles initialisering.
 * Resten af programmet bliver først initialiseret efter at objektet og
 * kaldet til metoden onCreate() er afsluttet, så det er vigtigt kun at
 * udføre de allermest nødvendige ting her.
 */
public class MinApplicationSingleton extends Application
{
  static MinApplicationSingleton instans = this;

  /** Data kunne lige så godt være gemt i en klassevariabel andetsteds */
  public static SharedPreferences prefs;
 
  @Override
  public void onCreate() {
    Log.d("MinApplicationSingleton", "onCreate() kaldt");
    super.onCreate();

    // Husk instansen, så den kan findes frem med 
    // MinApplicationSingleton.instans
    instans = this;

    // Initialisering der kræver en Context (som endnu ikke er tilgængelig 
    // i konstruktøren)
    prefs = PreferenceManager.getDefaultSharedPreferences(this);
    // Initialisering af hjælpeklasser, f.eks. mappen som en cache af filer
    // hentet over netværket behøver, er en fin ide at lægge her, for 
    //ellers skal tjek for denne initialisering ske i alle de aktiviteter,
    //services og receivers der er afhængig af hjælpeklasserne
    FilCache.init(this.getCacheDir());
  }
}

 

Husk at klassen, som skal instantieres, skal defineres i AndroidManifest.xml:

<application android:name="eks.livscyklus.MinApplicationSingleton">
  <activity ...

 

Opstart og responsivitet

Ofte ønsker man at tjekke for opdateringer over netværket. I det følgende vil jeg bruge eksempler fra DR Radio, som jeg blev involveret i via Lund&Bendsen for at rådgive om og programmere.

Man har en liste over kanaler i DR Radio og for hver kanal den URL, hvor lyden skal hentes fra. Disse stamdata ændrer sig sjældent, måske hver 3. måned. DR Radio kan ikke fungere uden disse stamdata, hvorfor man i tidlige versioner indlæste dem over netværket, hver gang programmet blev startet, til irritation for brugerne der måtte vente 1/2-5 sekunder ekstra ved hver opstart.

At programmer er langsomme til at starte giver en dårlig oplevelse, langt dårligere end hvis de starter op og bare viser et eller andet, som øjet kan bruge til at orientere sig med.

Hurtigstart med gamle data

Ofte viser det sig, at man – til gengæld for en langt bedre brugeroplevelse – godt kan leve med at programmet kortvarigt bruger en ældre udgave af data under opstart. I DR Radios tilfælde kan nye kanaler sagtens vente et par sekunder med at dukke op.

I så tilfælde kan man lægge data med i installationen af programmet (f.eks. i resursemappen res/raw) og, hver gang data bliver hentet over netværket, gemme en kopi (som fil eller som et felt i Preferences). Ved programstart indlæser man først en gammel udgave (en gammel kopi eller evt fra res/raw) og starter op. Først derefter hentes friske data i baggrunden. Når de er hentet opdateres brugergrænsefladen med de friske data.

Herunder ses hvordan det er gjort i DR Radio.

public class DRData {

  public static DRData instans;
  public static Context appCtx;
  public static SharedPreferences prefs;

  private static final int stamdataID = 24 ;
  private static final String stamdataUrl = 
				"http://www.dr.dk/tjenester/iphone/"
				+ "radio/settings/android24.drxml";

  public Stamdata stamdata;
  /** Bruges til at sende broadcasts om nye stamdata 
  */	
  public static final String OPDATERINGSINTENT_Stamdata = 
			"dk.dr.radio.afspiller.OPDATERING_Stamdata";

...

  public static synchronized DRData tjekInstansIndlæst(Context akt) 
    throws IOException, JSONException {
    appCtx = akt.getApplicationContext();
    if (instans == null) {
      prefs = PreferenceManager.getDefaultSharedPreferences(akt);

      // indlæs stamdata fra Prefs hvis de findes
      String stamdatastr = prefs.getString(STAMDATA, null);

      if (stamdatastr == null) {
        // Indlæs res/raw/stamdata_android24.json hvis vi ikke har nogle 
	// cachede stamdata i prefs
        InputStream is = 
	     akt.getResources().openRawResource(R.raw.stamdata_android24);
        stamdatastr = JsonIndlaesning.læsInputStreamSomStreng(is);
      }

      instans = new DRData();
      instans.stamdata = JsonIndlaesning.parseStamdata(stamdatastr);
    }

    // Start indlæsning i baggrunden
    baggrundstråd.start();

    return instans;
  }

  final Thread baggrundstråd = new Thread() {
    @Override
    public void run() {
      try {
        tjekForNyeStamdata();
      } catch (Exception ex) { Log.e(ex); }
    }
  };

 /**
  *  Tjek om en evt ny udgave af stamdata skal indlæses
  */
  private void tjekForNyeStamdata() {
    final String STAMDATA_SIDST_INDLÆST = "stamdata_sidst_indlæst";
    long sidst = prefs.getLong(STAMDATA_SIDST_INDLÆST, 0);
    long nu = System.currentTimeMillis();
    long alder = (nu - sidst)/1000/60;
    if (alder>= 30) try { // stamdata er ældre end en halv time
      Log.d("Stamdata er "+alder+" minutter gamle, opdaterer dem...");
      // Opdater tid (hvad enten indlæsning lykkes eller ej)
      prefs.edit().putLong(STAMDATA_SIDST_INDLÆST, nu).commit();

      String stamdatastr  = JsonIndlaesning.hentUrlSomStreng(stamdataUrl);
      final Stamdata stamdata2 = 
			JsonIndlaesning.parseStamdata(stamdatastr);
      // Hentning og parsning gik godt - vi gemmer den nye udgave i prefs
      prefs.edit().putString(STAMDATA, stamdatastr).commit();

      handler.post(new Runnable() {
        public void run() {
          // Al opdatering, herunder tildeling bør ske i GUI-tråden 
	  //for at undgå at GUIen er i gang med at bruge objektet 
	  //mens det opdateres
          stamdata = stamdata2;

          // Send broadcast om at stamdata er opdateret
          appCtx.sendBroadcast(new Intent(OPDATERINGSINTENT_Stamdata));
        }
      });
    } catch (Exception e) {
      Log.e("Fejl parsning af stamdata. Url="+stamdataUrl, e);
    }
  }
...

(her bruges Thread i stedet for AsyncTask så udviklere uden kendskab til Android lettere kan følge med)

Den fulde kildekode kan hentes på http://code.google.com/p/dr-radio-android/

Anbefalinger og gode praksisser

Herunder er nogle af de praksisser og arkitekturer jeg anbefaler, at man tillægger sig for at få et robust og vedligeholdbart program

  1. Tag højde for at adgang til netværkskommunikation og dens hastighed og stabilitet kan være stærkt svingende.
    • Pak essentielle stamdata med app’en, sådan at den kan starte første gang uden at vente på netværket.
    • Sørg for at gemme data du har hentet over netværket, som måske kan bruges igen.
    • Hvor det er muligt skal du vise de gamle data og tjekke for opdateringer i baggrunden og genopfriske skærmbilledet, hvis data er opdateret. Vis et drejende hjul (en ProgressBar) et sted, for at brugeren ved, at data er ved at blive opdateret
  2. Har du en substantiel mængde programlogik, der kan skilles ud fra brugergrænsefladen, bør du lægge den i separate klasser, gerne i en separat pakke.
    • Skriv programlogikken, herunder netværkskommunikation, parsning af filer etc. i et standard Javaprojekt (dvs J2SE, ikke Android)
    • Et standard Javaprojekt er langt hurtigere at prøvekøre, fejlfinde og afprøve end et tilsvarende Android-projekt
    • Når programlogikken er nogenlunde færdig kopieres pakken/klasserne over i Android-projektet, eller J2SE-projektet bruges som bibliotek fra Android-projektet.
  3. Har du en substantiel mængde programlogik eller data, som det meste af programmet er afhængig af, bør du lægge det i en Singleton. Tjek for Singletonens eksistens og evt. indlæsning af denne bør ske:
    • Enten i alle aktiviteter, alle services (herunder levende baggrunde) og alle receivers (herunder levende ikoner), der er afhængig af dens eksistens,
    • Eller i application-objektet, som beskrevet ovenfor.
  4. For separat programlogik og data som kun enkelte dele af programmet er afhængig af og som derefter skal glemmes kan du
    • Enten lægge data i separate Singletons som nulstilles efter brug.
    • Eller lægge data i Intents og Bundles.
  5. Data som er beregnet som ‘parametre’ til en aktivitet bør lægges i det Intent som aktiviteten aktiveres med.
    • Undgå at gemme ‘parametre’ i static-variabler eller Singletons, for så lever de for længe (men går tabt hvis app’ens proces bliver smidt ud af hukommelsen).
  6. Aktiviteter er flygtige, langt mere flygtige end de skærmbilleder, de repræsenterer.
    • På grund af deres flygtighed bør aktiviteter kun indeholde data, der er direkte relateret til brugergrænsefladen i aktiviteten, såsom referencer til dens views.
    • Undgå at gemme referencer til en aktivitet eller nogle af dens views uden for aktiviteten. Hvis du alligevel gør det, så sørg for at referencerne bliver fjernet igen i onDestroy()
  7. Undgå static-variabler i aktiviteter, undtaget evt. som referencer til Singletons (hvor du tjekker for deres eksistens i onCreate()).
  8. Opretter du views programmatisk bør du sætte deres id (med setId()) sådan at deres tilstand bliver genskabt ved vending/procesgenstart.
  9. Vigtige dialoger bør ikke oprettes og vises direkte med show(), for de overlever ikke en skærmvending eller genstart af processen. Kald i stedet showDialog() (og definér onCreateDialog() og evt. onPrepareDialog()) til vigtige dialoger.

About the Author -

Jacob Nordfalk