company_banner

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


    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-классы только ради этого — не лучшее решение.
    • Нужна ли реализация toString()?
      • Вряд ли существует бизнес-логика, зависящая от реализации toString(), поэтому иногда можно генерировать этот метод вручную, средствами IDE.
    • Нужен ли простой DTO для передачи данных в другой слой или задания конфигурации?
      • Обычный класс подойдёт для этих целей, если не требуются предыдущие пункты.

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


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

    Badoo
    Big Dating

    Комментарии 5

      +7

      Время потраченное за тщательное раздумывание нужен ли простой класс или дата класс дороже, чем место которое он займёт в результате. Так что может лучше использовать всегда дата классы? И не использовать их когда они реально не подходят под задачу?)

        +1

        Тоже вполне себе валидный подход.


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

        +3

        В заголовке не плохо бы указать, о каком ЯП речь.

          +1

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


          На десктопе мне сразу понятно о чём речь, а вот с телефона бывает, что заголовок совершенно не отражает суть статьи, поскольку слова в нём имеют другое значение в контексте использованных хабов. :/

          +8
          Оптимизацию в ~4% пользователи вряд ли заметят. А вот программисты будут тратить слишком много времени, чтобы обдумывать где нужен data класс, а где нет, а в дальнейшем будут проверять есть ли ключевое слово data у модели, с которой работает программист. В итоге, такое тщательное обдумывание может повлечь за собой больше багов, так как уменьшится предсказуемость кода.
          Автору большое спасибо за статью! Отличная работа :)

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

          Самое читаемое