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
.
-
Die Klasse
R
im Android-Projekt
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:
-
ER-Diagramm zur Demo-Datenbank
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.
Cursor
[Bearbeiten]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]- ↑ www.sqlite.org/
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html
- ↑ http://www.oracle.com/technetwork/java/javase/jdbc/index.html#corespec40
- ↑ http://developer.android.com/reference/android/R.html
- ↑ http://developer.android.com/reference/android/content/Context.html#getResources()
- ↑ http://developer.android.com/reference/android/content/res/Resources.html
- ↑ http://developer.android.com/reference/android/content/res/Resources.html#getStringArray(int)
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html#onCreate(android.database.sqlite.SQLiteDatabase)
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper.html#onUpgrade(android.database.sqlite.SQLiteDatabase,%20int,%20int)
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#execSQL(java.lang.String)
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#rawQuery(java.lang.String,%20java.lang.String[])
- ↑ http://developer.android.com/reference/android/database/Cursor.html
- ↑ http://developer.android.com/reference/android/database/Cursor.html#moveToNext()
- ↑ http://developer.android.com/reference/android/database/Cursor.html#moveToPrevious()
- ↑ http://developer.android.com/reference/android/database/Cursor.html#getString(int)
- ↑ http://developer.android.com/reference/android/database/Cursor.html#getInt(int)
- ↑ http://developer.android.com/reference/android/database/Cursor.html#getFloat(int)
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteDatabase.html#close()
- ↑ http://developer.android.com/reference/android/database/sqlite/SQLiteStatement.html#execute()
- ↑ 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)