Tutorial #01: Custom Control

Sollte ein benötigtes Plattform-Feature von der Murl Engine nicht angeboten werden, so kann dies in einem Custom-Control implementiert werden. Dieses Tutorial zeigt mit einem einfachen Beispiel, wie eigene Controls implementiert werden können.

Quicklinks zu den einzelnen Abschnitten in diesem Tutorial:

Basisklasse

Alle Control-Klassen implementieren das IControlable Interface mit den Methoden:

  • GetName()
  • FrameUpdate()
  • LogicUpdate()
  • ConfigChanged()

Das betrifft sowohl eigene Custom-Controls als auch bereitgestellte Controls, wie das IMusicPlayerControl, das IRumbleControl, das IWebControl, das IScreenshotControl etc.

Custom-Controls implementieren zusätzlich das ICustomControlable Interface mit den Methoden:

  • Init()
  • DeInit()
  • PauseEngine()
  • ContinueEngine()
  • SuspendEngine()
  • ResumeEngine()
  • ...

Die Klasse CustomControlable bietet eine leere Default-Implementierung der beiden Interfaces und kann als Basisklasse für eigene Custom-Controls verwendet werden.

MyControl

Für unser einfaches Custom-Control erstellen wir eine neue abstrakte Klasse MyControl mit CustomControlable als Basisklasse und den beiden pure virtual Methoden Test() und TestBack(). Zusätzlich definieren wir eine statische Create() Methode, die wir später nutzen werden, um ein MyControl Objekt zu erzeugen.

#include "murl_custom_controlable.h"

namespace Murl
{
    namespace App
    {
        class MyControl : public CustomControlable
        {
        public:
            static MyControl* Create(const String& name);

            MyControl(const String& name) : CustomControlable(name) {}

            virtual ~MyControl() {}

            virtual Bool Test(const String& text) = 0;
            virtual Bool TestBack(const String& text) = 0;
        };
    }
}

Der Einfachheit halber implementieren wir den leeren Konstruktor und den leeren Destruktor gleich in der Header Datei.

Registrierung

Wir können nun ein Objekt unserer MyControl-Klasse erstellen und beim Output::IDeviceHandler registrieren. Das machen wir am besten in der OnInit() Methode unserer Logik-Klasse:

Logic::IDeviceHandler* deviceHandler = state->GetDeviceHandler();
Output::IDeviceHandler* outputDeviceHandler = deviceHandler->GetOutputDeviceHandler();

// create MyControl instance
mMyControl = MyControl::Create("MyControl");

// add custom control to outputDeviceHandler
if (!outputDeviceHandler->AddCustomControl(mMyControl))
{
    return false;
}

Nach der Registrierung werden die im IControlable und CustomControlable Interface deklarierten Methoden bei den jeweiligen Events aufgerufen.

Wir rufen die definierten Methoden Test() bzw. TestBack() auf, wenn die entsprechende Taste gedrückt wurde:

if (deviceHandler->WasRawKeyPressed(RAWKEY_1) || mButton01->WasReleasedInside())
{
    mMyControl->Test("b1 pressed");
}

if (deviceHandler->WasRawKeyPressed(RAWKEY_2) || mButton02->WasReleasedInside())
{
    mMyControl->TestBack("b2 pressed");
}

In der OnDeInit() Methode müssen wir das Custom-Control-Objekt auch wieder deregistrieren. Das können wir mit der Referenz mMyControl machen oder mit dem Namen, den wir bei Create() angegeben haben.

Logic::IDeviceHandler* deviceHandler = state->GetDeviceHandler();
Output::IDeviceHandler* outputDeviceHandler = deviceHandler->GetOutputDeviceHandler();

// remove custom control from outputDeviceHandler
if (!outputDeviceHandler->RemoveCustomControl(mMyControl))
{
    return false;
}

Default-Implementierung

Für die neue abstrakte Klasse definieren wir eine Default-Implementierung in der Klasse MyControlDefault.

