Published on

SDL sur Android

Authors
  • Name
    Anthony Rabine

SDL2 offre un socle solide et vraiment multi-plateforme pour développer des jeux, principalement, mais aussi des applications. Il est une bonne alternative à des solutions payantes. Mais son usage sur Android n'est pas si simple. Voyons comment l'architecture se décompose avec une application simple.

Architecture de SDL sur Android

La librairie SDL est développée en C et offre une API en C. Dès lors, il faudra utiliser le NDK (Native Developement Kit) fourni par Google qui permet de compiler du code C/C++ pour android et le lier à du code Java.

Je parle de lier, car toute application Android doit avoir au minimum une classe Java principale, l'Activity.

SDL2 ne s'occupe pas que de l'affichage mais aussi des entrées/sorties, de l'audio et des particularités mobiles (par exemple la mise en sommeil et l'orientation de l'écran, l'appui sur les boutons du téléphone...). Tout ceci doit être redirigé vers l'application C++. Dès lors, les développeurs ont développé un set de classes Java qui permettent de faire de pont entre Android et SDL2.

image

Notre application minimale sera donc composée de :

  • Des fichiers Java fournis par SDL
  • Notre application (main.c)
  • La librairie SDL (tout son code source)

Eh oui, il nous faudra compiler SDL à partir de ses sources car aucune librairie pré-compilée n'existe en standard. Notez que les autres librairies utilitaires optionnelles de SDL peuvent aussi être ajoutées en partant des sources (SDL_image, SDL_ttf, SDL_net ...).

Outillage

Pour développer sur Android, vous aurez besoin :

  • Du SDK Android, soit avec Android Studio soit le "platform tools" en ligne de commandes
  • Un téléphone Android (c'est plus rapide), sinon un émulateur fourni dans le SDK
  • Un éditeur de code genre Visual Studio Code

Nous n'allons pas utiliser forcément Android Studio, la ligne de commande suffira. Et c'est tant mieux, l'IDE de Google est ultra lent et horrible à utiliser.

Le projet utilisera Gradle, le standard sous Android. Il serait possible de construire un paquet APK autrement, à l'aide d'un ensemble de scripts et d'appel à différents outils de Google (d8, appt2, ...) mais c'est déconseillé. Maintenir un tel ensemble de script prend du temps et est risqué : Google fait évoluer régulièrement ses outils et les règles de disffusion sur le PlayStore. Donc, restons sur Gradle.

image

Au niveau logiciel, tout est normalement fourni dans le NDK. Nous utiliserons CMake pour construire la partie C/C++ de notre application, CMake et le compilateur croisé Clang sont dans les outils du NDK.

Arborescence

Les fichiers Gradle de base seront à la racine du dépôt. Le répertoire app contiendra tous les fichiers propres à la cible android, dont des sous fichiers Gradle et du code Java (dans app/src/main/java). Les fichiers sorties, dont l'APK final, sera généré dans app/build.

.
├── app
│   ├── build
│   │   ├── generated
│   │   ├── intermediates
│   │   ├── outputs
│   │   │   ├── apk
│   │   │   └── logs
│   │   └── tmp
│   └── src
│       └── main
│           ├── assets
│           ├── java
│           └── res
├── gradle
│   └── wrapper
├── libs
│   ├── lib_sdl2
│   │   ├── include
│   │   └── src
└── src

Par défaut, le plugin Gradle compilera tous les fichiers Java localisés dans le répertoire src/main/java de votre projet (ou sous-projet).

Si vous désirez placer les fichiers dans un autre répertoire il faudra le préciser dans le fichier gradle :

groovy
sourceSets {
    main {
        java {
            srcDirs = ['src/java']
        }
        resources {
            srcDirs = ['src/resources']
        }
    }
}

Le répertoire libs contiendra la librairie SDL2 sous forme de code source.

Enfin, le répertoire src à la racine contiendra le code C/C++ propre à notre application.

Fichier projet Gradle

Les fichiers de base du projet Gradle sont classiques :

build.gradle
gradle.properties 
gradlew 
gradlew.bat
settings.gradle

Le fichier settings.gradle contiendra l'inclusion du sous projet Gradle via une inclusion : include ':app'. Les fichiers gradlew et gradlew.bat sont les scripts bash de lancement (ils sont générés par Gradle en exécutant un gradle init ou récupérés d'une autre application). Notez que vous trouverez dans le code source de la librairie SDL2 un exemple de projet Android dans le dossier android-project.

