Влияние data-классов на вес приложения

lzq2chv0lwbs9wyldd36bk-mn9e.png

Kotlin имеет много классных особенностей: null safety, smart casts, интерполяция строк и другие. Но одной из самых любимых разработчиками, по моим наблюдениям, являются data-классы. Настолько любимой, что их часто используют даже там, где никакой функциональности data-класса не требуется.

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


Data-классы и их функциональность

В процессе разработки часто создаются классы, основное назначение которых — хранение данных. В Kotlin их можно пометить как data-классы, чтобы получить дополнительную функциональность:


  • component1(), component2()componentX() для деструктурирующего присваивания (val (name, age) = person);
  • copy() с возможностью создавать копии объекта с изменениями или без;
  • toString() с именем класса и значением всех полей внутри;
  • equals() & hashCode().

qhhezzksl-5zyrco89i28xuxeuy.jpeg

Но мы платим далеко не за всю эту функциональность. Для релизных сборок используются оптимизаторы 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-классов в проекте.

vg1asom2iej0yvkkvz5lmz-able.jpeg

Ситуацию усугубляет использование монорепозитория для приложений. Это означает, что я не знаю точно, сколько из этих 7749 классов мне нужно изменить, чтобы измерить влияние data-классов только на одно приложение. Поэтому придётся менять все!


Плагин компилятора

Вручную такой объём изменений сделать невозможно, поэтому самое время вспомнить о такой прекрасной незадокументированной вещи, как плагины компилятора. Мы уже рассказывали про наш опыт создания плагина компилятора в статье «Чиним сериализацию объектов в Kotlin раз и навсегда». Но там мы генерировали новые методы, а здесь нам нужно их удалять.

В открытом доступе на GitHub есть плагин Sekret, который позволяет скрывать в toString() указанные аннотацией поля в data-классах. Его я и взял за основу своего плагина.

С точки зрения создания структуры проекта практически ничего не поменялось. Нам понадобятся:


  • Gradle-плагин для простой интеграции;
  • плагин компилятора, который будет подключён через Gradle-плагин;
  • проект с примером, на котором можно запускать различные тесты.

Самая важная часть в Gradle-плагине — это объявление KotlinGradleSubplugin. Этот сабплагин будет подключён через ServiceLocator. С помощью основного Gradle-плагина мы можем конфигурировать KotlinGradleSubplugin, который будет настраивать поведение плагина компилятора.

@AutoService(KotlinGradleSubplugin::class)
class DataClassNoStringGradleSubplugin : KotlinGradleSubplugin {

    // Проверяем, есть ли основной 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?
    ): List {
        // Опции плагина компилятора настраиваются через 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?
    ): 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)
    }

}

Весь код доступен в репозитории, там же есть пример интеграции плагина.


Результаты

9g9jwfbhyftchfgd5phtfnjmhty.png

Для сравнения мы будем использовать релизные сборки Bumble и Badoo. Результаты были получены с помощью утилиты Diffuse, которая выводит детальную информацию о разнице между двумя APK-файлами: размеры DEX-файлов и ресурсов, количество строк, методов, классов в DEX-файле.

Количество data-классов было определено эвристическим путём с помощью анализа удалённых из DEX-файла строк.

lfuaj_-g5nmtd_c6jaoiocvtb1k.png

Реализация toString() у data-классов всегда начинается с короткого имени класса, открывающей скобки и первого поля data-класса. Data-классов без полей не существует.

Исходя из результатов, можно сказать, что в среднем каждый data-класс обходится в 120 байт в сжатом и 400 байт — в несжатом виде. На первый взгляд, не много, поэтому я решил проверить, сколько получается в масштабе целого приложения. Выяснилось, что все data-классы в проекте обходятся нам в ~4% размера DEX-файла.

Также стоит уточнить, что из-за MVI-архитектуры мы можем использовать больше data-классов, чем приложения на других архитектурах, а значит, их влияние на ваше приложение может быть меньше.


Использование data-классов

Я ни в коем случае не призываю вас отказываться от data-классов, но, принимая решение об их использовании, нужно тщательно всё взвесить. Вот несколько вопросов, которые стоит задать себе перед объявлением data-класса:


  • Нужны ли реализации equals() и hashCode()?
    • Если нужны, лучше использовать data-класс, но помните про toString(), он не обфусцируется.
  • Нужно ли использовать деструктурирующее присваивание?
    • Использовать data-классы только ради этого — не лучшее решение.
  • Нужна ли реализация toString()?
    • Вряд ли существует бизнес-логика, зависящая от реализации toString(), поэтому иногда можно генерировать этот метод вручную, средствами IDE.
  • Нужен ли простой DTO для передачи данных в другой слой или задания конфигурации?
    • Обычный класс подойдёт для этих целей, если не требуются предыдущие пункты.

Мы не можем совсем отказаться от использования data-классов в проекте, и приведённый выше плагин ломает работоспособность приложения. Удаление методов было сделано ради оценки влияния большого количества data-классов. В нашем случае это ~4% от размера DEX-файла приложения.

Если вы хотите оценить, сколько места занимают data-классы в вашем приложении, то можете сделать это самостоятельно с помощью моего плагина.

© Habrahabr.ru