#include "my_control.h"

namespace Murl
{
    namespace App
    {
        class MyControlDefault : public MyControl
        {
        public:
            MyControlDefault(const String& name);
            virtual ~MyControlDefault();

            virtual Bool Test(const String& text);
            virtual Bool TestBack(const String& text);
        };
    }
}

Für die Klasse MyControlDefault werden sonst keine weiteren Methoden (Init(), DeInit(), FrameUpdate() ...) benötigt.

Die Create Methode erzeugt ein neues MyControlDefault Objekt und gibt einen Zeiger darauf zurück.

#include "my_control_default.h"

using namespace Murl;

App::MyControl* App::MyControl::Create(const String& name)
{
    return new App::MyControlDefault(name);
}

App::MyControlDefault::MyControlDefault(const String& name)
: MyControl(name)
{}

App::MyControlDefault::~MyControlDefault()
{}

Die beiden Methoden Test() und TestBack() geben lediglich eine Debug-Meldung aus.

Bool App::MyControlDefault::Test(const String& text)
{
    MURL_TRACE(0, "text=\"%s\"", text.Begin());
    return true;
}

Bool App::MyControlDefault::TestBack(const String& text)
{
    MURL_TRACE(0, "text=\"%s\"", text.Begin());
    return true;
}

Diese Default-Implementierung kann für alle Plattformen verwendet werden, für die keine spezielle Implementierung existiert.

Android-Implementierung

Als nächstes implementieren wir eine eigene Android-Implementierung in der Klasse MyControlAndroid.

Die Methode Test() soll dabei eine gleichnamige Methode in Java aufrufen. Zusätzlich definieren wir eine C++ Methode, die wir von Java aus aufrufen.

Zu beachten
Aufrufe von C++ nach Java und umgekehrt laufen immer über das Java Native Interface (JNI). Siehe auch Java Native Interface Specification.

Der Header für Android sieht ähnlich aus wie bei der Klasse MyControlDefault, allerdings benötigen wir auch die Methoden Init() und DeInit() sowie zwei Membervariablen für das NativePlatform-Objekt und das JniBridge-Objekt.

#include "my_control.h"
#include "murl_platform_android_native_platform.h"
#include "murl_platform_android_jni_bridge.h"

namespace Murl
{
    namespace App
    {
        class MyControlAndroid : public MyControl
        {
        public:
            MyControlAndroid(const String& name);
            virtual ~MyControlAndroid();

            virtual Bool Test(const String& text);
            virtual Bool TestBack(const String& text);

        protected:
            virtual Bool Init(IPlatform* platform);
            virtual Bool DeInit();

            Platform::Android::NativePlatform* mPlatform;
            Platform::Android::JniBridge* mJniBridge;           
        };
    }
}

Wir verwenden wieder die Create() Methode um ein MyControlAndroid Objekt zu erzeugen.

#include "my_control_android.h"

using namespace Murl;

App::MyControl* App::MyControl::Create(const String& name)
{
    return new App::MyControlAndroid(name);
}

App::MyControlAndroid::MyControlAndroid(const String& name)
: MyControl(name)
{
}

App::MyControlAndroid::~MyControlAndroid()
{
}

In der Init() Methode holen wir uns einen Zeiger auf die JniBridge und die NativePlatform. Zusätzlich speichern wir einen Zeiger auf das MyControlAndroid-Objekt in der statischen Variable sMyControl:

static App::MyControl* sMyControl = 0;

Bool App::MyControlAndroid::Init(IPlatform* platform)
{
    mPlatform = dynamic_cast<Platform::Android::NativePlatform*>(platform);
    if (mPlatform == 0)
    {
        MURL_TRACE(0, "Failed to get Android platform handle.");
        return false;
    }

    mJniBridge = mPlatform->GetJniBridge();
    if (mJniBridge == 0)
    {
        MURL_TRACE(0, "Failed to get JNI bridge.");
        return false;
    }

    sMyControl = this;

    return true;
}

