24 janv. 2013

Démarrer avec Android : les bases et le NDK

Logo Android

Une fois n'est pas coutume, je vais vous présenter ici non pas un article de fond, mais une sorte de tutoriel un peu plus détaillé que la moyenne. Le sujet du jour, c'est le développement d'une application Android. Pas n'importe quelle application cependant, puisqu'il s'agit de se préparer à l'arrivée ce cette console dont tout le monde parle - la dénommée OUYA.

Bien évidemment, étant donné la nature de l’exercice et la complexité du sujet, je ne peux pas me permettre d'aller au fond des choses. Si le sujet vous plait (et je le verrais d'après vos commentaires, n'est-ce pas ?) alors j'essaierais d'aborder encore la programmation Android dans un billet ultérieur. Je ne vous promet rien - à vous de me convaincre ?

Avant de commencer, un dernier mot : il est évident que pour comprendre ce tutoriel, vous avez besoin de connaître (un peu) Java, XML et C++. La connaissance de l'IDE eclipse est un plus intéressant, et il est tout de même préférable de connaître un peu Android du point de vue utilisateur.

Présentation

D'un point de vue technique, OUYA est une console de salon fonctionnant sous Android équipée d'un processeur Tegra 3, d'un GPU GeForce ULP et de 1 Go de RAM. La connectique est relativement classique, puisqu'on a quelques ports USB (dont un port micro-USB par lequel il est possible d'accéder au contenu de la flash), une sortie HDMI et un port RJ45 (ethernet 10/100). Coté radio, on retrouve du WiFi 802.11bgn et du Bluetooth LE 4.0.

Dans un premier temps, on va purement et simplement ignorer la partie radio de cette console, pour se focaliser sur 3 points : le CPU, le GPU et l'ethernet. Ces 3 points sont les plus intéressants lorsqu'on s'intéresse au développement de jeux video.

Je sais que certains ont tiqué lorsqu'ils ont entendu "Android". J'ai même entendu quelqu'un, en fond se salle, qui parlait d'injure faite à la communauté des développeurs de jeux, de langage inique, et que ce n'est pas demain la veille qu'on le prendra à faire du Java.

Soit. C'est un choix. Un choix un peu inutile, parce que sur Android, il n'est pas nécessaire de ne faire que du Java - certes, Java est nécessaire pour s'interfacer avec les périphériques du système ou le store imposé par le constructeur (que ce soit Play Store de Google ou le store OUYA). Mais Java cesse d'être un point de passage obligé dès que vous quittez ce royaume. Car Google, dans son immense sagesse (et, il faut bien l'avouer, parce que dans le cas contraire un grand nombre de développeurs n'allait même pas s'intéresser à ce système d'exploitation) a prévu que de très importantes parties d'une application pouvait être compilée à partir de code C ou C++, en mode natif. Pour cela, il a adjoint au SDK Android ce qu'il a appelé le NDK (native development kit).

Le NDK permet de compiler du code C et C++, et d'intégrer la version compilée nativement dans un package d'application, un APK (JNI servant de passerelle pour dialoguer avec la machine virtuelle Java). Il permet plusieurs modes de compilation - ARMv5, ARMv7, x86 ou MIPS. La compilation s'effectue avec le compilateur de son choix : la dernière mouture du NDK propose gcc-4.4.3, gcc-4.6, gcc-4.7 et clang-3.1.

Bien évidemment, celui qui nous intéresse est le mode ARMv7, pour plusieurs raisons :

  • le Tegra 3 est un processeur ARMv7
  • un coprocesseur flottant hardware, c'est plutôt bien
  • un coprocesseur SIMD, c'est encore mieux (NEON)

Reste que la console n'est pas encore disponible - elle devrait l'être d'ici à quelques semaines (tablons pour une release en fin de Q1 2013, ou début du Q2 2013). Sachant que nous voulons développer un jeu, utiliser un device émulé ne risque pas d'être une bonne chose[1]. Du coup, il semblerait que nous soyons un peu coincé...

En fait, pas tant que ça. Car pour une somme modique, on peut trouver d'autres devices Android à la puissance similaire, et accessible tout de suite. C'est le cas de la carte de développement ODROID U2 dont j'ai déjà parlé sur ces pages.

Plutôt que de vanter la puissance de cette carte, je vais considérer que vous avez, quelque part, un périphérique Android avec les possibilités suivantes :

  • Android version 4.0 ou ultérieure (Ice Cream Sandwich, Jelly Bean)
  • un CPU ARM bi-coeur Cortex A9 ou mieux (Exynos 4, Tegra 3, Snapdragon récent...)
  • un GPU supportant OpenGL ES 2.0 (MALI-400, SGX serie 5, GeForce ULP...)
  • au moins 512 Mo de RAM (1G, c'est mieux)

De très nombreux smartphones ou tablettes sont compatibles avec cette description et, bonne nouvelle, toutes accepterons de devenir pour vous des plateformes de développement - il suffit de les autoriser à installer des APK depuis une source quelconque et le tour est joué.

Bien entendu, ce n'est pas idéal pour cet article (même s'il est généralement possible de prendre des captures d'écran à partir d'un device Android). Du coup, tout cet article repose sur un device émulé dont les caractéristiques sont les suivantes[2] :

  • Ecran 21''
  • Résolution HD Ready (1280x720) ; il est tout à fait possible de préciser une taille supérieure, par exemple 1920x1080, au risque de voir les performances de la machine virtuelle diminuer. Si vous avez un écran de taille importante (24 ou 27 pouces) avec une résolution supérieure à la résolution HD, le tout relié à un PC très puissant (Core i7, 8 ou 16 Go de RAM, un GPU récent et puissant, un disque SSD) alors vous pourrez tenter l'expérience. Dans le cas contraire, je vous conseille de rester en résolution HD Ready[3].
  • 1Go de RAM
  • Architecture ARMv7
  • Pas de sensor, pas de caméra
  • Clavier hardware, D-Pad

En activant l'émulation d'OpenGL ES via OpenGL, on arrive à obtenir quelque chose de relativement fluide. Sous Linux, j'ai utilisé ce court script :

#!/bin/sh
p=$(pwd)
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$p/adt-bundle-linux-x86_64/sdk/tools/lib $p/adt-bundle-linux-x86_64/sdk/tools/emulator64-arm -avd Ouya -gpu on -qemu -sdl

Le but de ce tutoriel

Ce tutoriel n'aurait pas vraiment d'intérêt s'il n'avait pas un but concret : la réalisation d'un squelette d'application permettant de passer le plus rapidement possible en mode graphique plein écran et de sauter dès que possible en mode natif. Je n'ai rien contre Java, mais il faut reconnaître que la machine virtuelle Dalvik est tout de même plus lente qu'un programme compilé nativement - surtout que les compilateurs actuels génèrent du code extrêmement performant pour les processeurs ARM[4].

Dans un premier temps, on va construire une application plein écran minimale - c'est tout ce dont nous avons besoin pour commencer. Une telle application ne fera strictement rien, si ce n'est afficher un écran noir. Voyez ça comme une introduction à certains concepts importants d'Android.

Avant de commencer, je vous conseille d'installer le SDK Android, le NDK Android, et de lire les documentations concernant leur installation (ici pour le SDK, ici pour le NDK).

Première étape : une application Java en mode plein écran

C'est la partie la plus facile du développement, parce que le code va être presque entièrement généré par les assistant ADT d'eclipse. Vous n'avez qu'à le laisser faire.

  • Créer une application Android (File/New/Android Application Project)
  • Renseignez les différents champs important (nom du projet, nom de l'application, nom du package). Si besoin, changez le SDK cible (OUYA est livré avec Jelly Bean, donc vous n'avez pas besoin de supporter les anciennes API Android). Puis passez à la page suivante.
    • Pour cet exemple, j'ai choisi Purgatory comme nom de projet et d'application, et le nom de package est com.emmanueldeloget.purgatory.
  • Décochez la case Create Activity, et passez à la page suivante.
  • Cliquer sur Finish (vous pouvez, au besoin, changer l'icône de l'application, mais ce n'est pas nécessaire).

Une fois que vous avez fait tout ça, l'assistant a généré quelques fichiers que nous allons examiner.

Dans un premier temps, on s'aperçoit qu'il n'y a pas de code source : c'est normal, car nous n'avons pas demandé à l'assistant de nous en générer.

workspace/Purgatory/res/drawable-ldpi/ic_launcher.png
workspace/Purgatory/res/drawable-hdpi/ic_launcher.png
workspace/Purgatory/res/drawable-xhdpi/ic_launcher.png
workspace/Purgatory/res/drawable-mdpi/ic_launcher.png
workspace/Purgatory/res/values-v14/styles.xml
workspace/Purgatory/res/values/styles.xml
workspace/Purgatory/res/values/strings.xml
workspace/Purgatory/res/values-v11/styles.xml

Ces fichiers sont des ressources diverses et variées. Les fichiers *.png sont les icônes de l'application - proposé pour plusieurs résolution (low DPI, high DPI, medium DPI et extra high DPI). Les fichiers styles.xml concernent le thème et le style de l'application - sur les quelques fichiers présents, un seul nous intéresse : c'est celui qui est dans le répertoire res/values/. Les autres fichiers permettent d'affiner le style en fonction de la version de l'API (l'API v11 est liée à Honeycomb ; l'API v14 est liée à Ice Cream Sandwich).

Enfin, le fichier string.xml contient des chaînes de caractère.

Globalement, les fichiers XML contenus dans le répertoire res/ définissent des ressources qu'il est possible de référencer dans d'autres fichiers XML du projet, et notamment dans le manifeste Android. Cette référence utilise une syntaxe spéciale @typeResource/nomResource. Par exemple, si le ficher styles.xml contient une ressource de type <style> et de nom Fullscreen alors il est possible d'y faire référence dans AndroidManifest.xml grâce à la notation @style/Fullscreen.

workspace/Purgatory/libs/android-support-v4.jar

Ce fichier est une librairie contenant du code permettant de faire le lien entre l'application et le système Android.

workspace/Purgatory/proguard-project.txt
workspace/Purgatory/project.properties

Ces deux fichiers contiennent des informations supplémentaires sur la façon dont le projet sera construit. ProGuard est un outil permettant d'optimiser et obfusquer le code binaire produit pour la machine virtuelle Dalvik d'Android. Les options supplémentaires autres que celles qui sont spécifiées par défaut sont introduites dans proguard-project.txt. project.properties initialise certaines variables utilisées pendant le build de l'application, notamment la version cible du SDK. Ce fichier est généré automatiquement, il convient dont de ne pas le modifier.

workspace/Purgatory/ic_launcher-web.png

Cette image est utilisée en tant que support visuel par le Play Store de Google.

workspace/Purgatory/AndroidManifest.xml

Là, on entre dans le vif du sujet. Ce fichier particulier décrit l'application, son activité principale... Vu que notre projet est vide, ce fichier est relativement réduit - il devrait ressembler à ceci :

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.emmanueldeloget.purgatory"
    android:versionCode="1"
    android:versionName="1.0" >
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="17" />
<application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" > </application>
</manifest>

On remarque les chaînes de la forme @typeRessource/nomRessource que j'ai mentionné ci-dessus.

L'application que nous avons créé ne fait rien. Il est possible de la télécharger sur un device Android et de l'installer, mais son installation ne sera suivi d'aucun effet : il n'est pas possible de la lancer. Il lui manque quelque chose de crucial : une activité.

Les activités sous Android

Vous avez lu ce mot plusieurs fois dans les lignes précédentes, mais je ne l'ai pas encore expliqué. Etant donné qu'il s'agit d'un concept central à la programmation pour Android, il est nécessaire de le comprendre.

Si on va vers la simplification la plus extrême, une activité est une interface présentée à l'utilisateur. On associe à cet écran des actions et un comportement, et quelques fois des sous-activités qui sont autant d'interfaces supplémentaires. Si vous êtes habitué à la programmation sous Windows ou sous X Window, vous pouvez penser à une activité comme étant une fenêtre ou une boite de dialogue.

Un programme peut bien évidemment être composé de plusieurs activités - chaque activité correspondant à une interface particulière. Par exemple, on peut imaginer qu'une application possède une vue principale où les éléments graphiques sont affichés, et une vue "options" permettant à l'utilisateur de contrôler certaines parties du programme. L'un des concepts clefs d'Android est que ces différentes activités sont indépendantes les unes des autres, et que chaque activité est un point d'entrée possible du programme (par exemple, on peut imaginer l'application peut partager son panel d'options avec l'application de gestion des paramètres du device).

Créer une activité n'est pas spécialement compliqué - là encore, le plugin ADT va nous aider puisqu'il propose un assistant Android Activity qui va générer le code nécessaire pour vous. Ceci dit, les activités qu'il permet de créer sont plus adaptées à des applications traditionnelles qu'à des jeux. De plus, le code généré est inutilement compliqué par rapport à ce que nous souhaitons obtenir - l'assistant tente de rajouter de contrôle, du texte... afin de donner une base de travail à un développeur d'application. Ce qui nous intéresse principalement, c'est de passer en mode plein écran immédiatement, peut-être de présenter un écran de chargement, avant de demander à OpenGL ES de prendre le relai.

Comment fait-on pour ajouter une activité à un programme ?

Du point de vue du code, ça se fait en rajoutant une classe dans le namespace principal de l'application. Cette classe étends (au sens Java) la classe Activity proposée par le SDK Android. Un code compact pour cette classe est le suivant :

package com.emmanueldeloget.purgatory;
import android.app.Activity; import android.os.Bundle;
/** * An example, minimal full screen application, with no layout */ public class PurgatoryMainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); }
@Override protected void onPause() { super.onPause(); } }

