Открыть список
Как стать автором
Обновить

Системный гайд по созданию White Label android-приложений

Разработка мобильных приложенийРазработка под AndroidKotlin
Из песочницы

Как написать код один раз, а продать 20 мобильных приложений? Мы нашли ответ путём проб и факапов и разложили опыт по пунктам: из статьи вы узнаете, как безболезненно реализовать White Label android-проект.

Greetings and salutations! Меня зовут Кирилл, по работе я однажды получил крутую задачу по разработке White Label android-приложения. Изучил достижения коллег в этой области и нашёл только:

  • входные гайды (раз, два, три, etc) о механизмах, но без промышленного дизайна;

  • статьи, в которых освещены узкие аспекты задачи (раз, два, etc).

На мой взгляд, теме не хватает цельного гайда, который проведёт от потребностей к требованиям, от требований к архитектуре и вооружит best practices. Поэтому я решил сделать его сам.

1 Ставим задачу

Мы в «Лайв Тайпинге» уже 10 лет делаем приложения для сегмента eCommerce и retail. За это время мы поняли, что и малый, и средний бизнес нуждается в дешёвых приложениях для систем лояльности: крупные ретейлеры уже обзавелись такими продуктами и приучили пользователей к мобильным приложениям.

Работает система просто (покупатели показывают приложение на кассе вместо пластиковой карты и получают скидки и бонусы), а для её реализации нужны типовые фичи: регистрация, виртуальная дисконтная карта и прочие.

Бюджет ограничен... фичи типовые... да здравствует конструктор приложений! Или White Label продукт? Пока отложим термины и опишем задачу: генерировать приложения из единой кодовой базы, каждое – с дизайном под бренд клиента и только нужными ему фичами.

Задача: создавать приложения для разных клиентов из единой кодовой базы

1.1 Визуализируем решение

Посмотрим, как это должно работать на примере экрана дисконтной карты. В магазине карту можно использовать двумя способами: чтобы узнать баланс и чтобы получить тот или иной бонус.

У каждого бренда своя программа лояльности, например в SEPHORA накапливаются бонусные баллы и процент скидки, а в «Пятёрочке» только баллы. В приложениях это выглядит так:

Мы хотим поддерживать как можно больше программ лояльности, при этом интерфейс оставить общим. Предусмотрим вариативность, получим возможный вид экрана карты для разных клиентов:

Как реализовать такой проект без боли? Прочитайте статью и найдёте ответ.

Мы знаем, о чём говорим, ведь уже создали «Лояку» — White Label продукт, который помогает быстро собирать крутые мобильные приложения с программой лояльности. Рост выручки, клиенты в курсе всех акций — вот это вот всё :)

1.2 Детализируем требования

Разложим видение по полочкам: как в ТЗ, но проще.

Функциональные требования

  1. Реализовать общие модули фичей:

    • новости – клиент узнаёт об акциях и жизни сети магазинов;

    • лояльность – получает дисконтную карту, узнаёт баланс, пробивает на кассе;

    • ...

  2. Задавать отдельно для каждого приложения:

    • наборы фичей, чтобы выбирать сами модули и настраивать их параметры;

    • бренд, чтобы настраивать цвета и менять ресурсы: шрифты, картинки, зашитый контент.

Нефункциональные

  • у приложений должен быть общий код;

  • настройка нового приложения – меньше четырёх часов разработчика;

  • архитектура должна упрощать расширение модулей и поддержку от 10 до 100 приложений.

1.3 Что пилим то? Конструктор? White Label?

Требования ясны – разберёмся с терминами. На старте проекта я думал, что мы делаем «конструктор приложений». Искал статьи с опытом создания, а находил рекламу готовых продуктов. Потратил пару часов на гугл и с радостью сэкономлю время вам:

  1. Что даёт конструктор/платформа:

    • универсальный сервис сборки приложений из готовых компонентов;

    • например, AppGyver – drag’n’drop вёрстка, программирование на низкоуровневых фичах (открыть экран, сделать фото);

    • творим что угодно – от приложений по покупке золота до приёмки грузов.

  2. Что даёт White Label:

    • конструктор для конкретного типа приложений, например для такси;

    • ребрендинг под клиента и настройка высокоуровневых фич (новости, профиль)

