[Перевод] Kotlin и Jetpack Compose: портируем DOOM на смарт-часы

DOOM на смарт‑часах Samsung, скриншот автора
DOOM на смарт‑часах Samsung, скриншот автора

DOOM, пожалуй, самый известный шутер от первого лица в истории компьютерных игр. Эта игра не только завоевала коммерческий успех, но и заслужила репутацию одной из лучших и наиболее влиятельных видеоигр всех времен. В 1999 году исходный код Doom был выпущен под лицензией GNU General Public License, и с тех пор он был портирован на множество платформ.

Очевидно, что я не первый, кто решил запустить DOOM на Android. Эта статья была вдохновлена проектом Doom‑Android на GitHub, который, в свою очередь,  был основан на другом проекте под названием Doom‑Generic. Последний, в свою очередь, базируется на порте fbDOOM, который основан на… (ну вы поняли). Проект Doom‑Android работает хорошо, однако он использует «классический» Android API. Он не поддерживает Jetpack Compose, и я также раньше не видел Doom на современных устройствах Android Wear. Итак, без лишних слов, давайте приступать к портированию!

Я взялся за этот проект для Android Wear просто потому, что это интересно, и не так много людей видели, как на часах работает полноценная 3D‑игра. Однако я также хочу, чтобы проект был доступен и для «стандартного» Android. Таким образом, читатели, у которых нет смарт‑часов, смогут наслаждаться тем же кодом на своих смартфонах.

Структура приложения

Чтобы запустить DOOM в Jetpack Compose, нам предстоит проделать несколько важных шагов:

  • Связать исходный код DOOM (который был написан на C) с Kotlin, используя JNI (Java Native Interface). Это позволит нам запустить игру и получить данные из ее видеобуфера.

  • Внести некоторые изменения в исходный код DOOM, чтобы сделать его совместимым со стандартами Android.

  • Использовать GLSurfaceView и GLSurfaceView.Renderer — компоненты Android, которые будут отображать видеоданные из DOOM.

  • Наконец, запустить код «реактивно» с помощью Jetpack Compose.

Итак, давайте приступим!

1. JNI (Java Native Interface)

Исходный код DOOM очень старый. Настолько старый, что некоторые читатели, вероятно, еще даже не родились, когда он был написан. В начале файла d_main.h мы можем увидеть строки:

Щ//
// Copyright(C) 1993-1996 Id Software, Inc.
//

Очевидно, что этот код старше как Kotlin, так и Android, и даже самой Java (первая версия JDK 1.0 была выпущена в 1996 году). Однако, к счастью для нас, есть простой способ запустить код C++ на Android с помощью Java Native Interface (JNI). Эта технология, хотя и не является новой (я видел на форумах вопросы о JNI еще в 2005 году), по‑прежнему прекрасно работает на устройствах Android.

Чтобы запустить DOOM на Android, нам понадобятся как минимум три метода: init, который загрузит игру из WAD‑файла, main, который запустит саму игру, и getFrame, который предоставит нам страницу видеобуфера.

Сначала давайте создадим Kotlin‑класс под названием Doom:

package com.dmitrii.doomsmartwatch

class Doom {
    external fun init(wadData: ByteArray, wadFilename: String)
    external fun main()
    private external fun getFrame(screenBuffer: ByteArray)
}

Теперь мы можем создать соответствующие методы C++.

Чтобы связать код Kotlin и C/C ++, JNI использует специальное соглашение об именах. В нашем случае пакет называется com.dmitrii.doomsmartwatch, а класс — Doom. Чтобы создать метод init на C, нужно объединить эти имена:

#include 


JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init(
        JNIEnv* pEnv,
        jobject pThis,
        jbyteArray wadData,
        jstring wadFilename
        )
{
    ...
}

JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_main(
        JNIEnv* pEnv,
        jobject pThis)
{
    ...
}

JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame(
        JNIEnv* pEnv,
        jobject pThis,
        jbyteArray screenBuffer)
{
    ...
}

После этого все вызовы Kotlin будут автоматически связаны с соответствующими C‑методами.

