Это цикл статей, посвященных построению и архитектуре KMP SDK. Содержание для удобства навигации:

  1. Построение KMP SDK: наш опыт, плюсы и минусы, и как это изменило разработку

  2. Построение KMP SDK: базовая архитектура для общей библиотеки

  3. Построение KMP SDK: проектирование архитектуры для feature-модулей

  4. Построение KMP SDK: единая дизайн-система и управление ресурсами

  5. Построение KMP SDK: инсайты и подводные камни из нашего опыта

В предыдущих статьях (раз, два и три) мы рассматривали технические и архитектурные аспекты при создании Kotlin Multiplatform SDK, но немаловажной частью остается работа с UI и ресурсами в таком SDK, особенно если он поставляется сразу в несколько продуктов.

Да, в Compose Multiplatform есть базовая работа с ресурсами: можно работать со строками, картинками и файлами по аналогии с Android проектами, однако что делать, когда для каждого продукта под одним и тем же ключом локализации должна срываться своя строка? В этой статье мы поделимся своим подходом и инсайтами.

Кратко напомним про контекст и продукт: Instories — мобильный видеоредактор для маркетологов, SMM-специалистов и блогеров. Контекст проекта: желание получить ряд SDK (мы называем их Kit-ами, по сути это разные сборки SDK для разных продуктов, со своими ресурсами, фичами и дизайн системой) для наших уже существующих приложений, которые содержали бы в себе коробочные фичи (и бизнес-логику, и UI), готовые к подключению, а также были бы легко расширяемыми и переиспользуемыми для разных приложений компании.

Про Res класс и ресурсы

У JetBrains есть довольно подробная статья с описанием того, как работать с ресурсами в Compose Multiplatform, поэтому вдаваться в эти детали мы не будем. Однако перед нами стояла задача чуть больших масштабов: мы хотели хранить все строки и иконки в модуле core-design, и подключать его ко всем фича-модулям. По умолчанию, автогенерируемый файл Res не доступен вне модуля, но есть способ это изменить: нужно прописать следующие строки в build.gradle файле модуля, ресурсы которого должны быть публичными:

compose.resources {
	publicResClass = true/false //в зависимости от задачи
	generateResClass = true
}

Таким образом была решена задача доступности ресурсов извне. Но при таком подходе быстро стало понятно, что это сработает только при сборке SDK конкретно для какого-то одного продукта. И перед нами встала задача не просто использовать этот автогенерируемый файл Res в модулях, но и предварительно автоматически дополнять и изменять его после автогенерации самостоятельно, учитывая то, какой именно Kit мы собираем. Так родился наш кастомный класс RES.

Предварительно мы закрыли возможность обращения к ресурсам напрямую благодаря publicResClass = false. Таким образом, из фича модулей ушла возможность работать с автогенерируемым Res файлом ради избежания ошибок.

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

Для реализации этого подхода мы создали специальную Gradle task, которая запускается перед сборкой проекта. Эта task выполняет несколько важных функций:

  1. Читает автогенерированный файл с ресурсами

  2. Анализирует XML файл со строковыми ресурсами

  3. Объединяет информацию из обоих источников

  4. Генерирует новый класс RES с учетом специфики конкретного Kit'а

abstract class GenerateRESFileTask : DefaultTask() {
    
    @TaskAction
    fun generateFile() {
        // Определение путей к файлам
        val sourceFileDrawable = // путь к автогенерированному файлу ресурсов
        val sourceFileXml = // путь к файлу строковых ресурсов
        val targetFile = // путь к генерируемому RES файлу
        
        // Генерация кода
        ...
    }
}

По итогам работы таски создается кастомный RES класс. Он имеет схожую структуру с автогенерируемым Res файлом.

public object RES {
    // Утилитарные функции из Res класса
    fun getUri(path: String): String = BaseKit.Res.getUri(path)
    suspend fun readBytes(path: String): ByteArray = BaseKit.Res.readBytes(path)

    // Контейнеры для ресурсов
    public object string
    public object drawable

    // Автогенерированные в GenerateRESFileTask маппинги
    public val RES.string.welcome: StringResource
        get() = BaseKit.Res.getStringRes("welcome")
    
    public val RES.drawable.icon: DrawableResource
        get() = BaseKit.Res.drawable.icon
}

Внимательный читатель заметит проксирование методов с реализацией через BaseKit.Res. Это решение, которое позволяет нам работать с ресурсами из разных Kit-ов. Напомним, что в нашей архитектуре BaseKit представляет собой абстрактный класс, который определяет базовую функциональность для всех Kit-ов в системе. Одним из его ключевых компонентов является BaseKitRes — интерфейс для работы с ресурсами. Каждый конкретный Kit реализует этот интерфейс своим способом.

abstract class BaseKit {
    abstract fun getRes(): BaseKitRes
    // Другие абстрактные методы
}

interface BaseKitRes {