Le fichier Gradle de base est classique pour un projet Android et complètement générique. On passe, rien à signaler ici.

groovy
buildscript {
    repositories {
        jcenter()
        maven {
            url 'https://maven.google.com/'
            name 'Google'
        }
        google()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.1.2"
    }
}

allprojects {
    repositories {
        jcenter()
        maven {
            url 'https://maven.google.com/'
            name 'Google'
        }
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Dans le répertoire 'app', à la racine, nous retrouvons le fichier Gradle principale pour notre application.

groovy
apply plugin: 'com.android.application'

android {
    compileSdkVersion 30

    signingConfigs {
        demokey {
            storeFile file('demokey.jks')
            storePassword "demokey"
            keyAlias 'demokey'
            keyPassword 'demokey'
        }
    }
    defaultConfig {
        applicationId "eu.d8s.galaxie"
        minSdkVersion 18
        targetSdkVersion 30
        versionCode 3
        versionName "2.0.16"
        externalNativeBuild {
            cmake {
                arguments "-DANDROID_NATIVE_API_LEVEL=30", "-DANDROID_STL=c++_shared", "-DANDROID=true", "-DANDROID_TOOLCHAIN=clang"
                cppFlags "-std=c++11 -frtti -fexceptions"
                abiFilters "arm64-v8a"
            }
        }
    }
    buildTypes {
        debug {
            minifyEnabled false
            signingConfig signingConfigs.demokey
        }
        release {
            minifyEnabled true
            signingConfig signingConfigs.demokey
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    applicationVariants.all { variant ->
        variant.outputs.all {
            def fileName = project.name + '-' + variant.name + '-V' +
                    defaultConfig.versionCode + "-" + buildTime() + ".apk"
            outputFileName = fileName
        }
    }
    externalNativeBuild {
        cmake {
            path '../src/CMakeLists.txt'
        }
    }
}

static def buildTime() {
    return new Date().format("yyyyMMdd", TimeZone.getDefault())
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
}

Voici les parties les plus importantes :

  • Un fichier demokey.jks set de trousseau de clé et permet de générer un paquet Android signé facilement ; utile pour le débogage mais pas forcément à utiliser pour une livraison officielle sur le PlayStore
  • Nous retrouvons deux sections externalNativeBuild qui permettent de préciser les options de construction du code C/C++. Cette décomposition en deux parties est un peu bizarre mais en gros les configurations de CMake et du compilateur sont à placer dans defaultConfig et l'appel au CMake chapeau dans la deuxième section qui ne semble admettre aucune autre ligne que le path (en un seul exemplaire !)
  • Le reste est classique pour un projet Android, se rapporter à la documentation officielle pour en apprendre plus

Processus de construction

Le processus est représenté par l'illustration suivante :

image

Le plugin Java de Gradle sait comment compiler les fichiers Java situés dans le répertoire app/src/main, organisés à la Java selon une URL inversée.

Par contre, on ne peut spécifier qu'un seul fichier CMake pour construire le code C/C++. Il faut donc avoir un CMakeLists.txt chapeau qui appellera les sous CMakeLists.txt sous la forme d'appels à add_subdirectories.

Fichier CMake principal

Notre fichier principal SDL est très simple il affiche un carré rouge. Nous avons donc qu'un seul fichier C : main.c.

Nous construisons que des librairies dynamiques (.so sous Linux)

cmake
# ===========================================================================
# CMAKE PRINCIPAL APPELÉ PAR GRADLE
# ===========================================================================

cmake_minimum_required(VERSION 3.4)

set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_VERBOSE_MAKEFILE ON)

project(main C CXX)

add_subdirectory(../libs/lib_sdl2 SDL2)

set(MAIN_SRCS
    main.c
)

option(BUILD_SHARED_LIBS "Build using shared libraries" ON)

add_library(main ${MAIN_SRCS})

target_include_directories(main PUBLIC ../libs/lib_sdl2/SDL/include)

target_link_libraries(main PUBLIC dl GLESv1_CM GLESv2 OpenSLES log android SDL2)

target_compile_options(main PUBLIC -Wall -Wextra -Wdocumentation -Wdocumentation-unknown-command -Wmissing-prototypes -Wunreachable-code-break -Wunneeded-internal-declaration -Wmissing-variable-declarations 
 -Wfloat-conversion -Wshorten-64-to-32 -Wunreachable-code-return -Wshift-sign-overflow -Wstrict-prototypes -Wkeyword-macro -Wno-unused-parameter -Wno-sign-compare)

Tous nos projets CMake génèreront des librairies dynamiques. Ici, notre CMakeLists.txt inclut un sous projet, la librairie SDL, via l'appel de add_subdirectory. Les autres options sont classiques : des options au compilateurs et une liste de librairies à lier à notre librairie, ici essentiellement les librairies OpenGL mobile et les librairies Android.

Fichiers Java

SDL fournit dans son code source les fichiers Java à ajouter à votre projet :

HIDDeviceBLESteamController.java
HIDDevice.java
HIDDeviceManager.java
HIDDeviceUSB.java
SDLActivity.java
SDLAudioManager.java
SDLControllerManager.java
SDL.java

Le fichier principal, l'Activity Android, est SDLActivity.java. Ce fichier se charge de créer une application à la mode Android, initialiser SDL et notre application et rediriger tous les événements mobiles en SDL.

Le package utilisé est org.libsdl.app et on peut voir que beaucoup de méthodes de la classe SDLActivity peuvent être surchargées : comme le dit la documentation SDL sur Android, cette classe est vouée à être héritée et les méthodes éventuellement surchargées pour modifier le comportement par défaut.

image

Cet héritage va vous permettre de créer votre propre nom de package et à customiser le comportement de SDL. Dans notre exemple, on crée le fichier suivant :

java
package eu.d8s.galaxie;

import org.libsdl.app.SDLActivity; 

/* 
 * A sample wrapper class that just calls SDLActivity 
 */ 
public class GalaxieActivity extends SDLActivity
{
 /**
     * This method is called by SDL before loading the native shared libraries.
     * It can be overridden to provide names of shared libraries to be loaded.
     * The default implementation returns the defaults. It never returns null.
     * An array returned by a new implementation must at least contain "SDL2".
     * Also keep in mind that the order the libraries are loaded may matter.
     * @return names of shared libraries to be loaded (e.g. "SDL2", "main").
     */
     
     // Actually, it *is* overridden because we generate .so files manually
    protected String[] getLibraries() {
        return new String[] {
            "SDL2",
            // "SDL2_image",
            // "SDL2_mixer",
            // "SDL2_net",
            // "SDL2_ttf",
            "main"
        };
    }
}

Notre package se nomme eu.d8s.galaxie et le fichier est placé dans le répertoire app/src/main/java/eu/d8s/galaxie.

Une des méthodes que vous aurez le plus besoin de surcharger est peut-être la fonction getLibraries() qui permet de lister les librairies dynamiques à charger.

Dans notre cas, nous allons créer deux projets C++, donc deux librairies C++ : libSDL2.so et libmain.so.

Attention, il faut placer en dernier la librairie contenant votre main(). En effet, voici un extrait du code en charge de récupérer le nom de la librairie à appeler au démarrage :

java
if (libraries.length > 0) {
    library = "lib" + libraries[libraries.length - 1] + ".so";
} else {
    library = "libmain.so";
}

On voit donc que si la liste est non vide, la dernière librairie déclarée est récupérée.

Processus de démarrage

Continuons notre analyse du fonctionnement du portage de la SDL sur Android. Voici le processus un peu résumé de la phase de démarrage de votre application qu'il faut garder en tête :

image

Notre main() en C est renommé en SDL_main via une macro situé dans le fichier SDL.h :

c
#elif defined(__ANDROID__)
/* On Android SDL provides a Java class in SDLActivity.java that is the
   main activity entry point.

   See docs/README-android.md for more details on extending that class.
 */
#define SDL_MAIN_NEEDED

#if defined(SDL_MAIN_NEEDED) || defined(SDL_MAIN_AVAILABLE)
#define main    SDL_main
#endif

Un démarrage réussi va faire apparaître la ligne suivante sous adb logcat :

03-28 15:47:34.252  9746  9767 V SDL     : Running main function SDL_main from library /data/app/eu.d8s.galaxie-t17c1gNT9QZvTh-BahM4Ug==/lib/arm64/libmain.so
03-28 15:47:34.252  9746  9767 V SDL     : nativeRunMain()

Ici la fonction SDL_main a bien été trouvée dans la librairie libmain.so.

Fichier AndroidManifest.xml

Ici nous retrouvons un fichier de base assez classique. Les lignes à changer pour votre application sont :

  • Le nom du package Java : eu.d8s.galaxie
  • Le nom de la classe Activity principale : GalaxieActivity

Le fichier est extrait du code source de SDL et inclut quelques conseils.

xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="eu.d8s.galaxie"
    android:installLocation="auto">

    <!-- OpenGL ES 2.0 -->
    <uses-feature android:glEsVersion="0x00020000" />

    <!-- Touchscreen support -->
    <uses-feature
        android:name="android.hardware.touchscreen"
        android:required="false" />

    <!-- Game controller support -->
    <uses-feature
        android:name="android.hardware.gamepad"
        android:required="false" />

    <!-- External mouse input events -->
    <uses-feature
        android:name="android.hardware.type.pc"
        android:required="false" />

    <!-- Allow writing to external storage -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <!-- Allow access to the vibrator -->
    <uses-permission android:name="android.permission.VIBRATE" />

    <!-- if you want to capture audio, uncomment this. -->
    <!-- <uses-permission android:name="android.permission.RECORD_AUDIO" /> -->

    <!-- Create a Java class extending SDLActivity and place it in a
         directory under app/src/main/java matching the package, e.g. app/src/main/java/com/gamemaker/game/MyGame.java

         then replace "SDLActivity" with the name of your class (e.g. "MyGame")
         in the XML below.

         An example Java class can be found in README-android.md
    -->
    <application
        android:allowBackup="true"
        android:hardwareAccelerated="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
        tools:ignore="GoogleAppIndexingWarning">

        <!-- Example of setting SDL hints from AndroidManifest.xml:
        <meta-data android:name="SDL_ENV.SDL_ACCELEROMETER_AS_JOYSTICK" android:value="0"/>
         -->

        <activity
            android:name="GalaxieActivity"
            android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <!-- Drop file event -->
            <!--
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="*/*" />
            </intent-filter>
            -->
        </activity>
    </application>

</manifest>

La compilation de SDL

Alors c'est un peu le bazar.

D'une part, SDL2 est une "vieille" librairie mine de rien et son système de build est en cours d'évolution :

  • Il utilise à l'origine les autotools/automake, ces espèces de machineries de l'enfer mais qui fonctionnent.
  • Ils évoluent vers CMake (youpi)
  • Pour Android, ils fournissent un fichier Makefile Android.mk prêt à fonctionner avec le ndk-build.

Autre difficulté : toute la configuration de la SDL est centralisée dans un fichier unique : SDL_config.h. Des squelettes prêts à l'emploi sont disponibles dans le même répertoire (include) : SDL_config_android.h, SDL_config_emscripten.h, SDL_config_iphoneos.h ... Il faut donc remplacer le fichier SDL_config.h par la bonne version selon votre cible.

Cela pose un soucis au niveau du code source : il n'est pas très propre de modifier une librairie tierce, cela pose des soucis de maintenance. Sauf si cette librairie tierce n'est pas mise sur Git, que ce soit sous la forme de sous module ou directement sous forme de code source.

Nous allons utiliser une fonctionnalité de CMake qui permet de récupérer le code, soit à partir d'une archive, soit à partir d'un dépôt Git, et nous allons le patcher :

cmake
include(FetchContent)

FetchContent_Declare(
    sdl2_project
    URL      https://www.libsdl.org/release/SDL2-2.0.20.zip
)

FetchContent_GetProperties(sdl2_project)
if(NOT sdl2_project_POPULATED)
  FetchContent_Populate(sdl2_project)

  # Copy an additional/replacement file into the populated source
  # COPY_FILE is available from CMake 3.21 only
  #file(COPY_FILE ${sdl2_project_SOURCE_DIR}/include/SDL_config_android.h ${sdl2_project_SOURCE_DIR}/include/SDL_config.h)
  execute_process(COMMAND cp ${sdl2_project_SOURCE_DIR}/include/SDL_config_android.h ${sdl2_project_SOURCE_DIR}/include/SDL_config.h)
  message("Overwrite SDL_config.h with SDL_config_android.h")

endif()

FetchContent_MakeAvailable(sdl2_project)
add_subdirectory(${sdl2_project_SOURCE_DIR} SDL2)

set(SDL2_HEADERS
    ${sdl2_project_SOURCE_DIR}/include
)

Le code est vraiment simple et facile à maintenir :

  • On utilise FetchContent qui permet de télécharger l'archive immédiatement à l'exécution de CMake (et non au build)
  • La première fois, on demande à CMake de dézipper l'archive et de remplacer le fichier SDL_config.h par celui d'Android (attention à votre version de CMake, la commande interne n'est accessible qu'à partir de Cmake 3.21)
  • Ceci fait, on ajoute le CMakeLists.txt à la racine des source de SDL comme sous répertoire et on précise où se trouvent les fichiers d'en-tête

Bingo, la SDL se construit parfaitement et la librairie libSDL2.so est bien générée.

Application SDL

Terminons par notre application proprement dite située dans le fichier main.c : nous allons afficher un joli carré rouge. La boucle d'événements est classique pour un programme SDL :

c
#include <stdlib.h>
#include <stdio.h>

#include <SDL.h>

int main(int argc, char *argv[]) {
    SDL_Window *window;
    SDL_Renderer *renderer;

    if (SDL_CreateWindowAndRenderer(0, 0, 0, &window, &renderer) < 0)
        exit(2);
   
    /* Main render loop */
    Uint8 done = 0;
    SDL_Event event;
    while (!done) {
        /* Check for events */
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT || event.type == SDL_KEYDOWN ||
                event.type == SDL_FINGERDOWN) {
                done = 1;
            }
        }
        /* Draw a gray background */
        SDL_SetRenderDrawColor(renderer, 0xA0, 0xA0, 0xA0, 0xFF);
        SDL_RenderClear(renderer);
        
        //Render red filled quad
        SDL_Rect fillRect = { 20, 20, 100, 300 };// SCREEN_WIDTH / 4, SCREEN_HEIGHT / 4, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 };
        SDL_SetRenderDrawColor( renderer, 0xFF, 0x00, 0x00, 0xFF );        
        SDL_RenderFillRect( renderer, &fillRect );

        /* Update the screen! */
        SDL_RenderPresent(renderer);
        SDL_Delay(10);
    }
    exit(0);
}

Et voilà le résultat :

image

Retrouvez le code source de cet article (et plus) ici : https://github.com/arabine/galaxie-de-mots