Чтобы использовать C или C++ в проекте Android Studio, нам также нужно добавить CMakeLists.txt и внести изменения в build.gradle.kts:

CMakeLists.txt:

cmake_minimum_required(VERSION 3.22.1)

project("doomsmartwatch")

add_library(${CMAKE_PROJECT_NAME} SHARED
   # Список исходников C/C++
   doomgeneric.c
   d_main.c
   ...
)

target_link_libraries(${CMAKE_PROJECT_NAME}
    # Список библиотек, связанных с целевой библиотекой
    android
    log)


build.gradle.kts:

android {
    ...
    externalNativeBuild {
        cmake {
            path = file("src/main/cpp/CMakeLists.txt")
            version = "3.22.1"
        }
    }
}

Android Studio автоматически создает эти файлы, когда мы впервые добавляем класс C++ в проект. Связывать библиотеку log здесь необязательно, но это позволяет использовать отладку с помощью Logcat:

#include 

#ifdef __ANDROID__

#include 
#define printf(...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__)
#define fprintf(a, ...) __android_log_print(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__)
#define vfprintf(a, ...) __android_log_vprint(ANDROID_LOG_VERBOSE, "Doom", __VA_ARGS__)

#endif

2. Исходники DOOM

На этом этапе мы завершили «скучную» часть с конфигурациями CMake и переходим к самой интересной — изменениям в исходном коде DOOM.

2.1 WAD (Where’s All Data)

Все игровые данные, включая уровни, звуки и т. д., хранятся в так называемом WAD‑файле, который обычно имеет имя, подобное «doom2.wad». Этот файл представляет собой контейнер, в котором собраны все данные, что объясняет его расширение — «Where’s All Data». Если мы выберем другой файл, то запустим другую игру.

В проекте Android мы можем разместить этот файл в папке src/main/assets. Однако исходный код, написанный на C, использует метод fopen для чтения файла, и я не смог найти способ получить полный путь к ресурсам в Kotlin. Вместо этого мы можем отправить данные WAD‑файла в виде массива байтов:

import android.content.Context

external fun init(wadData: ByteArray, wadFilename: String)


filename = "doom2.wad"
val wadData = context.assets.open(filename).readBytes()
init(wadData, filename)

Соответствующий код на C выглядит следующим образом:

// C-код:

extern char  wadFileName[255];
extern unsigned int wadDataLength;
extern unsigned char *wadFileData;


JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_init(
        JNIEnv* pEnv,
        jobject pThis,
        jbyteArray wadData,
        jstring wadFilename
        )
{
    // Имя WAD-файла
    const char *nativeString = (*pEnv)->GetStringUTFChars(pEnv, wadFilename, 0);
    strcpy(wadFileName, nativeString);
    (*pEnv)->ReleaseStringUTFChars(pEnv, wadFilename, nativeString);
    // Данные WAD-файла
    wadDataLength = (*pEnv)->GetArrayLength(pEnv, wadData);
    wadFileData = (unsigned char*)malloc(wadDataLength);
    jbyte* content_array = (*pEnv)->GetByteArrayElements(pEnv, wadData, 0);
    memcpy(wadFileData, content_array, wadDataLength);
    (*pEnv)->ReleaseByteArrayElements(pEnv, wadData, content_array, JNI_OK);
}

Я не уверен в том, насколько обязательно wadFileName, но оно использовалось несколько раз в исходном коде, поэтому я решил оставить его как есть.

Исходный код DOOM хорошо структурирован, и все модули разделены на отдельные файлы. Например, все операции, связанные с файлами, сосредоточены в файле w_file.c. Здесь нам необходимо внести изменения в методы W_OpenFile и W_Read:

char  wadFileName[255] = {0};
unsigned char *wadFileData = NULL;
unsigned int   wadDataLength = 0;