Bool App::MyControlAndroid::DeInit()
{
    sMyControl = 0;
    return true;
}

Cpp2Java

Die Klasse JniBridge bietet hilfreiche Template-Methoden für den Aufruf von statischen Java-Methoden:

  • CallStaticJavaProc
  • CallStaticJavaIntFunc
  • CallStaticJavaObjectFunc

Die Methoden sind in der Datei murl_platform_android_jni_bridge.h definiert:

  • murl/base/source/platform/android/murl_platform_android_jni_bridge.h

Mit der Template-Methode CallStaticJavaProc können wir eine Java-Methode ohne Rückgabewert aufrufen.

Die Template-Parameter definieren die Anzahl und Art der Übergabeparameter für die Java-Methode (<jlong, jstring>)

Der erste Funktions-Parameter definiert die aufzurufende Java-Methode. Per Definition ist das der Name der Java-Klasse gefolgt von einem Punkt und dem Namen der Methode ("MyTestClass.Test").

Als zweiten Parameter übergeben wir einen Plattform-Handle (mPlatform->GetHandle()) und als dritten Parameter einen String ("Greetings from CPP land!").

Zu beachten
Das Plattform-Handle ist für das Beispiel nicht notwendig und wird nur zu Demonstrationszwecken übergeben. Mit dem Handle kann im Java-Code eine Referenz auf das MurlPlatform Objekt geholt werden, das Methoden für den Zugriff auf die Android Activity, Context etc. bietet.
Bool App::MyControlAndroid::Test(const String& text)
{
    MURL_TRACE(0, "text=%s", text.Begin());
    mJniBridge->CallStaticJavaProc<jlong, jstring>("MyTestClass.Test", mPlatform->GetHandle(), "Greetings from CPP land!");
    return true;
}

Die korrespondierende Java-Methode sieht so aus:

public class MyTestClass
{
// JNI from C++ to Java
public static void Test(long platformPtr, String text)
{
...
}

Die Java-Datei speichern wir im Verzeichnis

