
Проблема большого объёма 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 — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.
Комментарии (10)
MACTEPyc
18.02.2025 11:238 лет назад аналогичное решение Gliechtenstein уже реализовывал в своих проектах. Рекомендую обратить внимание на его проект ST (JSON Selector + Transformer).
Гифка
sshmakov
18.02.2025 11:23От акции к акции меняются только заголовок и картинка. Цвет, шрифт, размеры и другие параметры остаются одинаковыми для всех акций, однако их необходимо указывать для каждой, чтобы обеспечить корректное отображение
Можно указать один раз на всю группу целиком, тогда отдельные элементы будут содержать только текст и ссылку на картинку. Ну и (ключ на) диплинк, конечно.
Spinoza0
18.02.2025 11:23Какие метрики ещё собирались кроме размера? Выглядит немного как преждевременная оптимизация
nauruz07 Автор
18.02.2025 11:23Цифры конкретные сейчас уже не приведу, но метрики измерял: время загрузки экрана, при медленном интернете было ощутимо; потребляемые ресурсы, всё это не бесплатно, конечно, но на производительности особо не сказывается.
Если есть какие-то предложения по тому, что можно измерить, буду рад послушать)
zartdinov
Красивое решение, понятно, что с protobuf свои заморочки, но было интересно сравнить с
Content-Encoding: gzip. В теории сервер сам сожмет исходные данные намного лучше.
LesleySin
Мне кажется, что такое сравнение не совсем валидно по причине того, что в статье рассказывается о том, как люди избавлялись от повторяющихся частей JSON с помощью шаблонизации.
Сжатие, использование иных протоколов обмена данными - это другая сторона задачи по передаче как можно меньшего объема данных по сети, имхо.
zartdinov
Просто превью содержит такой текст:
Поэтому и подумал, что это не эстетическая необходимость. Сжатие и так хорошо должно справится с повторами, добавленные шаблоны наоборот только немного увеличат энтропию скорей всего.
Denai
сжатая версия после таких манипуляций будет на 20% тяжелее для сокращённого варианта конкретно в этой ситуации. т.е. сжатию такое преобразование определённо вредит.