Que voit-on dans cette classe ? En premier lieu, elle hérite de la classe Activity ainsi qu'on l'a annoncé ci-dessus. Cette classe, proposée par le SDK Android, implémente les fonctionnalités nécessaires permettant la passerelle entre le système Android, la machine virtuelle Dalvik et votre application.

Dans cette classe, on surcharge deux méthodes onCreate() et onPause() qui sont appelées par le framework Android à certains moments clefs de l'application. Dans notre cas, on choisi de ne rien faire faire à cette activité - on verra ci-dessous ce qu'on peut en tirer. Il y a bien évidemment d'autres méthodes à surcharger, chacune correspondant à une action spécifique. Le schéma ci-dessous montre certaines des étapes les plus importantes de la vie d'une activité.

activity_lifecycle.png

Vous trouverez plus de détails sur la création d'activités dans la documentation du SDK Android.

Le code donné ci-dessus ne suffit pas pour que l'application propose une interface utilisateur. Il manque quelque chose de très important : un lien avec le système. En effet, sans ce lien, le système ne saura pas quelle activité démarrer lorsque vous lancer l'application à partir du launcher Android ou OUYA.

Ce lien est créé en ajoutant une entrée <activity> (enfant de <application>) dans le fichier AndroidManifest.xml que nous avons décrit tout à l'heure. En plus de définir ainsi qu'on a une nouvelle activité, on en profite pour annoncer au système quelles sont les intentions de cette activité. Le tag <activity> a plusieurs attributs intéressants. Parmi eux, un seul est obligatoire : l'attribut name dans le namespace XML android. Cet attribut, une chaîne de caractère, précise le nom de l'objet Java lié à l'activité (dans notre exemple, android:name="com.emmanueldeloget.purgatory.PurgatoryMainActivity").