  • source/android/java/com/mycompany/mystuff/MyTestClass.java

Die Java-Methode Test() gibt Debug-Meldungen mit Log.d() aus und ruft anschließend die Methode TestBack() auf.

// Copyright 2015 Spraylight GmbH
package com.mycompany.mystuff;
import at.spraylight.murl.MurlPlatform;
import at.spraylight.murl.MurlJniBridge;
import android.util.Log;
public class MyTestClass
{
// JNI from C++ to Java
public static void Test(long platformPtr, String text)
{
Log.d("Murl", "MyTestClass::Test(): text=" + text);
MurlPlatform platform = MurlJniBridge.GetJavaPlatform(platformPtr);
if (platform == null)
{
Log.d("Murl", "MyTestClass::Test(): failed to get Java platform.");
return;
}
Log.d("Murl", "MyTestClass::Test(): Java platform=" + platform.toString());
Log.d("Murl", "Calling TestBack() ...");
TestBack("Greetings from Java land!");
}
// JNI from Java to C++
public static native void TestBack(String text);
}

Die Java-Datei müssen wir auch im Makefile mit MURL_ANDROID_JAVA_FILES angeben, damit sie beim Build-Vorgang berücksichtigt wird. Außerdem müssen wir die Java-Klasse mit MURL_ANDROID_JNI_CLASSES als JNI-Klasse deklarieren, damit sie von der JNI-Bridge registriert wird.

MURL_ANDROID_JAVA_PATH := source/android/java
MURL_ANDROID_JAVA_FILES += com/mycompany/mystuff/MyTestClass.java
MURL_ANDROID_JNI_CLASSES += com/mycompany/mystuff/MyTestClass
Zu beachten
Es können auch Java-Archive (JAR-Dateien) im Makefile mit MURL_ANDROID_JAR_FILES angegeben werden.
MURL_ANDROID_JAR_PATH := source/android/jar
MURL_ANDROID_JAR_FILES += myArchive.jar

Java2Cpp

Um eine native C++ Methode aus Java aufrufen zu können, muss zunächst eine Java-Methode als native deklariert werden:

// JNI from Java to C++
public static native void TestBack(String text);

Die korrespondierende Funktion wird in C++ mit Java_com_mycompany_mystuff_MyTestClass_TestBack() definiert:

extern "C" JNIEXPORT void JNICALL Java_com_mycompany_mystuff_MyTestClass_TestBack(JNIEnv * env, jobject obj, jstring text)
{
    String textStr;
    Platform::Android::JniData::GetCParam(env, textStr, text);

    MURL_TRACE(0, "text=%s", textStr.Begin());

    if (sMyControl == 0)
    {
        MURL_TRACE(0, "Java_com_mycompany_mystuff_MyTestClass_TestBack(): No test control");
        return;
    }

    sMyControl->TestBack(textStr);
}

extern "C" weist den Compiler an, die Funktion nach der C Spezifikation zu exportieren (statt der C++ Spezifikation). JNIEXPORT und JNICALL sind JNI-Makros, die sicherstellen, dass der Funktionsname richtig exportiert wird.

Die Namenskonvention für die C++ Funktion ist Java_{package_and_classname}_{function_name}(JNI arguments), wobei Punkte durch Unterstriche ersetzt werden.

Der erste Parameter liefert immer einen Zeiger auf das JNI-Environment. Der zweite Parameter liefert immer einen Zeiger auf das this Java-Objekt. Der dritte Parameter ist der erste Parameter der Java-Methode. In unserem Fall ist das ein Java-String Objekt (jstring).

Die Funktion GetCParam wandelt den jstring in einen Murl Engine String um. Diese und weitere Funktionen zum Umwandeln von Java-Datentypen in CPP-Datentypen und umgekehrt sind in der Datei murl_platform_android_jni_data.h definiert.

  • murl/base/source/platform/android/murl_platform_android_jni_data.h

Über die statische Variable sMyControl kann auf das MyControlAndroid-Objekt zugegriffen werden und die eigentliche Methode TestBack() aufgerufen werden.

Damit ist die Android-Implementierung fertig.

Abhängig von der Plattform, verwenden wir entweder die Default-Implementierung oder die Android-Implementierung für unser MyControl-Objekt. Wir müssen also in den Visual-Studio- und Xcode-Projekten die Android-Implementierung ausklammern. Umgekehrt müssen wir bei Android die Default-Implementierung ausklammern.

Dafür passen wir das Common-Makefile an:

ifeq ($(MURL_OS), android)
MURL_MODULE_SRC_FILES += my_control_android.cpp
else
MURL_MODULE_SRC_FILES += my_control_default.cpp
endif

Beim Drücken des b1-Button erhalten wir den erwarteten Output:

Murl::App::MyControlAndroid::Test(), line 52: text=b1 pressed
MyTestClass::Test(): text=Greetings from CPP land!
MyTestClass::Test(): Java platform=at.spraylight.murl.MurlPlatform@4051b6d0
Calling TestBack() ...
TestBack(), line 62: text=Greetings from Java land!
Murl::App::MyControlAndroid::TestBack(), line 76: text=Greetings from Java land!

Proguard

Im Debug-Build funktioniert die Android-Implementierung problemlos. Im Release-Build würde der Java-Optimizer Proguard die Java-Klasse allerdings wegoptimieren, da diese aus Java-Sicht unbenutzt scheint.

Wir müssen das Tool also anweisen, unsere Java-Klasse jedenfalls zu behalten. Dafür erstellen wir die Text-Datei proguard_MyTestClass.cfg

