Cómo integrar funciones de adaptabilidad a tu juego nativo

1. Introducción

70748189a60ed450.png

¿Por qué debo integrar las funciones de adaptabilidad en mi juego?

Las APIs de adaptabilidad te permiten obtener comentarios sobre el estado del dispositivo durante el tiempo de ejecución de la aplicación y ajustar la carga de trabajo de forma dinámica para optimizar el rendimiento de tu juego. También te permiten informar al sistema sobre tu carga de trabajo para que este pueda asignar recursos de manera óptima.

Qué compilarás

En este codelab, abrirás un juego de ejemplo nativo de Android, lo ejecutarás y, luego, integrarás funciones de adaptabilidad en él. Después de configurar y agregar el código necesario, podrás probar la diferencia en el rendimiento percibido entre el juego anterior y la versión con adaptabilidad.

Qué aprenderás

  • Cómo integrar la API de Thermal en un juego existente y hacer la adaptación al estado térmico para evitar el sobrecalentamiento
  • Cómo integrar la API de Game Mode y reaccionar a los cambios de selección
  • Cómo integrar la API de Game State para permitir que el sistema sepa en qué estado se está ejecutando el juego
  • Cómo integrar la API de Performance Hint para permitir que el sistema conozca tu modelo de subprocesos y tu carga de trabajo

Requisitos

2. Prepara tu entorno

Configura el entorno de desarrollo

Si no trabajaste con proyectos nativos en Android Studio, es posible que debas instalar el NDK de Android y CMake. Si ya los tienes instalados, ve a la sección para configurar el proyecto.

Comprueba que el SDK, el NDK y CMake estén instalados

Inicia Android Studio. Cuando se muestre la ventana Welcome to Android Studio, abre el menú desplegable Configure y selecciona la opción SDK Manager.

3b7b47a139bc456.png

Si ya tienes abierto un proyecto existente, puedes abrir el SDK Manager a través del menú Tools. Haz clic en el menú Tools y selecciona SDK Manager para que se abra la ventana correspondiente.

En la barra lateral, selecciona Appearance & Behavior > System Settings > Android SDK. Selecciona la pestaña SDK Platforms en el panel Android SDK para mostrar una lista de opciones de herramientas instaladas. Asegúrate de tener instalado el SDK de Android 12.0 o una versión posterior.

931f6ae02822f417.png

Luego, selecciona la pestaña SDK Tools y asegúrate de que el NDK y CMake estén instalados.

Nota: La versión exacta no debería importar, siempre y cuando sean bastante nuevas, pero estamos usando NDK 25.2.9519653 y CMake 3.24.0. La versión del NDK que se instale de forma predeterminada cambiará con el tiempo en los lanzamientos posteriores del NDK. Si necesitas instalar una versión específica del NDK, sigue las instrucciones de la referencia de Android Studio para instalar el NDK en la sección "Cómo instalar una versión específica del NDK".

d28adf9279adec4.png

Una vez que hayas marcado todas las herramientas necesarias, haz clic en el botón Apply ubicado en la parte inferior de la ventana para instalarlas. Luego, haz clic en el botón OK para cerrar la ventana Android SDK.

Configura el proyecto

El proyecto de ejemplo es un juego simple de simulación de física en 3D desarrollado con Swappy para OpenGL. No hay mucho cambio en la estructura de directorios en comparación con el proyecto nuevo creado a partir de la plantilla, pero se realizaron algunos trabajos para inicializar la física y el bucle de renderización, por lo que debes clonar el repo.

Clona el repo

Desde la línea de comandos, cambia al directorio donde deseas que esté el directorio raíz del juego y clónalo desde GitHub:

git clone -b codelab/start https://github.com/android/adpf-game-adaptability-codelab.git --recurse-submodules

Asegúrate de comenzar desde la confirmación inicial del repo titulada [codelab] start: simple game.

Configura las dependencias

El proyecto de ejemplo utiliza la biblioteca Dear ImGui para su interfaz de usuario. También usa física de Bullet para las simulaciones de física en 3D. Se da por sentado que estas bibliotecas existen en el directorio third_party, ubicado en la raíz del proyecto. Revisamos las bibliotecas correspondientes a través de los --recurse-submodules especificados en nuestro comando de clonación anterior.

Prueba el proyecto

En Android Studio, abre el proyecto desde la raíz del directorio. Asegúrate de que haya un dispositivo conectado. Luego, selecciona Build > Make Project y Run > Run 'app' para probar la demostración. El resultado final en el dispositivo debería verse de esta manera:

f1f33674819909f1.png

Acerca del proyecto

El juego es minimalista intencionalmente y se enfoca en los detalles de la implementación de funciones de adaptabilidad. Ejecuta una física y una carga de trabajo de gráficos fáciles de configurar para que podamos adaptar la configuración de forma dinámica durante el tiempo de ejecución a medida que cambia la condición del dispositivo.

3. Integra la API de Thermal

a7e07127f1e5c37d.png

ce607eb5a2322d6b.png

Escucha el estado térmico modificado en Java

A partir de Android 10 (nivel de API 29), los dispositivos Android deben informar a la aplicación en ejecución cada vez que cambia el estado térmico. Las aplicaciones pueden escuchar este cambio proporcionando OnThermalStatusChangedListener a PowerManager.

Dado que PowerManager.addThermalStatusListener solo está disponible a partir del nivel de API 29, debemos verificarlo antes de llamarlo:

// CODELAB: ADPFSampleActivity.java onResume
if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) {
   PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
      if (pm != null) {
         pm.addThermalStatusListener(this);
      }
}

Además, a partir del nivel de API 30, puedes registrar la devolución de llamada en código C++ con AThermal_registerThermalStatusListener para que puedas definir un método nativo y llamarlo desde Java de la siguiente manera:

// CODELAB: ADPFSampleActivity.java onResume
if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
   // Use NDK Thermal API.
   nativeRegisterThermalStatusListener();
}

Deberás agregar los objetos de escucha en la función del ciclo de vida onResume de tu actividad.

Recuerda que todo lo que agregues a onResume de tu actividad también deberá quitarse de onPause de dicha actividad. Entonces, definamos nuestro código de limpieza para llamar a PowerManager.removeThermalStatusListener y AThermal_unregisterThermalStatusListener:

// CODELAB: ADPFSampleActivity.java onPause
// unregister the listener when it is no longer needed
// Remove the thermal state change listener on pause.
if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
   // Use NDK Thermal API.
   nativeUnregisterThermalStatusListener();
} else if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) {
   PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
   if (pm != null) {
      pm.removeThermalStatusListener(this);
   }
}

Abstrae estas funciones moviéndolas a ADPFManager.java para que podamos volver a usarlas con facilidad en otros proyectos.

En la clase de actividad de tu juego, crea y mantén una instancia de ADPFManager y conecta el objeto de escucha térmico de adición/eliminación con su método de ciclo de vida de la actividad correspondiente.

// CODELAB: ADPFSampleActivity.java
// Keep a copy of ADPFManager instance
private ADPFManager adpfManager;

// in onCreate, create an instance of ADPFManager
@Override
protected void onCreate(Bundle savedInstanceState) {
   // Instantiate ADPF manager.
   this.adpfManager = new ADPFManager();
   super.onCreate(savedInstanceState);
}

@Override
protected void onResume() {
   // Register ADPF thermal status listener on resume.
   this.adpfManager.registerListener(getApplicationContext());
   super.onResume();
}

@Override
protected void onPause() {
   // Remove ADPF thermal status listener on resume.
  this.adpfManager.unregisterListener(getApplicationContext());
   super.onPause();
}

Registra los métodos nativos de tu clase C++ en JNI_OnLoad

A partir del nivel de API 30, podemos usar la API de Thermal del NDK AThermal_* para que puedas asignar el objeto de escucha de Java y llamar a los mismos métodos de C++. Para que los métodos de Java llamen al código C++, deberás registrar los métodos de C++ en JNI_OnLoad. Consulta otras sugerencias de JNI para obtener más información.

// CODELAB: android_main.cpp
// Remove the thermal state change listener on pause.
// Register classes to Java.
jint JNI_OnLoad(JavaVM *vm, void * /* reserved */) {
  JNIEnv *env;
  if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
    return JNI_ERR;
  }

  // Find your class. JNI_OnLoad is called from the correct class loader context
  // for this to work.
  jclass c = env->FindClass("com/android/example/games/ADPFManager");
  if (c == nullptr) return JNI_ERR;

  // Register your class' native methods.
  static const JNINativeMethod methods[] = {
      {"nativeThermalStatusChanged", "(I)V",
       reinterpret_cast<void *>(nativeThermalStatusChanged)},
      {"nativeRegisterThermalStatusListener", "()V",
       reinterpret_cast<void *>(nativeRegisterThermalStatusListener)},
      {"nativeUnregisterThermalStatusListener", "()V",
       reinterpret_cast<void *>(nativeUnregisterThermalStatusListener)},
  };
  int rc = env->RegisterNatives(c, methods,
                                sizeof(methods) / sizeof(JNINativeMethod));

  if (rc != JNI_OK) return rc;

  return JNI_VERSION_1_6;
}

Conecta tus objetos de escucha nativos con el juego

Nuestro juego de C++ necesitará saber cuándo cambió el estado térmico correspondiente, así que crearemos la clase adpf_manager en C++.

En la carpeta cpp de la fuente de tu app ($ROOT/app/src/main/cpp), crea un par de archivos adpf_manager.h y adpf_manager.cpp.

// CODELAB: adpf_manager.h
// Forward declarations of functions that need to be in C decl.
extern "C" {
   void nativeThermalStatusChanged(JNIEnv* env, jclass cls, int32_t thermalState);
   void nativeRegisterThermalStatusListener(JNIEnv* env, jclass cls);
   void nativeUnregisterThermalStatusListener(JNIEnv* env, jclass cls);
}

typedef void (*thermalStateChangeListener)(int32_t, int32_t);

Define las funciones de C en el archivo cpp fuera de la clase ADPFManager.

// CODELAB: adpf_manager.cpp
// Native callback for thermal status change listener.
// The function is called from Activity implementation in Java.
void nativeThermalStatusChanged(JNIEnv *env, jclass cls, jint thermalState) {
  ALOGI("Thermal Status updated to:%d", thermalState);
  ADPFManager::getInstance().SetThermalStatus(thermalState);
}

void nativeRegisterThermalStatusListener(JNIEnv *env, jclass cls) {
  auto manager = ADPFManager::getInstance().GetThermalManager();
  if (manager != nullptr) {
    auto ret = AThermal_registerThermalStatusListener(manager, thermal_callback,
                                                      nullptr);
    ALOGI("Thermal Status callback registered to:%d", ret);
  }
}

void nativeUnregisterThermalStatusListener(JNIEnv *env, jclass cls) {
  auto manager = ADPFManager::getInstance().GetThermalManager();
  if (manager != nullptr) {
    auto ret = AThermal_unregisterThermalStatusListener(
        manager, thermal_callback, nullptr);
    ALOGI("Thermal Status callback unregistered to:%d", ret);
  }
}

Inicializa el PowerManager y las funciones necesarias para recuperar el margen térmico

