Android-Wear-Micro-Apps

Android am Arm: Wie Sie eine eigene Android-Wear-App programmieren

Peter Friese

© Shutterstock/JMicic

Mehr Zeit in der echten Welt und weniger Zeit in unseren Smartphones – dies ist eines der Versprechen, mit denen Android Wear angetreten ist. Wie dies mit Micro-Apps möglich wird, zeigt dieser Artikel.

Im ersten Teil dieser Artikelserie über Android Wear haben wir uns mit der Entwicklung von Watchfaces beschäftigt. Die Anzeige der Zeit ist wohl eine der zentralsten Funktionen einer jeden Uhr und einer der Hauptgründe, sich ein Stück Metall an den Arm zu binden. Dies gilt natürlich auch für Smartwatches, doch wie der Name schon andeutet, können Smartwatches viel mehr als nur die Zeit anzeigen. In diesem Artikel gehen wir auf das „Smart“ in Smartwatch ein und beschäftigen uns damit, wie man so genannte Micro-Apps entwickeln kann, die auf Android Wear Smartwatches laufen können.

Zwar ist es technisch möglich, Apps zu entwickeln, die ausschließlich auf der Uhr laufen, und im Play Store ist eine Fülle von Standalone-Apps verfügbar, die die unterschiedlichsten Anwendungsfälle abdecken. Allerdings lassen sich viel mächtigere Apps bauen, wenn man die Fähigkeiten des mit der Uhr verbundenen Handhelds (in den meisten Fällen ein Android Phone) mit einbezieht. Neben dem Zugriff auf die zusätzlichen Sensoren, die Internetverbindung und die auf dem Smartphone enthaltenen Daten lässt sich so ein Teil der Datenverarbeitung von der Smartwatch auf das Smartphone auslagern, um Energie zu sparen bzw. von der höheren Rechenleistung zu profitieren. Aus Gründen der Energieeffizienz ist es derzeit z. B. nicht möglich, vom Wearable direkt auf das Internet zuzugreifen – stattdessen muss das Smartphone sozusagen als Relay verwendet werden. Wie das funktioniert, schauen wir uns im Verlauf dieses Artikels an.

Um den Artikel ein wenig anschaulicher zu gestalten, wird dem Artikel eine App als Grundlage dienen, die es Kaffeeliebhabern ermöglicht, ihrer Leidenschaft auf verantwortungsvolle Weise zu frönen. Von den vielen erdenklichen Use Cases werden wir in diesem Artikel folgende implementieren:

  1. Aufzeichnen des täglichen Kaffeekonsums: Hierzu ruft der Anwender die App auf dem Wearable auf, um dann aus einer Liste koffeinhaltiger Getränke dasjenige auszuwählen, das er oder sie zu trinken gedenkt. Das Koffein-Log wird so persistiert, dass es später auf dem Handheld oder auf einem Watchface (z. in Form eines Fortschrittsbalkens) angezeigt werden kann.
  2. Auffinden eines Cafés in der Nähe: In der Endausbaustufe sollte die mobile App die in der Umgebung befindlichen Cafés über ein im Hintergrund laufendes Location-Update ermitteln und dann als Notification auf dem Wearable anzeigen. Für diesen Artikel werden wir diesen Use Case ein wenig vereinfachen und manuell über das UI auslö

Anhand dieser Use Cases werden wir die folgenden technischen Aspekte von Android Wear betrachten:

  1. Implementierung von Wearable-UIs: Neben einfachen und komplexeren Listen sowie Hilfsanzeigen wie z. B. Bestätigungsanzeigen werden wir uns damit beschäftigen, wie man Layouts für runde und eckige Displays gestalten kann.
  2. Datenübertragung zwischen dem Wearable und dem Handset: Hier werden wir vor allem mit dem Data API arbeiten, aber auch darauf eingehen, wie Notifications eingesetzt werden können, um Daten zwischen den Nodes des Android-Wear-Netzwerks zu übertragen.

Implementierung einer Wearable-App

Android-Wear-Apps sind aus architektonischer Sicht ganz normale Android-Apps, die aus den üblichen Android-Komponenten wie Activities, Services, BroadcastReceivers, Intents etc. bestehen. Die Distribution von Wearable-Apps erfolgt als Micro-APK. Hierbei handelt es sich um ein ganz normales APK, das während des Build-Prozesses von Gradle in das APK der zugehörigen mobilen Companion-App eingebettet wird. Die Installation des Wearable-APKs erfolgt automatisch, sobald ein Android-Wear-Gerät mit dem entsprechenden Handset verbunden ist. Steht ein Update für die mobile App im Play Store zur Verfügung, wird auch das Wearable-APK automatisch aktualisiert. Wenn der Benutzer die mobile App von seinem Smartphone deinstalliert, wird die zugehörige Wearable-App ebenfalls von der verbundenen Smartwatch entfernt. Packaging und Signing des Micro-APKs sowie des Mobile-APKs werden automatisch von Gradle übernommen. Android Studio stellt im New Project Wizard ein Template zur Verfügung, welches die entsprechende Projektstruktur definiert und passende Build-Skripte enthält.

Während der Entwicklungszeit ist es erforderlich, die App für die Smartwatch sowie die App für das Smartphone individuell aus Android Studio heraus zu deployen. Basierend auf der Projektstruktur stehen dazu entsprechende Targets zur Verfügung.