wad_file_t *W_OpenFile(const char *path)
{
    stdc_wad_file_t *result;

    // Старый код
    // fstream = fopen(path, "rb");
    // if (fstream == NULL)
    //    return NULL;

    result = Z_Malloc(sizeof(stdc_wad_file_t), PU_STATIC, 0);
    result->wad.mapped = NULL;
    result->wad.length = wadDataLength;  // M_FileLength(fstream);
    result->fstream = NULL;

    return &result->wad;
}

size_t W_Read(wad_file_t *wad, long offset, void *buffer, size_t buffer_len)
{
    // Старый код
    // fseek(stdc_wad->fstream, offset, SEEK_SET);
    // Read into the buffer.
    // size_t result = fread(buffer, 1, buffer_len, stdc_wad->fstream);

    size_t result = 0;
    if (wadFileData != NULL) {
        memcpy(buffer, &wadFileData[offset], buffer_len);
        result = buffer_len;
    }
    return result;
}

Очевидно, что у нас уже есть все необходимые данные в буфере, поэтому нам больше не нужно использовать методы fseek и fopen. Размер WAD‑файла составляет примерно 16 МБ. Оригинальный DOOM использовал для чтения данных файл, проецируемый в память, но смарт‑часы в 2025 году в среднем имеют в 32 раза больше оперативной памяти (2 ГБ в сравнении с 64 МБ), чем персональные компьютеры 1996 года.

2.2 DoomMain

Второй метод, который мы должны модифицировать, находится в файле d_main.c и называется D_DOOMMAIN. Его исходный код выглядит следующим образом:

void D_DoomMain(void)
{
    printf("Z_Init: Init zone memory allocation daemon. \n");
    Z_Init();
    ...

    M_LoadDefaults();
    iwadfile = D_FindIWAD(IWAD_MASK_DOOM, &gamemission);

    W_CheckCorrectIWAD(doom);

    printf("I_Init: Setting up machine state.\n");
    I_InitSound(True);

    printf("R_Init: Init DOOM refresh daemon - ");
    R_Init();

    printf("\nP_Init: Init Playloop state.\n");
    P_Init();

    D_DoomLoop(); // бесконечный игровой цикл
}

void D_DoomLoop(void)
{
    I_SetWindowTitle(gamedescription);
    I_SetGrabMouseCallback(D_GrabMouseCallback);
    I_InitGraphics();

    V_RestoreBuffer();
    R_ExecuteSetViewSize();

    D_StartGameLoop();

    while (1)
    {
        // фрейм-синхронизированные операции ввода-вывода
        I_StartFrame();

        TryRunTics(); // выполнит хотя бы один тик

        S_UpdateSounds(players[consoleplayer].mo);  // перемещает позиционные звуки

        // обновляет отображение следующего кадра текущим состоянием
        if (screenvisible)
            D_Display();
    }
}

Как и в «классических» приложениях, DOOM имеет бесконечный основной цикл, который обрабатывает все события и обновляет графику. Однако в Jetpack Compose этот подход не работает, и мы не можем блокировать основной цикл приложения таким образом. Тем не менее, исправить это не так сложно. Давайте реорганизуем метод D_DoomLoop, разделив его на две функции: D_DoomInitLoop и d_dooomloopstep. Вот как это будет выглядеть:

void D_DoomInitLoop(void)
{
    I_SetWindowTitle(gamedescription);
    I_SetGrabMouseCallback(D_GrabMouseCallback);
    I_InitGraphics();

    V_RestoreBuffer();
    R_ExecuteSetViewSize();

    D_StartGameLoop();
}

void D_DoomLoopStep(void)
{
    // фрейм-синхронизированные операции ввода-вывода
    I_StartFrame();

    TryRunTics(); // выполнит хотя бы один тик

    S_UpdateSounds(players[consoleplayer].mo); // перемещает позиционные звуки

    // обновляет отображение следующего кадра текущим состоянием
    if (screenvisible)
        D_Display();
}

Теперь мы можем легко вызывать метод D_DoomLoopStep для обновления состояния игры каждый раз, когда Jetpack Compose обновляет представление.

2.3 Графика

