Реализация Theming Engine для White-Label мобильного приложения

TRUETECH занимается разработкой, поддержкой и обслуживанием мобильных приложений iOS, Android, PWA. Имеем большой опыт и экспертизу для публикации мобильных приложений в популярные маркеты Google Play, App Store, Amazon, AppGallery и другие.
Разработка и поддержка любых видов мобильных приложений:
Информационные и развлекательные мобильные приложения
Новостные приложения, игры, справочники, онлайн-каталоги, погодные, фитнес и здоровье, туристические, образовательные, социальные сети и мессенджеры, квиз, блоги и подкасты, форумы, агрегаторы
Мобильные приложения электронной коммерции
Интернет-магазины, B2B-приложения, маркетплейсы, онлайн-обменники, кэшбэк-сервисы, биржи, дропшиппинг-платформы, программы лояльности, доставка еды и товаров, платежные системы
Мобильные приложения для управления бизнес-процессами
CRM-системы, ERP-системы, управление проектами, инструменты для команды продаж, учет финансов, управление производством, логистика и доставка, управление персоналом, системы мониторинга данных
Мобильные приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, платформы предоставления электронных услуг, платформы кешбека, видеохостинги, тематические порталы, платформы онлайн-бронирования и записи, платформы онлайн-торговли

Это лишь некоторые из типы мобильных приложений, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента.

Предлагаемые услуги
Показано 1 из 1 услугВсе 1735 услуг
Реализация Theming Engine для White-Label мобильного приложения
Сложная
~1-2 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_mobile-applications_feedme_467_0.webp
    Разработка мобильного приложения для компании FEEDME
    756
  • image_mobile-applications_xoomer_471_0.webp
    Разработка мобильного приложения для компании XOOMER
    624
  • image_mobile-applications_rhl_428_0.webp
    Разработка мобильного приложения для компании RHL
    1054
  • image_mobile-applications_zippy_411_0.webp
    Разработка мобильного приложения для компании ZIPPY
    947
  • image_mobile-applications_affhome_429_0.webp
    Разработка мобильного приложения для компании Affhome
    874
  • image_mobile-applications_flavors_409_0.webp
    Разработка мобильного приложения для компании FLAVORS
    445

Реализация Theming Engine для White-Label мобильного приложения

Статических ресурсов в xcconfig и flavors хватает, когда tenants известны на этапе компиляции и их немного. Когда tenants становится 20+, или тема должна меняться в runtime (например, по сезонным акциям или по выбору пользователя), нужен динамический Theming Engine — система, которая применяет набор токенов дизайна ко всему UI без перекомпиляции.

Концепция дизайн-токенов

Theming Engine работает с дизайн-токенами — именованными переменными для всех визуальных параметров: цвета, шрифты, размеры, радиусы углов, тени. Токены загружаются из конфига, применяются глобально.

Структура конфига (JSON от backend или bundled):

{
  "tenant": "brand_b",
  "version": "2",
  "colors": {
    "primary": "#1A73E8",
    "primary_variant": "#1557B0",
    "secondary": "#FB8C00",
    "background": "#FFFFFF",
    "surface": "#F5F5F5",
    "error": "#B00020",
    "on_primary": "#FFFFFF",
    "on_secondary": "#000000"
  },
  "typography": {
    "font_family": "Inter",
    "scale_factor": 1.0
  },
  "shape": {
    "card_corner_radius": 12,
    "button_corner_radius": 8,
    "input_corner_radius": 4
  },
  "assets": {
    "logo_url": "https://cdn.brand-b.com/logo.png",
    "splash_bg_color": "#1A73E8"
  }
}

Реализация на Android (Jetpack Compose)

Jetpack Compose делает динамическую тему значительно проще, чем XML: MaterialTheme принимает ColorScheme и Typography как параметры и применяет их ко всему дереву компонентов.

// Загрузка и парсинг темы
data class TenantTheme(
    val colors: TenantColors,
    val typography: TenantTypography,
    val shapes: TenantShapes
)

@Composable
fun TenantThemedApp(
    theme: TenantTheme,
    content: @Composable () -> Unit
) {
    val colorScheme = lightColorScheme(
        primary = Color(android.graphics.Color.parseColor(theme.colors.primary)),
        primaryContainer = Color(android.graphics.Color.parseColor(theme.colors.primaryVariant)),
        secondary = Color(android.graphics.Color.parseColor(theme.colors.secondary)),
        background = Color(android.graphics.Color.parseColor(theme.colors.background)),
        surface = Color(android.graphics.Color.parseColor(theme.colors.surface)),
        error = Color(android.graphics.Color.parseColor(theme.colors.error))
    )

    val shapes = Shapes(
        small = RoundedCornerShape(theme.shapes.inputCornerRadius.dp),
        medium = RoundedCornerShape(theme.shapes.cardCornerRadius.dp),
        large = RoundedCornerShape(theme.shapes.buttonCornerRadius.dp)
    )

    MaterialTheme(
        colorScheme = colorScheme,
        shapes = shapes,
        content = content
    )
}

Использование в Activity:

setContent {
    val theme by themeViewModel.tenantTheme.collectAsState()
    TenantThemedApp(theme = theme) {
        AppNavHost()
    }
}

При изменении tenantTheme в ViewModel весь UI перерисовывается автоматически — это главное преимущество декларативного подхода.

Загрузка шрифтов в runtime

Кастомные шрифты бренда нужно загрузить до первого рендера. Downloadable Fonts API (Google Fonts) или ручная загрузка через Coil:

class FontLoader(private val context: Context) {
    suspend fun loadFont(fontUrl: String): Typeface? = withContext(Dispatchers.IO) {
        val cacheFile = File(context.cacheDir, "fonts/${fontUrl.md5()}.ttf")
        if (!cacheFile.exists()) {
            // Скачиваем и кешируем
            downloadFont(fontUrl, cacheFile)
        }
        Typeface.createFromFile(cacheFile)
    }
}

Шрифт кешируется после первой загрузки — при следующем старте читается из кеша без сетевого запроса.

Реализация на iOS (SwiftUI)

// Environment-based theming
struct TenantTheme {
    let primary: Color
    let secondary: Color
    let background: Color
    let cardCornerRadius: CGFloat
    let buttonCornerRadius: CGFloat
    let fontFamily: String

    static let `default` = TenantTheme(
        primary: .blue,
        secondary: .orange,
        background: .white,
        cardCornerRadius: 12,
        buttonCornerRadius: 8,
        fontFamily: "SF Pro"
    )
}

// Environment key
struct TenantThemeKey: EnvironmentKey {
    static let defaultValue = TenantTheme.default
}

extension EnvironmentValues {
    var tenantTheme: TenantTheme {
        get { self[TenantThemeKey.self] }
        set { self[TenantThemeKey.self] = newValue }
    }
}

// Применение в корне
@main
struct MyApp: App {
    @StateObject private var themeStore = ThemeStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.tenantTheme, themeStore.currentTheme)
        }
    }
}

Использование в любом компоненте:

struct PrimaryButton: View {
    @Environment(\.tenantTheme) var theme
    let title: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title)
                .foregroundColor(.white)
                .padding(.horizontal, 24)
                .padding(.vertical, 12)
                .background(theme.primary)
                .cornerRadius(theme.buttonCornerRadius)
        }
    }
}

Ни одного хардкодированного цвета в компонентах — только через theme.

Загрузка темы в runtime

class ThemeStore: ObservableObject {
    @Published var currentTheme: TenantTheme = .default

    func loadTheme(tenantId: String) async {
        do {
            // Сначала пробуем кеш
            if let cached = ThemeCache.load(tenantId: tenantId) {
                await MainActor.run { currentTheme = cached }
            }
            // Затем свежую версию с сервера
            let dto = try await api.fetchTheme(tenantId: tenantId)
            let theme = TenantTheme(from: dto)
            ThemeCache.save(theme, tenantId: tenantId)
            await MainActor.run { currentTheme = theme }
        } catch {
            // Fallback на дефолтную тему, не крэшимся
        }
    }
}

Паттерн stale-while-revalidate: показываем кешированную тему сразу, обновляем в фоне.

React Native / Flutter

Flutter: ThemeData в MaterialApp параметризируется аналогично Compose. Для полного контроля — InheritedWidget или Riverpod Provider с объектом темы. Загрузка шрифтов в runtime через FontLoader API.

React Native: ThemeContext через React Context API, StyleSheet.create вызывается с токенами из контекста. Горячая перезагрузка темы без рестарта — через useContext(ThemeContext) в компонентах.

Версионирование тем

Важно: тема — это данные с версией. При обновлении контракта (добавили новый токен) старые кешированные темы должны валидироваться:

data class ThemeDto(
    val version: Int,
    val colors: Map<String, String>,
    // ...
)

fun ThemeDto.toTenantTheme(): TenantTheme? {
    if (version < MIN_SUPPORTED_VERSION) return null  // несовместимая версия
    return TenantTheme(
        primary = colors["primary"]?.let { Color.parseColor(it) }
            ?: return null,  // обязательный токен отсутствует
        // ...
    )
}

Если тема невалидна — fallback на бандлированную дефолтную, не показываем сломанный UI.

Процесс работы

Аудит UI: инвентаризация всех цветов, шрифтов, радиусов в приложении. Выявление хардкода.

Проектирование схемы токенов совместно с дизайнером: какие параметры меняются между брендами.

Реализация ThemeProvider, Environment-based применения, загрузки из API.

Рефакторинг компонентов: замена хардкода на токены. Покрытие визуальными тестами (Paparazzi для Android, SwiftUI Previews для iOS).

Тестирование смены темы в runtime: все компоненты перерисовываются корректно, шрифты загружаются без мерцания.

Ориентиры по срокам

Theming Engine для нового приложения с нуля (Compose или SwiftUI) — 2–3 недели. Рефакторинг существующего приложения с хардкодированными цветами под динамическую тему — зависит от объёма кодовой базы, обычно 3–6 недель. Стоимость рассчитывается индивидуально.