Наш фокус на системах лояльности. Значит, делаем White Label. Гуглим «white label android development» и находим то, что нужно.

2 Проектируем и воплощаем

Строим системную схему White Label приложения

Для успешной реализации проекта нам нужно создать «понятную» и «расширяемую» архитектуру (раскрывать термины не буду, здесь хватит интуитивного понимания). Для этого заглянем вглубь системы, выделим подзадачи по слоям Clean Architecture…

… и получим четыре жирные проблемы:

  1. Как шарить кодовую базу между приложениями?

  2. Как сделать ребрендинг?

  3. Как задавать конфиги?

  4. Как отключать ненужные модули и настраивать необходимые?

В очередь, проблемы, в очередь!

2.1 Шарим код

Задача – одна кодовая база, до 100 приложений. Решение – Gradle Product Flavors.

Если вы ещё не знакомы с Gradle Product Flavors, советую почитать документацию или общие статьи. А можно и сразу в контексте White Label: кратко или в формате инструкции

В двух словах, создаём «варианты» базового приложения. Каждый получает изолированные папки с кодом и ресурсами, сохраняя доступ к общим из main.

Главное преимущество. Относительная простота переиспользования кода и ресурсов, удобство сборки.

Главный недостаток. Если вариантов больше 100, то в проекте и конфигах будет тяжело ориентироваться. Но у нас меньше, поэтому ок.

Альтернативы, на мой взгляд, рассматривать нет смысла: решение надёжное, из коробки.

Пример flavors. Допустим, на старте делаем два приложения:

  1. «Лояка» — абстрактная компания;

  2. «Ювелирия» — сеть ювелирных магазинов.

Назовём flavors соответственно — loyaka и jewelry. Сразу реализуем best practice — конфиг каждого flavor вынесем в отдельный файлик. Зачем? Станет ясно чуть позже.

Пока создадим:

  1. папку project_flavors;

  2. в ней — gradle-скрипты flavor_loyaka.gradle, flavor_jewelry.gradle и flavors_common.gradle;

  3. задействуем скрипты в build.gradle уровня app.

Здесь и далее привожу сокращённые примеры из тестового проекта к статье.

flavor_loyaka.gradle

apply from: "$rootDir/project_flavors/flavors_common.gradle"  
android {
    productFlavors {

        loyaka {
            dimension APP_DIMENSION

            resValue "string", APP_NAME_VAR, 'Лояка'
            applicationId BASE_PACKAGE + 'loyaka'
        }
    }
}

flavor_jewelry.gradle

apply from: "$rootDir/project_flavors/flavors_common.gradle"  
android {
    productFlavors {

        jewerly {
            dimension APP_DIMENSION

            resValue "string", APP_NAME_VAR, 'Ювелирия'
            applicationId BASE_PACKAGE + 'jewelry'
        }
    }
}

flavors_common.gradle

android {
    ext.DIMENSION_APP = "app"
    ext.APP_NAME_VAR = "app_name"
    ext.BASE_PACKAGE = "com.livetyping."
}

Наконец, задействуем flavors — в build.gradle уровня app:

...
apply from: "$rootDir/project_flavors/flavor_loyaka.gradle"
apply from: "$rootDir/project_flavors/flavor_jewelry.gradle"
apply from: "$rootDir/project_flavors/flavors_common.gradle"

android {
    ...
    flavorDimensions APP_DIMENSION
}
...

2.2 Перекрашиваем

2.2.1 Концепт

У каждого приложения свой бренд, который складывается из:

  • цветовой схемы;

  • шрифтов, картинок, строк;

  • зашитого контента (соглашений, ссылок в соц. сети).

Благодаря flavors тоже решим задачу просто. Загрузим в голову 3 факта:

  1. общие код и ресурсы проекта лежат в папке main;

  2. для gradle main это как дефолтный flavor;

  3. у каждого flavor свои исходники. Например, общие ресурсы лежат в main/res, а специфичные для флэйвора loyaka в loyaka/res;

Что произойдёт, если в main/res и loyaka/res будут картинки с одинаковым именем animal.webp? Возникнет конфликт, и чтобы решить его, Gradle переопределит базовые ресурсы кастомными. Если непонятно, поможет диаграмма:

Слева — ресурсы по flavor; справа — итоговый APK.
Слева — ресурсы по flavor; справа — итоговый APK.

