Rheinwerk Computing < openbook >

 
Inhaltsverzeichnis
Vorwort
Teil I Grundlagen
1 Android – eine offene, mobile Plattform
2 Hallo Android!
3 Von der Idee zur Veröffentlichung
Teil II Elementare Anwendungsbausteine
4 Wichtige Grundbausteine von Apps
5 Benutzeroberflächen
6 Multitasking
Teil III Gerätefunktionen nutzen
7 Telefonieren und surfen
8 Sensoren, GPS und Bluetooth
Teil IV Dateien und Datenbanken
9 Dateien lesen, schreiben und drucken
10 Datenbanken
Teil V Multimedia und Produktivität
11 Multimedia
12 Kontakte und Organizer
A Einführung in Kotlin
B Jetpack Compose
C Häufig benötigte Codebausteine
D Literaturverzeichnis
E Die Begleitmaterialien
Stichwortverzeichnis

Ihre Meinung?
Spacer
<< zurück
Android 11 von Thomas Künneth
Das Praxisbuch für App-Entwickler
Buch: Android 11

Android 11
Pfeil 5 Benutzeroberflächen
Pfeil 5.1 Views und ViewGroups
Pfeil 5.1.1 Views
Pfeil 5.1.2 Positionierung von Bedienelementen mit ViewGroups
Pfeil 5.1.3 Alternative Layouts
Pfeil 5.2 Vorgefertigte Bausteine für Oberflächen
Pfeil 5.2.1 Listen darstellen mit ListFragment
Pfeil 5.2.2 Programmeinstellungen mit dem PreferencesFragment
Pfeil 5.2.3 Dialoge
Pfeil 5.2.4 Menüs und Action Bar
Pfeil 5.3 Nachrichten und Hinweise
Pfeil 5.3.1 Toast und Snackbar
Pfeil 5.3.2 Benachrichtigungen
Pfeil 5.3.3 App Shortcuts
Pfeil 5.4 Trennung von Oberfläche und Logik
Pfeil 5.4.1 Bedienelemente ohne »findViewById()«
Pfeil 5.4.2 Android Architecture Components
Pfeil 5.5 Dark Mode
Pfeil 5.5.1 Das DayNight-Theme
Pfeil 5.5.2 Dark Mode in eigenen Themes
Pfeil 5.6 Zusammenfassung
 
Zum Seitenanfang

5.4    Trennung von Oberfläche und Logik Zur vorigen ÜberschriftZur nächsten Überschrift

So unterschiedlich alle bisherigen Beispiele im Hinblick auf ihre Funktion auch sind. Das Laden, Anzeigen und Verwenden der Benutzeroberfläche folgt stets demselben Muster:

  • Das Layout für eine Activity wird in einer XML-Datei definiert.

  • Die Oberfläche wird mit setContentView() geladen.

  • Um Bedienelemente verwenden und auf Benutzerinteraktionen reagieren zu können, wird mit findViewById() eine Referenz auf das Objekt ermittelt.

Um überall in einer Activity Zugriff auf UI-Komponenten zu haben, werden die Referenzen auf sie traditionell in Instanzvariablen abgelegt. In Kotlin sieht eine entsprechende Deklaration so aus:

private lateinit var bt: Button

Vor dem Zugriff (beispielsweise bt.setOnClickListener { ... }) muss sie durch die Zuweisung bt = findViewById( ... ) initialisiert worden sein, sonst hagelt es zur Laufzeit Ausnahmen. Sie könnten zwar mit isInitialized prüfen, ob das der Fall ist, allerdings sollten Sie das nur in Ausnahmefällen tun, weil Ihr Code sonst schnell unübersichtlich wird. Zum Glück gibt es mittlerweile mehrere Möglichkeiten, wie Sie auf findViewById() ganz verzichten können. Diese sehen wir uns in den folgenden Abschnitten genauer an.

 
Zum Seitenanfang

5.4.1    Bedienelemente ohne »findViewById()« Zur vorigen ÜberschriftZur nächsten Überschrift

Die Kotlin Android Extensions sind ein Gradle-Plugin von JetBrains und werden mit der Zeile

apply plugin: 'kotlin-android-extensions'