Как видно из кода, игровой цикл всегда вызывает метод D_Display. Этот метод отвечает за обновление пользовательского интерфейса игры и вызывает метод I_FinishUpdate, расположенный в файле i_video.c. Однако сейчас нас интересует переменная DG_ScreenBuffer, которая используется в том же файле:

uint32_t DG_ScreenBuffer[DOOMGENERIC_RESX * DOOMGENERIC_RESY];


void I_FinishUpdate(void)
{
   /* ЭКРАН ОТРИСОВКИ */
   line_in  = (unsigned char *) I_VideoBuffer;
   line_out = (unsigned char *) DG_ScreenBuffer;

   int y = SCREENHEIGHT;
   while (y--) 
   {
       for (int i = 0; i < fb_scaling; i++)
       {
           line_out += x_offset;
           cmap_to_fb((void*)line_out, (void*)line_in, SCREENWIDTH);
           line_out += (SCREENWIDTH * fb_scaling * (s_Fb.bits_per_pixel/8)) + x_offset_end;
       }
       line_in += SCREENWIDTH;
   }
   DG_DrawFrame();
}

Ключевым моментом для нас здесь является то, что DOOM визуализирует всю свою графику в массиве DG_ScreenBuffer. Это идеально подходит для нашей задачи — после каждого игрового шага мы можем отправлять эти данные обратно в Kotlin.

// Сторона Kotlin:
private external fun getFrame(screenBuffer: ByteArray)


// Сторона C:
JNIEXPORT void JNICALL Java_com_dmitrii_doomsmartwatch_Doom_getFrame(
        JNIEnv* pEnv,
        jobject pThis,
        jbyteArray screenBuffer)
{
    D_DoomLoopStep();

    uint32_t *buffer = DG_ScreenBuffer;
    size_t  bufferSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4;
    jboolean isCopy;
    jbyte *arr = (*pEnv)->GetByteArrayElements(pEnv, screenBuffer, &isCopy);
    memcpy(arr, buffer, bufferSize);
    (*pEnv)->ReleaseByteArrayElements(pEnv, screenBuffer, arr, JNI_OK);
}

Очевидно, что оригинальный метод DG_DrawFrame больше не нужен, поэтому мы можем оставить его пустым:

void DG_DrawFrame(void)
{
}

3.1 Android-приложение: Jetpack Compose

Наконец, давайте создадим Android‑приложение, которое будет использовать наш код DOOM. Как уже упоминалось ранее, я создал приложение для Android Wear, но обычное приложение для Android тоже должно нормально работать.

Для отрисовки игрового экрана я буду использовать OpenGL. Прежде всего, нам нужно «обернуть» его в AndroidView:

@Composable
fun WearApp() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colors.background),
        contentAlignment = Alignment.Center
    ) {
        DoomGLView()
    }
}

@Composable
fun DoomGLView() {
    val doom = remember {  Doom() }
    val viewActive = remember { mutableStateOf(false) }
    LifecycleResumeEffect(Unit) {
        viewActive.value = true
        onPauseOrDispose {
            viewActive.value = false
        }
    }
    if (viewActive.value) {
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .clipToBounds(),
            factory = { context ->
                DoomGLSurfaceView(context).apply {
                }
            },
            update = { view ->
            },
            onRelease = { view ->
                view.glClear()
            }
        )
    }
}

class DoomGLSurfaceView(context: Context) : GLSurfaceView(context) {
    private val renderer: GLGameRenderer

    init {
        setEGLContextClientVersion(2)
        renderer = GLGameRenderer()
        setRenderer(renderer)
        renderMode = RENDERMODE_CONTINUOUSLY
    }

    fun glClear() = renderer.glClear()
}

В этом фрагменте кода я использую небольшой хак с viewActive, который позволяет удалять представление, когда приложение больше не активно. Это единственный способ, который я нашёл, чтобы правильно высвободить все данные OpenGL. Без этого приложение не восстанавливалось корректно после перехода в фоновый режим (если кто‑то знает способ получше, пожалуйста, поделитесь им в комментариях).

Минимально рабочий код рендеринга OpenGL выглядит следующим образом:

import android.opengl.GLES20


