Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Kotlin имеет много классных особенностей: null safety, smart casts, интерполяция строк и другие. Но одной из самых любимых разработчиками, по моим наблюдениям, являются data-классы. Настолько любимой, что их часто используют даже там, где никакой функциональности data-класса не требуется.
В этой статье я с помощью эксперимента постараюсь понять, какова реальная цена использования большого количества data-классов в приложении. Я попробую удалить все data-классы, не сломав компиляцию, но сломав приложение, а потом расскажу о результатах и выводах этого эксперимента.
Data-классы и их функциональность
В процессе разработки часто создаются классы, основное назначение которых — хранение данных. В Kotlin их можно пометить как data-классы, чтобы получить дополнительную функциональность:
component1()
,component2()
…componentX()
для деструктурирующего присваивания (val (name, age) = person
);copy()
с возможностью создавать копии объекта с изменениями или без;toString()
с именем класса и значением всех полей внутри;equals()
&hashCode()
.
Но мы платим далеко не за всю эту функциональность. Для релизных сборок используются оптимизаторы R8, ProGuard, DexGuard и другие. Они могут удалять неиспользуемые методы, а значит, могут и оптимизировать data-классы.
Будут удалены:
component1()
,component2()
…componentX()
при условии, что не используется деструктурирующее присваивание (но даже если оно есть, то при более агрессивных настройках оптимизации эти методы могут быть заменены на прямое обращение к полю класса);copy()
, если он не используется.
Не будут удалены:
toString()
, поскольку оптимизатор не может знать, будет ли этот метод где-то использоваться или нет (например, при логировании); также он не будет обфусцирован;equals()
&hashCode()
, потому что удаление этих функций может изменить поведение приложения.
Таким образом, в релизных сборках всегда остаются toString()
, equals()
и hashCode()
.
Масштаб изменений
Чтобы понять, какое влияние на размер приложения оказывают data-классы в масштабе приложения, я решил выдвинуть гипотезу: все data-классы в проекте не нужны и могут быть заменены на обычные. А поскольку для релизных сборок мы используем оптимизатор, который может удалять методы componentX()
и copy()
, то преобразование data-классов в обычные можно свести к следующему:
data class SomeClass(val text: String) {
- override fun toString() = ...
- override fun hashCode() = ...
- override fun equals() = ...
}
Но вручную такое поведение реализовать невозможно. Единственный способ удалить эти функции из кода — переопределить их в следующем виде для каждого data-класса в проекте:
data class SomeClass(val text: String) {
+ override fun toString() = super.toString()
+ override fun hashCode() = super.hashCode()
+ override fun equals() = super.equals()
}
Вручную для 7749 data-классов в проекте.
Ситуацию усугубляет использование монорепозитория для приложений. Это означает, что я не знаю точно, сколько из этих 7749 классов мне нужно изменить, чтобы измерить влияние data-классов только на одно приложение. Поэтому придётся менять все!
Плагин компилятора
Вручную такой объём изменений сделать невозможно, поэтому самое время вспомнить о такой прекрасной незадокументированной вещи, как плагины компилятора. Мы уже рассказывали про наш опыт создания плагина компилятора в статье «Чиним сериализацию объектов в Kotlin раз и навсегда». Но там мы генерировали новые методы, а здесь нам нужно их удалять.
В открытом доступе на GitHub есть плагин Sekret, который позволяет скрывать в toString()
указанные аннотацией поля в data-классах. Его я и взял за основу своего плагина.
С точки зрения создания структуры проекта практически ничего не поменялось. Нам понадобятся:
- Gradle-плагин для простой интеграции;
- плагин компилятора, который будет подключён через Gradle-плагин;
- проект с примером, на котором можно запускать различные тесты.
Самая важная часть в Gradle-плагине — это объявление KotlinGradleSubplugin
. Этот сабплагин будет подключён через ServiceLocator
. С помощью основного Gradle-плагина мы можем конфигурировать KotlinGradleSubplugin
, который будет настраивать поведение плагина компилятора.
@AutoService(KotlinGradleSubplugin::class)
class DataClassNoStringGradleSubplugin : KotlinGradleSubplugin<AbstractCompile> {
// Проверяем, есть ли основной Gradle-плагин
override fun isApplicable(project: Project, task: AbstractCompile): Boolean =
project.plugins.hasPlugin(DataClassNoStringPlugin::class.java)
override fun apply(
project: Project,
kotlinCompile: AbstractCompile,
javaCompile: AbstractCompile?,
variantData: Any?,
androidProjectHandler: Any?,
kotlinCompilation: KotlinCompilation<KotlinCommonOptions>?
): List<SubpluginOption> {
// Опции плагина компилятора настраиваются через DataClassNoStringExtension с помощью Gradle build script
val extension =
project
.extensions
.findByType(DataClassNoStringExtension::class.java)
?: DataClassNoStringExtension()
val enabled = SubpluginOption("enabled", extension.enabled.toString())
return listOf(enabled)
}
override fun getCompilerPluginId(): String = "data-class-no-string"
// Это артефакт плагина компилятора, и он должен быть доступен в репозитории Maven, который вы используете
override fun getPluginArtifact(): SubpluginArtifact =
SubpluginArtifact("com.cherryperry.nostrings", "kotlin-plugin", "1.0.0")
}
Плагин компилятора состоит из двух важных компонентов: ComponentRegistrar
и CommandLineProcessor
. Первый отвечает за интеграцию нашей логики в этапы компиляции, а второй — за обработку параметров нашего плагина. Я не буду описывать их детально — посмотреть реализацию можно в репозитории. Отмечу лишь, что, в отличие от метода, описанного в другой статье, мы будем регистрировать ClassBuilderInterceptorExtension
, а не ExpressionCodegenExtension
.
ClassBuilderInterceptorExtension.registerExtension(
project = project,
extension = DataClassNoStringClassGenerationInterceptor()
)
ClassBuilderInterceptorExtension
позволяет изменять процесс генерации классов, а значит, с его помощью мы сможем избежать создания ненужных методов.
class DataClassNoStringClassGenerationInterceptor : ClassBuilderInterceptorExtension {
override fun interceptClassBuilderFactory(
interceptedFactory: ClassBuilderFactory,
bindingContext: BindingContext,
diagnostics: DiagnosticSink
): ClassBuilderFactory =
object : ClassBuilderFactory {
override fun newClassBuilder(origin: JvmDeclarationOrigin): ClassBuilder {
val classDescription = origin.descriptor as? ClassDescriptor
// Если класс является data-классом, то изменяем процесс генерации кода
return if (classDescription?.kind == ClassKind.CLASS && classDescription.isData) {
DataClassNoStringClassBuilder(interceptedFactory.newClassBuilder(origin), removeAll)
} else {
interceptedFactory.newClassBuilder(origin)
}
}
}
}
Теперь необходимо не дать компилятору создать некоторые методы. Для этого воспользуемся DelegatingClassBuilder
. Он будет делегировать все вызовы оригинальному ClassBuilder
, но при этом мы сможем переопределить поведение метода newMethod
. Если мы попытаемся создать методы toString()
, equals()
, hashCode()
, то вернём пустой MethodVisitor
. Компилятор будет писать в него код этих методов, но он не попадёт в создаваемый класс.
class DataClassNoStringClassBuilder(
val classBuilder: ClassBuilder
) : DelegatingClassBuilder() {
override fun getDelegate(): ClassBuilder = classBuilder
override fun newMethod(
origin: JvmDeclarationOrigin,
access: Int,
name: String,
desc: String,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
return when (name) {
"toString",
"hashCode",
"equals" -> EmptyVisitor
else -> super.newMethod(origin, access, name, desc, signature, exceptions)
}
}
private object EmptyVisitor : MethodVisitor(Opcodes.ASM5)
}
Таким образом, мы вмешались в процесс создания data-классов и полностью исключили из них вышеуказанные методы. Убедиться, что этих методов больше нет, можно с помощью кода, доступного в sample
-проекте. Также можно проверить JAR/DEX-байт-код и убедиться в том, что там эти методы отсутствуют.
class AppTest {
data class Sample(val text: String)
@Test
fun `toString method should return default string`() {
val sample = Sample("test")
// toString должен возвращать результат метода Object.toString
assertEquals(
"${sample.javaClass.name}@${Integer.toHexString(System.identityHashCode(sample))}",
sample.toString()
)
}
@Test
fun `hashCode method should return identityHashCode`() {
// hashCode должен возвращать результат метода Object.hashCode, он же по умолчанию System.identityHashCode
val sample = Sample("test")
assertEquals(System.identityHashCode(sample), sample.hashCode())
}
@Test
fun `equals method should return true only for itself`() {
// equals должен работать как Object.equals, а значит, должен быть равным только самому себе
val sample = Sample("test")
assertEquals(sample, sample)
assertNotEquals(Sample("test"), sample)
}
}
Весь код доступен в репозитории, там же есть пример интеграции плагина.
Результаты
Для сравнения мы будем использовать релизные сборки Bumble и Badoo. Результаты были получены с помощью утилиты Diffuse, которая выводит детальную информацию о разнице между двумя APK-файлами: размеры DEX-файлов и ресурсов, количество строк, методов, классов в DEX-файле.
Приложение | Bumble | Bumble (после) | Разница | Badoo | Badoo (после) | Разница |
---|---|---|---|---|---|---|
Data-классы | 4026 | - | - | 2894 | - | - |
Размер DEX (zipped) | 12.4 MiB | 11.9 MiB | -510.1 KiB | 15.3 MiB | 14.9 MiB | -454.1 KiB |
Размер DEX (unzipped) | 31.7 MiB | 30 MiB | -1.6 MiB | 38.9 MiB | 37.6 MiB | -1.4 MiB |
Строки в DEX | 188969 | 179197 | -9772 | 244116 | 232114 | -12002 |
Методы | 292465 | 277475 | -14990 | 354218 | 341779 | -12439 |
Количество data-классов было определено эвристическим путём с помощью анализа удалённых из DEX-файла строк.
Реализация toString()
у data-классов всегда начинается с короткого имени класса, открывающей скобки и первого поля data-класса. Data-классов без полей не существует.
Исходя из результатов, можно сказать, что в среднем каждый data-класс обходится в 120 байт в сжатом и 400 байт — в несжатом виде. На первый взгляд, не много, поэтому я решил проверить, сколько получается в масштабе целого приложения. Выяснилось, что все data-классы в проекте обходятся нам в ~4% размера DEX-файла.
Также стоит уточнить, что из-за MVI-архитектуры мы можем использовать больше data-классов, чем приложения на других архитектурах, а значит, их влияние на ваше приложение может быть меньше.
Использование data-классов
Я ни в коем случае не призываю вас отказываться от data-классов, но, принимая решение об их использовании, нужно тщательно всё взвесить. Вот несколько вопросов, которые стоит задать себе перед объявлением data-класса:
- Нужны ли реализации
equals()
иhashCode()
?
- Если нужны, лучше использовать data-класс, но помните про
toString()
, он не обфусцируется.
- Если нужны, лучше использовать data-класс, но помните про
- Нужно ли использовать деструктурирующее присваивание?
- Использовать data-классы только ради этого — не лучшее решение.
- Нужна ли реализация
toString()
?
- Вряд ли существует бизнес-логика, зависящая от реализации
toString()
, поэтому иногда можно генерировать этот метод вручную, средствами IDE.
- Вряд ли существует бизнес-логика, зависящая от реализации
- Нужен ли простой DTO для передачи данных в другой слой или задания конфигурации?
- Обычный класс подойдёт для этих целей, если не требуются предыдущие пункты.
Мы не можем совсем отказаться от использования data-классов в проекте, и приведённый выше плагин ломает работоспособность приложения. Удаление методов было сделано ради оценки влияния большого количества data-классов. В нашем случае это ~4% от размера DEX-файла приложения.
Если вы хотите оценить, сколько места занимают data-классы в вашем приложении, то можете сделать это самостоятельно с помощью моего плагина.