Um eine Android-Wear-App zu entwickeln, muss man entweder ein neues Projekt anlegen oder ein Wearable-Modul zu einem existierenden Projekt hinzufügen. Für diesen Artikel legen wir ein neues Projekt mit einem Modul für die mobile App sowie einem zweiten Modul für die Wearable-App an.

Das Eckige muss ins Runde

Anders als andere Plattformen unterstützt Android Wear sowohl eckige als auch runde Displays, was beim Layout des UI beachtet werden muss. Die Plattform stellt prinzipiell zwei Möglichkeiten zur Verfügung, um für beide Formfaktoren zu layouten:

  1. Verwendung der Klasse WatchViewStub, um je nach Displaygeometrie das passende Layout für runde bzw. eckige Displays zu laden. Dies ist der Ansatz, den der Wizard beim Anlegen eines neuen Projekts wä Offensichtlicher Vorteil: Man kann das Layout an die Bauform der Uhr anpassen, was z. B. von einigen Fitness-Apps dazu genutzt wird, um auf runden Uhren runde Fortschrittsanzeigen zu verwenden. In Listing 1 ist zu sehen, dass mittels der Attribute rectLayout und roundLayout die jeweiligen Layouts geladen werden.
  2. Verwendung der Klasse BoxInsetLayout. Es handelt sich hierbei um eine Subklasse von FrameLayout, die sich automatisch an die Displaygeometrie anpasst. Auf eckigen Displays wird die komplette zur Verfügung stehende Fläche genutzt. Auf runden Displays zentriert ein BoxInsetLayout seine eckige Displayfläche so, dass die Diagonale des Frames dem Durchmesser des Displays entspricht.
<?xml version="1.0" encoding="utf-8"?>
<android.support.wearable.view.WatchViewStub
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:id="@+id/watch_view_stub"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  app:rectLayout="@layout/rect_activity_drink_selection"
  app:roundLayout="@layout/round_activity_drink_selection"
  tools:context=".DrinkSelectionActivity"
  tools:deviceIds="wear">
</android.support.wearable.view.WatchViewStub>

Beide Layouts unterstützen die Verwendung einer Hintergrundgrafik, die auch auf runden Displays die komplette zur Verfügung stehende Fläche ausnutzt.

Da wir kein spezielles Layout für runde Displays verwenden wollen, ist für unsere Zwecke das BoxInsetLayout ausreichend, weswegen wir die beiden Layoutdateien rect_activity_drink_selection.xml und ro und _activity_drink_selection.xml löschen können.

Zur Darstellung der koffeinhaltigen Getränke können wir eine Liste verwenden. Android Wear stellt mit der Klasse WearableListView eine an die Spezifika der vergleichsweise kleinen Bildschirme von Wearables angepasste Listenimplementierung zur Verfügung. Die gleiche Implementierung wird auch in der Settings-App auf Android-Wear-Geräten eingesetzt und dürfte daher den meisten bekannt sein – siehe Abbildung 1.

Abb. 1: Screenshot Android-Wear-Settings-App

Abb. 1: Screenshot Android-Wear-Settings-App

Die vom Projekt-Wizard angelegte Layoutdefinition in activity_drink_selection.xml können wir also durch ein BoxInsetLayout ersetzen, das wiederum ein FrameLayout und eine WearableListView enthält (Listing 2).

<?xml version="1.0" encoding="utf-8"?>
<android.support.wearable.view.BoxInsetLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:background="@drawable/coffee_beans_background"
  android:layout_height="match_parent"
  android:layout_width="match_parent"
  android:padding="15dp">
  <FrameLayout
    android:id="@+id/frame_layout"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    app:layout_box="left|bottom|right">
    <android.support.wearable.view.WearableListView
      android:id="@+id/wearable_list"
      android:layout_height="match_parent"
      android:layout_width="match_parent">
    </android.support.wearable.view.WearableListView>
  </FrameLayout>
</android.support.wearable.view.BoxInsetLayout>

Die einzelnen Einträge in der Liste bestehen aus einem Icon, welches das Getränk repräsentiert, sowie einem Label mit dem Namen des Getränks. Genau wie in den Android Wear Settings soll das Icon und der es umgebende Kreis für den aktuell ausgewählten Eintrag optisch hervorgehoben werden, indem sie vergrößert dargestellt werden, wenn sie sich in der Mitte des Bildschirms befinden. Hierfür müssen wir zunächst das Layout für die Listeneinträge definieren. Für die Darstellung eines Icons innerhalb eines Kreises stellt die Android Wear Support Library die Klasse CirledImageView zur Verfügung. Mittels der Attribute circle_radius und circle_radius_pressed kann der Radius des Kreises im Normalzustand und beim Antippen festgelegt werden (Listing 3).

<?xml version="1.0" encoding="utf-8"?>
<de.peterfriese.kaffeinated.WearableListItemLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:gravity="center_vertical"
  android:layout_width="match_parent"
  android:layout_height="80dp">
  <android.support.wearable.view.CircledImageView
    android:id="@+id/image"
    android:alpha="0.5"
    android:layout_height="42dp"
    android:layout_width="42dp"
    android:layout_marginStart="6dp"
    app:circle_border_color="#FFFFFFFF"
    app:circle_border_width="2dp"
    app:circle_radius="20dp"
    app:circle_radius_pressed="18dp"
  />
  <TextView
    android:id="@+id/name"
    android:gravity="center_vertical|start"
    android:layout_width="wrap_content"    android:layout_marginEnd="6dp"
    android:layout_height="match_parent"
    android:fontFamily="sans-serif-condensed-light"
    android:lineSpacingExtra="-4sp"
    android:textColor="@color/text_color"
    android:textSize="16sp"/>
