Реализация 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 недель. Стоимость рассчитывается индивидуально.







