7.5 Webservices nutzen
Java (und damit auch Android) kennt seit vielen Jahren die beiden Klassen HttpURLConnection sowie die von ihr abgeleitete HttpsURLConnection. Beide gestatten mit wenig Aufwand den flexiblen Zugriff auf Ressourcen im World Wide Web. Mein Beispielprojekt WebserviceDemo1 zeigt Ihnen, wie Sie das aktuelle Wetter in Ihrer Lieblingsstadt ermitteln. Die App ist in Abbildung 7.8 zu sehen. Sie greift auf den Dienst OpenWeatherMap zu. Um ihn zu nutzen, müssen Sie auf der Website openweathermap.org einen in der Basisversion kostenlosen API-Schlüssel beantragen. Hierzu ist eine kurze Registrierung erforderlich, bei der aber nur Benutzername, Passwort und E-Mail-Adresse abgefragt werden.
7.5.1 Auf Webinhalte zugreifen
Über eine leicht zu handhabende Webschnittstelle lassen sich neben aktuellen Wetterinformationen auch Vorhersagen und historische Daten abfragen. Der Uniform Resource Identifier (URI) https://api.openweathermap.org/data/2.5/weather?q=Nuremberg,DE&appid=... (anstelle der drei Punkte geben Sie Ihren API-Schlüssel ein) liefert das gegenwärtige Wetter in Nürnberg als JSON-Datenstruktur. Diese ist in Listing 7.24 zu sehen:
{
"coord": {
"lon": 11.07,
"lat": 49.45
},
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"base": "stations",
"main": {
"temp": 290.69,
"feels_like": 286.55,
"temp_min": 289.26,
"temp_max": 292.04,
"pressure": 1020,
"humidity": 36
},
"visibility": 10000,
"wind": {
"speed": 3.6
},
"clouds": {
"all": 99
},
"dt": 1590921701,
"sys": {
"type": 1,
"id": 1268,
"country": "DE",
"sunrise": 1590894886,
"sunset": 1590952349
},
"timezone": 7200,
"id": 2861650,
"name": "Nuremberg",
"cod": 200
}
Neben den geografischen Koordinaten Breite (lat) und Länge (lon) der Stadt sind unter anderem die aktuelle Temperatur (temp), Höchst- und Tiefstwerte (temp_max bzw. temp_min) sowie Luftfeuchtigkeit (humidity) und Luftdruck (pressure) in der Datenstruktur enthalten. Die Temperatur wird in Kelvin angegeben. Um sie in Grad Celsius umzurechnen, müssen Sie 273,15 subtrahieren. Der Wert in Grad Fahrenheit lässt sich mit der Formel 9 × (temp – 273.15) / 5 + 32 berechnen. Das Attribut description fasst die Wettersituation kompakt zusammen. Als Sprache wird standardmäßig Englisch verwendet. Dies lässt sich aber mit dem Parameter lang=DE im Uniform Resource Identifier beeinflussen. Er hat folgenden Aufbau:
https:// – Protokoll
api.openweathermap.org/data/ – Adresse
2.5/ – Version der Schnittstelle
weather – Was soll geliefert werden?
?q=Nuremberg,DE – gesuchte Stadt
¶m=wert – optional, kann mehrfach vorkommen
Der erste Parameter wird wie in Webadressen üblich mit ? eingeleitet, alle weiteren sind durch ein & voneinander getrennt. Der API-Schlüssel wird als Parameter APPID=… übergeben, die gesuchte Stadt mit q=<Stadt>,<Land> (Land ist ein ISO-3166-Kürzel: Deutschland entspricht DE, Großbritannien ist GB). Der Suchstring für die Hauptstadt des Vereinigten Königreichs ist demnach q=London,GB. Anstelle von Städtenamen sind auch geografische Koordinaten möglich.
WebserviceDemo1 fragt über ein Eingabefeld den Namen einer Stadt ab, baut einen geeigneten URI zusammen und ruft damit den Dienst OpenWeatherMap auf. Die Ergebnis-JSON-Struktur enthält fast alle Daten, die für das Anzeigen der Wetterinformationen erforderlich sind. Nur die Grafik, die das Wettergeschehen visualisiert, muss separat geladen werden. Die Steuerung übernimmt die Klasse WebserviceDemo1Activity. Sie ist in Listing 7.25 zu sehen.
Nach dem Laden und Anzeigen der Benutzeroberfläche wird ein OnClickListener registriert. Tippt der Anwender auf Anzeigen, werden mit getWeather() die Wetterinformationen heruntergeladen und in einem Objekt des Typs WeatherData abgelegt. Wie diese Datenklasse aussieht, zeige ich Ihnen gleich. getImage() holt eine kleine Wettergrafik vom Server. Hierzu wird der Wert des Attributs icon der JSON-Datenstruktur nach folgendem Muster zu einem Uniform Resource Identifier erweitert:
https://openweathermap.org/img/w/ + icon + .png
Beide Download-Operationen laufen in einem gemeinsamen Hintergrund-Thread ab. Die Benutzeroberfläche bleibt auf diese Weise auch bei Problemen mit der Netzwerkverbindung bedienbar. Änderungen an Views müssen hingegen auf dem Mainthread erfolgen. Entsprechende setText()- bzw. setImageBitmap()-Aufrufe und Zuweisungen werden deshalb in einem Runnable gesammelt und an runOnUiThread() übergeben.
package com.thomaskuenneth.androidbuch.webservicedemo1
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import kotlin.concurrent.thread
private val TAG = WebserviceDemo1Activity::class.simpleName
class WebserviceDemo1Activity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button.setOnClickListener {
thread {
try {
val weather = getWeather(city.text.toString())
val bitmapWeather = getImage(weather)
runOnUiThread {
city.setText(weather.name)
image.setImageBitmap(bitmapWeather)
beschreibung.text = weather.description
val temp = weather.temp - 273.15
temperatur.text = getString(
R.string.temp_template,
temp.toInt()
)
}
} catch (e: Exception) {
Log.e(TAG, "getWeather()", e)
}
}
}
city.setOnEditorActionListener { _, _, _ ->
button.performClick()
true
}
}
}
Die Datenklasse WeatherData enthält keinerlei Logik. Ihr einziger Zweck ist, alle für die App relevanten Wetterdaten in schnell zugreifbarer Form vorzuhalten. Sie wird durch die Funktion getWeather() in der Datei WeatherUtils.kt erzeugt und befüllt.
package com.thomaskuenneth.androidbuch.webservicedemo1
data class WeatherData(
var name: String = "",
var description: String = "",
var icon: String = "",
var temp: Double = 0.0
)
Die in Listing 7.27 abgedruckte Datei WeatherUtils.kt ist keine Klasse. Sie enthält drei Funktionen: getWeather() liefert das Wetter in der als String übergebenen Stadt. getFromServer() lädt die zu einer Webadresse gehörenden Daten (in unserem Fall eine JSON-Struktur) als String herunter. Die Funktion ist privat, kann also von außen nicht verwendet werden. getImage() schließlich sorgt dafür, dass die App ein schickes Wettersymbol anzeigen kann. Sie liefert ein Objekt des Typs android.graphics.Bitmap.
Um Daten von einem Server zu laden, wird als Erstes ein Objekt des Typs java.net.URL erzeugt, dessen Methode openConnection() eine Instanz von HttpURLConnection liefert, sofern die korrespondierende Webadresse eine geeignete Ressource referenziert. Vor einem Zugriff sollten Sie mit responseCode das Ergebnis prüfen. Ist alles in Ordnung (in meinem Fall reicht HTTP_OK, aber es gibt weitere Statuscodes, die ebenfalls eine erfolgreiche Kommunikation anzeigen), liefert inputStream einen Eingabestrom. Aus Effizienzgründen sollten Sie ihn mit weiteren gepufferten Strömen oder Readern kapseln. Ich packe den InputStream zunächst in einen InputStreamReader und diesen wiederum in einen BufferedReader. Auf diese Weise kann ich das Ergebnis bequem zeilenweise in einen StringBuilder schreiben. Wichtig ist, nach der Datenübertragung selbst geöffnete Kanäle zu schließen und mit disconnect() die Verbindung zu trennen.
Oft müssen Sie eine Datenübertragung aber gar nicht selbst programmieren. Wie es auch ohne geht, zeigt getImage(). Die Funktion ruft die Methode decodeStream() der Klasse android.graphics.BitmapFactory auf, um aus einer Grafik im Format Portable Network Graphics ein Objekt zu machen, das direkt in Android genutzt werden kann.
package com.thomaskuenneth.androidbuch.webservicedemo1
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.text.MessageFormat
private const val URL =
"https://api.openweathermap.org/data/2.5/weather?q={0}&lang=de&appid={1}"
private const val KEY = "..."
private const val NAME = "name"
private const val WEATHER = "weather"
private const val DESCRIPTION = "description"
private const val ICON = "icon"
private const val MAIN = "main"
private const val TEMP = "temp"
@Throws(JSONException::class, IOException::class)
fun getWeather(city: String): WeatherData {
val data = WeatherData()
val jsonObject = JSONObject(
getFromServer(
MessageFormat.format(
URL,
city,
KEY
)
)
)
if (jsonObject.has(NAME)) {
data.name = jsonObject.getString(NAME)
}
if (jsonObject.has(WEATHER)) {
val jsonArrayWeather = jsonObject.getJSONArray(WEATHER)
if (jsonArrayWeather.length() > 0) {
val jsonWeather = jsonArrayWeather.getJSONObject(0)
data.description = jsonWeather.optString(DESCRIPTION)
data.icon = jsonWeather.optString(ICON)
}
}
if (jsonObject.has(MAIN)) {
val main = jsonObject.getJSONObject(MAIN)
data.temp = main.optDouble(TEMP)
}
return data
}
@Throws(IOException::class)
fun getImage(w: WeatherData): Bitmap {
val url = URL("https://openweathermap.org/img/w/${w.icon}.png")
val connection = url.openConnection() as HttpURLConnection
val bitmap = BitmapFactory.decodeStream(connection.inputStream)
connection.disconnect()
return bitmap
}
private fun getFromServer(url: String): String {
val sb = StringBuilder()
val connection = URL(url).openConnection() as HttpURLConnection
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
InputStreamReader(
connection.inputStream
).use { inputStreamReader ->
BufferedReader(
inputStreamReader
).use {
it.lines().forEach { line ->
sb.append(line)
}
}
}
}
connection.disconnect()
return sb.toString()
}
Lassen Sie uns noch einen Blick auf die Methode getWeather() werfen. Ihre Aufgabe ist es, die Wetterdaten vom Server zu laden und anschließend ein WeatherData-Objekt zu füllen. Ersteres ist mithilfe der Funktion getFromServer() ja schnell erledigt. Ihr Rückgabewert ist schon eine JSON-Datenstruktur, allerdings noch als String kodiert. Dieser wird mit JSONObject(...) umgewandelt, sodass wir mit optString() und optDouble() bequem Elemente suchen und auslesen können. Allerdings nicht direkt, denn sie befinden sich zum Teil in Kindelementen. Ob diese vorhanden sind, prüfe ich mit has(). Falls ja, können Sie mit jsonObject.getJSONObject() und jsonObject.getJSONArray() auf die Kinder und damit deren Elemente zugreifen. Falls eine JSON-Struktur kein Element enthält, das zu dem übergebenen Schlüssel passt, liefert optString() einen Leerstring und optDouble() den Wert Double.NaN. Alternativ könnten Sie auch has...() und get...() kombinieren.
Bitte denken Sie daran, dass Apps in ihrem Manifest die Berechtigung android.permission.INTERNET anfordern müssen, um auf Webserver und Webservices zugreifen zu können. Diese normale Berechtigung wird automatisch gewährt und muss zur Laufzeit nicht angefordert werden.
7.5.2 Senden von Daten
In diesem Abschnitt zeige ich Ihnen, wie Sie Daten an einen Server übermitteln können. Mein Beispiel WebserviceDemo2 sendet einen vom Benutzer eingegebenen Text (seinen Namen) an ein Backend, das daraus einen Gruß bastelt und an die App zurückgibt. Sehen Sie sich bitte die Klasse WebserviceDemo2Activity an. In der Methode onCreate() wird nur die Benutzeroberfläche geladen und angezeigt sowie ein OnClickListener registriert. Er kümmert sich um die Kommunikation mit dem Server und zeigt das Ergebnis an. setOnEditorActionListener() sorgt dafür, dass die Eingabe des Namens mit (¢) abgeschlossen werden kann.
package com.thomaskuenneth.androidbuch.webservicedemo2
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import java.io.*
import java.net.*
import java.nio.charset.Charset
import java.util.regex.Pattern
import kotlin.concurrent.thread
private val TAG = WebserviceDemo2Activity::class.simpleName
class WebserviceDemo2Activity : AppCompatActivity() {
private val pattern = Pattern.compile(".*charset\\s*=\\s*(.*)$")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
input.setOnEditorActionListener { _, _, _ -> button.performClick() }
button.setOnClickListener {
thread {
val result = talkToServer(input.text.toString())
runOnUiThread {
output.text = result
}
}
}
}
private fun talkToServer(name: String): String {
val sb = StringBuilder()
val url = URL("http://10.0.2.2:8080/hello")
try {
val connection = url.openConnection() as HttpURLConnection
// Verbindung konfigurieren
connection.doOutput = true
connection.requestMethod = "POST"
val data = name.toByteArray()
connection.setRequestProperty(
"Content-Type", "text/plain; charset="
+ Charset.defaultCharset().name()
)
connection.setFixedLengthStreamingMode(data.size)
// Daten senden
connection.outputStream.write(data)
connection.outputStream.flush()
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
var charSet = "ISO-8859-1"
val m = pattern.matcher(connection.contentType)
if (m.matches()) {
charSet = m.group(1) ?: charSet
}
InputStreamReader(
connection.inputStream, charSet
).use {
BufferedReader(
it
).use { bufferedReader ->
bufferedReader.lines().forEach { line ->
sb.append(line)
}
}
}
} else {
Log.d(TAG, "responseCode: ${connection.responseCode}")
}
connection.disconnect()
} catch (tr: Throwable) {
Log.e(TAG, "Fehler beim Zugriff auf $url", tr)
}
return sb.toString()
}
}
Die eigentliche Arbeit übernimmt die kombinierte Lese- und Schreibmethode talkToServer(). Bevor die Ergebnisstruktur gelesen werden kann, muss die Post-Methode vorbereitet werden (doOutput = true und requestMethod = "POST"). Dabei werden auch die HTTP-Header-Felder Content-Type und Content-Length gesetzt. Die Methode setFixedLengthStreamingMode() sollten Sie stets benutzen, wenn die Größe des zu sendenden Datenstroms bekannt ist. Das Senden der Daten ist mit zwei Methodenaufrufen erledigt. Bei größeren Strukturen lohnt das Kapseln in einen gepufferten Strom.
Ist Ihnen aufgefallen, dass ich vor dem Lesen der Ergebnisdaten den contentType prüfe und, sofern er nicht null ist, daraus die Zeichensatzcodierung des Servers ermittle? Dies war im vorangehenden Beispiel aus Gründen der Vereinfachung nicht enthalten. Auch sehr viele Beispiele im Internet verzichten darauf. Unter bestimmten Umständen kann das Weglassen aber zu falschen Daten führen. Der von inputStream gelieferte InputStream operiert gemäß der Spezifikation auf vorzeichenlosen Bytes, also in einem Bereich zwischen 0 und 255. Je nachdem, ob ein Server US-ASCII, UTF-8 oder UTF-16 verwendet, entstehen für denselben Text unterschiedliche Bytefolgen. Um diese erfolgreich »rekonstruieren« zu können, muss die Zeichensatzcodierung des Senders also bekannt sein. Dass das Problem oft unbemerkt bleibt, liegt daran, dass sich einige Codierungen zumindest in Teilen sehr ähnlich sind.
[»] Hinweis
Sie finden das Backend zu dem Beispiel in den Begleitmaterialien. Sie können die Spring-Boot-Anwendung über die Kommandozeile mit java -jar ... ausführen. Hierzu muss eine Java-Laufzeitumgebung vorhanden und der Umgebungsvariable PATH hinzugefügt worden sein. Bei Bedarf können Sie jre/bin unterhalb des Android-Studio-Installationsverzeichnisses verwenden.
Aus Performancegründen dürfen Netzwerkzugriffe nicht auf dem Haupt-Thread der Anwendung ausgeführt werden, da dieser für die Behandlung der Benutzeroberfläche verwendet wird. Langsame oder gar abreißende Verbindungen können dazu führen, dass Activities nicht mehr reagieren. Android unterbindet entsprechende Versuche und löst eine NetworkOnMainThreadException aus. Glücklicherweise lassen sich Netzwerkzugriffe mit sehr wenig Aufwand in einen eigenen Thread auslagern. Ob Sie dies, wie in meinem Beispiel, selbst erledigen oder beispielsweise auf Koroutinen zurückgreifen, ist letztlich eine Frage des persönlichen Geschmacks.