A partir del nivel de API 30, podemos usar la API de Thermal del NDK AThermal_*. Por lo tanto, durante la inicialización, llamamos a AThermal_acquireManager y la conservamos para uso futuro. En el nivel de API 29, necesitaremos encontrar las referencias de Java necesarias y retenerlas.

// CODELAB: adpf_manager.cpp
// Initialize JNI calls for the powermanager.
bool ADPFManager::InitializePowerManager() {
  if (android_get_device_api_level() >= 30) {
    // Initialize the powermanager using NDK API.
    thermal_manager_ = AThermal_acquireManager();
    return true;
  }

  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Retrieve class information
  jclass context = env->FindClass("android/content/Context");

  // Get the value of a constant
  jfieldID fid =
      env->GetStaticFieldID(context, "POWER_SERVICE", "Ljava/lang/String;");
  jobject str_svc = env->GetStaticObjectField(context, fid);

  // Get the method 'getSystemService' and call it
  jmethodID mid_getss = env->GetMethodID(
      context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
  jobject obj_power_service = env->CallObjectMethod(
      app_->activity->javaGameActivity, mid_getss, str_svc);

  // Add global reference to the power service object.
  obj_power_service_ = env->NewGlobalRef(obj_power_service);

  jclass cls_power_service = env->GetObjectClass(obj_power_service_);
  get_thermal_headroom_ =
      env->GetMethodID(cls_power_service, "getThermalHeadroom", "(I)F");

  // Free references
  env->DeleteLocalRef(cls_power_service);
  env->DeleteLocalRef(obj_power_service);
  env->DeleteLocalRef(str_svc);
  env->DeleteLocalRef(context);

  if (get_thermal_headroom_ == 0) {
    // The API is not supported in the platform version.
    return false;
  }

  return true;
}

Asegúrate de que se llame al método de inicialización

En nuestro ejemplo, se llama al método de inicialización desde SetApplication, y se llama a SetApplication desde android_main. Esta configuración es específica de nuestro framework, por lo que, si realizas la integración en tu juego, deberás encontrar el lugar correcto para llamar al método de inicialización.

// CODELAB: adpf_manager.cpp
// Invoke the API first to set the android_app instance.
void ADPFManager::SetApplication(android_app *app) {
  app_.reset(app);

  // Initialize PowerManager reference.
  InitializePowerManager();
}
// CODELAB: android_main.cpp
void android_main(struct android_app *app) {
  std::shared_ptr<NativeEngine> engine(new NativeEngine(app));

  // Set android_app to ADPF manager, which in turn will call InitializePowerManager
  ADPFManager::getInstance().SetApplication(app);

  ndk_helper::JNIHelper::Init(app);

  engine->GameLoop();
}

Supervisa el margen térmico con frecuencia

A menudo, es mejor evitar que el estado térmico se eleve a un nivel más alto, ya que es difícil disminuirlo sin detener la carga de trabajo por completo. Incluso después de apagarse por completo, el dispositivo tardará un tiempo en disipar el calor y enfriarse. Podemos observar el margen térmico con frecuencia y ajustar la carga de trabajo para mantenerlo bajo control y evitar que se eleve.

En nuestro ADPFManager, expongamos métodos para verificar el margen térmico.

// CODELAB: adpf_manager.cpp
// Invoke the method periodically (once a frame) to monitor
// the device's thermal throttling status.
void ADPFManager::Monitor() {
  float current_clock = Clock();
  if (current_clock - last_clock_ >= kThermalHeadroomUpdateThreshold) {
    // Update thermal headroom.
    UpdateThermalStatusHeadRoom();
    last_clock_ = current_clock;
  }
}
// CODELAB: adpf_manager.cpp
// Retrieve current thermal headroom using JNI call.
float ADPFManager::UpdateThermalStatusHeadRoom() {
  if (android_get_device_api_level() >= 30) {
    // Use NDK API to retrieve thermal status headroom.
    thermal_headroom_ = AThermal_getThermalHeadroom(
        thermal_manager_, kThermalHeadroomUpdateThreshold);
    return thermal_headroom_;
  }

  if (app_ == nullptr || get_thermal_headroom_ == 0) {
    return 0.f;
  }
  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Get thermal headroom!
  thermal_headroom_ =
      env->CallFloatMethod(obj_power_service_, get_thermal_headroom_,
                           kThermalHeadroomUpdateThreshold);
  ALOGE("Current thermal Headroom %f", thermal_headroom_);
  return thermal_headroom_;
}

Por último, tendremos que exponer métodos para establecer el estado térmico y su objeto de escucha. Obtenemos el valor del estado térmico de la API térmica del NDK o del SDK de Java que llama a nuestro código nativo.

// CODELAB: adpf_manager.cpp
thermalStateChangeListener thermalListener = NULL;

void ADPFManager::SetThermalStatus(int32_t i) {
  int32_t prev_status_ = thermal_status_;
  int32_t current_status_ = i;
  thermal_status_ = i;
  if ( thermalListener != NULL ) {
    thermalListener(prev_status_, current_status_ );
  }
}

void ADPFManager::SetThermalListener(thermalStateChangeListener listener)
{
  thermalListener = listener;
}

Incluye tu adpf_manager.cpp en la unidad de compilación en CMakeLists.txt

Recuerda agregar el adpf_manager.cpp que acabas de crear a la unidad de compilación.

Ya terminamos con las clases reutilizables java y cpp de ADPFManager, por lo que puedes tomar estos archivos y volver a usarlos en otros proyectos en lugar de volver a escribir el código de adhesión.

// CODELAB: CMakeLists.txt
# now build app's shared lib
add_library(game SHARED
        adpf_manager.cpp # add this line
        android_main.cpp
        box_renderer.cpp
        demo_scene.cpp

Define los parámetros dentro del juego que deben cambiar cuando se deteriora el estado térmico

Desde esta parte en adelante, el contenido será específico del juego. En nuestro ejemplo, reduciremos los pasos físicos y la cantidad de cuadros cada vez que se eleve el estado térmico.

También supervisaremos el margen térmico, pero no haremos nada más que mostrar el valor en el HUD. En tu juego, puedes reaccionar al valor si ajustas la cantidad de procesamientos posteriores que realiza la tarjeta gráfica, cuando reduces el nivel de detalle, etcétera.

// CODELAB: demo_scene.cpp
// String labels that represents thermal states.
const char* thermal_state_label[] = {
    "THERMAL_STATUS_NONE",     "THERMAL_STATUS_LIGHT",
    "THERMAL_STATUS_MODERATE", "THERMAL_STATUS_SEVERE",
    "THERMAL_STATUS_CRITICAL", "THERMAL_STATUS_EMERGENCY",
    "THERMAL_STATUS_SHUTDOWN"};

const int32_t thermal_state_physics_steps[] = {
        16, 12, 8, 4,
};
const int32_t thermal_state_array_size[] = {
        8, 6, 4, 2,
};

Crea una función de objeto de escucha de estado térmico cambiado en el juego

Ahora, necesitaremos crear un thermalListener cpp para que ADPFManager lo llame cuando detecte que cambió el nivel térmico del dispositivo. Crea esta función en tu juego para escuchar los valores de estado cambiado. Estamos haciendo un seguimiento del last_state para saber si el nivel térmico sube o baja.

// CODELAB: demo_scene.cpp
// Dedicate a function to listen to the thermal state changed
void DemoScene::on_thermal_state_changed(int32_t last_state, int32_t current_state)
{
  if ( last_state != current_state ) {
    demo_scene_instance_->AdaptThermalLevel(current_state);
  }
}

...

// remember to pass it to the ADPFManager class we've just created, place this in DemoScene constructor: 
ADPFManager::getInstance().SetThermalListener(on_thermal_state_changed);

Cuando el estado térmico cambie en tu juego, haz la adaptación según corresponda

Cada juego tiene sus propias necesidades y prioridades, y lo que es muy importante para un juego puede no ser esencial para otro, por lo que tendrás que decidir cómo hacer la optimización para evitar que el dispositivo se caliente más.

En nuestro ejemplo, reducimos la cantidad de objetos en pantalla y la fidelidad de la física. De este modo, se aliviará la carga de trabajo de la CPU y la GPU, y, con suerte, se reducirá un poco el calor. Ten en cuenta que, a menudo, es difícil bajar mucho la temperatura, a menos que el jugador tome un descanso y permita que el dispositivo se enfríe. Es por esto que supervisamos el margen térmico de cerca y evitamos que el dispositivo alcance el estado de límite térmico.

// CODELAB: demo_scene.cpp
// Adapt your game when the thermal status has changed
void DemoScene::AdaptThermalLevel(int32_t index) {
  int32_t current_index = index;
  int32_t array_size = sizeof(thermal_state_physics_steps) / sizeof(thermal_state_physics_steps[0]);
  if ( current_index < 0 ) {
    current_index = 0;
  } else if ( current_index >= array_size ) {
    current_index = array_size - 1;
  }

  ALOGI("AdaptThermalLevel: %d", current_index);

  // in this sample, we are reducing the physics step when the device heated
  current_physics_step_ = thermal_state_physics_steps[current_index];
  // and also reduce the number of objects in the world
  // your situation may be different, you can reduce LOD to remove some calculations for example...
  int32_t new_array_size_ = thermal_state_array_size[current_index];

  if ( new_array_size_ != array_size_ ) {
      recreate_physics_obj_ = true;
  }
}

Además, recuerda que el rendimiento máximo que proporcionan los chips de CPU y GPU suele ser ineficiente, es decir, los chips por lo general ofrecen un rendimiento máximo con un consumo de energía mucho más alto y disipan una gran cantidad de calor. Compara eso con el rendimiento sostenido, que es el más óptimo por unidad de energía consumida y calor disipado. Puedes obtener más información al respecto en Administración del rendimiento del Proyecto de código abierto de Android.

Compila y ejecuta tu proyecto. Se mostrarán el estado térmico y el margen térmico actuales y, si el estado térmico se deteriora, se reducirán los pasos físicos y la cantidad de objetos.

4bdcfe567fc603c0.png

Si algo sale mal, puedes comparar tu trabajo con la confirmación del repo titulada [codelab] step: integrated thermal-api.

4. Integra la API de Game Mode

La API de Game Mode te permite optimizar el juego para obtener el mejor rendimiento o una mayor duración de batería según la selección del jugador. Está disponible en dispositivos Android 12 seleccionados y en todos los dispositivos Android 13 y versiones posteriores.

Actualiza el manifiesto de Android

Configura appCategory

Para usar la API de Game Mode, la categoría de tu aplicación debe ser un juego. Lo indicaremos en la etiqueta <application>.

// CODELAB: AndroidManifest.xml
<application
   android:appCategory="game">

Para Android 13 y versiones posteriores

Se recomienda que te orientes a los usuarios de Android 13 siguiendo los próximos 2 pasos secundarios:

Agrega game_mode_config <meta-data> y el archivo en formato XML correspondiente

// CODELAB: AndroidManifest.xml
   <!-- ENABLING GAME MODES -->
   <!-- Add this <meta-data> under your <application> tag to enable Game Mode API if you're targeting API Level 33 (recommended) -->
   <meta-data android:name="android.game_mode_config"
        android:resource="@xml/game_mode_config" />
// CODELAB: app/src/main/res/xml/game_mode_config.xml
<?xml version="1.0" encoding="UTF-8"?>
<game-mode-config
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:supportsBatteryGameMode="true"
    android:supportsPerformanceGameMode="true" />

Si te orientas a dispositivos Android 12

Agrega cada gamemode <meta-data> directamente en AndroidManifest

// CODELAB: AndroidManifest.xml
   <!-- Use meta-data below instead if you are targeting API Level 31 or 32; you do not need to apply the 2 steps prior -->
   <meta-data android:name="com.android.app.gamemode.performance.enabled"
      android:value="true"/>
   <meta-data
      android:name="com.android.app.gamemode.battery.enabled"
      android:value="true"/>

Implementa GameModeManager.java para abstraer la función GameMode

Dado que la API de Game Mode aún no tiene interfaz cpp, necesitaremos usar la interfaz de Java y proporcionar interfaces JNI. Hagamos la abstracción en GameModeManager.java para poder reutilizar la funcionalidad en otros proyectos.

// CODELAB: GameModeManager.java
// Abstract the functionality in GameModeManager so we can easily reuse in other games
public class GameModeManager {

    private Context context;
    private int gameMode;

    public void initialize(Context context) {
        this.context = context;
        this.gameMode = GameManager.GAME_MODE_UNSUPPORTED;
        if ( context != null ) {
            if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ) {
                // Get GameManager from SystemService
                GameManager gameManager = context.getSystemService(GameManager.class);

                // Returns the selected GameMode
                gameMode = gameManager.getGameMode();
            }
        }
        // tell the native game about the selected gameMode
        this.retrieveGameMode(this.gameMode);
    }

    protected native void retrieveGameMode(int gameMode);
}

Adapta tu actividad para inicializar GameModeManager y recuperar GameMode en onResume

Conéctala al ciclo de vida de la actividad. Cada vez que se cambie el modo de juego, se reiniciará la actividad para que podamos capturar el valor durante onResume.

// CODELAB: ADPFSampleActivity.java
// we may keep and cache the object as class member variable
private GameModeManager gameModeManager;

...

@Override
protected void onCreate(Bundle savedInstanceState) {
   ...
   // Instantiate our GameModeManager
   this.gameModeManager = new GameModeManager();
   ...
   super.onCreate(savedInstanceState);
}

...

@Override
protected void onResume() {
   ...
   this.gameModeManager.initialize(getApplicationContext());
   ...
   super.onResume();
}

Implementa la clase GameModeManager para almacenar el GameMode que seleccionó el usuario para la recuperación en el juego

Crearemos un wrapper de cpp y almacenaremos el valor de Game Mode en cpp para lograr una recuperación más sencilla.

// CODELAB: game_mode_manager.h
class GameModeManager {
 public:
  // Singleton function.
  static GameModeManager& getInstance() {
    static GameModeManager instance;
    return instance;
  }
  // Dtor. Remove global reference (if any).
  ~GameModeManager() {}

  // Delete copy constructor since the class is used as a singleton.
  GameModeManager(GameModeManager const&) = delete;
  void operator=(GameModeManager const&) = delete;

  void SetGameMode(int game_mode) { game_mode_ = game_mode; }
  int GetGameMode() { return game_mode_; }

 private:
  // Ctor. It's private since the class is designed as a singleton.
  GameModeManager() {}

  int game_mode_ = 0;
};

Implementa retrieveGameMode en tu código nativo para pasar el valor de GameMode a tu juego

Esta es la forma más sencilla y eficiente. Cuando comiences, recupera el valor de Game Mode y pásalo a tu variable cpp para obtener un acceso fácil. Podemos utilizar el valor almacenado en caché sin necesidad de realizar llamadas de JNI cada vez.

// CODELAB: game_mode_manager.cpp
extern "C" {

void Java_com_android_example_games_GameModeManager_retrieveGameMode(
    JNIEnv* env, jobject obj, jint game_mode) {
  GameModeManager& gmm = GameModeManager::getInstance();
  int old_game_mode = gmm.GetGameMode();
  ALOGI("GameMode updated from %d to:%d", old_game_mode, game_mode);
  GameModeManager::getInstance().SetGameMode(game_mode);
}

}

Incluye tu game_mode_manager.cpp en la unidad de compilación en CMakeLists.txt

Recuerda agregar el game_mode_manager.cpp que acabas de crear a la unidad de compilación.

Ya terminamos con las clases reutilizables java y cpp de GameModeManager, por lo que puedes tomar estos archivos y volver a usarlos en otros proyectos en lugar de volver a escribir el código de adhesión.

// CODELAB: CMakeLists.txt
# now build app's shared lib
add_library(game SHARED
        game_mode_manager.cpp # add this line
        android_main.cpp
        box_renderer.cpp
        demo_scene.cpp

Adapta tu juego según el GameMode que seleccionó el usuario

Una vez que recuperas el modo de juego, debes diferenciar el juego según el valor que seleccionó el usuario. La diferencia más significativa (y los valores más polarizantes) se encuentra entre los modos PERFORMANCE y BATTERY. Por lo general, en el modo PERFORMANCE, los usuarios quieren sumergirse en el juego y disfrutar de la mejor experiencia sin preocuparse por la duración de batería, por lo que puedes ofrecer la mejor fidelidad siempre que la velocidad de fotogramas sea estable. En el modo BATTERY, los usuarios querrán jugar tu juego durante más tiempo y podrán aceptar parámetros de configuración más bajos. Asegúrate de que la velocidad de fotogramas sea estable en todo momento, ya que una velocidad inestable brinda la peor experiencia a los jugadores.

// CODELAB: demo_scene.cpp
void DemoScene::RenderPanel() {
  ...
  GameModeManager& game_mode_manager = GameModeManager::getInstance();
  // Show the stat changes according to selected Game Mode
  ImGui::Text("Game Mode: %d", game_mode_manager.GetGameMode());
}
// CODELAB: native_engine.h
// Add this function to NativeEngine class, we're going to check the gameMode and set the preferred frame rate and resolution cap accordingly
void CheckGameMode();

En nuestro ejemplo, hacemos la renderización a resolución completa y a 60 FPS en modo PERFORMANCE. Como este es un ejemplo bastante sencillo, la mayoría de los dispositivos podrán ejecutar el juego de manera fluida con los FPS máximos, por lo que no realizaremos más verificaciones para simplificar el proceso. En el modo BATTERY, limitaremos la renderización a 30 FPS y un cuarto de resolución. Deberás hacer los cambios que creas necesarios para la optimización. Recuerda siempre que la experiencia de juego y el ahorro de energía no se limitan a los FPS y la resolución. Para obtener inspiración sobre cómo mejorar la optimización, consulta la historia de éxito de nuestros desarrolladores.

// CODELAB: native_engine.cpp
void NativeEngine::CheckGameMode() {
  GameModeManager &gameModeManager = GameModeManager::getInstance();
  int game_mode = gameModeManager.GetGameMode();
  if (game_mode != mGameMode) {
    // game mode changed, make necessary adjustments to the game
    // in this sample, we are capping the frame rate and the resolution
    // we're also hardcoding configs on the engine 🫣
    SceneManager *sceneManager = SceneManager::GetInstance();
    NativeEngine *nativeEngine = NativeEngine::GetInstance();
    int native_width = nativeEngine->GetNativeWidth();
    int native_height = nativeEngine->GetNativeHeight();
    int preferred_width;
    int preferred_height;
    int32_t preferredSwapInterval = SWAPPY_SWAP_30FPS;
    if (game_mode == GAME_MODE_STANDARD) {
      // GAME_MODE_STANDARD : fps: 30, res: 1/2
      preferredSwapInterval = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 2;
      preferred_height = native_height / 2;
    } else if (game_mode == GAME_MODE_PERFORMANCE) {
      // GAME_MODE_PERFORMANCE : fps: 60, res: 1/1
      preferredSwapInterval = SWAPPY_SWAP_60FPS;
      preferred_width = native_width;
      preferred_height = native_height;
    } else if (game_mode == GAME_MODE_BATTERY) {
      // GAME_MODE_BATTERY : fps: 30, res: 1/4
      preferred_height = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 4;
      preferred_height = native_height / 4;
    } else {  // game_mode == 0 : fps: 30, res: 1/2
      // GAME_MODE_UNSUPPORTED
      preferredSwapInterval = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 2;
      preferred_height = native_height / 2;
    }
    ALOGI("GameMode SetPreferredSizeAndFPS: %d, %d, %d", preferred_width,
          preferred_height, preferredSwapInterval);
    sceneManager->SetPreferredSize(preferred_width, preferred_height);
    sceneManager->SetPreferredSwapInterval(preferredSwapInterval);
    mGameMode = game_mode;
  }
}
// CODELAB: native_engine.cpp
void NativeEngine::DoFrame() {
  ...

  // here, we are checking on every frame for simplicity
  // but you can hook it to your onResume callback only
  // as gameMode changes will trigger Activity restart
  CheckGameMode();
  SwitchToPreferredDisplaySize();

  ...
}

Recuerda verificar tus sdkVersions en el archivo de compilación de Gradle

La API de Game Mode está disponible en todos los dispositivos Android a partir de Android 13. Los dispositivos Android 12 seleccionados también tendrán habilitada esta función.

// CODELAB: app/build.gradle
android {
    compileSdk 33
    ...

    defaultConfig {
        minSdkVersion 30 
        targetSdkVersion 33 // you can use 31 if you're targeting Android 12 and follow the Note section above
        ...
    }

Si algo sale mal, puedes comparar tu trabajo con la confirmación del repo titulada [codelab] step: integrate game-mode-api.

496c76415d12cbed.png

5. Integra la API de Game State

La API de Game State te permite indicarle al sistema el estado de nivel superior del juego y saber si el contenido actual se puede interrumpir sin interferir en el juego que no se puede pausar. También te permite indicar si tu juego realiza operaciones de E/S intensas, como cargar recursos desde discos o la red. Cuando se conocen todos estos datos, el sistema puede asignar los recursos correctos en el momento indicado (p. ej., asignar la cantidad correcta de CPU y ancho de banda de memoria cuando cargas o priorizas el tráfico de red de tu juego multijugador cuando el jugador está en una sesión multijugador crucial.

Define enums para facilitar la asignación a constantes de GameState

Debido a que la API de Game State no tiene una interfaz cpp, comenzaremos por copiar los valores a cpp.

// CODELAB: game_mode_manager.h
enum GAME_STATE_DEFINITION {
    GAME_STATE_UNKNOWN = 0,
    GAME_STATE_NONE = 1,
    GAME_STATE_GAMEPLAY_INTERRUPTIBLE = 2,
    GAME_STATE_GAMEPLAY_UNINTERRUPTIBLE = 3,
    GAME_STATE_CONTENT = 4,
};

Define SetGameState en tu código cpp que llamará a la API de Java a través de JNI

Una función cpp es todo lo que necesitamos para pasar toda la información a la API de Java.

// CODELAB: game_mode_manager.h
void SetGameState(bool is_loading, GAME_STATE_DEFINITION game_state);

No te abrumes; es solo una llamada de JNI…

// CODELAB: game_mode_manager.cpp
void GameModeManager::SetGameState(bool is_loading,
                                   GAME_STATE_DEFINITION game_state) {
  if (android_get_device_api_level() >= 33) {
    ALOGI("GameModeManager::SetGameState: %d => %d", is_loading, game_state);

    JNIEnv* env = NativeEngine::GetInstance()->GetJniEnv();

    jclass cls_gamestate = env->FindClass("android/app/GameState");

    jmethodID ctor_gamestate =
        env->GetMethodID(cls_gamestate, "<init>", "(ZI)V");
    jobject obj_gamestate = env->NewObject(
        cls_gamestate, ctor_gamestate, (jboolean)is_loading, (jint)game_state);

    env->CallVoidMethod(obj_gamemanager_, gamemgr_setgamestate_, obj_gamestate);

    env->DeleteLocalRef(obj_gamestate);
    env->DeleteLocalRef(cls_gamestate);
  }
}

Llama a SetGameState cada vez que cambie el estado de juego

Solo llama a nuestra función cpp durante el tiempo adecuado, como cuando se inicia la carga, se la detiene, o se ingresa a diferentes estados y se sale de ellos dentro de tu juego.

// CODELAB: welcome_scene.cpp
void WelcomeScene::OnInstall() {
  // 1. Game State: Start Loading
  GameModeManager::getInstance().SetGameState(true, GAME_STATE_NONE);
}
// CODELAB: welcome_scene.cpp
void WelcomeScene::OnStartGraphics() {
  // 2. Game State: Finish Loading, showing the attract screen which is interruptible
  GameModeManager::getInstance().SetGameState(
      false, GAME_STATE_GAMEPLAY_INTERRUPTIBLE);
}
// CODELAB: welcome_scene.cpp
void WelcomeScene::OnKillGraphics() {
  // 3. Game State: exiting, cleaning up and preparing to load the next scene
  GameModeManager::getInstance().SetGameState(true, GAME_STATE_NONE);
}

Puedes pasar UNKNOWN si no conoces el estado en ese momento. En nuestro caso, estamos descargando la escena y no sabemos hacia dónde irá el usuario a continuación, pero pronto, se cargará la siguiente, y podremos llamar a otro SetGameState con el nuevo estado conocido.

// CODELAB: welcome_scene.cpp
void WelcomeScene::OnUninstall() {
  // 4. Game State: Finished unloading this scene, it will be immediately followed by loading the next scene
  GameModeManager::getInstance().SetGameState(false, GAME_STATE_UNKNOWN);
}

Fácil, ¿verdad? Una vez que hayas integrado la API de Game State, el sistema conocerá los estados de tu aplicación y comenzará a optimizar los recursos para mejorar el rendimiento y la eficiencia para tu jugador.

No te pierdas la próxima actualización de la API de Game State, en la que te explicaremos cómo puedes usar la etiqueta y la calidad para optimizar aún más tu juego.

6. Integra la API de Performance Hint

La API de Performance Hint te permite enviar sugerencias de rendimiento al sistema. Para ello, crea sesiones para cada grupo de subprocesos responsable de una tarea específica, establece la duración objetivo inicial del trabajo y, luego, en cada fotograma, informa la duración real del trabajo y actualiza la duración esperada para el siguiente fotograma.

Por ejemplo, si tienes un grupo de subprocesos responsables de la IA enemiga, el cálculo físico y el subproceso de renderización, todas estas subtareas deben completarse en cada fotograma, y un exceso de uno de ellos hará que se retrase y no se alcance el FPS objetivo. Puedes crear una sesión de PerformanceHint para este grupo de subprocesos y establecer la targetWorkDuration en los FPSs objetivo. A medida que informas reportActualWorkDuration después del trabajo realizado en cada fotograma, el sistema puede analizar esta tendencia y ajustar los recursos de la CPU según corresponda para asegurarse de lograr el objetivo deseado en cada fotograma. Esto resulta en una mejor estabilidad de los fotogramas y un consumo de energía más eficiente para tu juego.

InitializePerformanceHintManager de ADPFManager

Tendremos que crear la sesión de sugerencias para los subprocesos. Existe la API de C++, pero solo está disponible a partir del nivel de API 33. Para los niveles de API 31 y 32, necesitaremos usar la API de Java. Vamos a almacenar en caché algunos métodos de JNI que podemos usar más adelante.

// CODELAB: adpf_manager.cpp
// Initialize JNI calls for the PowerHintManager.
bool ADPFManager::InitializePerformanceHintManager() {
  #if __ANDROID_API__ >= 33
    if ( hint_manager_ == nullptr ) {
        hint_manager_ = APerformanceHint_getManager();
    }
    if ( hint_session_ == nullptr && hint_manager_ != nullptr ) {
        int32_t tid = gettid();
        thread_ids_.push_back(tid);
        int32_t tids[1];
        tids[0] = tid;
        hint_session_ = APerformanceHint_createSession(hint_manager_, tids, 1, last_target_);
    }
    ALOGI("ADPFManager::InitializePerformanceHintManager __ANDROID_API__ 33");
    return true;
#else  
  ALOGI("ADPFManager::InitializePerformanceHintManager __ANDROID_API__ < 33");
  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Retrieve class information
  jclass context = env->FindClass("android/content/Context");

  // Get the value of a constant
  jfieldID fid = env->GetStaticFieldID(context, "PERFORMANCE_HINT_SERVICE",
                                       "Ljava/lang/String;");
  jobject str_svc = env->GetStaticObjectField(context, fid);

  // Get the method 'getSystemService' and call it
  jmethodID mid_getss = env->GetMethodID(
      context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
  jobject obj_perfhint_service = env->CallObjectMethod(
      app_->activity->javaGameActivity, mid_getss, str_svc);

  // Add global reference to the power service object.
  obj_perfhint_service_ = env->NewGlobalRef(obj_perfhint_service);

  // Retrieve methods IDs for the APIs.
  jclass cls_perfhint_service = env->GetObjectClass(obj_perfhint_service_);
  create_hint_session_ =
      env->GetMethodID(cls_perfhint_service, "createHintSession",
                       "([IJ)Landroid/os/PerformanceHintManager$Session;");
  jmethodID mid_preferedupdaterate = env->GetMethodID(
      cls_perfhint_service, "getPreferredUpdateRateNanos", "()J");

  // Create int array which contain current tid.
  jintArray array = env->NewIntArray(1);
  int32_t tid = gettid();
  env->SetIntArrayRegion(array, 0, 1, &tid);
  const jlong DEFAULT_TARGET_NS = 16666666;

  // Create Hint session for the thread.
  jobject obj_hintsession = env->CallObjectMethod(
      obj_perfhint_service_, create_hint_session_, array, DEFAULT_TARGET_NS);
  if (obj_hintsession == nullptr) {
    ALOGI("Failed to create a perf hint session.");
  } else {
    obj_perfhint_session_ = env->NewGlobalRef(obj_hintsession);
    preferred_update_rate_ =
        env->CallLongMethod(obj_perfhint_service_, mid_preferedupdaterate);

    // Retrieve mid of Session APIs.
    jclass cls_perfhint_session = env->GetObjectClass(obj_perfhint_session_);
    report_actual_work_duration_ = env->GetMethodID(
        cls_perfhint_session, "reportActualWorkDuration", "(J)V");
    update_target_work_duration_ = env->GetMethodID(
        cls_perfhint_session, "updateTargetWorkDuration", "(J)V");
    set_threads_ = env->GetMethodID(
        cls_perfhint_session, "setThreads", "([I)V");
  }

  // Free local references
  env->DeleteLocalRef(obj_hintsession);
  env->DeleteLocalRef(array);
  env->DeleteLocalRef(cls_perfhint_service);
  env->DeleteLocalRef(obj_perfhint_service);
  env->DeleteLocalRef(str_svc);
  env->DeleteLocalRef(context);

  if (report_actual_work_duration_ == 0 || update_target_work_duration_ == 0) {
    // The API is not supported in the platform version.
    return false;
  }

  return true;
#endif // __ANDROID_API__ >= 33

}

Llámala en ADPFManager::SetApplication

Recuerda llamar a la función de inicialización que definimos desde android_main.

// CODELAB: adpf_manager.cpp
// Invoke the API first to set the android_app instance.
void ADPFManager::SetApplication(android_app *app) {
  ...

  // Initialize PowerHintManager reference.
  InitializePerformanceHintManager();
}
// CODELAB: android_main.cpp
void android_main(struct android_app *app) {
  ...

  // Set android_app to ADPF manager & call InitializePerformanceHintManager
  ADPFManager::getInstance().SetApplication(app);

  ...
}

Define ADPFManager::BeginPerfHintSession y ADPFManager::EndPerfHintSession

Define los métodos cpp para invocar las APIs a través de JNI y acepta todos nuestros parámetros obligatorios.

// CODELAB: adpf_manager.h
// Indicates the start and end of the performance intensive task.
// The methods call performance hint API to tell the performance
// hint to the system.
void BeginPerfHintSession();
void EndPerfHintSession(jlong target_duration_ns);
// CODELAB: adpf_manager.cpp
// Indicates the start and end of the performance intensive task.
// The methods call performance hint API to tell the performance hint to the system.
void ADPFManager::BeginPerfHintSession() { 
  perf_start_ = std::chrono::high_resolution_clock::now(); 
}

void ADPFManager::EndPerfHintSession(jlong target_duration_ns) {
#if __ANDROID_API__ >= 33
    auto perf_end = std::chrono::high_resolution_clock::now();
    auto dur = std::chrono::duration_cast<std::chrono::nanoseconds>(perf_end - perf_start_).count();
    int64_t actual_duration_ns = static_cast<int64_t>(dur);
    APerformanceHint_reportActualWorkDuration(hint_session_, actual_duration_ns);
    APerformanceHint_updateTargetWorkDuration(hint_session_, target_duration_ns);
#else
  if (obj_perfhint_session_) {
    auto perf_end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(perf_end - perf_start_).count();
    int64_t duration_ns = static_cast<int64_t>(duration);
    JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

    // Report and update the target work duration using JNI calls.
    env->CallVoidMethod(obj_perfhint_session_, report_actual_work_duration_,
                        duration_ns);
    env->CallVoidMethod(obj_perfhint_session_, update_target_work_duration_,
                        target_duration_ns);
  }
#endif // __ANDROID_API__ >= 33

  }
}

Llámalos al principio y al final de cada fotograma

En cada fotograma, debemos registrar la hora de inicio al comienzo y, luego, informar la hora real al final. Llamaremos a reportActualWorkDuration y a updateTargetWorkDuration al final del fotograma. En nuestro ejemplo simple, la carga de trabajo no cambia entre los fotogramas updateTargetWorkDuration con el valor objetivo coherente.

// CODELAB: demo_scene.cpp
void DemoScene::DoFrame() {
  // Tell ADPF manager beginning of the perf intensive task.
  ADPFManager::getInstance().BeginPerfHintSession();

  ...
  
  // Tell ADPF manager end of the perf intensive tasks.
  ADPFManager::getInstance().EndPerfHintSession(jlong target_duration_ns);
}

7. Felicitaciones

Felicitaciones. Integraste correctamente las funciones de adaptabilidad en un juego.

Mantente al tanto, ya que agregaremos más funciones del framework de adaptabilidad a Android.