Реалізація 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",
"secondary": "#FB8C00",
"background": "#FFFFFF",
"error": "#B00020"
},
"typography": {
"font_family": "Inter",
"scale_factor": 1.0
},
"shape": {
"card_corner_radius": 12,
"button_corner_radius": 8
}
}
Реалізація на 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)),
secondary = Color(android.graphics.Color.parseColor(theme.colors.secondary)),
background = Color(android.graphics.Color.parseColor(theme.colors.background))
)
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
Використання у Activity:
setContent {
val theme by themeViewModel.tenantTheme.collectAsState()
TenantThemedApp(theme = theme) {
AppNavHost()
}
}
При зміні tenantTheme весь UI перерисовується автоматично — це головна перевага декларативного підходу.
Завантаження шрифтів у runtime
Кастомні шрифти бренда мають завантажитися до першого рендера. Downloadable Fonts API або ручне завантаження через 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)
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"
)
}
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)
.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 параметризується аналогічно. Для повного контролю — 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.
Рефакторинг компонентів: заміна хардкода на токени. Покриття візуальними тестами.
Тестування смени теми в runtime: всі компоненти перерисовуються коректно, шрифти завантажуються без мерехтіння.
Ориєнтири по срокам
Theming Engine для нової програми з нуля (Compose або SwiftUI) — 2–3 тижні. Рефакторинг існуючої програми з хардкодованими кольорами під динамічну тему — 3–6 тижнів залежно від обсягу кодової бази. Вартість розраховується індивідуально.