class GLGameRenderer : GLSurfaceView.Renderer {
    override fun onSurfaceCreated(glUnused: GL10, config: EGLConfig) {
    }

    override fun onSurfaceChanged(glUnused: GL10, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
        glInit()
    }

    override fun onDrawFrame(glUnused: GL10) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
        GLES20.glClearColor(0f, 0.5f, 0f, 1f)
    }

    fun glInit() {
    }

    fun glClear() {
    }
}

Здесь мы не инициализируем никаких текстур или шейдеров, а просто очищаем экран цветом RGB = (0, 0.5, 0). Методы onSurfaceCreated и onSurfaceChanged вызываются, когда приложение создает представление. Ранее я установил для renderMode значение RENDERMODE_CONTINUOUSLY, чтобы операционная система постоянно вызывала метод onDrawFrame (в моём эмуляторе интервал между обновлениями составляет около 17 мс). При каждом вызове onDrawFrame мы можем обновлять состояние игры и перерисовывать изображение.

Хоть это приложение и не претендует на звание «Дизайн года», мы уже можем убедиться, что OpenGL функционирует должным образом. Если все было сделано правильно, мы должны увидеть зеленую поверхность, как показано на рисунке ниже:

Простой рисунок в OpenGL
Простой рисунок в OpenGL

Теперь, на заключительном этапе, настало время отобразить реальные игровые данные.

3.2. Android-приложение: OpenGL

Программирование на OpenGL — это обширная тема, и в этой статье я представлю лишь основные концепции, необходимые для запуска игры. Те, кто желает узнать больше, могут почитать официальную документацию на developer.android.com. Также в конце статьи вы можете найти ссылку на исходный код.

На предыдущем шаге мы создали класс GLGameRenderer. При каждом вызове onDrawFrame мы будем обновлять состояние игры и получать обновленный графический массив.

Код на Kotlin выглядит следующим образом:

// Привязка JNI к C++, обсуждавшаяся ранее
private external fun getFrame(screenBuffer: ByteArray)


val frameSize = DOOMGENERIC_RESX * DOOMGENERIC_RESY * 4; // 4 байта на пиксель
val frameBuffer = ByteArray(size = frameSize)
getFrame(frameBuffer)

В OpenGL мы можем отображать изображения в виде текстур. Чтобы нарисовать текстуру, нам необходимо создать два шейдера и саму текстуру:

private val textures = intArrayOf(0)
private var vertexShader = 0
private var fragmentShader = 0
private var program = 0


private fun glInit() {
    vertexShader = loadShader(
        GLES20.GL_VERTEX_SHADER,
        VERTEX_SHADER_CODE
    )
    fragmentShader = loadShader(
        GLES20.GL_FRAGMENT_SHADER,
        FRAGMENT_SHADER_CODE
    )
    program = GLES20.glCreateProgram().also { program ->
        GLES20.glAttachShader(program, vertexShader)
        GLES20.glAttachShader(program, fragmentShader)
        GLES20.glLinkProgram(program)
    }
    uniformMvpMatrix = GLES20.glGetUniformLocation(program, "uMvpMatrix")
    attributeVertexPosition = GLES20.glGetAttribLocation(program, "aPosition")
    attributeTexturePosition = GLES20.glGetAttribLocation(program, "aCoordinate")
    uniformTexture = GLES20.glGetUniformLocation(program, "uTexture")
    GLES20.glGenTextures(1, textures, 0)
}

OpenGL — это довольно старый фреймворк, созданный в 1990-х годах. В нем отсутствуют современные функции, такие как смарт‑объекты и сборщик мусора. Когда компонент высвобождается, нам необходимо вручную очистить выделенные под него ресурсы:

fun glClear() {
    if (program != 0) {
        GLES20.glDeleteProgram(program)
        program = 0
    }
    if (vertexShader != 0) {
        GLES20.glDeleteShader(vertexShader)
        vertexShader = 0
    }
    if (fragmentShader != 0) {
        GLES20.glDeleteShader(fragmentShader)
        fragmentShader = 0
    }
    if (textures[0] != 0) {
        GLES20.glDeleteTextures(1, textures, 0)
        textures[0] = 0
    }
}