		//Этот метод используется для получения локализованных строк
    fun getStringRes(key: String): StringResource
    
    //Позволяет получить URI для доступа к ресурсу
    fun getUri(path: String): String
    
    //Асинхронное чтение бинарных данных ресурса
    suspend fun readBytes(path: String): ByteArray
}

Таким образом, внутри каждого Kit-а лежат свои ресурсы и строки, для которых благодаря Compose Multiplatform генерируется внутренний файл Res. Этот класс используется в наследниках BaseKitRes для каждого Kit-а, и благодаря этому пробросу мы можем работать с файлами конретного Kit-а, по сути подставляя их при компиляции в модуль core-design в файл RES.

В результате всех этих доработок использование ресурсов в коде стало максимально простым и интуитивным. Разработчикам не нужно задумываться о том, как именно работает система — они просто добавляют свои ресурсы и строки в нужный им Kit, и потом после выполнения кастомной gradle-таски получают доступ к нужным ресурсам:

// Пример использования строковых ресурсов
Text(RES.string.welcome)

// Пример использования изображений
Image(RES.drawable.icon)

Про строки и локализацию

Compose Multiplatform предоставляет встроенный механизм для работы с локализованными строками. По умолчанию система использует ресурсы, определенные в XML-файлах в директории commonMain/resources. Для разных языков создаются отдельные файлы в соответствующих директориях, как и в Android приложениях.

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

  1. Необходимость динамического обновления переводов без релиза приложения

  2. Централизованное управление переводами для всех продуктов

  3. Оптимизация процесса локализации для большого количества языков

Сервисов локализаций на рынке достаточно много, однако у нас уже была своя внутренняя система работы с локалями и переводами, поэтому мы просто воспользовались ей. Каждый Kit имеет свою ссылку для загрузки переводов под каждый продукт, поэтому мы реализовали специальную gradle-таску, которая отвечает за загрузку и обновление переводов при каждой сборке SDK, загружая только те строки, которые нужны компилируемому Kit-у.

Важно отметить, что приложения, в которые встраивается этот SDK, берут свои строки с локализациями из этого же источника, поэтому ключи и в SDK, и в приложениях согласованы. При использовании текстового ключа, мы не берем его напрямую через RES.string, а пользуемся статическим методом, основанным на методе Platform-контракта:

fun getString(res: StringResource): String? {
	  return BaseKit.PlatformContract.getString(res.key)
}

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

Про дизайн-систему, темы, цвета и шрифты

У каждого Kit-а своя дизайн система, однако мы еще на берегу договорились с дизайнерами о том, что для общих фичей между несколькими продуктами им разрешено менять только цветовые схемы, шрифты и какие-то общие компоненты типа кнопок и текстовых полей. Это решение стало фундаментальным для всей архитектуры нашего SDK и определило путь развития визуальной составляющей наших продуктов. Такой подход родился не случайно: по сути получился конструктор, где базовые детали остаются неизменными, но их внешний вид можно модифицировать. Именно так работает наша дизайн-система. Возьмем, к примеру, кнопку — её поведение, отклик на действия пользователя, базовая структура остаются одинаковыми во всех Kit-ах. Однако её внешний вид — цвета, шрифты, тени, закругления углов — может существенно различаться между продуктами. Это позволяет создавать узнаваемый UI для каждого приложения, сохраняя при этом консистентность пользовательского опыта.

Цветовые схемы стали одним из ключевых элементов кастомизации. Каждый Kit определяет свои собственные цветовые палитры как для светлой, так и для темной темы, через интерфейсы BaseKitThemeSettings и Colors:

abstract class BaseKit {
    abstract fun getThemeSettings(): BaseKitThemeSettings
}

interface BaseKitThemeSettings {
    val lightThemeColors: Colors
    val darkThemeColors: Colors
    ...
}

Colors - это не просто набор произвольных цветов, но тщательно продуманная система, где каждый цвет имеет свое предназначение и контекст использования. Концептуально, это очень похоже на темы из Material. Выглядит это так:

interface Colors {

    fun Background(): Color
    fun Accent(): Color
    fun TextPrimary(): Color
    ...
}

Типографика также играет важную роль в создании уникального облика каждого продукта. Текстовые стили заранее ограничены, как и цвета, и определены для каждого Kit-а: например, стиль Title может иметь разную реализацию, начертание, размеры. Все это позволяет создавать неповторимй облик одних и тех же фичей для каждого отдельного продукта.

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

Также богатая библиотека компонентов в нашем SDK позволяет нам очень быстро создавать разнообразные пользовательские интерфейсы. Совместно с дизайнерами, мы разработали набор базовых элементов, которые легко адаптируются под нужды каждого продукта, сохраняя при этом свою основную функциональность и логику работы. Это позволяет нам не изобретать велосипед каждый раз, когда нужно добавить новую фичу в очередной Kit.

