IoT für jedermann

Android Things: Tipps und Tricks fürs IoT mit Android
Keine Kommentare

Mit Android Things möchte Google den Erfolg der Android-Plattform in den Bereichen Internet of Things (IoT) und Embedded Devices wiederholen. Das Erfolgsrezept, das nach zwei weniger gelungenen Anläufen in diesem Marktsegment den Durchbruch bringen soll, lautet: „Ein sicheres Betriebssystem ohne Fragmentierung“ und „Jeder Android-Entwickler ist auch ein Android-Things-Entwickler.“

Android Things ist ein Betriebssystem für das Internet of Things (IoT). Die ersten Versuche, im IoT-Markt Fuß zu fassen, verliefen 2011 mit Android@Home und 2016 mit Brillo nicht wie gewünscht, was 2011 dem Fehlen geeigneter Hardware und 2016 der aufwändigen Programmierung in C/C++ geschuldet war. Beim dritten Anlauf setzt Google auf günstige Hardwareplattformen, auf Java als Programmiersprache und auf die möglichst einfache Integration mit dem Android-Ökosystem. Android Things liegt zurzeit (April 2018) noch als Developer Preview in der Version 0.7 vor, und man kann noch keine auf Android Things basierten Geräte kaufen. Aber das von Google geschnürte Paket ist so verlockend, dass es sich schon jetzt lohnt, einen ersten Blick auf Android Things zu werfen.

Die Android-Things-Infrastruktur

Die von Google zur Verfügung gestellte Infrastruktur basiert auf vier Komponenten:

  1. Eine für IoT-Szenarien optimierte Version des Android-Betriebssystems mit langfristiger Unterstützung durch Google.
  2. Direkt von Google zur Verfügung gestellte Updates des Betriebssystems einschließlich einer sicheren Infrastruktur für Over-the-Air-(OTA-)Updates.
  3. Von Google zertifizierte Hardware mit Board Support Packages (BSP), wobei zurzeit Raspberry Pi 3 und NXP Pico als Developerboards unterstützt werden.
  4. Die Entwicklung von Anwendungen für Android Things und für Android-Smartphones erfolgt mit den gleichen Tools und nutzt dort, wo es sinnvoll ist, auch die gleichen Programmierschnittstellen. Android-Things-spezifische Erweiterungen sind dabei so implementiert, dass sich ein Android-Entwickler heimisch fühlt.

Google hält bei Android Things die Zügel bei Hardware und Betriebssystem fest in der Hand. Die Hersteller der Hardware haben keine Möglichkeit, in Eigenregie Änderungen am Betriebssystem vorzunehmen. Die für die jeweilige Hardwareplattform benötigten Anpassungen von Android Things werden in Zusammenarbeit mit Google entwickelt und in Form der Board Support Packages auf den Geräten installiert. Aber auch hier ist Google wieder für den Updateprozess verantwortlich. Mit dieser Reglementierung soll dem vielleicht größten Problem des Android-Universums, der Fragmentierung, ein Riegel vorgeschoben werden. Updates, das Erstellen von Bugfixes und Security-Patches sowie die Infrastruktur für die Softwareverteilung liegen in der Hand von Google.

Was unterscheidet Android Things von Android?

Android Things ist dafür optimiert, eine einzelne Anwendung auszuführen, die beim Hochfahren des Systems automatisch gestartet wird. Es gibt keinen Play Store und keine Möglichkeit für den Endanwender, zusätzliche Anwendungen zu installieren. Klassische Systemanwendungen wie z. B. Kalender, Telefonie, Downloadmanager oder die Kontaktverwaltung sind nicht vorhanden. Android Things benutzt zwar das gleiche User Interface (UI) Toolkit wie das klassische Android, ein Display ist allerdings optional.

Ein Android-Things-Rechner ist im Gegensatz zu einem Android-Smartphone ein offenes System, an das über vordefinierte Schnittstellen auch unbekannte externe Hardware angebunden werden kann. Die dazu benötigten Programmierschnittstellen befinden sich in der Things Support Library. Es handelt sich dabei um das Peripherial I/O API und das User Driver API. Das Peripherial I/O API unterstützt die folgenden Schnittstellen:

  • GPIO (General Purpose Input/Output) für die Ansteuerung einfacher Geräte, die nur die Stati EIN und AUS kennen.
  • PWM (Pulse Width Modulation) für die Ansteuerung von Geräten, die zur Steuerung eine variable Signalstärke benötigen, wie z. B. Servomotoren.
  • Serielle Verbindungen per SPI (Serial Peripherial Interface), I2C (Inter-Integrated Circuit) und UART (Universal).

Über das User Driver API kann der Entwickler Sensoren so in Android Things einbinden, dass sie sich transparent in die entsprechenden Android-Dienste integrieren. Dies erfordert im Vergleich zum Peripherial I/O API einen etwas größeren Entwicklungsaufwand, der sich aber dann lohnt, wenn eine Vielzahl von Sensoren angesprochen werden soll, deren Messdaten dann in einer konsistenten Art und Weise behandelt werden können.

Die Support Library bietet weiterhin noch folgende Schnittstellen:

  • Das Device Updates API ist für Softwareupdates per OTA zuständig.
  • Das LowPAN API (Low-power Wireless Personal Area Network) ist für den Aufbau von Peer-to-Peer-Netzwerken gedacht, wobei ein besonderes Augenmerk auf Sicherheit, Redundanz und geringen Stromverbrauch gelegt wurde.
  • Das Settings API wird genutzt, um die Bildschirmeinstellungen (Rotation und Helligkeit) und das Gebietsschema (Locale) einzustellen.

Auch wenn der Begriff Android Things des Öfteren im Zusammenhang mit Embedded Devices genannt wird, soll hier nochmals explizit angemerkt werden, dass Android Things kein Betriebssystem ist, das harte Echtzeitanforderungen mit vorgegebenen Antwortzeiten erfüllen kann.

Die Installation von Android Things

Für Android Things gibt es keinen Emulator. Man benötigt deshalb für die Softwareentwicklung echte Hardware. Android Things unterstützt auch den Raspberry Pi 3, der schon für wenig Geld bei fast jedem Elektronikversender erhältlich ist. Der Rechner kann per GPIO oder per I2C mit externen Sensoren und Peripheriegeräten verbunden werden, und es existiert auch ein genormter Anschluss für Kameras. Für deutlich unter 100 € erhält man einen Rechner mit Gehäuse und einer Grundausstattung an Sensoren sowie einer einfachen Kamera.

Für den Raspberry Pi 3 wird Android Things auf einer microSD-Karte mit mindestens 8 GB Speicherkapazität installiert. Dazu lädt man zunächst eine Kommandozeilenanwendung auf seinen PC und entpackt die ZIP-Datei. In der Datei befinden sich jeweils eine ausführbare Datei für Linux, Windows und MacOS. Nach dem Start der Anwendung wird man durch die Installation geführt (Abb. 1), bei der sowohl das Betriebssystem-Image als auch die benötigten Tools heruntergeladen werden. Nach spätestens fünfzehn Minuten hat man eine microSD-Karte, von der der Raspberry Pi booten kann. Er wird dann per Netzwerkkabel an das Netzwerk angeschlossen, in dem sich auch der Rechner befindet, der für die Entwicklung der Anwendung verwendet werden soll.

Abb. 1: Installation von Android Things auf einer SD-Karte

Abb. 1: Installation von Android Things auf einer SD-Karte

Um später den Raspberry Pi im Netzwerk identifizieren zu können, benötigt man dessen IP-Adresse. Man schließt deshalb vor dem Starten des Rechners einen Monitor per HDMI-Kabel an den Raspberry Pi an. Nach dem Hochfahren des Betriebssystems sieht man eine Statusmeldung auf dem Bildschirm, einschließlich der IP-Adresse (Abb. 2). Man notiert sich am besten die IP-Adresse, da man sie später bei der Entwicklung einer Anwendung benötigt. Hat man den Rechner an eine FRITZ!Box angeschlossen, kann man alternativ auch die IP-Adresse über das Konfigurationsprogramm des Routers ermitteln.

Abb. 2: Android Things UI

Abb. 2: Android Things UI

Eine einfache Beispielanwendung

Als Beispiel dient im Folgenden die Implementierung einer Anwendung für die Steuerung einer Kamera. Abbildung 3 zeigt schematisch den physikalischen Aufbau. Kern ist ein Raspberry Pi 3, auf dem Android Things läuft. An den Rechner sind ein Bewegungssensor und eine Kamera angeschlossen. Für das Speichern der Bilder und die Kommunikation werden von Google Firebase zur Verfügung gestellte Dienste genutzt, die bei kleinem Datenvolumen kostenlos genutzt werden können.

Abb. 3: Der schematische Aufbau der Beispielanwendung

Abb. 3: Der schematische Aufbau der Beispielanwendung

Firebase stellt eine JSON-basierte Datenbank zur Verfügung, die in der Lage ist, Clients über Änderungen an der Datenbank in Echtzeit zu informieren. Firebase Storage ist eine Cloud-basierte Dateiablage. Durch die Nutzung der Firebase-Dienste benötigt man keine eigene Infrastruktur. Abbildung 4 zeigt detailliert die Funktionsweise. Der Bewegungssensor und eine auf einem Smartphone installierte App können einen SnapshotRequest in eine Google-Firebase-Datenbank schreiben (#1). Das Schreiben des Requests triggert die auf dem Raspberry Pi laufende Android-Things-Anwendung (#2), die dann mithilfe der Kamera eine Aufnahme macht (#3). Diese Aufnahme wird im letzten Schritt (#4) als JPEG-Datei in den Firebase Cloud Storage hochgeladen. Nach dem Upload der Datei wird diese automatisch auf dem Smartphone dargestellt.

Abb. 4: Die Funktionsweise der Anwendung

Abb. 4: Die Funktionsweise der Anwendung

Abbildung 5 zeigt die auf dem Smartphone installierte App, wobei in diesem Fall ein Testbild dargestellt wird. Über den Button mit dem Kamerasymbol wird eine Aufnahme mit der am Raspberry Pi angeschlossenen Kamera gemacht. Abbildung 6 zeigt die zugehörigen Daten in der Firebase-Datenbank und im Firebase Cloud Storage.

Abb. 5: Die App zur Fernsteuerung der Kamera

Abb. 5: Die App zur Fernsteuerung der Kamera

 

Abb. 6: Beispieldaten in der Firebase-Datenbank und im Firebase Cloud Storage

Abb. 6: Beispieldaten in der Firebase-Datenbank und im Firebase Cloud Storage

Im Folgenden werden die wichtigsten Aspekte bei der Implementierung der Anwendung skizziert. Die vollständige Anwendung steht hier zur Verfügung.

Softwareentwicklung mit Android Things

Als Softwareentwicklungsumgebung für Android Things verwendet man Android Studio, das zum Zeitpunkt der Erstellung des Artikels in der Version 3.1.1 vorlag. Mit der Android Debug Bridge (adb) sorgt man dafür, dass der Raspberry Pi aus Android Studio heraus angesprochen werden kann. Dazu gibt man auf einer Kommandozeile den Befehl adb connect xxx.xxx.xxx.xx ein, wobei man den Platzhalter mit der vorher ermittelten IP-Adresse (Abb. 2) ersetzt. Dann legt man in Android Studio ein neues Projekt an, als Target Device wählt man Android Things aus.

Man kann einige Peripheriegeräte für das Projekt konfigurieren, aber diese Option wird in diesem Fall nicht genutzt. Die Firebase-Dienste werden durch einen Wizard eingerichtet, den Android Studio unter Tools zur Verfügung stellt und die für Authentication, Realtime Database und Storage benötigt werden. Damit die Firebase-Dienste genutzt werden können, fügt der Wizard in der Gradle-Datei des Projekts unter Dependencies die vier Zeilen für die Firebase-Bibliotheken ein:

 
dependencies {
implementation 'com.google.rebase:rebase-storage:11.4.2'
implementation 'com.google.rebase:rebase-auth:11.4.2'
implementation 'com.google.rebase:rebase-core:11.4.2'
implementation 'com.google.rebase:rebase-database:11.4.2'
implementation 'com.google.android.things:androidthings:0.7-devpreview'
implementation project(':lib')
}

Die Android-Things-Anwendung besitzt kein User Interface. Daher ist auch die Android Activity leer, die gesamte Funktionalität wird in der zugehörigen Application-Klasse implementiert. Man legt die von android.app.application abgeleitete Klasse RemoteCameraApplication an und trägt in der Datei AndroidManifest.xml den Namen der neuen Klasse ein. Beim Starten der Anwendung wird ein Objekt der Klasse RemoteCameraApplication erzeugt und dessen onCreate-Methode aufgerufen, in der die Methoden für die Initialisierung der Kamera, des Bewegungssensors und der Firebase-Anbindung aufgerufen werden:

public void onCreate() {
super.onCreate();
initCameraHandler();
initMotionSensor();
initFirebase();
}

Die Firebase-Anbindung

Die Authentifizierung der Anwendung für die Firebase-Services erfolgt der Einfachheit halber anonym. In der Methode initFirebase() authentifiziert sich die Android-Things-Anwendung und ruft im Erfolgsfall
die Methode initListener() auf (Listing 1). Dadurch wird die Anwendung bei jeder Änderung unterhalb des Datenbankknotens camera_snapshot/last per Callback informiert (Abb.6).

 
private void initFirebase() {
FirebaseAuth.getInstance().signInAnonymously().addOnCompleteListener(new
OnCompleteListener() {
@Override
public void onComplete(@NonNull Task task) {
if (task.isSuccessful()) {
initListener();
} else {
Log.e(TAG, "ERROR: Please activate anonymous login");
}
}
});
}
private void initListener() {
DatabaseReference dbRef = FirebaseDatabase.getInstance().
getReference("camera_snapshot/last");
dbRef.addValueEventListener(
new ValueEventListener() {
@Override
public void onDataChange(DataSnapshot dataSnapshot) {
mCameraHandler.handleDatabaseSnapshot(dataSnapshot);
}
@Override
public void onCancelled(DatabaseError databaseError) {
Log.e(TAG, "ERROR: " + databaseError.getMessage());
}
};
)
}

Der Bewegungssensor

Die Bewegungen werden über einen HC-SR501-Infrarot-PIR-Bewegungssensor erfasst. Zur Anbindung des Sensors an den Raspberry Pi 3 wird der im Blog vorgestellte Treiber genutzt. Im ersten Schritt werden der Rechner und der Sensor verbunden (Abb.7).

Das schwarze Kabel ist die Masse, das rote Kabel stellt die Versorgungsspannung von 5 Volt zur Verfügung, und über das gelbe Kabel liefert der Sensor sein Signal zurück. Der Sensor ist an den Pin BCM18 angeschlossen. Das Signal liefert keine Information über die Richtung oder die Geschwindigkeit der erkannten Bewegung, sondern gibt nur Auskunft, ob eine Bewegung erkannt wurde oder nicht. Die Anbindung von Elementen, die ein binäres Signal liefern oder über ein binäres Signal gesteuert werden, erfolgt über die GPIO-Schnittstelle.

API Summit 2018

From Bad to Good – OpenID Connect/OAuth

mit Daniel Wagner (VERBUND) und Anton Kalcik (business.software.engineering)

Die Schnittstelle benötigt die Information, ob das Bauteil ein Sensor ist und eine zu verarbeitende Information liefert, oder ob es sich wie z.B. bei einer LED um ein zu schaltendes Bauteil handelt. Weiterhin wird konfiguriert, ob eine hohe Spannung dem Status EIN oder dem Status AUS entspricht. Die GPIO Schnittstelle von Android Things kann per Callback auch automatisch über Änderungen der Spannung informieren. Dazu muss konfiguriert werden, ob ein Spannungsanstieg, -abfall oder beide Ereignisse den Aufruf der Callback-Funktion triggern sollen.

Für die Anbindung des Sensors wird zunächst das Interface MotionSensor definiert:

public interface MotionSensor {
void startup();
void shutdown();
interface Listener {
void onMovement();
}
}

startup() und shutdown() dienen der Initialisierung des Sensors bzw. der Freigabe der beanspruchten Ressourcen. Das lokale Interface Listener mit der Methode on-Movement() wird für den Callback bei der Änderung der Spannung genutzt. Die Implementierung des Interface in der Klasse PirMotionSensor ist in Listing 2 zu sehen.

  
public class PirMotionSensor implements MotionSensor {
private nal Gpio bus;
private nal MotionSensor.Listener listener;
public PirMotionSensor(Gpio bus, Listener listener) {
this.bus = bus;
this.listener = listener;
}
@Override
public void startup() {
try {
bus.setDirection(Gpio.DIRECTION_IN);
bus.setActiveType(Gpio.ACTIVE_HIGH);
bus.setEdgeTriggerType(Gpio.EDGE_RISING);
} catch (IOException e) {
throw new IllegalStateException("Sensor can't start",e);
}
try {
bus.registerGpioCallback(callback);
} catch (IOException e) {
throw new IllegalStateException("Sensor can't register", e);
}
}
@Override
public void shutdown() {
bus.unregisterGpioCallback(callback);
try {
bus.close();
} catch (IOException e) {
Log.e("TUT", "Failed to shut down.”, e);
}
}
private nal GpioCallback callback = new GpioCallback() {
@Override
public boolean onGpioEdge(Gpio gpio) {
listener.onMovement();
return true; // True to continue listening
}
};
}

In der Methode startup() wird der Sensor mit den Parametern Gpio.DIRECTION_IN, Gpio.ACTIVE_HIGH und Gpio.EDGE_RISING konfiguriert. Der Sensor liefert ein Signal, bei dem eine hohe Spannung den Status EIN definiert und Spannungswechsel von niedrig auf hoch den Aufruf der Callback-Funktion auslöst. In RemoteCameraApplication wird dann ein Attribut vom Typ MotionSensor definiert und in der privaten Methode initMotionSensor initialisiert (Listing 3).

  
private MotionSensor mMotionSensor;
private void initMotionSensor() {
Gpio bus;
try { // BCM18 is the GPIO pin for the sensor
bus = new PeripheralManagerService().openGpio("BCM18");
} catch (IOException e) {
throw new IllegalStateException("Can't open GPIO", e);
}
mMotionSensor = new PirMotionSensor(bus, this);
mMotionSensor.startup();
}
Abb. 7: Die Anbindung des Bewegungssensors an den Raspberry Pi 3

Abb. 7: Die Anbindung des Bewegungssensors an den Raspberry Pi 3

 

Die Kamera

Die Anbindung einer an den Raspberry Pi angeschlossenen Kamera ist deutlich aufwendiger als die Sensoranbindung, aber es gibt hier eine sehr detaillierte Beschreibung, die als Basis für die Implementierung verwendet wurde. Im ersten Schritt ergänzt man die Manifestdatei des Projekts um alle benötigten Berechtigungen:

  
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="com.google.android.things.permission.MANAGE_INPUT_DRIVERS" />

Dann startet man den Raspberry Pi neu. Das ist wichtig, da die Berechtigungen erst nach einem Neustart wirksam werden. Die Anbindung der Kamera erfolgt über die Klassen RemoteCamera und RemoteCameraHandler, wobei die Klasse RemoteCamera für die Hardwareerkennung der Kamera, die Konfiguration des Aufnahmeformats und die Aufnahme eines Bilds zuständig ist. In ihrer Methode init() sorgt die Klasse Remote-CameraHandler dafür, dass diese Aktivitäten asynchron in einem eigenen HandlerThread stattfinden:

  
public void init(Context context) {
mCameraThread = new HandlerThread("CameraBackground");
mCameraThread.start();
Handler mCameraHandler = new Handler(mCameraThread.getLooper());
mCamera = RemoteCamera.getInstance();
mCamera.initializeCamera(context, mCameraHandler,
mOnImageAvailableListener);
}

Die Klasse ist auch für die Speicherung des Bilds zuständig. Die Methode takePicture() der Klasse RemoteCamera löst eine Aufnahme aus, indem eine CaptureSession gestartet wird (Listing 4).

  
public void takePicture() {
try {
List surfaceList = Collections.singletonList(mImageReader.
getSurface());
mCameraDevice.createCaptureSession(surfaceList, mSessionCallback,
null);
} catch (CameraAccessException cae) {
Log.e(TAG, "access exception while preparing pic", cae);
}
}

Sobald ein Bild gemacht wurde und die zu speichernden Daten zur Verfügung stehen, wird die Methode onPictureTaken der Klasse RemoteCameraHandler aufgerufen, in der das erzeugte Bild weiterverarbeitet wird (Listing 5).

  
private void onPictureTaken(final byte[] data) {
  final long currentTime = System.currentTimeMillis();
  final String storagePath = "images/" + currentTime + ".jpg";

  //#1 Referenz für Firebase Storage anlegen
  StorageReference storageReference = FirebaseStorage.getInstance().getReference(storagePath);
  //#2 Daten schreiben
  UploadTask uploadTask = storageReference.putBytes(data);
  //#3 Erfolgsmeldung protokollieren
  uploadTask.addOnSuccessListener(
    new OnSuccessListener() {
      @Override
        public void onSuccess(UploadTask.TaskSnapshot taskSnapshot) {
          SnapshotRequest snapshotRequest
= new SnapshotRequest(true, storagePath, currentTime);
          //#4 aktuelles Bild aktualisieren
          FirebaseDatabase.getInstance()
                          .getReference("camera_snapshot/last")
                          .setValue(snapshotRequest);

          //#5 Version mit Zeitstempel speichern
          FirebaseDatabase.getInstance()
                          .getReference("camera_snapshot/" + currentTime)
                          .setValue(snapshotRequest);
        }
      });

  //#6 Fehlermeldungen protokollieren
  uploadTask.addOnFailureListener(
      new OnFailureListener() {
        @Override
        public void onFailure(@NonNull Exception exception) {
          Log.e(TAG, "Upload error " + exception.getMessage());
        }
      });
}

Zuerst (#1) wird ein Objekt der Klasse StorageReference erzeugt, das dann im zweiten Schritt (#2) für das Speichern der Bilddaten genutzt wird. Das Speichern erfolgt asynchron im Hintergrund. Es wird daher jeweils eine Callback-Funktion für das erfolgreiche Speichern (#3) und den Fehlerfall (#6) beim Objekt UploadTask registriert. Im Erfolgsfall werden in der Firebase-Datenbank jeweils ein Eintrag mit aktuellem Zeitstempel (#4) und der Eintrag für das aktuellste Bild (#5) geschrieben. Die Einträge werden durch ein Objekt der Klasse Snapshot- Request repräsentiert (Listing 6). Die Klasse besitzt ein Flag, das vermerkt, ob der Request verarbeitet wurde, einen Zeitstempel und den Pfad, unter dem das Bild gespeichert wurde.

  
public class SnapshotRequest {
private String storageReference;
private long dateCreated;
private boolean processed;
public SnapshotRequest(boolean processed, String storageRef, long
dateCreated) {
this.processed = processed;
this.storageReference = storageReference;
this.dateCreated = dateCreated;
}
// used by Firebase
public SnapshotRequest(){ }
// setter + getter
...
}

Die Kamera macht immer dann eine Aufnahme, wenn in der Firebase-Datenbank unter dem Knoten camera_snapshot/last ein nicht verarbeiteter SnapshotRequest gespeichert wird. Wird durch den Bewegungssensor eine Bewegung erkannt, wird in der Methode onMovement() der benötigte Eintrag in der Firebase-Datenbank geschrieben:

  
public void onMovement() {
CameraSnapshot snapShot = new CameraSnapshot();
snapShot.setProcessed(false);
snapShot.setDateCreated(new Date().getTime());
FirebaseDatabase.getInstance().getReference("camera_snapshot/last").
setValue(snapShot);
}

Damit hat man die in Abbildung 4 skizzierten Funktionen implementiert. Es gibt aber noch viele Möglichkeiten, die Anwendung weiter auszubauen. Interessante Anregungen findet man unter Awesome Android Things. Aber auch die Auswertung von Sensordaten in Kombination mit Machine Learning zeigt die Möglichkeiten von Android Things .

Fazit

Dieser Artikel hat einen Überblick über Android Things gegeben und die Entwicklung einer Anwendung für einen Raspberry Pi 3 an einem einfachen Beispiel skizziert. Android Things läuft auch als Developer Preview stabil und besticht durch die gute Integration der Google-Cloud-Dienste. Die Einstiegshürden sind gering, da geeignete Hardware mit einer Grundausstattung an Sensoren und mit einer Kamera für unter 100 Euro erhältlich ist. Die gute Unterstützung mit Dokumentation und Tutorials erleichtert den Einstieg, außerdem macht die Entwicklung auch noch Spaß. Der dritte Anlauf von Google in diesem Marktsegment könnte von Erfolg gekrönt sein.

Entwickler Magazin

Entwickler Magazin abonnierenDieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -