Neuigkeiten von trion.
Immer gut informiert.

Antipatterns bei der Android-Entwicklung

Android

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.

Table 1. Dynamische Einheiten
Einheit Beschreibung

dp/dip

Basiert auf der Pixel-Dichte des Bildschirms

sp

Basiert auf Pixeldichte und Schriftgröße

Schlechte Skalierung von Elementen
Vermeiden sollte man also:
<ImageView
  android:id="imageView1"
  android:layout_width="20px"
  android:layout_height="40px"
/>
Bessere Skalierung von Elementen mit "dp"
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.

translation editor
Abbildung 1. Translation Editor

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.

Beispiel Zugriff auf Ressourcen im Code
Button button = new Button("TestButton");
button.setColor(getResources().getColor(R.color.buttonColor));
Beispiel color.xml
<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.

Beispiel Thread als separate Klasse
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:

Beispiel Thread inline
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.

Beispiel UI-Thread
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();
Note

Zum asynchronen Laden von Bildern sollten Libraries wie Picasso oder Glide verwendet, da sie kompakt, robust und performant. Eigene Implementationen sollte man also vermeiden.

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.

Table 2. Android Lifecycle in Reihenfolge
Event Beschreibung

onCreate() / onRestart() / onResume()

Wird bei Start/Restart/Fortführung aus dem Arbeitsspeicher der Anwendung aufgerufen.

onRestoreInstanceState()

Wird nach Wiederherstellung des InstanceState ausgeführt.

onSavedInstanceState()

Wird direkt vor Speichern des InstanceState aufgerufen, unsere letzte Möglichkeit noch etwas abzuspeichern!

onPause()

Wird aufgerufen wenn die Aktivität nicht mehr sichtbar ist

onStop()

Wenn die Aktivität beendet wird oder die App "gekillt" wurde

Note

Anmerkung: onCreate() verfügt auch über die Möglichkeit Daten aus dem InstanceState auszulesen!

Beispiel Video Player
/*
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).

Syntax SharedPreferences
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).

Beispiel externalisierte 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.

Beispiel: Test Video Player
//Ü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.

Feedback oder Fragen zu einem Artikel - per Twitter @triondevelop oder E-Mail freuen wir uns auf eine Kontaktaufnahme!

Los geht's!

Bitte teilen Sie uns mit, wie wir Sie am besten erreichen können.