Googles Android/ Datenbankzugriffe

Zurück zu Googles Android

SQLite – eine gute Wahl

Bearbeiten

Daten wie die Einstellungen des Android-Systems, Kontakte oder die Telefonprotokolle legt Android in einer SQLite-Datenbank ab.

Das zugehörige Datenbanksystem SQLite[1] gehört zu den stabilsten und am meisten ausgereiften Werkzeugen, das der Open-Source-Bereich zu bieten hat. Wenn es keine Anforderungen an Transaktionen oder Mehrbenutzerfähigkeit gibt – wie es im Embedded-Bereich meistens der Fall ist – dann ist SQLite auch an Geschwindigkeit kaum zu schlagen, und das bei einer Größe des Installationspaketes von deutlich unter 1 MB. Zwar kann man in Android-Anwendungen auch mit einem anderen Datenbanksystem arbeiten, doch gibt es keinen Grund dazu.

Für die Arbeit mit SQLite stellt Android Klassen wie SQLiteDatabase[2] zur Verfügung, die auf das System zugeschnitten sind.

In Java-Anwendungen ist JDBC [3] (Java Database Connectivity) ja eigentlich der Standard, doch wird in Android-Anwendungen nicht zur Verwendung von JDBC geraten. Wir werden in diesem Kapitel sehen, wie einfach die Arbeit mit der SQLite-Schnittstelle des Android SDK von der Hand geht.

Konfigurieren statt Programmieren

Bearbeiten

Wenn wir in unserer Android-Anwendung mit SQLite arbeiten, müssen wir immer mal wieder SQL-Anweisungen formulieren. Dabei entstellt es den ganzen Programmcode, wenn wir mehrzeilige SQL-Anweisungen als Text in den Code schreiben. Außerdem müssen wir den Java-Code neu übersetzen, sobald sich etwas an den SQL-Anweisungen ändert. Hier sorgt das Android-Plugin von Eclipse für Erleichterung:

Wir können Texte – und somit auch SQL-Anweisungen – in XML-Dateien schreiben, die dann zur Laufzeit als Objekte vom Typ String zur Verfügung stehen:

Im Android-Projekt gibt es parallel zum layout-Ordner den Ordner values. In diesem Ordner finden wir die Datei strings.xml, in die wir einige SQL-Anweisungen wie create table zum Initialisieren der Datenbank hinterlegen. Neben dem Array, das wir create genannt haben, sind hier auch Texte für app_name, version und dbname vereinbart:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">AndroidDatabase</string>
  <string name="version">1</string>
  <string name="dbname">songsdb</string>
  <string-array name="create">
    <item>
      create table artists(
        id integer primary key autoincrement,
        name varchar(20) not null
      )
    </item>
    <item>
      create table songs(
        id integer  primary key autoincrement,
        title varchar(20) not null,
        artist int references artist
      )
    </item>
    <item>
      insert into artists(name) values (\'The Beatles\')
    </item>
    <item>
      insert into artists(name) values (\'Pink Floyd\')
    </item>
    <item>
      insert into songs(title, artist) values (\'Yellow Submarine\', 1);
    </item>
    <item>
      insert into songs(title, artist) values (\'Help\', 1);
    </item>
    <item>
      insert into songs(title, artist) values (\'Get Back\', 1);
    </item>
    <item>
      insert into songs(title, artist) values (\'Wish You Were Here\', 2);
    </item>
    <item>
      insert into songs(title, artist) values (\'Another Brick in the Wall\', 2);
    </item>
  </string-array>
</resources>

Im Rahmen des Build-Prozesses wird aus dieser Datei eine Java-Klasse namens R[4] generiert, die wir – wie in der folgenden Abbildung gezeigt – im Android-Projektverzeichnis finden. Diese Klasse enthält IDs in Form von ganzen Zahlen für unsere Objekte aus strings.xml.

Wir sehen jetzt, wie wir die in strings.xml hinterlegten SQL-Anweisungen ganz einfach ausgeben können: Wir erzeugen ein neues Android-Projekt und hängen an die onCreate-Methode der Standard-Activity die folgenden Zeilen Code:

for(String sql : getResources().getStringArray(R.array.create))
   System.out.println(sql);

Zu jedem Android-Projekt kann es Ressourcen geben, wie etwa die Texte, die wir hinterlegt haben. Die Methode Context.getResources[5] liefert uns ein Objekt vom Typ Resources,[6] das wiederum die Methoden wie getStringArray[7] enthält, mit dem wir wiederum auf unser String-Array zugreifen können. Dieser Methode übergeben wir die IDs unseres Arrays aus der Klasse R und können es dann wie jedes andere Array durchiterieren. Die println-Methode schreibt unsere SQL-Anweisungen in das Protokoll.

Texte und Arrays von Texten sind nur ein Teil der Ressourcen, die der Entwickler konfigurieren kann. Die Dokumentation zu den Typen R und Resources zeigt uns weitere Möglichkeiten auf.

Die Datenbank erzeugen

Bearbeiten

Objekte vom Typ SQLiteOpenHelper[8] versorgen uns mit Datenbankverbindungen, die wir brauchen, um überhaupt SQL-Anweisungen zu SQLite schicken zu können.

Da diese Klasse aber abstrakt ist, müssen wir eine eigene Unterklasse entwickeln. In unserem Beispiel haben wir sie SongDatabaseHelper genannt. Das folgende Listing zeigt die Implementierung; ihre Details werden wir uns gleich erarbeiten.

public class SongDatabaseHelper extends SQLiteOpenHelper {
 
  private Context context;

  SongDatabaseHelper(Context context){
    super(
        context,
        context.getResources().getString(R.string.dbname),
        null,
        Integer.parseInt(context.getResources().getString(R.string.version)));
    this.context=context;
  }

  @Override
  public void onCreate(SQLiteDatabase db) {
    for(String sql : context.getResources().getStringArray(R.array.create))
      db.execSQL(sql);
  }

  @Override
  public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  }
}

Der (einzige) Konstruktor der Basisklasse SQLiteOpenHelper hat die folgende Signatur:

SQLiteOpenHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version)

Unser eigener Konstruktor hat nur den Kontext als Parameter, aus dem wir dann die Argumente für den Konstruktor der Basisklasse ermitteln, den super repräsentiert.

  • Den Namen der Datenbank und ihre Version lesen wir wie weiter oben beschrieben aus strings.xml aus.
  • Der Dokumentation entnehmen wir, dass null im dritten Parameter die Standard-CursorFactory repräsentiert – und die soll erst einmal reichen.
  • Die Version der Datenbank bezeichnet nicht die Produktversion von SQLite, sondern eine Versionsnummer, die von unserer Anwendung verwaltet wird. Immer wenn sich die Anwendung ändert, kann dies auch Änderungen an der Datenbank erfordern: sei es, dass Tabellen und Spalten kommen und gehen oder dass neue Datensätze hinzugefügt werden. Wir sehen später in diesem Kapitel, wie die Methode onUpgrade für Maßnahmen beim Versionswechsel greifen kann.

Da wir den Kontext auch an anderer Stelle benötigen, kopieren wir ihn in ein privates Attribut. Zunächst interessiert uns aber die Methode onCreate.[9] Sie ist ebenso wie onUpgrade[10] in der Basisklasse mit dem Schlüsselwort abstract markiert und muss daher überschrieben werden. Die Methode onCreate wird immer dann aufgerufen, wenn es beim Aufbau der Verbindung die Datenbank, die mit dem Konstruktorparameter name bezeichnet wird, noch nicht gibt. Es ist sehr praktisch, dass diese Methode mit einem Parameter vom Typ SQLiteDatabase aufgerufen wird. So können wir mit execSQL[11] gleich unsere in strings.xml hinterlegten SQL-Anweisungen ausführen. Bevor wir uns mit onUpgrade beschäftigen, wollen wir die Datenbank erzeugen, einige Tabellen anlegen und ein paar Daten einfügen.

Dazu gehen wir in die Standard-Activity unseres Projektes und fügen an das Ende ihrer onCreate-Methode die folgenden Zeilen:

SQLiteOpenHelper database = new SongDatabaseHelper(this);
SQLiteDatabase connection = database.getWritableDatabase();

Wir erzeugen ein Objekt vom Typ SongDatabaseHelper und geben dem Konstruktor eine Referenz auf unsere Activity mit, die ja vom Typ Context abgeleitet ist. Über die Methode getWritableDatabase erhalten wir dann eine Datenbankverbindung vom Typ SQLiteDatabase, über die wir SQL-Anweisungen verschicken und wieder einsammeln können. Doch dazu später mehr.

Das ER-Diagramm, das zu den beiden Tabellen gehört, die wir mit den „create table“-Anweisungen angelegt haben, finden wir in der folgenden Abbildung:

Ein Blick hinter die Kulissen

Bearbeiten

Zunächst schauen wir uns unsere Datenbank genauer an. Dazu führen wir auf der Unix-Shell oder der Win32-Konsole unserer Entwicklungsmaschine das folgende Kommando aus, das zum Android-SDK gehört:

adb shell

Wir erhalten so eine Shell auf unserem virtuellen Device. Es sollte ein einfacher Prompt erscheinen:

#

Wenn de.wikibooks.android der Paketname unseres Android-Projektes ist, dann führt uns der Verzeichniswechsel

cd /data/data/de.wikibooks.android/databases

zu dem Verzeichnis, in dem auch die von SQLite angelegte Datenbankdatei liegt:

# ls
songsdb

Wir starten SQLite und verbinden uns mit der Datenbank songsdb:

# sqlite3 songsdb
SQLite version 3.6.22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite>

Mit der Anweisung .help bekommen wir eine Übersicht über alle Anweisungen, die für die interaktive Arbeit mit dem Datenbanksystem zur Verfügung stehen. Zunächst reicht uns eine Übersicht über die Tabellen:

sqlite> .tables
android_metadata  artists  songs

Die Tabellen artists und songs haben wir in der onCreate-Methode unseres SQLiteOpenHelper-Objektes angelegt, die Tabelle android_metadata wurde vom Android-System angelegt. Sie enthält aber keine interessanten Informationen:

sqlite> select * from android_metadata;
en_US

Bei SQL-Anweisungen müssen wir auch immer an das abschließende Semikolon denken.

Neue Versionen

Bearbeiten

Wir haben uns davon überzeugt, dass die Datenbank, ihre Tabellen und einige Daten jetzt angelegt sind. Immer wenn wir unsere App neu starten, arbeiten wir mit dieser gleichen Datenbank. Die onCreate-Anweisung wird nur ein einziges Mal ausgeführt.

Wenn es im Laufe der Zeit nötig wird, die Datenbank zu ändern, geben wir ihr einfach eine neue Version. In unserem Szenario ist die Versionsänderung bereits mit einer kleinen Änderung in strings.xml getan:

<string name="version">2</string>

Wenn wir unser SQLiteOpenHelper-Objekt jetzt erzeugen, wird erkannt, dass es einen Versionswechsel gegeben hat und die Methode

onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

aufgerufen. Die Parameternamen verraten uns dabei bereits die Bedeutung. In unserem Fall soll beim Wechsel von der Version 1 auf die Version 2 einfach eine Spalte zur Tabelle artists hinzugefügt werden, die aussagt, ob der Interpret eine Gruppe oder ein Solist ist. Da wir in unserem initialen Datenbestand nur die Gruppen ‚The Beatles‘ und ‚Pink Floyd‘ haben, setzen wir die entsprechenden Einträge auf ‚Y‘. Die zugehörigen Anweisungen verpacken wir in XML und fügen sie zu strings.xml hinzu:

<string-array name="v1to2">
  <item>
    alter table artists add column band char(1);
  </item>
  <item>
    update artists set band=\'Y\';
  </item>
</string-array>

Beim nächsten Start der Anwendung wird zwar nicht die onCreate-Methode aufgerufen, dafür aber die Methode onUpgrade, die jetzt wie folgt aussieht:

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
  if(oldVersion==1 && newVersion==2)
    for(String sql : context.getResources().getStringArray(R.array.v1to2))
      db.execSQL(sql);
  else
    System.out.println("onUpgrade called - version not handled");
}

Diese defensive Programmierweise gewährleistet, dass bei jedem Versionswechsel entweder eine Änderung an der Datenbank ausgeführt oder eine entsprechende Warnung im Protokoll verzeichnet wird. Besser wäre hier sicher die Nutzung der Klasse Log, die den Eintrag noch mit einem entsprechenden Tag versieht, doch sind dies Details, die in diesem Kapitel keine übergeordnete Rolle spielen. Wenn wir die Protokolle gewissenhaft auswerten, entgeht uns jedenfalls nichts. Möglicherweise ist es sogar angemessener, eine Ausnahme zu machen, wenn nicht mit einem bestimmten Versionswechsel gerechnet wird.

Weiter oben haben wir ja bereits gesehen, dass wir etwa innerhalb einer Activity mit

SQLiteOpenHelper database = new SongDatabaseHelper(this);
SQLiteDatabase connection = database.getWritableDatabase();

eine Verbindung zur Datenbank bekommen. Wenn wir nicht die Absicht haben, Daten zu verändern, können wir die letzte Zeile auch durch die defensivere Variante

SQLiteDatabase connection = database.getReadableDatabase();

ersetzen. Genauso wie in der onUpdate-Methode unserer SongDatabaseHelper-Klasse können wir jetzt SQL-Anweisungen gegen die Datenbank laufen lassen.

connection.execSQL("insert into artists(name) values ('The Rolling Stones')")

Dieses Mal haben wir die Anweisung der Einfachheit halber nicht in strings.xml eingetragen. Ganz ähnlich können wir auch Datensätze mit update ändern oder mit der SQL-Anweisung delete löschen. Grundsätzlich können wir der Methode execSQL auch select-Anweisungen übergeben, doch werden wir daraus keinen Nutzen ziehen: Da die Methode void als Rückgabetyp hat, erfahren wir nicht, welche Daten der select gefunden hat. Für Abfragen gibt es etwa die Methode rawQuery[12] mit der folgenden Signatur:

public Cursor rawQuery (String sql, String[] selectionArgs)

Die select-Anweisung wird einfach als Text übergeben. Zusätzlich haben wir auch die Möglichkeit, mit Platzhaltern zu arbeiten und diese dann über den zweiten Parameter mit Werten zu versorgen. Doch dazu später mehr.

Zunächst interessiert uns der Rückgabetyp. Die Ergebnisse unserer Abfrage werden uns als Cursor[13] zurückgegeben. Ein Cursor ist eine listenartige Datenstruktur, aus der wir alle gefundenen Datensätze abrufen können. In der einfachsten Form sieht das dann so aus:

Cursor result=connection.rawQuery("select name from artists", null);
String s="";
while(result.moveToNext ())
    s+=result.getString(0)+"\n";
Toast.makeText(this, s, Toast.LENGTH_LONG).show();

Die App blendet einen Toast mit allen Interpreten ein, die in unserer Datenbank verzeichnet sind. Mit Hilfe von Methoden wie moveToNext[14] und moveToPrevious[15] können wir uns in der Ergebnismenge der select-Anweisung bewegen.

Mit Methoden wie getString[16] kopieren wir Daten aus dem Cursor in unsere eigenen Variablen. Wie der Cursor das macht, ist für uns als Anwender transparent. Im Idealfall fordert er von SQLite nur einige Datensätze an. Diese bilden einen Ausschnitt, in dem wir uns bewegen. Sobald die Grenzen diese Teilmenge überschritten werden, fordert der Cursor neue Daten vom Datenbanksystem – und das alles ohne, dass wir etwas davon merken. Neben getString gibt es für einige Standard-Datentypen eine eigene Methode. Für Zahlen vom Typ int gibt es etwa getInt,[17] für solche vom Typ float gibt es entsprechend getFloat.[18]

Das Argument ist bei jeder dieser Methoden der Spaltenindex. Der Index 0 bezieht sich dabei auf die erste Spalte in der Ergebnismenge und 1 auf die zweite Spalte. Der Leser sollte sich mit Hilfe der Dokumentation zum Typ Cursor einen Überblick über dessen vielfältige Methoden verschaffen.

Insgesamt hat es sich als eine gute Praxis erwiesen, die Daten dort zu belassen, wo sie sind. Oft wird der Fehler gemacht, die Daten aus dem Cursor in „eigene“ Datenstrukturen – vorzugsweise Arrays – zu kopieren. Doch sind Cursor gerade für solche Ergebnismengen von select-Anweisungen entwickelt worden und sollten auch von uns dazu genutzt werden.

Eine weitere gute Praxis besteht darin, Ressourcen, die wir angefordert haben, auch wieder freizugeben. In Java haben wir die Garbage-Collection, die ja hinter uns herräumt und etwa nicht benötigten Speicherplatz freigibt. Wir müssen uns darum nicht kümmern. Die Welt außerhalb von Java kennt aber vielfach keine Garbage-Collection. Wenn wir also Datenbankverbindungen angefordert haben, sollten wie sie nach getaner Arbeit auch wieder schließen, um zu vermeiden, dass SQLite Ressourcen für die Verbindung bunkert:

connection.close();

Nicht nur der Typ SQLiteDatabase hat eine close-Methode,[19] sondern auch andere Typen aus dem Paket android.database. Und dazu gehört auch Cursor. Wenn wir den Cursor aus dem Beispiel nicht mehr brauchen, teilen wir das SQLite über die folgende Anweisung mit:

result.close();

Prepared Statements

Bearbeiten

Wenn eine SQL-Anweisung bei SQLite eintrifft, wird es einer echten Rosskur unterzogen:

  • Die Syntax wird geprüft.
  • Es wird überprüft, ob die Tabellen und Spalten aus der Anweisung überhaupt existieren und
  • ob der Benutzer überhaupt berechtigt ist, auf sie zuzugreifen.
  • Es wird optimiert.
  • Es wird in eine ausführbare Form gebracht
  • … und schließlich ausgeführt.

Und das passiert bei jedem execSQL aufs Neue. Dabei hätte es gereicht, die ersten fünf Schritte ein einziges Mal je Anweisung auszuführen und dann den Befehl in seiner ausführbaren Form wieder zu verwenden. Und genau das geht mit Hilfe des Typs SQLiteStatement. Die Methode

public SQLiteStatement compileStatement (String sql)

aus der Klasse SQLiteDatabase bringt eine als Text vorliegende SQL-Anweisung in seine ausführbare Form. Objekte vom Typ SQLiteStatement haben wiederum eine parameterfreie Methode namens execute[20] zum Ausführen der SQL-Anweisung. Insgesamt ergibt sich so

String sql="insert into artists(name) values('Beach Boys')";
SQLiteStatement insert=connection.compileStatement(sql);
for(int i=0; i<10; i++)
    insert.execute();
insert.close();

Die insert-Anweisung wird einmal „präpariert“ und zehnmal ausgeführt. Die Verwendung von Prepared Statements stellt in der Entwicklung von Enterprise-Anwendungen, in denen sekündlich hunderte, wenn nicht gar tausende von Anweisungen beim Datenbanksystem eintreffen, eine ganz zentrale Tuning-Technik dar. Da in Android-Anwendungen typischerweise nur vergleichsweise wenig Datenbankaktivität stattfindet, spielen Prepared Statements hier eine nicht ganz so zentrale Rolle.

Unserem Beispiel mangelt es etwas an Dynamik: Kein Mensch fügt zehnmal einen Datensatz ein, der im Wesentlichen das Gleiche enthält. Wenn wir aber jedes Mal einen anderen Künstler wählen, müssen wir auch immer wieder aufs Neue präparieren und unser ohnehin sehr bescheidener Tuning-Effekt wäre ganz hinüber. Genau hier greifen Platzhalter. Wenn wir die insert-Anweisung folgendermaßen formulieren:

String sql="insert into artists(name) values(?)";

können wir mit der Methode bindString dem Platzhalter '?' neue Werte zuweisen, ohne die Anweisung neu zu präparieren. Im Idealfall wird eine Anweisung nur ein einziges Mal präpariert und dann immer wieder recycelt:

String[] artists={"The Who", "Jimi Hendrix", "Janis Joplin", "The Doors"};
String sql="insert into artists(name) values(?)";
SQLiteStatement insert=connection.compileStatement(sql);
for(String s : artists){
    insert.bindString(1, s);
    insert.execute();
}
insert.close();

SQL häppchenweise

Bearbeiten

Mit der Methode rawQuery können wir eine SQL-Anweisung in Textform zu SQLite schicken. Es besteht aber auch die Möglichkeit, die Anweisung in ihre Bestandteile zu zerlegen und diese dann mit der Methode query[21] wegzuschicken. Das Datenbanksystem baut die Teile wieder zu einer select-Anweisung zusammen und führt sie aus. Wir wollen uns das mal anhand der folgenden etwa komplexeren Abfrage klarmachen:

select artist, count(title)
from songs where id>1
group by artist
having count(*)>1
order by id", null)

In dieser Form würden wir die Anweisung an rawQuery übergeben. Die Zerlegung sehen wir an dem folgenden äquivalenten Aufruf der Methode query:

Cursor result =connection.query(
  "songs", new String[]{"artist","count(title)"},
  "id>?", new String[]{"1"}, "artist", "count(*)>1", "id"
);

Welche der beiden Varianten man bevorzugt, ist allein eine Frage des Programmierstils. Aus technischer Sicht sind beide Methoden gleichberechtigt.

Einzelnachweise

Bearbeiten
  1. www.sqlite.org/
  2. http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html
  3. http://www.oracle.com/technetwork/java/javase/jdbc/index.html#corespec40
  4. http://developer.android.com/reference/android/R.html
  5. http://developer.android.com/reference/android/content/Context.html#getResources()
  6. http://developer.android.com/reference/android/content/res/Resources.html
  7. http://developer.android.com/reference/android/content/res/Resources.html#getStringArray(int)
  8. http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html
  9. http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html#onCreate(android.database.sqlite.SQLiteDatabase)
  10. http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html#onUpgrade(android.database.sqlite.SQLiteDatabase,%20int,%20int)
  11. http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#execSQL(java.lang.String)
  12. http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#rawQuery(java.lang.String,%20java.lang.String[])
  13. http://developer.android.com/reference/android/database/Cursor.html
  14. http://developer.android.com/reference/android/database/Cursor.html#moveToNext()
  15. http://developer.android.com/reference/android/database/Cursor.html#moveToPrevious()
  16. http://developer.android.com/reference/android/database/Cursor.html#getString(int)
  17. http://developer.android.com/reference/android/database/Cursor.html#getInt(int)
  18. http://developer.android.com/reference/android/database/Cursor.html#getFloat(int)
  19. http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#close()
  20. http://developer.android.com/reference/android/database/sqlite/SQLiteStatement.html#execute()
  21. http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#query(java.lang.String,%20java.lang.String%5B%5D,%20java.lang.String,%20java.lang.String%5B%5D,%20java.lang.String,%20java.lang.String,%20java.lang.String,%20java.lang.String)