
Проблема большого объёма JSON
Представим, что мы отображаем на экране несколько однотипных элементов, например, список акций.

Представим SDUI-разметка для данного экрана. Акции отображаются с помощью компонента DataView
, обёрнутого в StackView
, где:
DataView
— это элемент дизайн-системы, способный отображать заголовок, подзаголовок, изображение и другие параметры. В текущем контексте используются только заголовок и иконка.StackView
выступает в роли контейнера, который способен отображать несколько элементов как вертикально, так и горизонтально, напоминаяLinearLayout
в Android.
Ниже представлен JSON-код, отображающий два элемента DataView
из пяти. Остальные элементы аналогичны, за исключением изменений в полях "value"
и "url"
.
JSON для акций
{
"rootElement": {
"type": "StackView",
"content": {
"axis": "vertical",
"alignment": "fill",
"children": [
{
"type": "DataView",
"content": {
"dataContent": {
"title": {
"textContentKind": "plain",
"value": "Московская Биржа",
"color": "textColorPrimary",
"typography": "ParagraphPrimaryMedium"
}
},
"iconView": {
"backgroundIcon": {
"url": "http://stock_echange.png"
},
"size": "small",
"shape": "superellipse"
}
}
},
{
"type": "DataView",
"content": {
"dataContent": {
"title": {
"textContentKind": "plain",
"value": "Газпром",
"color": "textColorPrimary",
"typography": "ParagraphPrimaryMedium"
}
},
"iconView": {
"backgroundIcon": {
"url": "http://gazprom.png"
},
"size": "small",
"shape": "superellipse"
}
}
},
// Остальные 3 DataView
]
}
}
}
От акции к акции меняются только заголовок и картинка. Цвет, шрифт, размеры и другие параметры остаются одинаковыми для всех акций, однако их необходимо указывать для каждой, чтобы обеспечить корректное отображение.
Это подводит к необходимости функции, которая могла бы вынести и переиспользовать общую логику. В нашем SDUI уже существует концепция «функций», и их разнообразные реализации, но ни одна из них не умела сокращать JSON, поэтому нужна была новая.
Решение: шаблонизация
Что должна делать функция?
Функция должна брать общую часть и заменять в ней только те части, которые отличаются от элемента к элементу, оставляя остальное неизменным. Подобный подход давно применяется в виде шаблонизации, что и было необходимо реализовать. Идея в том, чтобы взять шаблон и подставлять в него различные значения. В нашем SDUI мы уже умели подставлять значения из других мест, и для этого у нас есть собственный синтаксис. Далее опишу сам синтаксис.
В структуре JSON присутствуют ключи и значения. Чтобы определить, что значение является динамическим, его нужно пометить. В нашем случае такая метка — это "$", которая ставится не на само значение, а на ключ, в то время как в значении описывается путь к данным. Пример:
{
"$text": "${data.stock.text}"
}
Когда наш самописный десериализатор видит, что ключ является динамическим, он использует значение ключа, где указан путь до данных, и пытается извлечь это значение из раздела data. Сама секция data будет выглядеть следующим образом:
{
"data": {
"stock": {
"text": "Акция"
}
}
}
Реализация шаблонизации
Сначала обозначим наши бизнес-данные в виде списка объектов и добавим их в секцию data:
{
"data": {
"stocks": [
{
"title": "Московская Биржа",
"icon": "http://stoc_exchange.png"
},
{
"title": "Газпром",
"icon": "http://gazprom.png"
}
// Другие объекты...
]
}
}
Теперь установим сам шаблон:
{
"template": {
"stock": {
"type": "DataView",
"content": {
"dataContent": {
"title": {
"textContentKind": "plain",
"$value": "${source.title}",
"color": "textColorPrimary",
"typography": "ParagraphPrimaryMedium"
}
},
"iconView": {
"backgroundIcon": {
"$url": "${source.icon}"
},
"size": "small",
"shape": "superellipse"
}
}
}
}
}
Функцию шаблонизации назовем applyTemplate
. Существующая функция map
идеально подходит для использования вместе с applyTemplate
. Суть applyTemplate
заключается в подстановке данных, трансформации шаблона, а map
помогает пройтись по списку элементов и преобразовать один список в другой. Все функции находятся в отдельной секции computed
. Вот как выглядят эти функции вместе:
{
"computed": {
"stockTemplate": {
"type": "map",
"$source": "${data.stocks}",
"$function": {
"type": "applyTemplate",
"$source": "${it}",
"$template": "${template.stock}"
}
}
}
}
Основная идея этой конструкции заключается в том, чтобы пройтись по всем элементам из data.stocks
и применить к ним шаблон template.stock
. Так как applyTemplate
ничего не знает о map
, ей явно необходимо указать о существовании некоего ключа it
, по которому лежат данные функции map
.
Теперь, когда у нас есть понимание, как это должно выражаться в JSON, можем перейти к реализации в коде. В реализации будут три сущности:
Функция
ApplyTemplate
, которая принимает и валидирует данные.Сущность
JsonExpressionProcessor
для обхода шаблона и замены динамических ключей их значениями.Сущность
JsonExtractor
, которая находит значение в JSON по указанному пути и это значение возвращает.
Техническая реализация
В упрощенном виде код сущности JsonExtractor
выглядит так:
JsonExtractor
internal object JsonExtractor {
fun extract(jsonElement: JsonElement, jsonPath: String): JsonElement {
val path = parseJsonPath(jsonPath)
return jsonElement.extract(path)
}
private fun JsonElement.extract(remainingPath: ArrayDeque<String>): JsonElement {
if (remainingPath.isEmpty()) return this
return when (this) {
is JsonObject -> extract(remainingPath)
is JsonArray -> extract(remainingPath)
else -> throw IllegalArgumentException()
}
}
private fun JsonObject.extract(remainingPath: ArrayDeque<String>): JsonElement {
val value = this.get(remainingPath.removeFirst())
return value.extract(remainingPath, fullPath)
}
private fun JsonArray.extract(remainingPath: ArrayDeque<String>): JsonElement {
val index = remainingPath.removeFirst()
return this.get(index).extract(remainingPath, fullPath)
}
}
В текущей реализации JsonExpressionProcessor основывается на библиотеке GSON. Однако в будущем планируется его переработать, чтобы заменить зависимость от библиотеки на собственные абстракции.
Основные сущности библиотеки GSON
JsonElement
— абстрактный базовый класс с наследникамиJsonObject
JsonArray
,JsonPrimitive
иJsonNull
, которые позволяют отображать различные типы JSON-данных.JsonObject
моделирует JSON-объект и предоставляет методы для доступа к его свойствам.JsonArray
отображает массив JSON, предоставляя доступ к его элементам по индексу.JsonPrimitive
используется для базовых типов данных, таких как строки, числа и логические значения.JsonNull
обозначает значениеnull
в JSON.
Возвращаясь к JsonExtractor
, рассмотрим основные методы детальнее и начнем с первого — extract
:
private fun JsonElement.extract(remainingPath: ArrayDeque<String>): JsonElement {
if (remainingPath.isEmpty()) return this
return when (this) {
is JsonObject -> extract(remainingPath)
is JsonArray -> extract(remainingPath)
else -> throw IllegalArgumentException("Invalid path")
}
}
Эта функция управляет рекурсивным поиском элемента в JSON-структуре, используя очередь для отслеживания пути. Если очередь пуста, возвращается текущий элемент. Для JsonObject
и JsonArray
поиск продолжается, а для JsonPrimitive
или JsonNull
выбрасывается исключение, так как они могут быть только конечной точкой пути.
Рассмотрим подробнее оставшиеся функции extract
:
private fun JsonObject.extract(remainingPath: ArrayDeque<String>): JsonElement {
val value = this.get(remainingPath.removeFirst())
return value.extract(remainingPath, fullPath)
}
private fun JsonArray.extract(remainingPath: ArrayDeque<String>): JsonElement {
val index = remainingPath.removeFirst()
return this.get(index).extract(remainingPath, fullPath)
}
Эти функции предназначены для извлечения данных из соответствующих JSON-структур.
В функции для
JsonObject
извлечение осуществляется по ключу. Функция извлекает значение и удаляет фрагмент пути из очереди.В функции для
JsonArray
извлечение происходит по индексу. Она также удаляет первый элемент (индекс) из очереди и с его помощью извлекает элемент из массива.
Обе функции возвращают извлеченный элемент, продолжая рекурсивный процесс по оставшемуся пути.
Также стоит упомянуть функцию parseJsonPath
, реализация которой опущена для сокращения примера. Эта функция разбивает строку пути (например, "${source.title}"
) на элементы и превращает её в список — в нашем случае ["title"]
, который затем используется в качестве очереди. При этом "source"
в данном контексте игнорируется. Позже станет понятно почему.
В конечном счете задача JsonExtractor
— найти значение для пути и это значение вернуть.
Теперь рассмотрим следующую сущность — JsonExpressionProcessor
:
JsonExpressionProcessor
internal object JsonExpressionProcessor {
fun processElement(
template: JsonElement,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
return when (template) {
is JsonObject -> processJsonObject(template, source, expressionValueExtractor)
is JsonArray -> processJsonArray(template, source, expressionValueExtractor)
is JsonPrimitive -> processJsonPrimitive(template, source, expressionValueExtractor)
else -> template
}
}
private fun processJsonObject(
jsonObject: JsonObject,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonObject {
return JsonObject().apply {
jsonObject.entrySet().forEach { (key, value) ->
add(
key.removePrefix(EXPRESSION_FIELD_PREFIX),
processElement(value, source, expressionValueExtractor)
)
}
}
}
private fun processJsonArray(
jsonArray: JsonArray,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonArray {
return JsonArray().apply {
jsonArray.forEach { element ->
add(processElement(element, source, expressionValueExtractor))
}
}
}
private fun processJsonPrimitive(
jsonPrimitive: JsonPrimitive,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
if (!jsonPrimitive.isExpression()) return jsonPrimitive
val expression = jsonPrimitive.asString
val (rootKey, fullPath) = requireNotNull(pathRegex.find(expression)?.destructured)
return when (rootKey) {
SOURCE_ROOT_KEY -> JsonExtractor.extract(source, fullPath).deepCopy()
TEMPLATE_ROOT_KEY -> {
val innerTemplate = expressionValueExtractor.extract(expression)
processElement(innerTemplate, source, expressionValueExtractor)
}
else -> expressionValueExtractor.extract(expression)
}
}
}
Начнём по порядку с функции processElement
:
fun processElement(
template: JsonElement,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
return when (template) {
is JsonObject -> processJsonObject(template, source, expressionValueExtractor)
is JsonArray -> processJsonArray(template, source, expressionValueExtractor)
is JsonPrimitive -> processJsonPrimitive(template, source, expressionValueExtractor)
else -> template
}
}
Здесь processElement
принимает в качестве входных данных шаблон (template
), основной источник извлечения данных (source
) и дополнительный источник (expressionValueExtractor
). Основной источник указан в функции applyTemplate
.
Дополнительный источник используется, когда в шаблоне содержатся ссылки на другие секции (на что-то кроме source
). Эта функция вызывается рекурсивно для каждого элемента в шаблоне и делегирует обработку конкретного типа элемента (JsonObject
, JsonArray
, или JsonPrimitive
) соответствующей функции.
Следующая на очереди processJsonObject
:
private fun processJsonObject(
jsonObject: JsonObject,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonObject {
return JsonObject().apply {
jsonObject.entrySet().forEach { (key, value) ->
add(
key.removePrefix(EXPRESSION_FIELD_PREFIX),
processElement(value, source, expressionValueExtractor)
)
}
}
}
Функция processJsonObject
обрабатывает JSON-объекты, содержащие пары ключ-значение, создавая при этом новый JsonObject
. Новый JsonObject
создается для того, чтобы каждый шаблон был независимым экземпляром, не связанным с исходным шаблоном. Для каждого элемента из исходного объекта удаляется префикс “$”
из ключа, поскольку значениями станут данные, полученные по соответствующим путям.
Теперь рассмотрим processJsonArray
:
private fun processJsonArray(
jsonArray: JsonArray,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonArray {
return JsonArray().apply {
jsonArray.forEach { element ->
add(processElement(element, source, expressionValueExtractor))
}
}
}
Здесь ничего примечательного не происходит: создаётся новый массив и каждый его элемент дополнительно обрабатывается ранее упомянутой функциейprocessElement
.
И последняя на очереди — самая интересная функция processJsonPrimitive
:
private fun processJsonPrimitive(
jsonPrimitive: JsonPrimitive,
source: JsonElement,
expressionValueExtractor: ExpressionValueExtractor,
): JsonElement {
if (!jsonPrimitive.isExpression()) return jsonPrimitive
val expression = jsonPrimitive.asString
val (rootKey, fullPath) = pathRegex.find(expression).destructured
return when (rootKey) {
SOURCE_ROOT_KEY -> JsonExtractor.extract(source, fullPath).deepCopy()
TEMPLATE_ROOT_KEY -> {
val innerTemplate = expressionValueExtractor.extract(expression)
processElement(innerTemplate, source, expressionValueExtractor)
}
else -> expressionValueExtractor.extract(expression)
}
}
Здесь происходит несколько вещей:
Если
jsonPrimitive
не является выражением (не содержит синтаксис“${…}”
), он возвращается без изменений.Если
jsonPrimitive
выражение, то сначала выражение разделяется на ключ ссылки (rootKey
) и путь (fullPath
) с помощью несложной регулярки. Например, для выражения“${source.title}”
, секция —source
, а путь —title
.
После определения ссылки начинается поиск значения с возможными сценариями:
При ссылке на
source
используетсяJsonExtractor
, который извлекает значение из пути.При ссылке на другой шаблон возникает ситуация при которой один шаблон обращается к другому. В таком случае сначала с помощью
expressionValueExtractor
находится сам шаблон, а затем повторно вызываетсяprocessElement
, чтобы объединить шаблоны в один.Когда ссылка указывает на другие источники, ответственность передается
expressionValueExtractor
, который возвращает соответствующее значение пути. Мы не будем подробно рассматривать его работу в контексте шаблонизации, но в целом он осуществляет поиск в других доступных местах и возвращает найденное значение. Это тема для отдельной статьи в рамках SDUI Dynamics.
Осталось самое простое — рассмотреть сущность самой функции applyTemplate
.
ApplyTemplateFunction
internal class ApplyTemplateFunction() : BinaryFunction<JsonElement, JsonElement>(), HighOrderArgName {
override val type: ComputedFunctionType = ComputedFunctionType.APPLY_TEMPLATE
override fun getComputedArgs(dto: ApplyTemplateDto): List<ComputedArg> {
return listOf(
ComputedArg.createPlain(dto.sourceExpression, dto.sourceValue),
ComputedArg.createPlain(dto.templateExpression, dto.templateValue)
)
}
override fun evaluate(
source: JsonElement,
template: JsonElement,
): Any {
require(template is JsonObject || template is JsonArray) {
getIllegalArgumentExceptionMessage(template)
}
return JsonExpressionProcessor.processElement(template, source)
}
override fun getArgs(
dto: ApplyTemplateDto,
argumentExtractor: ComputedFunctionArgumentExtractor,
context: ComputedFunctionContext,
): Pair<JsonElement, JsonElement> {
val (source, template) = getComputedArgs(dto)
val sourceArg = argumentExtractor.tryExtractArgument(source.expression, source.constant, context)
val templateArg = argumentExtractor.tryExtractArgument(template.expression, template.constant, context)
return sourceArg, templateArg
}
}
Начнем рассматривать с getArgs
:
override fun getArgs(
dto: ApplyTemplateDto,
argumentExtractor: ComputedFunctionArgumentExtractor,
context: ComputedFunctionContext,
): Pair<JsonElement, JsonElement> {
val (source, template) = getComputedArgs(dto)
val sourceArg = argumentExtractor.tryExtractArgument(source.expression, source.constant, context)
val templateArg = argumentExtractor.tryExtractArgument(template.expression, template.constant, context)
return sourceArg, templateArg
}
В этой функции осуществляется извлечение аргументов с помощью сущностиComputedFunctionArgumentExtractor
, которые будут предоставляться функции evaluate
. Подробности работы этой сущности мы также рассматривать не будем, но если кратко, она извлекает аргументы: если это выражение (как в нашем примере), то возвращает соответствующее значение; если это объект, то возращает тот же объект. Таким образом, всегда получается ненулевой JsonElement
. Также у нас есть аргумент ComputedFunctionContext
, который необходим для получения результата из вышестоящей функции, в нашем примере это map
.
Рассмотрим функцию evaluate
:
override fun evaluate(
source: JsonElement,
template: JsonElement,
): Any {
require(template is JsonObject || template is JsonArray) {
getIllegalArgumentExceptionMessage(template)
}
return JsonExpressionProcessor.processElement(template, source)
}
Задача этой функции — произвести действие самой функции и вернуть результат. Основная логика обработки делегируется JsonExpressionProcessor
, поэтому функция лишь подготавливает аргументы, передает их процессору и возвращает полученный результат. Также стоит отметить, что JSON-примитивы отсеиваются, поскольку шаблоны со строками не сокращают размера JSON и смысла в них нет.
В конечном итоге из функции evaluate
мы получаем обработанный шаблон в виде JsonObject
или JsonArray
, который будет готов к десериализации в более привычный объект дизайн системы, который можно отрисовать на экране.
Так выглядит финальный json:
Финальный json акций
{
"data": {
"stocks": [
{
"title": "Московская Биржа",
"icon": "http://stoc_exchange.png"
},
{
"title": "Газпром",
"icon": "http://gazprom.png"
},
// другие акции
]
},
"computed": {
"stockTemplate": {
"type": "map",
"$source": "${data.stocks}",
"$function": {
"type": "applyTemplate",
"$source": "${it}",
"$template": "${template.stock}"
}
}
},
"template": {
"stock": {
"type": "DataView",
"content": {
"dataContent": {
"title": {
"textContentKind": "plain",
"$value": "${source.title}",
"color": "textColorPrimary",
"typography": "ParagraphPrimaryMedium"
}
},
"iconView": {
"backgroundIcon": {
"$url": "${source.icon}"
},
"size": "small",
"shape": "superellipse"
}
}
}
},
"rootElement": {
"type": "StackView",
"content": {
"axis": "vertical",
"alignment": "fill",
"$children": "${computed.stockTemplate}"
}
}
}
Каковы же результаты?
Шаблонизация существенно уменьшила объём JSON при работе с однотипными элементами. Результат для нашего примера:
Сокращение на 5 элементах составило около 45%.
При 100 элементах — примерно 74% (учитывая количество символов, а не строк).
Но помимо уменьшения объёма, JSON стал более читабельным благодаря четкому разделению данных и статичной разметки. Делаю вывод, что усилия не были напрасны.
В статье также упоминались другие функции, сущности и SDUI Dynamics в целом. Если эта тема вам интересна, оставьте комментарий, и мы подробно рассмотрим её в следующей статье.
Рекомендуемая литература:
«Компиляторы. Принципы, технологии и инструментарий», Ахо, Ульман, Лам.
«Теория вычислений для программистов» — Том Стюарт.
Подробнее о SDUI можно узнать из наших статей:
Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.