</de.peterfriese.kaffeinated.WearableListItemLayout>

Die von der vertikalen Position der Listeneinträge abhängige Animation des Icons und des Labels erfolgt in der Layoutklasse WearableListItemLayout mithilfe eines ObjectAnimator. Diese Dokumentation beschreibt, wie mithilfe dieser Klasse einzelne Properties animiert werden können. Um zu ermitteln, wann sich ein Listeneintrag in der Mitte des Bildschirms befindet, implementiert die Klasse WearableListItemLayout das Interface WearableListView.OnCenterProximityListener. Befindet sich ein Listeneintrag in der Mitte des Bildschirms, wird die Methode onCenterPosition aufgerufen. Verlässt ein Eintrag die Mitte des Bildschirms, wird die Methode onNonCenterPosition aufgerufen (Listing 4).

package de.peterfriese.kaffeinated;

import android.content.Context;
import android.support.wearable.view.CircledImageView;
import android.support.wearable.view.WearableListView;
import android.util.AttributeSet;
import android.widget.LinearLayout;
import android.widget.TextView;

public class WearableListItemLayout extends LinearLayout
implements WearableListView.OnCenterProximityListener {

  private CircledImageView mCircle;
  private TextView mName;

  public WearableListItemLayout(Context context) {
    this(context, null);
  }

  public WearableListItemLayout(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public WearableListItemLayout(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
  }

  // Get references to the icon and text in the item layout definition
  @Override
  protected void onFinishInflate() {
    super.onFinishInflate();
    // These are defined in the layout file for list items
    // (see next section)
    mCircle = (CircledImageView) findViewById(R.id.image);
    mName = (TextView) findViewById(R.id.name);
  }

  @Override
  public void onCenterPosition(boolean animate) {
    mName.animate().alpha(1);
    mCircle.animate().scaleX(1f).scaleY(1f).alpha(1);
  }

  @Override
  public void onNonCenterPosition(boolean animate) {
    mCircle.animate().scaleX(0.8f).scaleY(0.8f).alpha(0.6f);
    mName.animate().alpha(0.6f);
  }
}

Nachdem Layout und Animation implementiert sind, können wir uns darum kümmern, die Liste der koffeinhaltigen Getränke tatsächlich darzustellen. Hierzu verwenden wir einen von WearableListView.Adapter abgeleiteten Adapter. Fürs Erste verwenden wir eine statische Liste mit Kaffeespezialitäten, für eine spätere Ausbaustufe ist denkbar, diese Liste abhängig vom Standort des Benutzers zu machen und die vom nächstgelegenen Café angebotenen Getränke mit einzubeziehen. In Listing 5 ist die Implementierung des Adapters zu sehen.

private static final class Adapter extends WearableListView.Adapter {
  private String[] mDataset;
  private final Context mContext;
  private final LayoutInflater mInflater;

  // Provide a suitable constructor (depends on the kind of dataset)
  public Adapter(Context context, String[] dataset) {
    mContext = context;
    mInflater = LayoutInflater.from(context);
    mDataset = dataset;
  }

  // Provide a reference to the type of views you’re using
  public static class ItemViewHolder extends WearableListView.ViewHolder {
    private TextView textView;
    private CircledImageView imageView;

    public ItemViewHolder(View itemView) {
      super(itemView);
      // find the text view within the custom item's layout
      textView = (TextView) itemView.findViewById(R.id.name);
      imageView = (CircledImageView) itemView.findViewById(R.id.image);
    }
  }

  // Create new views for list items
  // (invoked by the WearableListView’s layout manager)
  @Override
  public WearableListView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    // Inflate our custom layout for list items
    return new ItemViewHolder(mInflater.inflate(R.layout.list_item, null));
  }

  // Replace the contents of a list item
  // Instead of creating new views, the list tries to recycle existing ones
  // (invoked by the WearableListView’s layout manager)
  @Override
  public void onBindViewHolder(WearableListView.ViewHolder holder, int position) {
    // retrieve the text view
    ItemViewHolder itemHolder = (ItemViewHolder) holder;

    TextView view = itemHolder.textView;
    view.setText(mDataset[position]);

    CircledImageView imageView = itemHolder.imageView;
    if (position % 2 == 0) {
      imageView.setImageResource(R.drawable.ic_espresso);
    }
    else {
      imageView.setImageResource(R.drawable.ic_latte);
    }

    // replace list item's metadata
    holder.itemView.setTag(position);
  }

  // Return the size of your dataset
  // (invoked by the WearableListView's layout manager)
  @Override
  public int getItemCount() {
    return mDataset.length;
  }
}

Wenn der Benutzer einen Eintrag aus der Liste auswählt, soll ein entsprechender Eintrag in sein tägliches Koffein-Log geschrieben werden. Um auf die Auswahl eines Listenelements zu reagieren, implementieren wir das Interface WearableListView.ClickListener auf der Activity DrinkSelectionActivity. In der Methode onClick ermitteln wir den vom Benutzer ausgewählten Eintrag und sorgen dafür, dass ein entsprechender Log-Eintrag geschrieben wird. Die Verwaltung des Koffein-Logs soll mittels der App auf dem Smartphone erfolgen – dies ist sinnvoll, damit die App auch von Anwendern benutzt werden kann, die noch nicht über eine Smartwatch verfügen (Natürlich müssen wir dazu ein passendes mobiles UI implementieren, was jedoch den Rahmen dieses Artikels sprengen würde). Die Übertragung der benötigten Daten erfolgt mittels des Wearable-Data-API.