  • source/android/proguard_MyTestClass.cfg

und geben als Inhalt eine Keep-Option für Proguard an:

-keep class com.mycompany.mystuff.MyTestClass { *; }

Diese Datei müssen wir wieder im Common-Makefile als Proguard-Input angeben, damit Sie im Build-Prozess berücksichtigt wird.

MURL_ANDROID_PROGUARD_PATH := source/android/
MURL_ANDROID_PROGUARD_FRAGMENTS += proguard_MyTestClass.cfg

Zusammenfassung

Cpp2Java

// CPP
mJniBridge->CallStaticJavaProc<jlong, jstring>("MyTestClass.Test", mPlatform->GetHandle(), "Greetings from CPP land!");
// Java
public static void Test(long platformPtr, String text)

Java2Cpp

// Java
public static native void TestBack(String text);
// CPP
extern "C" JNIEXPORT void JNICALL Java_com_mycompany_mystuff_MyTestClass_TestBack(JNIEnv * env, jobject obj, jstring text)

Makefile

ifeq ($(MURL_OS), android)
MURL_MODULE_SRC_FILES += my_control_android.cpp
else
MURL_MODULE_SRC_FILES += my_control_default.cpp
endif
MURL_ANDROID_JAVA_PATH := source/android/java
MURL_ANDROID_JAVA_FILES += com/mycompany/mystuff/MyTestClass.java
MURL_ANDROID_JNI_CLASSES += com/mycompany/mystuff/MyTestClass
MURL_ANDROID_PROGUARD_PATH := source/android/
MURL_ANDROID_PROGUARD_FRAGMENTS += proguard_MyTestClass.cfg
MURL_ANDROID_JAR_PATH := source/android/jar
MURL_ANDROID_JAR_FILES += myArchive.jar

OSX-Implementierung

Nach demselben Prinzip können wir auch eigene Implementierungen für Windows, Linux, OSX (oder iOS) entwickeln. Dabei ist der Umweg über eine JNI-Bridge (wie bei Android) nicht notwendig, da der plattformabhängige Code direkt in den C++-Dateien geschrieben werden kann.

Exemplarisch zeigen wir eine OSX-Implementierung und erstellen dafür die Klasse MyControlOsx:

#include "my_control.h"

namespace Murl
{
    namespace App
    {
        class MyControlOsx : public MyControl
        {
        public:
            MyControlOsx(const String& name);
            virtual ~MyControlOsx();

            virtual Bool Test(const String& text);
            virtual Bool TestBack(const String& text);
        };
    }
}

Die Implementierung der Klasse erfolgt in der Objective-C++ Datei my_control_osx.mm. In dieser Datei können wir sowohl C++ Code als auch Objective-C Code verwenden.

Wir importieren die Datei Foundation.h um die Objective-C Methode NSLog() verwenden zu können. In der Methode Create() erzeugen wir wieder das MyControlOsx Objekt.

#include "my_control_osx.h"
#import <Foundation/Foundation.h>

using namespace Murl;

App::MyControl* App::MyControl::Create(const String& name)
{
    return new App::MyControlOsx(name);
}

App::MyControlDefault::MyControlDefault(const String& name)
: MyControl(name)
{}

App::MyControlDefault::~MyControlDefault()
{}

Die Methoden Test() und TestBack() verwenden NSLog() um eine Meldung auszugeben.

Bool App::MyControlOsx::Test(const String& text)
{
    NSString* nsString = [NSString stringWithUTF8String:text.Begin()];
    NSLog(@"text=%@", nsString);
    return true;
}

Bool App::MyControlOsx::TestBack(const String& text)
{
    NSString* nsString = [NSString stringWithUTF8String:text.Begin()];
    NSLog(@"text=%@", nsString);
    return true;
}

Wir müssen wiederum diese beiden Dateien dem OSX Xcode-Projekt hinzufügen und die Default- und Android-Implementierungen ausklammern.

Screenshot

tut0400_custom_control.png
Custom Control


Copyright © 2011-2024 Spraylight GmbH.