Une fois que l'activité est déclarée, il faut prévenir le système de nos intentions la concernant. Pour cela, on utilise une série d'intentions de différents types, regroupées dans un <intent-filter>.

J'ai repris le fichier AndroidManifest.xml présenté plus haut, et j'y ai ajouté les modifications dont nous venons de discuter. On obtient :

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.emmanueldeloget.purgatory"
    android:versionCode="1"
    android:versionName="1.0" >
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="17" />
<application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" > <activity android:name="com.emmanueldeloget.purgatory.PurgatoryMainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>
</manifest>

En lançant l'application dans l'émulateur, on obtient ceci :

Purgatory, étape 1

Si on va dans la liste des applications, on se rend compte que l'icône de notre application est bel et bien présente dans le launcher. Par contre, pour quitter, c'est une autre histoire... En fait, on peut toujours se servir des boutons classique Back ou Home de Android ou, si on a opté pour un clavier+DPad hardware dans la configuration de sa machine, on peut appuyer sur la touche ESC de son clavier (équivalent à la touche Back).

On commence à obtenir un résultat, mais on voit immédiatement deux problèmes :

  • en premier lieu, alors qu'on s'attends à un fond uni, on a un dégradé de gris - ou autre chose encore.
  • en second lieu, on souhaite une application en mode plein écran - et là, on voit clairement la barre de notifications et la barre de titre de l'application.