Daten synchronisieren

Für die Synchronisierung von Daten stellt Android Wear das Wearable-DataLayer-API zur Verfügung. Für die Übertragung von Daten stehen darin zwei grundlegende Mechanismen zur Verfügung: Data Items und das Messages-API. Das Messages-API ist für den unidirektionalen Versand von Nachrichten zwischen zwei Knoten innerhalb eines Wearable-Networks ausgelegt. Eine Garantie, dass gesendete Nachrichten ausgeliefert wurden, gibt es nicht. Gute Einsatzbereiche für das Messages-API sind RPC-artige Kommunikation (z. B. Auslösen einer Aktion auf dem Wearable oder dem Handset wie etwa Start/Stop der Medienwiedergabe) oder als Basis für Handshake-basierte Kommunikationsprotokolle.

DataItems hingegen bieten eine garantierte Auslieferung und dienen der Synchronisation von Daten zwischen mehreren Knoten eines Wearable-Networks. Seit Google Play Service 7.3 ist es möglich, mehrere Android-Wear-Geräte mit einem Handset zu verbinden. Außerdem können Android-Wear-Geräte, die über ein WiFi-Modul verfügen, Daten über WiFi mit dem mit ihnen gekoppelten Handset synchronisieren. Ein Android-Wear-Network besteht somit üblicherweise aus einem Node für das Wearable selbst, einem weiteren Node für das Handset, sowie dem so genannten Cloud Node.

Bei der Kommunikation mittels des Messages-API muss man folglich zunächst den korrekten Kommunikationspartner ermitteln. Bei der Verwendung von Data Items ergeben sich keine Änderungen, da diese automatisch auf alle Knoten im Netzwerk synchronisiert werden (Abb. 2).

Abb. 2: Architektur eines Android-Wear-Netzwerks

Abb. 2: Architektur eines Android-Wear-Netzwerks

Für den vorliegenden Use Case muss der Name des konsumierten Getränks (sowie gegebenenfalls die enthaltene Anzahl an Koffeineinheiten) auf garantierte Weise vom Wearable zum Handset übertragen werden, somit eignen sich Data Items am besten für die Implementierung.

Wie viele andere Google-APIs wird auch das Wearable-DataLayer-API über einen GoogleAPIClient angesprochen. Sobald die Verbindung zum API-Client aufgebaut ist, kann die weitere Kommunikation synchron oder asynchron erfolgen. Asynchrone Aufrufe gehen mit einer Menge von Callbacks einher und führen zu eher unübersichtlichem Code. Synchrone Aufrufe sind übersichtlicher, führen zu Code, der linear lesbar ist, haben aber auch Implikationen (dazu später mehr).

Ein synchroner Aufbau zum GoogleApiClient erfolgt mittels blockingConnect(), wobei angegeben werden kann, nach welcher Zeit ein Timeout aufritt und der Verbindungsversuch als fehlgeschlagen angesehen wird.

Konnte die Verbindung erfolgreich aufgebaut werden, müssen die zu übertragenden Daten zusammengestellt werden. DataItems können eine Nutzlast von 100 KB enthalten. Diese Nutzlast wird als ByteArray übertragen. Weil es ein wenig umständlich wäre, Applikationsdaten wie den Namen eines Getränks und seines Koffeingehalts manuell zwischen Datentypen wie String, Integer, etc. und dem ByteArray hin- und herzukonvertieren, bietet Android Wear die Klasse DataMap an. Hierbei handelt es sich um eine Hilfsklasse, die sich ähnlich wie ein Bundle verwenden lässt und die somit die Verarbeitung der wichtigsten Datentypen wesentlich vereinfacht.

DataMaps werden mittels PutDataRequest für den Transport mittels DataApi.putDataItem() vorbereitet.

Der Aufruf von putDataItem() erfolgt ebenfalls synchron mittels await() – der zurückgelieferte Statuscode kann mittels isSuccess() auf Erfolg oder Fehlschlag überprüft werden. Abschließend kann die Verbindung zum GoogleApiClient wieder getrennt werden (Listing 6).

private void handleActionDrinkCoffee(String name, String timeStamp) {
  GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this)
    .addApi(Wearable.API)
    .build();

  // It's OK to use blockingConnect() here as we are running in an
  // IntentService that executes work on a separate (background) thread.
  ConnectionResult connectionResult = googleApiClient.blockingConnect(
    Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS);

  if (connectionResult.isSuccess() && googleApiClient.isConnected()) {

    DataMap coffeeData = new DataMap();
    coffeeData.putString(Constants.EXTRA_NAME, name);

    PutDataMapRequest dataMapRequest = PutDataMapRequest.create(Constants.DRINK_COFFEE_PATH);
    dataMapRequest.getDataMap().putDataMap(Constants.EXTRA_DRINK_COFFEE, coffeeData);
    dataMapRequest.getDataMap().putLong(Constants.EXTRA_TIMESTAMP, new Date().getTime());
    PutDataRequest request = dataMapRequest.asPutDataRequest();

    // Send the data over
    DataApi.DataItemResult result =
      Wearable.DataApi.putDataItem(googleApiClient, request).await();

    if (!result.getStatus().isSuccess()) {
      Log.e(TAG, String.format(“Error sending data using DataApi (error code = %d)”,
          result.getStatus().getStatusCode()));
    }

  } else {
    Log.e(TAG, String.format(Constants.GOOGLE_API_CLIENT_ERROR_MSG,
      connectionResult.getErrorCode()));
  }
  googleApiClient.disconnect();

}

Wie man am Code in Listing 6 schön sehen kann, konnten wir auf sämtliche Callbacks verzichten und haben einen vollständig linear lesbaren Code erhalten. Diesen Komfort erkaufen wir allerdings mit dem Preis, dass große Teile des Codes synchron laufen und somit den aktuellen Thread blockieren. Wie es hier und an anderen Stellen ausführlich beschrieben ist, sollte man tunlichst vermeiden, blockierende Aufrufe im Main Thread auszuführen. Es gibt mehrere Strategien, um dies zu vermeiden und blockierende Aufrufe aus dem Main Thread zu verbannen, hier gibt es eine gute Übersicht, wann man Services, Threads, IntentServices oder AsyncTasks verwenden sollte. Für unsere Zwecke ist ein IntentService passend:

  • IntentServices werden bei Bedarf gestartet, führen die ihnen übertragene Arbeit in einem Hintergrundthread aus und werden automatisch beendet, wenn es keine weitere Arbeit mehr gibt.
  • Alle Requests werden von einem einzigen Worker-Thread ausgeführt, sodass alle Requests der Reihe nach ausgeführt werden.

Android Studio hat einen recht nützlichen Wizard, der das Implementieren von IntentServices erleichtert: zu erreichen unter New > Service > Service (IntentService).

Der allgemeine Ablauf eines IntentService ist in Abbildung 3 dargestellt, wobei der im Hintergrund ausführende Code letztendlich in der Methode handleActionDrinkCoffee zu finden ist.

Abb. 3: IntentService

Abb. 3: IntentService

package de.peterfriese.kaffeinated.service;

import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.wearable.DataApi;
import com.google.android.gms.wearable.DataMap;
import com.google.android.gms.wearable.PutDataMapRequest;
import com.google.android.gms.wearable.PutDataRequest;
import com.google.android.gms.wearable.Wearable;

import java.util.Date;
import java.util.concurrent.TimeUnit;

import de.peterfriese.shared.Constants;

public class UtilityService extends IntentService {

  private static final String TAG = UtilityService.class.getSimpleName();

  private static final String ACTION_DRINK_COFFEE = “de.peterfriese.kaffeinated.action.DRINK_COFFEE”;

  private static final String DRINK_NAME = “de.peterfriese.kaffeinated.extra.DRINK_NAME”;
  private static final String TIMESTAMP = “de.peterfriese.kaffeinated.extra.DRINK_TIMESTAMP”;

  public static void startActionDrinkCoffee(Context context, String name, String timeStamp) {
    Intent intent = new Intent(context, UtilityService.class);
    intent.setAction(ACTION_DRINK_COFFEE);
    intent.putExtra(DRINK_NAME, name);
    intent.putExtra(TIMESTAMP, timeStamp);
    context.startService(intent);
  }

  public UtilityService() {
    super(“UtilityService”);
  }

  @Override
  protected void onHandleIntent(Intent intent) {
    if (intent != null) {
      final String action = intent.getAction();
      if (ACTION_DRINK_COFFEE.equals(action)) {
        final String name = intent.getStringExtra(DRINK_NAME);
        final String timeStamp= intent.getStringExtra(TIMESTAMP);
        handleActionDrinkCoffee(name, timeStamp);
      }
    }
  }

  private void handleActionDrinkCoffee(String name, String timeStamp) {
    // see Listing N
  }

}

Um die auf diese Weise über den Wearable DataLayer übertragenen Daten auf der Gegenseite (in diesem Fall: die mobile App auf dem Handset) zu empfangen, müssen wir einen WearableListenerService implementieren. Dieser Service beinhaltet Methoden, die bei Ereignissen auf dem DataLayer aufgerufen werden, z. B. wenn Daten geändert, Nachrichten empfangen oder Knoten zum Android-Wear-Netzwerk hinzukommen oder entfernt werden. Pro App kann es nur eine Instanz dieses Service geben, wobei der Lifecycle des Service von Android Wear gesteuert wird. Ereignisse über geänderte DataItems werden über die Methode onDataChanged gemeldet. Da es in einer App natürlich noch andere Nachrichten und andere Sender geben kann, müssen wir beim Empfang eines DataEventBuffer zunächst überprüfen, ob die Nachricht tatsächlich für uns bestimmt war. Falls ja, extrahieren wir die Nutzlast des DataItems mittels DataMapItem.fromDataItem(dataItem) – siehe Listing 8.

public class ListenerService extends WearableListenerService {

  private static final String TAG = ListenerService.class.getSimpleName();

  @Override
  public void onDataChanged(DataEventBuffer dataEvents) {
    Log.d(TAG, “onDataChanged: “ + dataEvents);

    for (DataEvent event : dataEvents) {
      if (event.getType() == DataEvent.TYPE_CHANGED
        && event.getDataItem() != null
        && Constants.DRINK_COFFEE_PATH.equals(event.getDataItem().getUri().getPath())) {

        DataMapItem dataMapItem = DataMapItem.fromDataItem(event.getDataItem());
        DataMap dataMap = dataMapItem.getDataMap().getDataMap(Constants.EXTRA_DRINK_COFFEE);
        String name = dataMap.getString(Constants.EXTRA_NAME);
        long timestampLong = dataMapItem.getDataMap().getLong(Constants.EXTRA_TIMESTAMP);
        Date timeStamp = new Date(timestampLong);

        String message = String.format(“You drank a %s at %tT”, name, timeStamp);

        Toast.makeText(this, message, Toast.LENGTH_SHORT).show();

        // persist to local storage
      }
    }

  }
}

Gehen wir einen Kaffee trinken!

Der Benutzer kann nun zwar seinen Kaffeekonsum aufzeichnen, doch wo findet er ein Café, um seiner Leidenschaft zu frönen? In diesem Teil des Artikels schauen wir uns an, wie Daten von der App auf dem Smartphone auf das Wearable übertragen werden können, wie lokale Notifications zum Starten einer Wearable-App genutzt werden können, und wie man komplexere Listen mithilfe des GridViewPagers aufbaut.

Wie bereits eingangs angedeutet, werden wir die Übertragung der Daten vom Smartphone zum Wearable manuell triggern. In der Endausbaustufe der App würden wir stattdessen im Hintergrund auf Standortwechsel des Benutzers lauschen und bei einem Wechsel des Standorts die Liste der nächstgelegenen Cafés von einem Service wie z. B. Foursquare oder Google Maps Places herunterladen. Für diesen Artikel genügt uns eine statische Liste von Cafés, die wir zur Uhr schicken, wenn der Benutzer den entsprechenden Eintrag aus dem Overflow-Menü in der App auswählt. Der Mechanismus zur Übertragung der Daten ist genau der gleiche wie schon bei der Aufzeichnung der konsumierten Getränke, nur dass wir die Daten diesmal von der Phone-App zur Wearable-App senden. Auch hier verwenden wir einen IntentService, um die zu übertragenden Daten (nämlich die Liste der Cafés) zusammenzustellen und als DataItems über den Wearable DataLayer zu übertragen. Da wir zusätzlich zu den normalen Daten der Cafés (wie z. B. den Namen, den Ort etc.) auch noch jeweils ein Foto des Cafés übertragen wollen, müssen wir die entsprechenden Bilddaten mittels des Asset-API auf die Smartwatch übertragen. Um eine möglichst effiziente Übertragung der Daten sicherzustellen, komprimieren wir die Bilder vor der Übertragung. Die so komprimierten Bitmap-Daten können mittels DataMap.putAsset() an ein DataItem angehängt und übertragen werden.

Listing 9 und 10 zeigen, wie die Daten komprimiert und dann mittels des Asset-API übertragen werden.

private void handleActionTriggerMicroApp(List cafes) {
  GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this)
    .addApi(Wearable.API)
    .build();

  // It's OK to use blockingConnect() here as we are running in an
  // IntentService that executes work on a separate (background) thread.
  ConnectionResult connectionResult = googleApiClient.blockingConnect(
    Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS);

  ArrayList cafesData = new ArrayList<>(cafes.size());
  for (Cafe cafe: cafes) {
    DataMap cafeData = new DataMap();

    cafeData.putString(Constants.EXTRA_NAME, cafe.getName());
    cafeData.putAsset(Constants.EXTRA_IMAGE, Utils.createAssetFromBitmap(cafe.getImage()));

    cafesData.add(cafeData);
  }

  if (connectionResult.isSuccess() && googleApiClient.isConnected()) {

    PutDataMapRequest dataMap = PutDataMapRequest.create(Constants.NEARBY_CAFES_PATH);
    dataMap.getDataMap().putDataMapArrayList(Constants.EXTRA_CAFES, cafesData);
    dataMap.getDataMap().putLong(Constants.EXTRA_TIMESTAMP, new Date().getTime());
    PutDataRequest request = dataMap.asPutDataRequest();

    // Send the data over
    DataApi.DataItemResult result =
      Wearable.DataApi.putDataItem(googleApiClient, request).await();

    if (!result.getStatus().isSuccess()) {
      Log.e(TAG, String.format(“Error sending data using DataApi (error code = %d)”,
        result.getStatus().getStatusCode()));
    }

  } else {
    Log.e(TAG, String.format(Constants.GOOGLE_API_CLIENT_ERROR_MSG,
      connectionResult.getErrorCode()));
  }
  googleApiClient.disconnect();
}
public static Asset createAssetFromBitmap(Bitmap bitmap) {
  if (bitmap != null) {
    final ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteStream);
    return Asset.createFromBytes(byteStream.toByteArray());
  }
  return null;
}

Zum Empfang der Daten implementieren wir auf dem Wearable einen WearableListenerService, der in der Methode onDataChanged auf über DataItems gesendete Daten lauscht. Nachdem wir die Daten aus dem DataItem extrahiert haben, lösen wir eine lokale Notification aus. Neben einem Text, der die Anzahl der in der Umgebung befindlichen Cafés enthält, verfügt die Notification über eine zweite Seite, die eine Action enthält, mit der der Anwender die auf seiner Smartwatch laufende Micro-App starten kann, die dann die Details zu den einzelnen Cafés anzeigt. Als Hintergrundgrafik für die Notification verwenden wir das Bild des ersten Cafés in der Liste. Um die Micro-App zu starten, legen wir einen PendingIntent an, der auf die zu startende Activity verweist (Listing 11). Als Extra senden wir den URI, der auf die soeben übertragenen Daten verweist – so kann die Activity die Liste der Cafés aus dem DataLayer auslesen und im UI darstellen.

Intent intent = new Intent(this, NearbyCafesActivity.class);
// Pass through the data Uri as an extra
intent.putExtra(Constants.EXTRA_CAFES_URI, cafesUri);
PendingIntent pendingIntent =
  PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);

int count = cafes.size();

DataMap cafe = cafes.get(0);

Bitmap bitmap = Utils.loadBitmapFromAsset(
  googleApiClient, cafe.getAsset(Constants.EXTRA_IMAGE));

// PendingIntent deletePendingIntent = PendingIntent.getService(
// this, 0, UtilityService.getClearRemoteNotificationsIntent(this), 0);


Notification notification = new NotificationCompat.Builder(this)
  .setSmallIcon(R.mipmap.ic_launcher)
  .setContentTitle("Hello")
  .setContentText(getResources().getQuantityString(
    R.plurals.cafes_found, count, count))
  .addAction(R.drawable.ic_full_explore,
    getString(R.string.action_explore),
    pendingIntent)
  .extend(new NotificationCompat.WearableExtender()
    .setBackground(bitmap))
  .build();

NotificationManagerCompat notificationManager =
  NotificationManagerCompat.from(this);

Implementierung eines 2D Pickers

Die Darstellung der Getränkeliste im ersten Abschnitt des Artikels erfüllte zwar ihren Zweck und war obendrein einfach zu implementieren, war allerdings auch etwas spröde. Für die Darstellung der Liste der Cafés wählen wir daher einen GridViewPager, mit dessen Hilfe wir eine optisch attraktivere und auch funktionalere zweidimensionale Liste implementieren können. Um einen GridViewPager zu implementieren, sind folgende Schritte notwendig:

  1. Layout der Activity definieren
  2. PageAdapter implementieren
  3. GridViewPager mit dem Adapter verbinden
  4. Layouts für die einzelnen Seiten des Pagers definieren und implementieren

Die Definition des Layouts für den GridViewPager ist recht trivial (Listing 12).

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/frame_layout"
  android:layout_height="match_parent"
  android:layout_width="match_parent"
  app:layout_box="left|bottom|right"
  tools:deviceIds="wear_round">

  <android.support.wearable.view.GridViewPager
    android:id="@+id/gridViewPager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

</FrameLayout>

Für die Implementierung des Adapters stehen die beiden Basisklassen GridPagerAdapter und FragmentGridPagerAdapter zur Verfügung. Wie der Name andeutet, ermöglicht FragmentGridPagerAdapter die Verwendung von Fragmenten für die einzelnen Seiten des Pagers, was die Implementierung erleichtert, da nur die Methoden getFragment(final int row, final int col), getRowCount() und getColumnCount(int rowNum) implementiert werden müssen. Wie die Signaturen der Methoden andeuten, können die einzelnen durch den Pager dargestellten Zeilen aus unterschiedlich vielen Spalten bestehen, wie in Abbildung 4 dargestellt.

Abb. 4: GridViewPager mit unterschiedlich vielen Spalten pro Zeile

Abb. 4: GridViewPager mit unterschiedlich vielen Spalten pro Zeile

In unserem Fall richtet sich die Anzahl der Zeilen nach der Anzahl der darzustellenden Cafés und die Anzahl der Spalten ist für alle Zeilen gleich, da wir für jedes Café die gleichen Seiten darstellen: Ein Foto des Cafés, das mit dem Namen des Cafés beschriftet ist, eine Seite mit Detailinformationen zum Café, sowie eine Seite mit einer Action, damit die Benutzer die Navigation zum Café auslösen können. Die für die Darstellung der einzelnen Seiten benötigten Fragments erzeugen wir in der Methode getFragment des Adapters (Listing 13).

public class CafesFragmentGridPagerAdapter extends FragmentGridPagerAdapter {

  private final Context mContext;
  private ArrayList mCafes;
  private List mRows;

  public CafesFragmentGridPagerAdapter(Context ctx, FragmentManager fragmentManager, ArrayList cafes) {
    super(fragmentManager);
    mContext = ctx;
    mCafes = cafes;
  }

  @Override
  public Fragment getFragment(final int row, final int col) {
    final Cafe cafe = mCafes.get(row);

    if (col == 0) {
      ImageFragment imageFragment = ImageFragment.create(cafe.getName(), cafe.getImage());
      return imageFragment;
    }
    else if (col == 1) {
      CardFragment cardFragment = CardFragment.create(cafe.getName(),"Great coffee at affordable prices.. ", R.drawable.ic_espresso);
      cardFragment.setExpansionEnabled(true);
      cardFragment.setExpansionDirection(CardFragment.EXPAND_DOWN);
      cardFragment.setExpansionFactor(2.0f);
      return cardFragment;
    }
    else if (col == 2) {
      ActionFragment actionFragment = ActionFragment.create("Navigate", R.drawable.ic_full_directions_walking, new View.OnClickListener() {
        @Override
        public void onClick(View v) {
          String msg = String.format(“Drinking coffee at %s - good choice!”, cafe.getName());
          Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show();
        }
      });
      return actionFragment;
    }
    return new Fragment();
  }