Задача решена! Уберём дефолтные ресурсы в main, а в конкретных flavor будем переопределять по необходимости.

2.2.2 Best practices

Крайне важно заранее договориться с дизайнерами:

  • ресурсы в приложениях называем одинаково — вставляем в проект прямиком из дизайна;

  • тему задаём чётким набором цветов — для перекрашивания копируем colors.xml в новый flavor и просто меняем значения.

И, конечно, соблюдаем договорённости, ведь впереди ждут испытания. Например, мы сразу решили, что задаём строгий набор цветов. Однако в очередном приложении цвета не сошлись — часть элементов цвета primary в новом дизайне стали accent. Сразу обсудили и изменили дизайн, а ведь могли бы и вставить костыль.

Проблема в том, что костыли масштабируются на 100 приложений! Представьте, что дизайн оставили, а для решения проблемы ввели новый цвет в общей теме. Приходит новое приложение, а там опять несоответствие: вводим очередной цвет, итог — тема на сотни значений и проект, который тяжело поддерживать.

Создать и поддерживать чёткую схему для всех приложений на порядок дешевле, чем расширять код, состоящий из «уникальных» косяков.

2.2.3 Пример схемы цветов

Задаём цвета бренда в файле project_styleguide.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="active">#68b881</color>
    <color name="background">#36363f</color>
    <color name="disabled">#daede0</color>
    <color name="field_dark">#f5f5f5</color>
    ...
</resources
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="active">#a160d5</color>
    <color name="background">#f6ebff</color>
    <color name="disabled">#e2c8f6</color>
    <color name="field_dark">#f5f5f5</color>
    ...
</resources>

2.3 Задаём конфиг

2.3.1 Концепт

Фичи настраиваем на двух уровнях:

  1. отключаем ненужные модули;

  2. меняем параметры внутри самих модулей.

Начнём с того, что зафиксируем правила в типовой форме и пошарим на команду. Настройка нового приложения упрощена: аналитик собирает с клиента требования и присылает форму. Разработчику остаётся механически перевести её в конфиг.

Упрощённый пример дока в формат «модуль-фича-параметры»:

  1. Подключаемые модули:

    • лояльность;

    • новости;

  2. Аутентификация:

    • логин пользователя: телефон или email;

    • маска логина.

  3. Карта лояльности:

    • тип штрих-кода: EAN-8, EAN-13, CODE-128.

2.3.2 Пути решения

Как сделать качественный конфиг? Само качество определим так:

  1. удобство работы – «простота» чтения, «простота» заполнения (в идеале, хотим DSL);

  2. Скорость обработки – важно, чтобы чтение конфига не тормозило приложение.

Выделим основные пути:

  1. Gradle buildConfigField

    • задаём переменные в gradle скрипте;

    • во время компиляции генерится java класс BuildConfig, переменные доступны как его поля.

  2. JSON

    • json объект в файле;

    • зашит локально, либо получаем с сервера.

Кратко оценим пути по критериям.

2.3.3 Путь №1. Gradle buildConfigField

Плюсы:

  • удобство создания — делаем DSL на минималках: выносим типы и возможные значения параметров в переменные; выявляем синтаксические ошибки на компиляции;

  • простота — большинству уже знаком;

  • скорость — обращаемся к классу BuildConfig в памяти.

Главный минус — отвратительная читаемость: переменная состоит из четырёх блоков, подсветки синтаксиса нет.

Пример переменной на условном DSL:

buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_SHOPS

2.3.4 Путь №2. JSON

Плюсы:

  • удобство чтения — особенно в формате HOCON;

  • удобство создания — делаем DSL через JSON Schema, проверяем на ошибки по мере написания;

  • переиспользование — шарим между iOS и Android.

Минусы:

  • скорость — придётся перед запуском считать из файла или получать с сервера;

  • время на освоение — по сравнению с первым вариантом JSON Schema наверняка менее популярна.

2.3.5 Так что же лучше?

Когда делали проект, даже не изучали альтернативы. Сразу сделали через Gradle. На мой взгляд, JSON + Schema его побеждает. Удобство чтения — приоритет, при этом удобство создания остаётся на том же уровне, если не лучше. Дополнительная секунда для загрузки файла на общем фоне незначительна.