Chacun de ces points nécessite d'être pris séparément.

Thèmes et styles

Le premier problème n'est évidemment pas lié à un quelconque code que nous avons écrit - rien dans notre code minimal ne provoque l'affichage d'un dégradé. Si ce n'est pas moi qui m'en charge, alors c'est nécessairement le système - sous une forme ou une autre. Fort logiquement, le SDK doit prévoir quelque chose qui me permet de ne pas afficher ce dégradé.

Et ce quelque chose, c'est un style. L'application que nous avons créé n'a aucun style défini - elle utilise donc le style par défaut d'Android (et puisqu'on utilise Jelly Bean, le fond de l'application est un dégradé de gris).

On peut définir autant de style qu'on veut (dans le fichier res/values/styles.xml), en tant que fils de l'élément racine <resources>. Le style va notamment permettre de définir la brosse ou la couleur utilisée pour dessiner le fond de l'activité.

Dans un premier temps, on utilise le fichier styles.xml suivant :

<resources>
	<style name="FullscreenTheme" parent="android:Theme.Light"
		<item name="android:windowBackground">@null</item>
	</style>
</resources>

Ce fichier défini un style FullscreenTheme dont le parent est android:Theme.Light. Dans ce style, on utilise aucune couleur/brosse pour dessiner le fond de la fenêtre.

Un style peut être attaché à une activité en ajoutant un attribut android:theme à l'entrée <activity> dont on souhaite modifier le thème. Cette entrée fait référence à une ressource de type style, et dont le nom est spécifié par l'attribut name de la ressource - si vous vous rappellez de ce que vous avez lu plus haut, dans notre cas on obtient android:theme="@style/FullscreenTheme".

Avec ces modifications, on obtient quelque chose de plus raisonnable : le fond de l'activité est uni et opaque - mais nous avons encore une barre de titre et la barre de notification.

android-purgatory-0001.jpg

Là encore, le thème que nous venons de définir peut nous permettre d'affiner notre premier jet. Premièrement, on remarque que notre style "hérite" d'un style parent nommé Theme.Light. Ce thème est a priori différent du thème par défaut puisque la barre de titre a changé d'apparence. Existe-t-il d'autres styles parents qui peuvent nous intéresser ? Bien évidemment, la réponse est oui - et leur nombre est assez impressionnant. Dans cette liste, on trouve deux thèmes qui sont intéressants ;

  • Theme.NoTitleBar
  • Theme.NoTitleBar.Fullscreen

Le premier supprime la barre de titre mais nous laisse la barre de notification. Le second supprime et la barre de titre, et la barre de notification ; il indique en outre que l'application va être en mode plein écran - il semblerait que ce thème soit parfait, ou presque !

<resources>
	<style name="FullscreenTheme" parent="android:Theme.NoTitleBar.Fullscreen"
		<item name="android:windowBackground">@null</item>
	</style>
</resources>

En effectuant cette modification, on obtient le résultat demandé : une fenêtre opaque, en plein écran, sans barre de titre no barre de notification. Je vous fait grâce de la capture d'écran - qui a peu d'intérêt : ce n'est qu'un rectangle noir de 1280x720 pixels.

Code natif sous Android

Java est un langage adapté à la réalisation de nombreux projets Android - on y reviendra peut-être plus tard, dans un autre article. Cependant, il reste un langage interprété - et même si la machine virtuelle Dalvik et le runtime proposé par Google sont fortement optimisés, il n'en reste pas moins que le code produit sera à priori plus lent que du code purement natif.

Interface Java/code natif avec JNI et le NDK.

Il existe deux manières d'utiliser le mode natif sous Android. Dans le premier cas, l'application démarre en Java et donne ensuite la main à une section de code natif. Pour cela, la machine virtuelle a besoin d'une passerelle technologique. Cette passerelle est appelée JNI, pour Java Native Interface. Elle consiste en un ensemble de fonctions C ayant un prototype particulier et fonctionnant comme une façade vers votre code[5]. Ces fonctions sont regroupées dans une librairie qui est chargée par le programme Java. La machine virtuelle se charge de faire le lien avec les fonctions native lorsque celle-ci sont appelées la première fois.