in der modulspezifischen build.gradle-Datei aktiviert. Danach haben Sie über deren ID direkten Zugriff auf alle Elemente eines Layouts. Wie das funktioniert, zeige ich Ihnen anhand des Beispiels WidgetDemoKAE. Es handelt sich um das Projekt WidgetDemo aus Abschnitt 5.1.1, »Views«, nur eben ohne Instanzvariablen und findViewById(). Die Layoutdatei widgetdemo.xml ist in Listing 5.1 am Anfang des Kapitels zu sehen. Die minimal geänderte Hauptklasse WidgetDemoKAEActivity zeigt Listing 5.38.

package com.thomaskuenneth.androidbuch.widgetdemokae

import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.widgetdemo.*

private val TAG = WidgetDemoKAEActivity::class.simpleName
class WidgetDemoKAEActivity: AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.widgetdemo)
val params = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT)
apply.setOnClickListener {
val name = textfield.text.toString()
try {
val c = Class.forName(name)
val o = c.getDeclaredConstructor(Context::class.java)
.newInstance(this)
if (o is View) {
frame.removeAllViews()
frame.addView(o, params)
frame.forceLayout()
}
} catch (tr: Throwable) {
val str = getString(R.string.error, name)
Toast.makeText(this, str, Toast.LENGTH_LONG).show()
Log.e(TAG, "Fehler beim Instanzieren von $name", tr)
}
}
textfield.setOnEditorActionListener { _, _, _ ->
apply.performClick()
true
}
}
}

Listing 5.38    Die Klasse »WidgetDemoKAEActivity«

Wie Sie sehen, kann auf die Variablen apply, frame und textfield zugegriffen werden, ohne sie deklariert und mit findViewById() initialisiert zu haben. Solche synthetischen Eigenschaften werden von den Kotlin Android Extensions zur Verfügung gestellt und mit der Zeile

import kotlinx.android.synthetic.main.<Name des Layouts>.*

importiert. Alle Beispiele in den folgenden Kapiteln nutzen, sofern es sinnvoll ist, diesen äußerst praktischen Mechanismus. Wie bei jeder Magie gibt es aber auch hier ein paar Aspekte, die Sie in Erinnerung behalten sollten. Achten Sie darauf, die richtige Eigenschaft zu importieren, wenn Sie in unterschiedlichen Layouts den gleichen Namen (ID) verwenden. Der Name des Layouts ist Bestandteil der import-Anweisung, lässt sich also leicht prüfen. Das Gradle-Plugin generiert gar keine Eigenschaften, sondern wandelt den vorhandenen Code beim Build nur um. Aus apply.setOnClickListener { ... } wird folgender Java-Code:

((Button)this._$_findCachedViewById(id.apply))
.setOnClickListener((OnClickListener)(new OnClickListener() {
...

Die Referenzen werden also in einem Cache gehalten. Das ist zwar eigentlich ein Implementierungsdetail und könnte sich ändern, hat auf Ihren Code aber möglicherweise Auswirkungen. Wenn Sie nämlich Teile der Oberfläche neu entfalten, sind die gecachten Referenzen nicht mehr gültig, und Sie sollten ihn mit clearFindViewByIdCache() leeren.

Ein letzter Punkt: Rein formal sind die synthetischen Eigenschaften eine JetBrains-Erfindung und damit keine offizielle Android-Technologie. Im folgenden Abschnitt zeige ich Ihnen deshalb noch Googles Vorschlag, wie Sie findViewById() loswerden können.

View Binding

Auch bei View Binding werden Klassen generiert. Sie müssen das in der modulspezifischen build.gradle-Datei aktivieren:

android {
...
buildFeatures {
...
viewBinding = true
...
}
}

Listing 5.39    View Binding aktivieren (ab Android Studio 4.0)

Listing 5.39 zeigt das Vorgehen ab Android Studio 4.0. In früheren Versionen (ab 3.6) sah das noch folgendermaßen aus (falls Sie im Internet auf entsprechende Beispiele stoßen):

android {
viewBinding {
enabled = true
}
}

Listing 5.40    View Binding in älteren Android-Studio-Versionen aktivieren

Listing 5.41 zeigt die Klasse WidgetDemoViewBindingActivity meines Projekts WidgetDemoViewBinding. In der Methode onCreate() wird mit WidgetdemoBinding.inflate() ein WidgetdemoBinding-Objekt erzeugt. Die Eigenschaften apply, frame und textfield dieser generierten Klasse repräsentieren die Elemente mit der entsprechenden ID in der Layoutdatei widgetdemo.xml. root ist die Wurzel des Komponentenbaums. Sie wird an setContentView() übergeben.

package com.thomaskuenneth.androidbuch.widgetdemoviewbinding

import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.thomaskuenneth.androidbuch.widgetdemoviewbinding.databinding.WidgetdemoBinding

private val TAG = WidgetDemoViewBindingActivity::class.simpleName
class WidgetDemoViewBindingActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = WidgetdemoBinding.inflate(layoutInflater)
setContentView(binding.root)
val params = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT)
binding.apply.setOnClickListener {
val name = binding.textfield.text.toString()
try {
val c = Class.forName(name)
val o = c.getDeclaredConstructor(Context::class.java)
.newInstance(this)
if (o is View) {
binding.frame.removeAllViews()
binding.frame.addView(o, params)
binding.frame.forceLayout()
}
} catch (tr: Throwable) {
val str = getString(R.string.error, name)
Toast.makeText(this, str, Toast.LENGTH_LONG).show()
Log.e(TAG, "Fehler beim Instanzieren von $name", tr)
}
}
binding.textfield.setOnEditorActionListener { _, _, _ ->
binding.apply.performClick()
true
}
}
}

Listing 5.41    Die Klasse »WidgetDemoViewBindingActivity«

Wenn Sie den Komponentenbaum bereits auf andere Weise entfaltet haben, können Sie trotzdem View Binding nutzen, um auf findViewById() zu verzichten. Übergeben Sie dessen Wurzel einfach an <Name des Layouts>Binding.bind(). Die Methode liefert ein Objekt, auf das Sie wie nach einem inflate() zugreifen. Für jedes Layout in Ihrem Projekt entsteht eine Klasse, deren Name dem Schema <Name des Layouts>Binding folgt. Der erste Buchstabe wird großgeschrieben, Unterstriche entfallen.

Zweifellos wird Ihr Code mit synthetischen Eigenschaften und View Binding kürzer und weniger fehleranfällig. Für größere Projekte reicht das aber möglicherweise nicht aus, um sie langfristig wartbar zu halten. Wie Sie das erreichen, zeige ich Ihnen im nächsten Abschnitt.

 
Zum Seitenanfang

5.4.2    Android Architecture Components Zur vorigen ÜberschriftZur nächsten Überschrift

Die Struktur einer Android App lässt sich mit wenigen Sätzen beschreiben: Activities bilden die fachlichen Funktionseinheiten. Sie kommunizieren mit Intents. Wie Sie in Kapitel 6, »Multitasking«, noch sehen werden, kümmern sich Services um Hintergrundaktivitäten. Broadcast Receiver reagieren auf Systemereignisse, und Content Provider (über sie erfahren Sie in Kapitel 10, »Datenbanken«, mehr) ermöglichen den Zugriff auf tabellenartige Daten. Die Benutzeroberfläche wird zur Entwicklungszeit als baumartige Struktur definiert. Android macht dann zur Laufzeit einen Objektbaum daraus. Das klingt recht einfach, führt aber schon bei kleinen Apps (und die Beispiele dieses Buches sind klein) zu einer ganzen Menge Quelltext.

Sieht man sich den Code mit der Brille des Softwarearchitekten an, fällt auf, dass Activities Domänenobjekte (Variablen mit fachlichem Inhalt), Geschäftslogik (Methoden, die etwas berechnen) und UI-bezogene Funktionen (zum Beispiel Setzen von Farben und Texten) enthält. Je nach Zweck der App werden außerdem Lifecycle-Methoden überschrieben, zum Beispiel um eine Hintergrundverarbeitung zu initialisieren oder zu beenden. Man sagt deshalb, Benutzeroberfläche und Activities sind stark gekoppelt.

Für Beispiele ist das prima. Sie möchten den betreffenden Code möglichst am Stück sehen und sich nicht durch viele Dateien oder Klassen wühlen. Aber: Versuchen Sie einmal, sich vorzustellen, wie mein Code aussehen würde, wenn Stück für Stück UI-Komponenten hinzukommen oder die Geschäftslogik signifikant erweitert wird. Die Erfahrung zeigt, dass so etwas zunächst ganz gut funktioniert. Irgendwann wird der Quelltext dann aber unübersichtlich, es schleichen sich Fehler ein, die zunächst noch geflickt oder umgangen werden können. Nach mehreren Jahren gelingt auch das nicht mehr so richtig ...

LiveData und ViewModel

Eine enge Kopplung ist kein Android-spezifisches Problem. Viele andere UI-Frameworks haben oder hatten ihre Probleme damit. Aufbrechen lässt sie sich mit Entwurfsmustern. MVC (Model View Controller), MVP (Model View Presenter) und MVVM (Model View ViewModel) sorgen für eine Trennung der Zuständigkeiten und damit für bessere Wartbarkeit. Entwickler hätten von der ersten Android-Version an eines dieser Muster verwenden können. Das wurde von Google aber viele Jahre lang nicht propagiert. Erst auf der Entwicklerkonferenz I/O 2017 hat man die Android Architecture Components vorgestellt. Sie bestehen aus mehreren Bausteinen, unter anderem Lifecycle, LiveData und ViewModel. Um diese in Ihren Apps zu verwenden, tragen Sie sie in der modulspezifischen build.gradle-Datei im Block dependencies { ... } ein:

implementation "android.arch.lifecycle:extensions:1.1.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"

Wie Sie die Komponenten verwenden, zeige ich Ihnen anhand meiner App StopWatchDemo. Sie implementiert eine Stoppuhr mit den zwei Buttons Start bzw. Stop und Reset (Abbildung 5.19). Ihre Zeitanzeige sollte sich aktualisieren, wenn sich die seit dem Beginn der Messung vergangene Zeit ändert. Hierfür bietet sich das Entwurfsmuster Observer an. Es wird eingesetzt, wenn eine Komponente über Änderungen an einem anderen Objekt informiert werden möchte. LiveData implementiert dieses Muster. Das Besondere an LiveData ist, dass solche Objekte den Lebenszyklus anderer App-Komponenten kennen und darauf reagieren. Konkret werden Änderungen nur dann publiziert, wenn Fragmente oder Activities sichtbar und aktiv sind. Ist die Zeitanzeige meiner Stoppuhr nicht zu sehen, muss die App auch nicht versuchen, den aktualisierten Wert anzuzeigen. Wie das funktioniert, sehen Sie etwas später.

Die App »StopWatchDemo«

Abbildung 5.19    Die App »StopWatchDemo«

ViewModels liefern die Daten für Bedienelemente. Sie überleben Konfigurationsänderungen und sind vom Lebenszyklus von Activities und Fragmenten unabhängig. Idealerweise nutzen ViewModels keine Klassen des Android Frameworks. Keinesfalls aber dürfen sie App-Bausteine referenzieren. Das würde nämlich bedeuten, dass unter Umständen der Speicher von zerstörten Activities nicht freigegeben werden kann. Eigene Modelle leiten von androidx.lifecycle.ViewModel ab (Listing 5.42).

package com.thomaskuenneth.androidbuch.stopwatchdemo

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class StopWatchDemoViewModel : ViewModel() {
val running = MutableLiveData(false)
val diff = MutableLiveData(0L)
val started = MutableLiveData(0L)
}

Listing 5.42    Die Klasse »StopWatchDemoViewModel«

StopWatchDemoViewModel enthält drei Werte. running gibt an, ob die Stoppuhr gerade läuft. started speichert, wann mit der Messung begonnen wurde. Hierfür verwende ich System.currentTimeMillis(). diff enthält die Differenz zwischen started und der aktuellen Zeit. Das Modell speichert die Daten nicht direkt, sondern packt sie in androidx.lifecycle.MutableLiveData-Container. Warum, wird nach einem Blick auf die Klasse StopWatchDemoActivity (Listing 5.43) deutlich.

package com.thomaskuenneth.androidbuch.stopwatchdemo

import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import java.text.SimpleDateFormat
import java.util.*

class StopWatchDemoActivity : AppCompatActivity() {

private val dateFormat = SimpleDateFormat(
"HH:mm:ss:SSS",
Locale.US
)

init {
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
}

private val model: StopWatchDemoViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val observer = StopWatchDemoLifecycleObserver(model)
model.running.observe(this, { running: Boolean? ->
running?.let {
startStop.setText(if (it) R.string.stop else R.string.start)
reset.isEnabled = !it
}
})
model.diff.observe(this) { diff: Long? ->
diff?.let {
time.text = dateFormat.format(Date(it))
}
}
startStop.setOnClickListener {
model.running.value?.let { running ->
if (running) {
observer.stop()
} else {
observer.scheduleAtFixedRate()
}
model.running.value = !running
}
}
reset.setOnClickListener { model.diff.setValue(0L) }
lifecycle.addObserver(observer)
}
}

Listing 5.43    Die Klasse »StopWatchDemoActivity«

StopWatchDemoActivity überschreibt nur die Methode onCreate(). Neben dem Laden und Anzeigen der Benutzeroberfläche finden folgende Aktionen statt:

  • Registrieren von Callbacks bei Änderungen am ViewModel

  • LifecycleObserver mit addObserver() erzeugen und aktivieren

Die Referenz auf das ViewModel wird bei der Initialisierung der Variable model mit StopWatchDemoViewModel by viewModels() ermittelt. Damit das funktioniert, muss wie weiter oben gezeigt in der build.gradle-Datei die Abhängigkeit androidx.fragment:fragment-ktx eingetragen werden. Änderungen am Datenmodell haben üblicherweise Auswirkungen auf die Benutzeroberfläche. Wechselt beispielsweise der Wert running von false auf true, muss unter anderem die Schaltfläche Reset deaktiviert werden. Ändert sich der Wert von diff, wird die Zeitanzeige aktualisiert. Hierzu wird mit observe() ein Callback registriert. Beim Aufruf erhält dieser den aktuellen Wert aus dem Modell.

LifecycleObserver und LifecycleOwner

StopWatchDemo nutzt java.util.Timer und java.util.TimerTask, um die seit dem Beginn der Messung verstrichene Zeit zu aktualisieren. Ohne die Architecture Components würde sich der Code hierfür vermutlich in den Activity-Methoden onResume() und onPause() befinden. Aus Gründen der Wartbarkeit ist es zielführend, ihn in eigene Klassen zu verlagern. Möglich macht dies ein weiterer Bestandteil der Architecture Components. Lifecycle hält den lebenszyklus-bezogenen Zustand einer Komponente und erlaubt anderen Objekten, diesen zu beobachten und auf Zustandsänderungen zu reagieren. Es gilt: Komponenten, die an Lebenszyklusänderungen interessiert sind, implementieren das Interface LifecycleObserver. Klassen, die einen Lebenszyklus haben, implementieren LifecycleOwner.

Die Methode addObserver() gehört zu der abstrakten Klasse androidx.lifecycle.Lifecycle. Ein Objekt dieses Typs wird von der Methode getLifecycle() (in Kotlin einfach lifecycle) geliefert, die einzige Methode des Interface androidx.lifecycle.LifecycleOwner. Es wird von der Klasse androidx.appcompat.app.AppCompatActivity implementiert. Apps, die nicht auf AppCompatActivity aufbauen können oder möchten, implementieren das Interface LifecycleOwner und liefern über getLifecycle() eine eigene Lifecycle-Instanz.

[+]  Tipp

Es hat sich gezeigt, dass die Activity-Lifecycle-Methoden recht schnell unübersichtlich werden, wenn mehrere Komponenten initialisiert und freigegeben werden müssen. Ganz im Sinne der Trennung von Abhängigkeiten sollten Sie in solchen Fällen die Architecture Components verwenden und für jede Komponente einen eigenen LifecycleObserver spendieren.

Aber warum spielt der Lebenszyklus eigentlich eine so wichtige Rolle? Wie Sie wissen, sind Android-Apps kein monolithischer Block-Code, der einmal geladen und danach bis zum Programmende im Speicher gehalten wird. Apps bestehen aus Bausteinen, die zwar üblicherweise durch den Benutzer aufgerufen, danach aber vom System verwaltet werden. Wie lange eine Activity im Speicher verbleibt, lässt sich nicht vorhersagen. Konfigurationsänderungen und Speichermangel führen zu ihrer Zerstörung und (falls nötig) erneuten Erzeugung. Bereits eingegebene Daten dürfen dabei natürlich nicht verloren gehen. Deshalb sind ViewModels vom Lebenszyklus von Activities und Fragmenten unabhängig.

Die Klasse StopWatchDemoLifecycleObserver ist in Listing 5.44 zu sehen. LifecycleObserver werden (zum Beispiel in Activities) mit addObserver() hinzugefügt. Sie enthalten mit der Annotation @OnLifecycleEvent versehene Methoden. Diese werden aufgerufen, wenn ein LifecycleOwner (eine Activity oder ein Fragment) aufgrund eines Ereignisses einen bestimmten Zustand (ON_RESUME oder ON_PAUSE) erreicht.

package com.thomaskuenneth.androidbuch.stopwatchdemo

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import java.util.*

class StopWatchDemoLifecycleObserver
internal constructor(private val model: StopWatchDemoViewModel)
: LifecycleObserver {

private lateinit var timer: Timer
private lateinit var timerTask: TimerTask

@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun startTimer() {
timer = Timer()
val running = model.running.value ?: false
if (running) {
scheduleAtFixedRate()
}
}

@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun stopTimer() {
timer.cancel()
}

fun stop() {
timerTask.cancel()
}

fun scheduleAtFixedRate() {
val now = System.currentTimeMillis()
val diff = model.diff.value ?: now
model.started.value = now - diff
timerTask = object : TimerTask() {
override fun run() {
model.started.value?.let {
model.diff.postValue(System.currentTimeMillis() - it)
}
}
}
timer.scheduleAtFixedRate(timerTask, 0, 200)
}
}

Listing 5.44    Die Klasse »StopWatchDemoLifecycleObserver«

Wird die Activity StopWatchDemoActivity fortgesetzt, wird ein Objekt des Typs java.util.Timer erzeugt. Es wird verwendet, um nach Anklicken des Startknopfs mit scheduleAtFixedRate() alle 200 Millisekunden mithilfe einer TimerTask den Wert diff des ViewModels mit postValue() zu aktualisieren.

[»]  Hinweis

postValue() sorgt dafür, dass die Aktualisierung auf dem Mainthread stattfindet. Warum das wichtig ist, erkläre ich Ihnen in Kapitel 6, »Multitasking«.

Um auf das ViewModel zugreifen zu können, wird dem StopWatchDemoLifecycleObserver-Konstruktor eine Referenz auf StopWatchDemoViewModel übergeben. Beim Pausieren der Activity wird die Methode stopTimer() aufgerufen. Sie beendet den Timer mit cancel().

 


Ihre Meinung?

Wie hat Ihnen das Openbook gefallen? Wir freuen uns immer über Ihre Rückmeldung. Schreiben Sie uns gerne Ihr Feedback als E-Mail an kommunikation@rheinwerk-verlag.de

<< zurück
Zur Rheinwerk-Konferenz für Kotlin
 Buchempfehlungen
Zum Rheinwerk-Shop: Kotlin

Kotlin


Zum Rheinwerk-Shop: Praxisbuch Usability und UX

Praxisbuch Usability und UX


Zum Rheinwerk-Shop: Flutter und Dart

Flutter und Dart


Zum Rheinwerk-Shop: App-Design

App-Design


 Lieferung
Versandkostenfrei bestellen in Deutschland, Österreich und in die Schweiz
InfoInfo

 
 


Copyright © Rheinwerk Verlag GmbH 2023
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das Openbook denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt.
Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.

 
[Rheinwerk Computing]

Rheinwerk Verlag GmbH, Rheinwerkallee 4, 53227 Bonn, Tel.: 0228.42150.0, Fax 0228.42150.77, service@rheinwerk-verlag.de

Cookie-Einstellungen ändern