Каждый такой компонент хранится в модуле core-design и доступен всем фича модулям. Обычно, все они принимают набор параметров, определяющих их внешний вид: цвета, формы, размеры шрифтов. При этом сама структура компонента, его поведение и базовая анимация остаются неизменными. Например, кнопка всегда остается кнопкой с определенными состояниями и поведением, но может выглядеть по-разному в разных Kit-ах за счет применения соответствующей темы. Такой подход значительно упрощает разработку новых фич. Когда мы создаем новую функциональность, которая потенциально может использоваться в нескольких продуктах, мы сразу проектируем её с использованием компонентов из core-design.

Уроки и выводы

За время разработки нашего SDK мы прошли большой путь проб и ошибок, который привел нас к текущей архитектуре дизайн-системы. Изначально мы пытались создать максимально гибкую систему, где каждый Kit мог бы полностью кастомизировать любой компонент. Однако этот подход быстро показал свои недостатки: код становился сложным для поддержки, появлялось много дублирования, а времени на разработку новых фич уходило все больше. Именно тогда мы приняли решение о стандартизации базовых компонентов и ограничении степени их кастомизации.

Важным уроком стало понимание того, что успешная дизайн-система - это не только технический, но и организационный вызов. Тесное сотрудничество с командой дизайнеров с самого начала проекта позволило нам избежать многих проблем. Когда дизайнеры понимают технические ограничения и возможности платформы, а разработчики — принципы дизайна и важность визуальной согласованности, работа становится более эффективной. Благодаря таким инструментам, как Figma, наши договоренности легко соблюдать и поддерживать.

Технические инсайты

Любимый раздел с подводными камнями и неочевидными трудностями.

Про argb и rgba

При разработке SDK мы столкнулись с интересной особенностью работы с цветами. Android и Skia используют формат ARGB (Alpha, Red, Green, Blue), где альфа-канал идет первым, в то время как iOS работает в формате RGBA (Red, Green, Blue, Alpha), где альфа-канал находится в конце. Казалось бы, небольшое различие, но в какой-то момент нам потребовалась более глубокая работа с изображениями, и вместо фотографий людей начали появляться синие «аватары» при переводе ImageBitmap (Skia) в UIImage (iOS). Для этой конвертации набросали утилитарный метод с помощью AI:

fun ImageBitmap.toUIImage(): UIImage? {
    val width = this.width
    val height = this.height
    val buffer = IntArray(width * height)

    this.readPixels(buffer)

    // Swap Red and Blue channels (ARGB -> RGBA)
    for (i in buffer.indices) {
        val color = buffer[i]
        val alpha = (color shr 24) and 0xFF
        val red = (color shr 16) and 0xFF
        val green = (color shr 8) and 0xFF
        val blue = (color shr 0) and 0xFF
        buffer[i] =
            (alpha shl 24) or (blue shl 16) or (green shl 8) or (red shl 0) // Convert to RGBA
    }

    val colorSpace = CGColorSpaceCreateDeviceRGB()
    val context = CGBitmapContextCreate(
        data = buffer.refTo(0),
        width = width.toULong(),
        height = height.toULong(),
        bitsPerComponent = 8u,
        bytesPerRow = (4 * width).toULong(),
        space = colorSpace,
        bitmapInfo = CGImageAlphaInfo.kCGImageAlphaPremultipliedLast.value
    )

    val cgImage = CGBitmapContextCreateImage(context)
    return cgImage?.let { UIImage(it) }
}

Про копирование ресурсов для iOS

В процессе разработки нашего SDK мы столкнулись с особенностью Kotlin Multiplatform: фреймворк ищет все ресурсы (картинки, строки, шрифты) в главном бандле приложения по относительному пути. Обычно это не создает проблем, но нам хотелось сделать подключение SDK максимально удобным для iOS-разработчиков через Swift Package Manager (SPM).

Сам SDK мы можем собрать в XCFramework и подключить через SPM – с этим проблем нет. Но вот с ресурсами все оказалось сложнее: при таком способе подключения они просто не попадают в нужное место, и приложение не может их найти. Мы долго думали, как это исправить, и в итоге придумали простое решение: создали в нашем пакете дополнительный таргет специально для ресурсов.

Чтобы все работало автоматически, мы написали небольшие скрипты сборки. Они запускаются во время компиляции проекта и делают простую вещь: находят папку с ресурсами SDK (используя переменные окружения с названием пакета и таргета) и копируют все нужные файлы в главный бандл приложения. Благодаря этому разработчикам не нужно делать дополнительную настройку — они просто подключают SDK через SPM, и все ресурсы автоматически оказываются там, где их ожидает увидеть Kotlin Multiplatform.

Следующая статья: Построение KMP SDK: инсайты и подводные камни из нашего опыта

Предыдущая статья: Построение KMP SDK: проектирование архитектуры для feature-модулей

Комментарии (0)