Pour créer une librairie avec le NDK, il faut :

  • mettre le code source de votre librairie native dans le répertoire $project/jni, ou $project est le nom du projet
  • rajouter un fichier Android.mk dans ce même répertoire
  • utiliser ndk-build pour compiler votre librairie native.
  • déclarer dans le code Java les classes et méthodes définies dans la librairie native.

Une librairie native est une librairie partagée (DLL sous Windows, .so sous Unix) qui propose un ou plusieurs points d'entrée dont les noms ressemblent par exemple à Java_com_emmanueldeloget_purgatory_my_func.

Le fichier Android.mk est un Makefile tout ce qu'il y a de plus classique. Il utilise la syntaxe GNU make, et s'intègre dans un système de construction plus complexe. Un effort considérable a été effectué pour rendre ce fichier très simple à écrire et à maintenir. Si on prends l'exemple hello-jni du NDK, son fichier Android.mk contient (hors commentaires) :

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := hello-jni
LOCAL_SRC_FILES := hello-jni.c
include $(BUILD_SHARED_LIBRARY)

LOCAL_MODULE défini le nom du module à créer ; LOCAL_SRC_FILES liste les fichiers source. Les deux directives include permettent de lier ce makefile au système de construction du NDK.

Un point important : même si vous développez en C++, l'interface JNI est nécessairement écrite en C - ou, tout au moins, exporté sous la forme d'un symbole C, sans les décorations imposées par le C++. De fait, si les fonctions d'interfaces sont définies dans un fichier contenant du C++, il faut penser à les marquer comme étant extern "C".

Avoir une librairie native compilée et prête à l'emploi n'est pas suffisant pour qu'elle se retrouve soudain à être utilisée comme par magie. Il faut bien évidemment appeler les fonctions ou instancier les objets que vous avez implémenté dans cette librairie native. Pour cela, deux étapes sont nécessaires - comme dans tout projet utilisant JNI.

  • il faut écrire les déclarations Java correspondantes. Ces déclarations utilisent le mot-clef native. Vu que l'interface JNI implémentée ne permet de définir que des fonctions, on ne déclare avec ce mot-clef que des méthodes.
class Test
{
    public native void maFonction(); 
}
  • il faut préciser à la machine virtuelle que lors de l'instanciation de la classe proposant une ou plusieurs méthodes natives, il lui faut charger la librairie dynamique implémentant la ou les fonctions correspondantes. On utilise pour cela le système de construction statique du chargeur de classe Java. Celui-ci cherche une section statique dans la définition de la classe, et l'exécute sur la première instanciation.
class Test
{
    static
    {
        System.loadLibrary("malib");
    }
public native void maFonction(); }

La chaîne passée en paramètre à la méthode loadLibrary ne contient ni le préfixe (lib) ni l'extension utilisée par la librairie dynamique (.so). Sous Windows, ce code essaiera de charger un fichier malib.dll. Sous Unix, ce code provoquera une tentative de chargement de libmalib.so.

Créations d'activités natives avec le NDK.

Même s'il s'agit de la solution préconisée par Google - parce qu'un grand nombre d'application n'ont pas besoin d'être en mode natif en permanence, et parce que le runtime Dalvik propose de nombreux services qu'il est plus facile d'utiliser en Java -, la solution ci-dessus n'est pas adaptée à tout les modèles de développements, et notamment aux développements de certains jeux.

Fort heureusement, il existe une autre technique : sous Android, on peut créer une activité complète en mode natif - et, par extension, une application complète aussi.