Сделали конфиг через Gradle, не изучая альтернатив. Но оказалось, что JSON Schema удобнее для чтения – и это её главное преимущество.

2.3.6 Best practices для buildConfigField

Если выбрали buildConfigField, то в «сыром» виде с ним будут проблемы:

  1. чтобы использоватьEnum, придётся указать полный путь к пакету как в типе, так и в значениях;

  2. при изменение имени или типа переменной придётся делать Find & Replace по всем конфигам.

Решение: DSL на минималках. Заводим переменные для названий параметров, а также кастомных типов и вариантов значений. Создаём отдельный gradle-скрипт на каждый модуль. Параметры описываем в формате «экран-параметр-переменные». Скрипты кладём в папку business_rules.

Пример: модуль лояльности loyalty_business_rules.gradle:

/*_______________ENTER USER ID________________*/

/*________User ID________*/

/*__Variable__*/
ext.USER_ID_VAR = "USER_ID"
ext.USER_ID_TYPE = "com.example.whitelabelexample.domain.models.UserIdType"

/*__Values__*/
ext.UI_PHONE = USER_ID_TYPE + ".PHONE"
ext.UI_EMAIL = USER_ID_TYPE + ".EMAIL"

/*_______________NO CARD________________*/

/*________Obtain card methods________*/

/*__Variable__*/
ext.OBTAIN_METHODS_VAR = "OBTAIN_CARD_METHODS"
ext.OBTAIN_METHODS_ENUM = "com.example.whitelabelexample.domain.models.ObtainCardMethod"
ext.OBTAIN_METHODS_TYPE = "java.util.List<" + OBTAIN_METHODS_ENUM + ">"

/*__Optional values__*/
ext.OM_GENERATE = OBTAIN_METHODS_ENUM + ".GENERATE_VIRTUAL"
ext.OM_BIND = OBTAIN_METHODS_ENUM + " .BIND_PHYSICAL"

...

UI_PHONE — что за UI_? Это сокращение переменной UserId: добавляем префиксы, чтобы избежать коллизий.

Дальше настраиваем приложения в скриптах flavor, которые на первом шаге заботливо вытащили по файлам.

Пример: flavor_loyaka.gradle:

...

loyaka {
    ...

    /* MAIN SCREEN */
    buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_CARD

    /* MODULES */
    buildConfigField APP_MODULES_TYPE, APP_MODULES_VAR, list(AM_LOYALTY, AM_SHOWCASE)

    /* REGISTRATION */
    buildConfigField USER_ID_TYPE, USER_ID_VAR, UI_EMAIL

    ...
}

flavor_jewelry.gradle:

...

jewelry {
    ...

    /* MAIN SCREEN */
    buildConfigField MAIN_SCREEN_TYPE, MAIN_SCREEN_VAR, MS_SHOPS

    /* MODULES */
    buildConfigField APP_MODULES_TYPE, APP_MODULES_VAR, list(AM_LOYALTY, AM_SHOPS)

    /* REGISTRATION */
    buildConfigField USER_ID_TYPE, USER_ID_VAR, UI_PHONE

    ...
}

2.3.7 Получаем доступ к конфигу

Спроектируем решение в контексте Clean Architecture.

Классы конфигов приравниваю к источникам в слое data, ибо они только предоставляют данные. Тогда ui получает параметры посредством domain.

Как сгруппировать параметры по классам? Мы на проекте за группу взяли экран, а зря. С одной стороны, компактные классы, а с другой — возникли коллизии, например на экранах ввода номера телефона и подтверждения кода нужна маска номера. Пришлось реализовать считывание из конфига 2 раза.

С BuildConfig это легко, но с JSON будет грязно. Считаю, что оптимально группировать по процессу (флоу). Под процессом здесь понимаю целевой use case и вторичные по отношению к нему. Обычно это группа экранов, например в модуле лояльности два целевых процесса:

  1. аутентификация — чтобы авторизоваться, придётся ввести логин, а затем код подтверждения;

  2. просмотр дисконтной карты — чтобы получить доступ, нужно сначала её привязать, либо сгенерировать.

Пример реализации конфига для второго процесса: фрагмент BuildCardConfig.kt:

class BuildCardConfig : CardConfig {

    override fun numberMask(): String = BuildConfig.CARD_NUMBER_MASK