При каждом вызове метода onDrawFrame мы можем обновлять текстуру данными, полученными из DOOM:

const val GAME_WIDTH = 640
const val GAME_HEIGHT = 400

private fun updateTextureFromBuffer(byteArray: ByteArray) {
    val buffer = ByteBuffer.wrap(byteArray)
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0])
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST.toFloat())
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat())
    GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, GAME_WIDTH, GAME_HEIGHT, 0,
                        GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer)
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
}

Если все сделано правильно, мы можем запустить приложение и увидеть игру:

Первый запуск, изображение автора
Первый запуск, изображение автора

Что ж, это работает. Почти. Очевидно (по крайней мере, для тех, кто хотя бы раз играл в Doom:), что цветовое отображение некорректно.

Исправить это не так сложно. В коде мы можем увидеть константу GLES20.GL_RGBA. Однако в методе I_InitGraphics в коде DOOM цветовые каналы настроены иначе:

void I_InitGraphics(void)
{
    memset(&s_Fb, 0, sizeof(struct FB_ScreenInfo));
    s_Fb.xres = DOOMGENERIC_RESX;
    s_Fb.yres = DOOMGENERIC_RESY;
    s_Fb.bits_per_pixel = 32;

    s_Fb.blue.length = 8;
    s_Fb.green.length = 8;
    s_Fb.red.length = 8;
    s_Fb.transp.length = 8;

    s_Fb.blue.offset = 0;
    s_Fb.green.offset = 8;
    s_Fb.red.offset = 16;
    s_Fb.transp.offset = 24;

    ...
}

Чтобы исправить эту проблему, нам нужно лишь подправить цветовые смещения, чтобы сделать их совместимыми с RGBA:

    s_Fb.red.offset = 0;
    s_Fb.green.offset = 8;
    s_Fb.blue.offset = 16;
    s_Fb.transp.offset = 24;

Если все сделано правильно, мы должны увидеть на экране часов полностью работающий DOOM:

Видео автора
Видео автора


При записи видео некоторая резкость изображения теряется. На настоящих смарт‑часах игра выглядит очень четко, поскольку плотность пикселей на устройствах Android значительно выше, чем на обычных 14-дюймовых дисплеях с разрешением 800×600, которые использовались в 1990-х годах.

Заключение

В этой статье я рассказал, как портировать игру DOOM, созданную в 1993–1996 годах, на современный фреймворк Jetpack Compose для Android. Я протестировал игру на смарт‑часах просто забавы ради, но тот же подход должен работать и на «полноразмерном» устройстве Android.

Как мы видим, игра работает, но, к сожалению, в нее пока нльзя играть. Некоторые важные функции еще не реализованы:

  • Экранные элементы управления. Это может быть непросто на экране часов из‑за его небольшого размера, но это должно быть выполнимо.

  • Звук. Звуковой модуль еще не реализован.

  • Сеть / мультиплеер. Может быть забавно играть в Doom на двух Android‑устройствах, однако я понятия не имею, пробовал ли кто‑нибудь это реализовать.

Если вы хотите увидеть продолжение этой статьи, пожалуйста, поставьте лайк или оставьте комментарий ниже. Я буду ориентироваться на количество лайков и просмотров, чтобы понять, стоит ли писать следующую часть.

Если вы хотите глубже разобраться в инструментах и технологиях, используемых в проекте, вот несколько открытых уроков от Otus, которые вас точно заинтересуют:

  • 3 апреля: Оптимизация CI/CD для мобильных тестов на Kotlin: как избавиться от нестабильных тестов и ускорить развертывание?
    Записаться

  • 16 апреля: Контрактное тестирование в Kotlin QA: как гарантировать, что фронтенд и бэкенд понимают друг друга?
    Записаться

  • 17 апреля: Применение возможностей Kotlin в UI тестировании
    Записаться

Больше открытых уроков по мобильной разработке и не только ищете в календаре мероприятий.

© Habrahabr.ru