Du point de vue de la mise en oeuvre du NDK, le développement d'une application native n'est pas plus compliqué que le développement d'une librairie native. Il suffit de respecter une interface particulière pour que l'application soit correctement exécutée et prise en compte par la machine virtuelle Java (car oui, une application native s'exécute quand même dans la machine virtuelle Java).

  • le code source est encore placé dans le répertoire jni/ de l'application.
  • un fichier jni/Android.mk doit être présent. Si besoin, on peut ajouter un fichier jni/Application.mk permettant de contrôler la version du SDK utilisée pour la compilation.
  • un fichier lié au projet doit contenir une fonction C nommée ANativeActivity_onCreate() - c'est le point d'entrée du programme, il permet de créer une activité native. Cette fonction a pour but de renseigner les champs d'une structure du type ANativeActivity.
  • le programme est compilé comme une librairie partagée, avec ndk-build.
  • il faut indiquer dans AndroidManifest.xml qu'on base l'application sur une activité native android.app.NativeActivity[6].

A noter que le NDK propose une interface simplifiant notablement la création d'application natives (la librairie statique android_native_app_glue, qui est une surcouche à ANativeActivity_onCreate()). Si vous le souhaitez, j'aborderais ce point dans un futur billet.

Le code minimal qui doit être présent dans un des fichiers C du projet est le suivant :

#include <android/native_activity.h>
void ANativeActivity_onCreate(ANativeActivity* activity, void* savedState, size_t savedStateSize) { }

Ce code ne fait rien - il laisse les callback contenues dans activity à leurs valeurs par défaut. On peut créer un fichier Android.mk pour compiler ce fichier :

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := npurgatory LOCAL_SRC_FILES := npurgatory.c
include $(BUILD_SHARED_LIBRARY)

Comme vous le voyez, ce fichier est exactement semblable à un fichier Android.mk servant à créer une librairie de fonctions JNI.

On peut maintenant modifier le fichier AndroidManifest.xml de cette manière :

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.emmanueldeloget.npurgatory"
    android:versionCode="1"
    android:versionName="1.0" >
<uses-sdk android:minSdkVersion="14" />
<application android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:hasCode="false" > <activity android:name="android.app.NativeActivity" android:label="@string/app_name"> <meta-data android:name="android.app.lib_name" android:value="npurgatory" /> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>
</manifest>

Il y a plusieurs chose à noter dans cette instance du fichier AndroidManifest.xml. En premier lieu, <application> gagne un nouvel attribut hasCode, qui prévient que l'application n'a pas de code Java - donc inutile d'en chercher. En second, l'activité ajoutée a pour nom de classe android.app.NativeActivity. C'est nécessaire dans le cas où l'on souhaite implémenter une activité native, et cela signifie que l'activité est prise en compte par une instance de cette classe présente dans le SDK Android.

Ce besoin est accompagné d'un autre, car il faut dire au système dans quelle librairie il doit aller chercher l'activité native en question. Cela se fait grâce à un tag <meta-data> pour lequel on spécifie les attributs android:name="android.app.lib_name" et la valeur de cet attribute android:value="nom de la librairie". Le nom de la librairie est identique au nom spécifié pour la valeur LOCAL_MODULE dans Android.mk.

Une fois l'APK créé[7], on peut le télécharger sur le device et le lancer.

device-2013-01-17-005531.png

Un problèmes est très rapidement apparents : la barre de notification est toujours là, comme le montre l'image ci-dessus. C'est normal : le système nous donne une fenêtre, mais nous ne faisons rien avec celle-ci. Il est temps d'y remédier.

Les callbacks de l'activité

L'instance de ANativeActivity reçue par la fonction ANativeActovity_onCreate() contient un membre callbacks listant toutes les callbacks qui peuvent être implémentée par une application native. Ces callbacks correspondent aux méthodes surchargeables de l'objet android.app.Activity utilisé en Java. On y retrouve :

  • void onStart(ANativeActivity* a) : appelée au démarrage de l'activité.
  • void onStop(ANativeActivity* a) : appelée lorsque l'activité est stoppée.
  • void onPause(ANativeActivity* a) : appelée lorsque l'activité va passer en pose (c'est à dire au moment de quitter ou lorsqu'on lance une autre activité). Cette fonction a l'obligation de sauvegarder toutes les données qui doivent être persitante - car rien ne dit qu'on reviendra de la pause. Le système peut tuer le processus pendant qu'il est en pause s'il doit, par exemple, récupérer un peu de mémoire.
  • void onResume(ANativeActivity* a) : appelée lorsqu'on l'activité revient de la pause.
  • void onDestroy(ANativeActivity* a) : appelée à la destruction de l'activité. Attention tout de même : selon l'état de l'activité, le système peut décider de ne pas appeler cette fonction.

Ces fonctions permettent de gérer le cycle de vie de l'activité, telle que décrit sommairement ci-dessus. D'autres callbacks sont liées à la gestion de la fenêtre principale :

  • void onNativeWindowCreated(ANativeActivity* a, ANativeWindow *w) : la fenêtre de l'activité a été créée. C'est le moment idéal pour créer un contexte OpenGL ES par exemple.
  • void onNativeWindowResized(ANativeActivity* a, ANativeWindow *w) : la fenêtre de l'activité est redimensionnée.
  • void onNativeWindowRedrawNeeded(ANativeActivity* a, ANativeWindow *w) : il faut redessiner la fenêtre de l'activité.
  • void onNativeWindowDestroyed(ANativeActivity* a, ANativeWindow *w) : la fenêtre de l'activité a été détruite. C'est le moment idéal pour libérer un contexte OpenGL par exemple.
  • void onWindowFocusChanged(ANativeActivity* a, int has_focus) : la "fenêtre" de l'activité gagne ou perd le focus (selon la valeur de has_focus).

Enfin, on y trouve aussi un certain nombre d'autres callbacks aux buts divers et variés.

  • void onInputQueueCreated(ANativeActivity* a, AInputQueue *q) : une file d'entrées utilisateur a été créée pour cette activité. Si l'activité avait déjà une file d'entrées, alors elle est remplacée.
  • void onInputQueueDestroyed(ANativeActivity* a, AInputQueue *q) : la file d'entrées utilisateur de l'activité a été détruite.
  • void onContentRectChanged(ANativeActivity* a, const ARect *r) : le rectangle d'affichage de l'activité a été modifié.
  • void onConfigurationChanged(ANativeActivity* a) : la configuration de l'activité a été modifiée.
  • void onLowMemory(ANativeActivity* a) : le système a besoin de mémoire, et demande à votre activité de libérer de l'espace si possible.
  • void *onSaveInstanceState(ANativeActivity* a, size_t *outSize) : le système vous demande d'allouer une zone mémoire et d'y stocker des données de manière à pouvoir vous les rendre sur un démarrage à venir de l'activité. N'espérez pas pouvoir stocker ici des données importantes et persistantes, car cet état d'instance ne sera peut-être pas renvoyé à l'activité plus tard. A noter que vous devez allouer la mémoire avec malloc() - le système se chargera de la libérer.

Ces différentes callback sont documentées d'une part dans le fichier <android/native_activity.h> du NDK et d'autres part dans la documentation de la classe @@android.app.Activity@@ du SDK Android.

A noter que vous n'êtes pas obligé d'implémenter toutes les callbacks. Si vous en laissez une de coté alors elle garde à leur valeur par défaut (NULL), ce qui signifie que la callback ne sera associée à aucun comportement particulier pendant la durée de vie de l'application - dans la plupart des cas (en fait, à une exception importante près) le comportement fournit par la classe android.app.NativeActivity est suffisant pour que l'application fonctionne à peu près correctement, à un détail prêt ; la touche back ne réponds pas.

La présence de ces callback et leur nom doit vous permettre de mettre un nom sur ce type de développement : il s'agit bien évidemment de programmation évènementielle - dans laquelle votre application réagit à des évènements qui lui sont signalés par le framework et par le système. Dans notre cas, le signalement se fait par l'appel d'une callback, et notre réaction se fait en exécutant le code de cette callback.

Le système suppose qu'aucune callback n'est bloquante - si vous bloquez une callback, alors vous ne pouvez plus recevoir d'évènements par la suite. Pire, Android pourrait décider que votre application ne réponds plus, et la tuer après un certain temps (ce qu'on nomme dans le monde Android un ANR, pour Application Not Responding). Vous devez donc faire votre traitements rapidement, puis redonner le contrôle à l'appelant.

