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-классы в вашем приложении, то можете сделать это самостоятельно с помощью моего плагина.