  @Override
  public Drawable getBackgroundForPage(int row, int column) {
    return mContext.getResources().getDrawable(R.drawable.costa, null);
  }

  @Override
  public int getRowCount() {
    return mCafes.size();
  }

  @Override
  public int getColumnCount(int rowNum) {
    return 3;
  }
}

Die Detailinformationen zu einem Café stellen wir in einem CardFragment dar – diese Klasse wird samt dem zugehörigen Layout von der Android Wear Support Library zur Verfügung gestellt und kann ohne Weiteres genutzt werden. Für die Darstellung der ersten Seite mit einem Bild des Cafés müssen wir selbst ein Fragment und das passende Layout beisteuern. Das Layout ist recht einfach, wie in Listing 14 zu sehen.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <ImageView
    android:id="@+id/imageView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scaleType="centerCrop" />

  <FrameLayout
    android:id="@+id/overlaytext"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:background="#66000000">

    <TextView
      android:id="@+id/textView"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:gravity="center"
      style="@style/PagerTitleStyle"
      android:maxLines="2"
      tools:text="Sample Text" />

  </FrameLayout>

</FrameLayout>

Die Implementierung des Fragments selbst ist ebenfalls geradlinig: Im Konstruktor merken wir uns die übergebenen Parameter, die wir dann in der Methode onCreateView in die aus dem Layout geladenen UI-Komponenten übertragen (Listing 15).

public class ImageFragment extends Fragment {

  private Bitmap image;
  private CharSequence title;

  public static ImageFragment create(CharSequence title, Bitmap image) {
    ImageFragment fragment = new ImageFragment();
    fragment.title = title;
    fragment.image = image;
    return fragment;
  }

  @Nullable
  @Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    final View view = inflater.inflate(R.layout.gridpager_fullscreen_image, container, false);
    ImageView imageView = (ImageView) view.findViewById(R.id.imageView);
    TextView textView = (TextView) view.findViewById(R.id.textView);
    FrameLayout overlayTextLayout = (FrameLayout) view.findViewById(R.id.overlaytext);

    FrameLayout.LayoutParams params =  (FrameLayout.LayoutParams) textView.getLayoutParams();
    textView.setLayoutParams(params);
    textView.setText(title);

    imageView.setImageBitmap(image);
    return view;
  }

}

Bei der Navigation in einem GridViewPager ist zu beachten, dass die Navigation nicht strikt linear erfolgt: Wechselt man die Zeile, springt die Navigation immer in die erste Spalte der neuen Zeile. Was zunächst kontraintuitiv wirkt, ist der Tatsache geschuldet, dass die einzelnen Zeilen des GridViewPagers eine unterschiedliche Anzahl von Spalten haben können.

Navigation auf Android Wear

Wenn der Anwender die Navigate Action auswählt, soll er mittels der Maps-App zum ausgewählten Café geleitet werden. Um die Maps-App auf dem Wearable zu starten, müssen wir einen passenden Intent starten und die Koordinaten des Ziels übergeben (Listing 15).

String latLng = cafe.getLocation().latitude + "," + cafe.getLocation().longitude;
Uri uri = Uri.parse(Constants.MAPS_NAVIGATION_INTENT_URI + Uri.encode(latLng));
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mContext.startActivity(intent);

Fazit

Smartwatches bringen durch ihren vergleichsweise kleinen Formfaktor ganz neue Herausforderungen bei der Gestaltung von User Interfaces mit sich. Durch die Unterstützung von eckigen und runden Displays wird dieser Effekt unter Android Wear nochmals verstärkt. Erfreulicherweise liefert Android Wear dem Entwickler mit BoxInsetLayout bzw. WatchViewStub das passende Handwerkszeug, um ohne großen Mehraufwand flexible Layouts sowohl für runde als auch eckige Displays zu gestalten.

Die in diesem Artikel entwickelte App ermöglicht es dem Benutzer, einige spezielle Use Cases zu absolvieren, ohne ein einziges Mal das Telefon in die Hand zu nehmen. Eine entsprechende Beschäftigung mit den Gestaltungsprinzipien von Android Wear und den möglichen Implementierungstechniken vorausgesetzt, lassen sich gute Möglichkeiten finden, ausgewählte Use Cases auf Android Wear Smartwatches umzusetzen und den Anwender in die Lage zu versetzen, schnell und ohne langwierige Interaktion mit seinem Smartphone alltägliche Aufgaben zu erledigen. Viel Freude beim Experimentieren mit den Möglichkeiten!

Aufmacherbild: Smart watch consept illustration von Shutterstock / Urheberrecht: JMicic

Verwandte Themen:

Geschrieben von
Peter Friese
Peter Friese
Peter Friese arbeitet als Developer Advocate bei Google im Developer Relations Team in London. Er trägt regelmäßig zu Open-Source-Projekten bei, spricht auf Konferenzen und bloggt unter http://www.peterfriese.de. Auf Twitter findet man ihn unter @peterfriese und auf Google+ unter +PeterFriese.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Android am Arm: Wie Sie eine eigene Android-Wear-App programmieren"

avatar
400
  Subscribe  
Benachrichtige mich zu:
Fluxx
Gast

Erstklassiger Artikel, vielen Dank!