Antipatterns bei der Android-Entwicklung
Android-Entwicklung kommt mit seinen Eigenheiten, spezifischen Dos and Don’ts einher. Dieser Artikel fasst einige praktische Entwickler Erfahrungen zusammen. Man muss ja nun nicht alle Fehler selber machen 😉.
Eine "responsive" App entwickeln
Android ist wie MacOS oder Windows ein Betriebssystem (mit einem speziell angepassten und optimierten Linux Kernel), welches auf einer komplexen Hardware (Sensoren, Kameras, Biometriesysteme, etc.) lauffähig sein muss. Android bietet mit Android Studio (IDE) und dem zugrundeliegenden Android SDK (APIs, CLI-Werkzeuge) die Möglichkeit, native Apps für Smartphones, Tablets, Smart Watches, Smart TVs, Autos zu entwickeln.
Eines der wesentlichen Konzepte bei der Android Entwicklung ist die Aktivität (umgesetzt mittels einer Java- bzw. Kotlin Activity
Klassenstruktur). Jede Aktivität verfügt über ein zugehöriges Layout in einer .xml
Datei, in welcher man jedem dargestelltem Element Attribute zuordnen kann. Quellcode und Layout sind also getrennt und werden parallel zueinander mit separaten Werkzeugen in Android Studio entwickelt.
Im Layout ist zu beachten, dass man Maße nicht explizit in Pixel festlegen sollte, da nicht auf jedem Bildschirm Pixel gleich Pixel ist.
Daher sollte man auf dynamische, von der Hardware abstrahierende Einheiten zurückgreifen.
Einheit | Beschreibung |
---|---|
dp/dip |
Basiert auf der Pixel-Dichte des Bildschirms |
sp |
Basiert auf Pixeldichte und Schriftgröße |
Vermeiden sollte man also:
<ImageView
android:id="imageView1"
android:layout_width="20px"
android:layout_height="40px"
/>
Besser:
<ImageView
android:id="imageView2"
android:layout_width="15dp"
android:layout_height="30dp"
/>
Note
|
Anmerkung
Nicht nur Größe und Auflösung des Bildschirms unterscheiden sich von Gerät zu Gerät. Auch die Performance des Prozessors ist z.B. beim Laden von hochauflösenden Bildern und Videos wichtig. |
Die richtigen Libraries nutzen
Seiten wie Android Arsenal bieten zwar viele Libraries, jedoch sind viele davon nicht unbedingt empfehlenswert. Dies kann daran liegen, dass sie entweder nicht mehr weiterentwickelt und gepflegt werden oder für den jeweiligen Anwendungsfall zu überladen sind und unnötig wertvollen Speicherplatz in dem Installationspaket (APK) belegen.
Note
|
Android verfügt über ein Methodenlimit von 65536, inklusive der Android Core Funktionen, aller Library Funktionen und den eigenen Funktionen. Dem kann man mit einem Verfahren namens MultiDex zwar entgegenwirken; besser ist natürlich eine saubere Pflege der Abhängigkeiten. |
Dennoch sollte man grundsätzlich Libraries nutzen, um sich das Entwicklerleben einfacher zu machen. Die richtige Auswahl machts! Für HTTP-Anfragen ist sich die Community beispielsweise einig, OkHttp zu nutzen.
Der Android-Arsenal User futurice hat zu dem Thema eine umfassende Liste zusammengestellt, welche Libraries ein "Must-Use" auf ihrem Gebiet sind.
Android Ressourcen nutzen
Die Android Ressourcen sind ein praktisches Werkzeug, um eine App um einiges leichter wartbar zu machen. Bilder können beispielsweise zentral in verschiedenen Größen gespeichert werden. Dies ist z.B. wichtig, wenn man Tablets unterstützen möchte.
Note
|
Um vollständige Abdeckung zu garantieren kann man direkt den nächsten Schritt machen und auf Vektor-Grafiken umsteigen. |
Eine weitere Einsatzmöglichkeit der Android Ressourcen ist die einfache Übersetzung der App mittels des Translation Editors in Android Studio. Dieser macht es einfach, alle Strings in der App einheitlich und konsistent zu übersetzen.
Den Editor kann man direkt mit einem Rechtsklick auf die Ressource strings.xml
aufrufen.
Durch die an gleicher Stelle vorhandene Ressource color.xml
können auch global aufrufbare Farben festgelegt werden, welche den Design- sowie Wartungsprozess einer App deutlich vereinfachen können.
Button button = new Button("TestButton");
button.setColor(getResources().getColor(R.color.buttonColor));
<resources>
<color name="buttonColor">#0d2d3e</color>
<resources>
Dies erspart viel Arbeit wenn die App in eine andere Sprache übersetzt werden soll oder wenn in einem neuen Release das Design der App verändern möchte.
Den Main-Thread frei halten
Der Main-Thread der App dient insbesondere der Darstellung des User-Interfaces und dem damit verbundenen Event-Handling (Benutzereingaben, etc.).
Länger laufende Netzwerk-, Datenbankanfragen o.ä. sollten nicht synchron ablaufen, da die Benutzeroberfläche sonst nicht mehr bzw. stark verzögert auf Eingaben reagiert. [r1]
Note
|
Unterschied: Synchron und Asynchron
Ein synchron aufgerufener Code pausiert alles andere bis er zu Ende gelaufen ist. Asynchroner Code hingegen läuft parallel zum anderen Code, wodurch die App noch flüssig weiter laufen kann. |
Hier kommen dann sogenannte Threads
ins Spiel, mit welchen wir länger andauernde Aktionen in den Hintergrund verlagern können.
public class TestThread extends Thread {
@Override
public void run(){
//Hier im Hintergrund laufenden Code einfügen
}
}
//Dieser kann dann aus einer anderen Klasse aufgerufen werden:
TestThread testThread = new TestThread();
testThread.start();
Ein Thread kann auch "inline" eingefügt werden:
Thread thread = new Thread(new Runnable(){
public void run(){
//Hier im Hintergrund laufenden Code einfügen
}
});
thread.start() //Startet den Thread
Richtig angewandt können wir mit Threads die Benutzererfahrung deutlich verbessern ohne auf Qualität verzichten zu müssen. Jedoch sollte man mit dem Threading nicht übertreiben, da schon wenige parallel laufende Threads einen schwachen Smartphone Prozessor in die Knie zwingen können.
Anmerkung
Ein Thread kann nicht auf Layout-Elemente zugreifen, also beispielsweise Text manipulieren. Dort bietet Android eine Möglichkeit die Änderung synchron, also im Main-Thread ablaufen zu lassen.
TextView textView = findViewById(R.id.textView);
new Thread(new Runnable(){
@Override
public void run(){
// Netzwerk lädt Text zum Plazieren in Variable "text"
(ExampleClass.this).runOnUiThread(new Runnable(){ //Context der Klasse wird genutzt um eine Runnable im Main Thread auszuführen
textView.setText(text); //TextView legt neuen Text fest
});
}
}).start();
Anwendungszustand separieren
Ein zentraler Aspekt jeder App sollte die Separation des Anwendungszustandes sein. Er hilft dabei, dass der Endnutzer bei jedem Neustart der App die selben Eingaben nicht immer und immer wieder tätigen muss.
Android bietet hierzu einfach zu nutzende APIs; der InstanceState
sowie die SharedPreferences
sind direkt verfügbar.
Der InstanceState
liegt im Arbeitsspeicher des Gerätes, daher sollte man dort nur kurzzeitig wichtige Informationen ablegen, wie beispielsweise die Position eines Videos oder den Fortschritt eines Prozesses. Außerdem sollten wir noch beachten, dass der InstanceState
nicht immer zuverlässig wiederhergestellt wird und auch Informationen "verschluckt" werden können (das Drehen und der damit verbundene Orientierungswechsel führen z.B. zu einem grundsätzlichen Zustandswechsel (Lifecycle s.u.) der Aktivität).
Die SharedPreferences
hingegen sind im lokalen Speicher der App abgespeichert, also verhältnismäßig langlebig. Hier kann über lange Zeit wichtige Informationen abgespeichert werden, etwa ein Highscore eines Spiel. Die Daten können auch nicht so einfach wieder gelöscht werden. Sie werden beim Deinstallieren der Anwendung, oder wenn der User in den Einstellungen dies explizit anstößt, gelöscht.
Um die SharedPreferences
sinnvoll nutzen zu können sollte man den Android Activity Lifecycle kennen. Das ist der Ablauf, den jede Aktivität vom ersten Aufruf bis zur Beendigung durchläuft.
Event | Beschreibung |
---|---|
|
Wird bei Start/Restart/Fortführung aus dem Arbeitsspeicher der Anwendung aufgerufen. |
|
Wird nach Wiederherstellung des |
|
Wird direkt vor Speichern des |
|
Wird aufgerufen wenn die Aktivität nicht mehr sichtbar ist |
|
Wenn die Aktivität beendet wird oder die App "gekillt" wurde |
Note
|
Anmerkung: |
/*
Das Video, welches abgespielt wird, soll am selben Punkt weitermachen wo man aufgehört hat, sobald man die App neustartet.
*/
private VideoView videoView;
static String VIDEO_OFFSET = "video_offset";
public class VideoPlayer {
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
videoView = findViewById(R.id.testVideo);
videoView.setVideoURI(URI.parse("android.resource://" + getPackageName() + "/test.mp4")); //Ruft Video im eigenen Paket ab.
if (videoView != null) {
if (savedInstanceState.getInt(VIDEO_OFFSET) != null) throw new Exception("First Start."){ //Überprüft ob schon ein Wert gespeichert wurde.
videoView.seekTo(savedInstanceState.getInt(VIDEO_OFFSET)); //Springt zum Punkt, welcher im savedInstanceState gespeichert wurde
}
videoView.start(); //Spielt das Video ab
}
[...]
}
@Override
public void onSavedInstanceState(Bundle savedInstanceState){
super.onSavedInstanceState(savedInstanceState);
savedInstanceState.putInt(VIDEO_OFFSET, videoView.getCurrentPosition()); //Legt den aktuelle Videoposition im savedInstanceState ab.
}
}
Die Vorteile, die sich daraus ergeben liegen auf der Hand. Videos werden nicht immer von vorn gespielt (auch nicht bei einer Drehung des Bildschirms - hier wird standardmäßig onCreate()
aufgerufen).
final static String HIGHSCORE = "highscore";
//Abrufen eines Wertes
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
int defaultValue = 0;
int result = preferences.getInt(HIGHSCORE, defaultValue);
//Speichern eines Wertes
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
int newScore = 10;
editor.putInt(HIGHSCORE, newScore);
editor.apply();
//Entfernen eines Wertes
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this).edit();
editor.remove(HIGHSCORE);
editor.apply();
Jeder Wert kann nach seiner Festlegung unter seinem Identifier
, im Beispiel HIGHSCORE
abgefragt werden. Sollte kein Wert festgelegt sein, wird der definierte Standardwert wiedergegeben. Mögliche Typen sind String
, int
, Float
, Long
, Boolean
und StringSet
. Soll beispielsweise ein JSONObject
gespeichert werden müsste man es zuerst mit .toString()
zu seinem Text-Äquivalent verwandeln.
Jedoch ist jeder Wert in seiner Größe bei etwa 1,44 Megabyte begrenzt, was etwa 1.400.000 Zeichen entspricht, weshalb es beispielsweise nicht sinnvoll ist, Bilder abzulegen.
Die Separation wichtiger Variablen in eine eigene Klasse kann auch vom Vorteil sein. Dies ermöglicht schnelle und konsistente Abänderungen von zentralen Variablen (z.B. Animationsdauer).
public class AppState {
static final int animationDurationTitle = 600;
static final int animationDurationSubtitle = 350;
static final int animationDurationImage = 330;
}
public class Animation {
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
ImageView imageView = findViewById(R.id.imageView1);
imageView.animate() // Einfache Animation, skaliert das Bild x2
.scaleX(2)
.scaleY(2)
.setDuration(AppState.animationDurationImage) // Dauer wird aus der AppState Klasse ausgelesen
.start();
}
}
Wenn wir das auf die komplette Anwendung angewandt haben, können wir die Feinheiten der Anwendung im Nachhinein schnell verändern.
Tests nutzen
Um eine möglichst fehlerfreie Erfahrung für die Nutzer einer App zu gewährleisten, sollten Tests verwendet werden. Tests sollten vor allem eingesetzt werden, wenn User-Input verwendet wird, um mögliche Verarbeitungsfehler zu vermeiden. Ein Beispiel dafür sind kyrillische Schriftzeichen, welche eventuell mit der genutzten Schriftart, Datenbank oder Textverarbeitung nicht funktionieren.
Beispiel: Ein Test, der überprüft, ob die App bei unüblichen Zeichen abstürzt:
@RunWith(RobolectricTestRunner.class)
public class UnicodeTest {
private Activity activity;
private EditText editText;
@Before
public void setup() {
ShadowLog.stream = System.out;
activity = Robolectric.buildActivity(TestActivity.class)
.create()
.get();
editText = activity.findViewById(R.id.editText);
}
@Test
public void testAllUnicodeChars() {
for (int i = 0; i <= 65535; i+=5){
String unicode1 = Character.toString((char) i);
String unicode2 = Character.toString((char) (i+1));
String unicode3 = Character.toString((char) (i+2));
String unicode4 = Character.toString((char) (i+3));
String unicode5 = Character.toString((char) (i+4));
// Checkt alle UNICODE Chars in Reihenfolge.
ShadowLog.v("Trying Text: ", unicode1 + unicode2 +
unicode3 + unicode4 + unicode5);
editText.setText(unicode1 + unicode2+ unicode3 +
unicode4 + unicode5);
//Leert das Textfeld wieder.
editText.setText("");
}
// Überprüft, ob die App abgestürzt ist.
assertEquals(TestActivity.class, activity.getClass());
}
}
Üblicherweise sollten alle Zeichen des Unicode Charsets unterstützt oder abgefangen werden, aber in keinem Falle die App zum Absturz bringen.
//Überprüfen ob unsere oben eingebaute Funktion funktioniert.
@RunWith(RobolectricTestRunner.class)
public class VideoTest{
static String VIDEO_OFFSET = "video_offset";
@Test
public void test(){
//Erstellt ein Bundle
Bundle customInstanceState = new Bundle();
Random random = new Random();
// Zum Testen reicht eine Zufallszahl
int expectation = random.nextInt(10000);
customInstanceState.putInt(VIDEO_OFFSET, expectation);
Activity activity = Robolectric.buildActivity(videoPlayer.class)
.create(customInstaceState) instanceState
.resume()
.get();
VideoView videoView = activity.findViewById(R.id.testVideo);
//Vergleicht die Videoposition mit dem erwarteten wert.
assertThat(videoView.getCurrentPosition(), equalTo(expected));
}
}
Zu den Themen Android und Java (und vielen anderen) bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:
Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.