    override fun barcodeType(): BarcodeType = BuildConfig.BARCODE_TYPE

    override fun obtainmentMethods(): List<ObtainCardMethod> = BuildConfig.OBTAIN_CARD_METHODS

    ...
}

В итоге получим архитектуру работы с конфигом (диаграмма классов UML; в ui MVVM):

UseCase работает только с одним конфигом, ибо в этом классе есть все параметры целевого процесса. Если требуется больше одного — звоночек, похоже UseCase делает много вещей.

2.3.8 Валидируем конфиг

«Зачем нужна прослойка в domain? Она же будет пустая!» Необязательно. В идеале, хотим защиту от дурака — проверку параметров фичей на непротиворечивость. Допустим, дано 2 параметра:

  1. включённые модули;

  2. главный экран.

Если модуль «новости и акции» выключён, то логично, что главным экраном «новости» быть не может. Но на уровне Gradle или JSON Schema подобное ограничение сделать нетривиально — таким правилам и место в domain.

Например, реализуем описанное условие в GetMainTabUseCase.kt:

class GetMainTabUseCase(
    private val mainConfig: MainConfig
) {

    operator fun invoke(): NavigationTab {
        val mainTab = mainConfig.mainTab()
        val mainModule = tabsByModules.entries.find { it.value == mainTab }!!.key
        val isModuleEnabled = BuildConfig.APP_MODULES.contains(mainModule)
        if (isModuleEnabled.not()) {
            throw IllegalStateException("Can't use a tab ($mainTab) as main, it's module is disabled  — fix config!")
        }
        return mainTab
    }
}

Возникает проблема: если создавать UseCase на каждый параметр, то будет много пустых классов. Ведь правила есть не везде.

Альтернатива — создавать UseCase только по надобности, но тогда возникает неоднородность: в ui используются одновременно и Config и UseCase. Рискуем использовать параметры, которые требуют валидации, в её обход, и следом за этим растёт вероятность багов.

Впрочем, если правил у вас субъективно мало и коллизии очевидные, такая валидация не нужна. Мы обошлись без неё и багов из-за этого пока не ловили.

2.4 Настраиваем фичи

2.4.1 Выбираем модули

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

Модуль здесь – крупный связный и независимый кусок функциональности.

В идеале хочется выпиливать код и ресурсы выключенных модулей из APK. Но на мой взгляд, это оверкилл, поэтому ограничимся функциональным отключением.

Рассмотрим главные точки связи модуля с приложением:

  1. Переходы изui — боттом навигация, рандомная кнопка, etc;

  2. Реакция на события — кастомные (выбран город), платформы (найдена сеть), etc.

По опыту, обычно хватит убрать пункт модуля из меню, например, для лояльности — это таб из боттом навигации. Я реализовал пример в MainViewModel и MainActivity тестового проекта.

Но бывает, что есть межмодульные переходы, например когда по кнопке из акции попадаем на экран карты – такое придётся обработать отдельно для каждого кейса.

На события в «Лояке» реагирует только модуль пушей. Когда юзер выбирает свой город – подписываемся на соответствующий новостной канал. Опять же обрабатываем каждый кейс.

Всплывает неочевидный минус buildConfigField – придётся указывать параметры даже для выключенных модулей, хотя бы как null, иначе проект не соберётся.

2.4.2 Настраиваем экраны и бизнес-правила

Настройка конкретных фичей основана на уже спроектированной архитектуре. Например, при отсутствии сети мы хотим показывать карту лояльности из кэша. Однако хотим это настраивать, чтобы отключать кэш для кейсов, когда критична свежесть данных.

На уровне UseCase берём параметр из нужного класса Config.

GetCardUseCase.kt:

class GetCardUseCase(
    private val netRep: CardNetRepository,
    private val storageRep: CardStorageRepository,
    private val config: CardConfig
) {

    operator fun invoke(): Card? {
        return if (config.isCacheCard()) {
            try {
                val card = netRep.getCard()
                storageRep.save(card)
                card
            } catch (exception: Exception) {
                return storageRep.get()
            }
        } else {
            netRep.getCard()
        }
    }
}

В ui же обращаемся к UseCase на уровне ViewModel или Presenter.

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

Реализация: NoCardViewModel.kt:


class NoCardViewModel(
    private val getObtainMethodsUseCase: GetObtainMethodsUseCase,
    ...
){
    private val cardObtainMethods by lazy { getObtainMethodsUseCase() }

    val isShowGetVirtualButton by lazy {
        cardObtainMethods.contains(ObtainCardMethod.GENERATE_VIRTUAL)
    }
    val isShowBindPlasticButton by lazy {
        cardObtainMethods.contains(ObtainCardMethod.BIND_PHYSICAL)
    }

    ...
}

fragment_nocard.xml:

...

<com.google.android.material.button.MaterialButton
    android:id="@+id/no_card_bind_plastic_button"
    ...
    app:isVisible="@{viewmodel.isShowBindPlasticButton}" />

<com.google.android.material.button.MaterialButton
    android:id="@+id/no_card_get_virtual_button"
    ...
    app:isVisible="@{viewmodel.isShowGetVirtualButton}" />

...

2.4.3 Ещё один трюк

Иногда вариативную вёрстку целесообразнее сделать без конфига.

Вспомним начало поста, хотим показать дисконтную карту – номер, статус, размер скидки и баланс бонусов. Однако процессинг лояльности у клиентов разный, поэтому не все поля гарантированы:

Аналитик собрал наиболее вероятные поля, дизайнер продумал кейсы и переиспользовал вьюхи. Получилось так, что обошлись даже без конфига — смотрим на то, какие данные приходят с бэкенда и показываем необходимое состояние. В тестовом проекте реализован пример — CardInfoFragment.

3 Подведём итог

Мы успешно спроектировали архитектуру White Label android-проекта, которая соответствует поставленным требованиям, а именно позволяет:

развивать общую кодовую базу – расширять модули фичей и собирать из одного кода разные приложения, от 10 до 100;

создавать приложения под клиента – настраивать набор модулей и параметры фичей, менять бренд, делать это относительно быстро (до четырёх часов, по моей оценке).

Горькими уроками поделились, best practices передали. Надеюсь, наш опыт создал цельное представление о создании White Label android-приложений и комфортную отправную точку для вашего проекта.

Напомню, что полный код примера доступен на гитхабе — можно изучить реализацию подробнее, поиграть с настройками. Пример полноценного приложения есть в сторе.

Если возникли вопросы — пишите в комменты, буду рад ответить! А если заинтересовала «Лояка» как продукт, то мы с удовольствием расскажем больше :)

4 Куда развить решение?

  • Мы продаём целую систему, а что если продавать модули в другие приложения? Тот же White Label, но на системный уровень ниже. Мы такую задачу решали, если интересно – напишите в комментах, расскажем.

  • Когда количество приложений растёт, хочется CI и CD. В этом репозитории есть подробный гайд по настройке Azure Devops.

  • Если не нужна детальная настройка фичей, а писать flavors руками надоело – сделайте автогенерацию flavors по json конфигу.

  • Бизнес бьёт ключом, клиентов больше сотни? Пора автоматизировать создание приложений.

Если знаете кейсы, на которые нет ссылок в нашей статье, то обязательно скиньте их в комменты – вместе мы точно соберём крутую библиографию!

P.S. Shout-out Дмитрию Алексеенкову за огромный вклад в разработку android-приложения, Валерии Васильевой за чуткую редактуру, Валерии Панаковой за яркие иллюстрации, а студии «Лайв Тайпинг» и команде «Лояки» вообще за то, что сделали эту статью возможной :)

Теги:white labelandroidархитектура приложенийкейсархитектураbest practicesконструктор приложенийплатформа для разработки
Хабы: Разработка мобильных приложений Разработка под Android Kotlin
Всего голосов 11: ↑9 и ↓2 +7
Просмотры3.8K

Похожие публикации

Разработчик мобильных приложений (Flutter)
от 200 000 до 300 000 ₽Voxter, S.A.Можно удаленно
Android developer (Kotlin)
от 160 000 до 220 000 ₽БАНК УРАЛСИБМосква
Android Developer (Kotlin)
от 220 000 до 250 000 ₽Почта БанкМожно удаленно
Программист Android
от 94 000 ₽ТатнефтьКазань
Android developer
от 80 000 до 120 000 ₽ГК ServiceSoftМожно удаленно

Лучшие публикации за сутки