Un thread séparé pour traiter les messages

Notre but avoué est de créer une surface sur laquelle on va pouvoir utiliser OpenGL ES pour afficher des objets 2D ou 3D animés (on va faire un très imple animation dans un premier temps : on va afficher une image et faire un fondu au noir). Qui dit animation dit qu'on ne peut pas se satisfaire du modèle évènementiel proposé par Android. Par définition, on a besoin pour afficher une animation d'un temps relativement long, et ce temps ne peut pas être trouvé dans la réponse à un évènement de la liste ci-dessus - d'autant plus que ces callbacks se doivent de redonner rapidement la main à la machine virtuelle, sous peine d'ANR.

La solution à ce problème est relativement aisée à deviser : il faut créer un thread qui va prendre en charge tout le rendu de l'activité native. Ce thread prend de fait une autre responsabilité, puisque c'est lui qui sera le seul à même de traiter les messages envoyés par le système à notre activité (puisque c'est lui qui connaîtra le contexte de l'application). Les callbacks appelées via la classe android.app.NativeActivity vont donc provoquer l'envoi d'un message à notre thread privé, qui va ensuite traiter ce message de manière asynchrone[8].

Le NDK propose un système très complet de gestion des queues de messages. Ce système est basé sur deux notions :

  • les queues d'évènements (AInputQueue), qui permet de traiter les évènements liés aux entrées utilisateur (touchscreen, touchpad, clavier...)
  • des primitives de démultiplexage (ALooper), qui effectue un polling sur un ou plusieurs descripteurs de fichiers.

Le looper est lié à la queue d'évènements via la callback onInputQueueCreated() ; la callback onInputQueueDestroyed() sert à prévenir le looper que la queue d'évènements va bientôt être détruite par le système. Pour traiter les évènements système qui ne sont pas des entrées utilisateur, on utilise le même looper en lui attachant l'extrémité d'un pipe anonyme.

  • lorsque Android appelle une de nos callback (onPause(), onResume()...) on écrit un code spécifique dans l'entrée du pipe.
  • le thread de traitement attends un évènement avec ALooper_pollOnce().

Certains messages particuliers (notamment ceux ayant trait aux interactions avec la pseudo-fenêtre de l'activité) doivent être traités de manière synchrone - mais, fort logiquement, il ne peuvent être traités que dans notre thread puisque c'est celui-ci qui tiens les informations dont nous avons besoin (sans compter que certains systèmes de rendu acceptent mal d'être initialisé dans un thread et utilisé dans un autre). Pour forcer un traitement synchrone, on peut utilise des variables de conditions bloquante[9] ou toute autre solution équivalente.

Modèle de développement d'une application native

Reprenons un peu tout ça. Le fonctionnement d'une activité native est le suivant :

  1. Android instancie la classe android.app.NativeActivity
  2. cette classe cherche dans la librairie partagée le point d'entrée ANativeActovity_onCreate()
  3. ce point d'entrée
    1. initialise les callbacks
    2. crée un pipe anonyme
    3. crée un thread utilisateur
    4. retourne à l'appelant.
  4. le thread se lance
    1. il prépare un looper (en fait, il instancie un looper qui est lié à ce thread)
    2. il associe l'extrémité en lecture du pipe anonyme au looper
    3. il se met en attente sur le looper
  5. l'instance de android.app.NativeActivity appelle une callback
    1. la callback écrit un code spécifique dans l'extrémité en ecriture du pipe anonyme
    2. elle retourne à l'appelant
    3. le thread détecte l'arrivée d'un message, et traite celui-ci
      1. si le message est envoyé par la callback onInputQueueCreated(), le thread lie la queue d'évènement à son looper
  6. Android détecte une entrée utilisateur (touchscreen, clavier...)
    1. il envoie l'évènement dans la queue d'évènements
    2. le thread détecte l'arrivée d'un message, et traite celui-ci
  7. le thread détecte qu'il doit terminer son exécution
    1. il appelle ANativeActivity_finish().

Cette vue paraît simple - et elle l'est, dans son principe. L'implémentation est par contre un peu plus complexe, car on doit traiter différents cas. Par exemple, pour la seule boucle de messages :

  • le message reçu par le thread peut être synchrone (gestion de la fenêtre, récupération de l'état de l'instance) ou asynchrone (gestion du cycle de vie...) ;
  • le message peut être un message système ou un message provenant d'une entrée utilisateur ;
  • on peut aussi ajouter au looper les messages provenant de différents capteurs (accéléromètre, GPS...) ;
  • le système peut préempter un message provenant d'une entrée utilisateur ou d'un capteur (dans ce cas, interdit de le traiter) ;
  • si on ne traite pas le message, alors il faut en avertir le système (on termine le message, grâce à la fonction AInputEvent_finish()) ;
  • le message peut nous forcer à changer la queue d'évènement courante (elle peut avoir été détruite).

Le tout, dans un environnement multithread relativement peu documenté.

Conclusion

Si la première partie de l'article proposait un peu de code, celui-ci à vite disparu de la seconde partie - et pour cause : la quantité de code C ou C++ a écrire pour obtenir un système fonctionnel est beaucoup plus importante.

Mais sachez tout de même que ce code existe - il est caché dans une librairie nommée anapi++.

La librairie anapi++

Me sentant l'âme d'un chevalier, j'ai décidé de consacrer un peu de temps à l'écriture d'une librairie C++ qui, à terme, devrait simplifier le développement d'activités natives en C++. Cette librairie, qui reprends à la sauce C++11 certain des concepts de la librairie native_app_glue de Google, est disponible sur Google Code en version 0.1.0 à l'heure ou j'écris ce texte. Pour être tout à fait franc avec vous, elle présente pas mal de petits défauts[10] et n'est pas complètement complète, mais elle permet déjà de créer des activités natives assez complexes.

La page Google Code du projet vous permet d'accéder au code source (license libre type zlib), au système de suivi de tickets, à la documentation, etc.

Fin finale

D'autres articles consacrés à Android suivront dans un futur proche. N'hésitez pas à me contacter si vous avez relevé une erreur ou si vous avez un commentaire à faire. En attendant, merci de votre patience, et à bientôt !

Notes

[1] j'ai testé pour vu. C'est très, très lent, sur un Core i7 avec 8 Go de RAM et un SSD.

[2] accessoirement, ce sont les recommandations de OUYA

[3] la console OUYA ne supporte que deux résolutions : HD et HD Ready.

[4] notamment grâce au travail de l'équipe linaro

[5] rassurez vous - il existe des outils qui génère ce code C pour vous, de manière à limiter la difficulté de l'exercice.

[6] il est tout à fait possible de sous-classer android.app.NativeActivity pour proposer ses propres services.

[7] Le plugin ADT pour eclipse supporte le NDK, et permet donc de créer des applications natives. Il faut juste indiquer le chemin du NDK dans les préférences d'eclipse (section Android) ; une fois cela effectué, un click droit sur le nom du projet permet de choisir l'option ''Android Tools/Add Native Support". eclipse prends alors en charge la création des répertoires et des fichiers manquants.

[8] Cette solution n'est pas simple à mettre en oeuvre, mais il n'en reste pas moins que c'est la solution préconisée par Google dans son NDK. Elle est mise en oeuvre dans la librairie statique native_app_glue dont les sources sont disponibles dans le répertoire sources/android/native_app_glue/.

[9] Les variables de condition sont implémentées dans la librairie pthread disponible sous Android. Voir cette page man pour plus d'informations.

[10] La librairie anapi++ présente des défauts à la fois dans son design, dans son implémentation et dans l'utilisation du langage C++11. Je m'en excuse par avance, et je n'ai pas vraiment de raison à vous donner pour me justifier - mis à part que cette librairie a été développée sur mon temps libre, et que celui-ci est compté. Certaines des limitations sont liées au système de build du NDK - par exemple, je n'ai pas réussi à utiliser la classe std::thread, pour une raison inconnue. Il est probable qu'en prenant un peu plus de temps, je trouverai et corrigerai ce problème. Ce n'est toutefois pas à l'ordre du jour.

Commentaires

1. Le jeudi, janvier 24 2013, 23:27 par gbdivers

Les grands esprits se rencontrent, j'ai commencé à regarder aussi le développement sur Android cette semaine. Par contre, j'ai survoler le NDK pour passer rapidement sur Qt (plus précisément Necessitas, le port de Qt sur Android)
Peut-être que j'achèterai aussi une OUYA.
Peut-être aussi que je testerai Qt dessus.
Peut-être aussi que je ferais un tutoriel dessus...
;)

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet