Хабрахабр

Kotlin performance on Android

Поговорим сегодня о производительности Kotlin на Android в production. Посмотрим под капот, реализуем хитрые оптимизаци, сравним байт-код. Наконец, серьезно подойдем к сравнению и замерим бенчмарки.

Эта статья основана на докладе Александра Смирнова на AppsConf 2017 и поможет разобраться, можно ли написать код на Kotlin, который не будет уступать Java по скорости.

О спикере: Александр Смирнов CTO в компании PapaJobs, ведет видеоблог «Android в лицах», а также является одним из организаторов сообщества Mosdroid.
Начнем с ваших ожиданий.

Или быстрее? Как вы считаете, Kotlin в runtime работает медленнее, чем Java? Ведь оба работают на байт-коде, который нам предоставляет виртуальная машина. Или, может быть, нет особой разницы?

Давайте разбираться. Традиционно, когда возникает вопрос сравнения производительности, все хотят видеть бенчмарки и конкретные цифры. К сожалению, для Android нет JMH (Java Microbenchmark Harness), поэтому мы не можем все так же круто замерить, как это можно сделать на Java. Так что же нам остается делать замер, как написано ниже?

fun measure() : Long { val startTime = System.nanoTime() work() return System.nanoTime() - startTime
} adb shell dumpsys gfxinfo %package_name%

Если вы когда-либо попробуете так замерить свой код, то кто-то из разработчиков JMH будет грустить, плакать и приходить к вам во сне — никогда так не делайте.

Они сказали, что они сильно улучшили виртуальную машину, в данном случае ART, и, если на Android 4. На Android можно делать бенчмарки, в частности, Google продемонстрировал это еще на прошлогоднем I/O. Т.е. 1 одна аллокация объекта занимала примерно 600-700 наносекунд, то в восьмой версии она будет занимать порядка 60 наносекунд. Почему мы не можем сделать также — у нас нет таких инструментов. они смогли замерить это с такой точностью на виртуальной машине.

Если мы посмотрим всю документацию, то единственное, что сможем найти, это ту рекомендацию что выше, как измерять UI:

adb shell dumpsys gfxinfo %package_name%

Но сначала определим, что мы будем замерять и что еще мы можем сделать. Собственно, давайте так и сделаем, и посмотрим в конце, что это даст.

Как вы считаете, где важен performance, когда вы создаете первоклассное приложение? Следующий вопрос.

  1. Однозначно везде.
  2. UI Thread.
  3. Custom view + animations.

С этим я тоже согласен — это очень-очень важно. Мне больше всего нравится первый вариант, но скорее всего большинство считает, что невозможно сделать так, чтобы весь код отрабатывал очень-очень быстро и важно, чтобы хотя бы не лагал UiThread или custom view. То, что у вас в отдельном потоке JSON будет десериализоваться на 10 миллисекунд дольше будет, то этого никто не заметит.

И тогда эти 10 миллисекунд погоды не делают. Гештальтпсихология говорит, что, когда мы моргаем, примерно 150-300 милисекунд человеческий глаз находится в расфокусе и не видит, что там, собственно, четко происходит. Но если мы вернемся к гештальтпсихологии, важно не то, что я реально вижу и что реально происходит, — важно то, что я понимаю как пользователь.

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

Поэтому, если взять два приложения с одинаковым временем обработки, но на разных платформах, и положить их рядышком, будет казаться, что на iOS все быстрее. Мотивы гештальт-психологии в iOS продвигались достаточно давно. Анимация в iOS обрабатывает немножко быстрее, раньше начинается показ анимации при загрузке и многих других анимаций, чтобы это было красиво.

Итого, первое правило — думать о пользователе.

А за вторым правилом нужно погрузиться в хардкор.

KOTLIN STYLE

Чтобы честно оценить производительность Kotlin, мы будем сравнивать его с Java. Поэтому, получается, нельзя измерить некоторые вещи, которые есть только в Kotlin, например:

  • Сollection Api.
  • Method default parameters.
  • Data classes.
  • Reified types.
  • Coroutines.

Сollection АPI, который нам предоставляет Kotlin, очень классный, очень быстрый. В Java, такого попросту нет, есть только разные реализации. Например, библиотека Liteweight Stream API будет медленнее, потому что она делает все то же самое, что и Kotlin, но с одной или двумя дополнительными аллокациями на операцию, поскольку все оборачивается в дополнительный объект.

Если мы включаем parallel, на больших объемах данных Stream API в Java обойдет Kotlin Сollection АPI. Если мы возьмем Stream API, из Java 8, то он будет работать медленней, чем Kotlin Сollection АPI, но с одним условием — в Сollection АPI нет такой парализации, как в Java 8. Поэтому такие вещи мы не можем сравнивать, потому что мы проводим сравнение именно с точки зрения Android.

Когда вы вызываете какой-то метод, у него могут быть какие-то параметры, которые могут принимать какое-то значение, а могут быть NULL. Вторая вещь, которую, как мне кажется, нельзя сравнивать, это Method default parameters — очень классная фишка, которая, кстати, есть в Dart. Т.е. он будет смотреть, пришел параметр, либо не пришел. И поэтому вы не делаете 10 разных методов, а делаете один метод и говорите, что один из параметров может быть NULL, и в дальнейшем используете его без какого-либо параметра. Это синтаксический сахар: вы, как разработчик, считаете, что это один метод API, а в реальности под капотом в байт-коде генерируется каждая вариация метода с отсутствующими параметрами. Очень удобно в том плане, что можно писать намного меньше кода, но неудобство заключается в том, что за это приходится платить. Если он пришел, то ok, если не пришел, то дальше составляем битову маску, и в зависимости от этой битовой маски уже вызывается, собственно, тот изначальный метод, который вы написали. И еще в каждом из этих методов происходит проверка побитово, пришел ли этот параметр. Мне кажется, что это абсолютно нормально. Побитовые операции, все if / else стоят чуть-чуть денег, но очень мало, и это нормально, что удобство вам приходится заплатить.

Следующий пункт, который нельзя сравнивать — это Data classes.

Т.е. вы берете параметры и делаете еще методы, геттеры и сеттеры для всех этих параметров. Все плачутся, что в Java есть параметры, для которых есть модельные классы. Причем, если вы не пользуетесь генераторами, то это приходится писать руками, что вообще ужасно. Получается, что для класса с десятью параметрами нужна еще целая портянка геттеров, сеттеров и еще кучи всего.

Во-первых, так как в Kotlin есть свойства, вам не нужно писать геттеры и сеттеры. Kotlin позволяет от всего этого уйти. Во всяком случае, мы так думаем. У него нет параметров класса, все свойства. Например, equals(), toStrung()/ hasCode() и т.д. Во- вторых, если вы напишете, что это Data classes, сгенерируется целая куча всего остального.

Например, мне не нужно было, чтобы у меня в equals() сравнивались сразу все 20 параметров моего data classes, нужно было сравнить только 3. Конечно, у этого есть и недостатки. То есть, если вы напишете все руками, кода будет меньше, чем если вы используете data classes. Кому-то это все не нравится, потому что на этом теряется производительность, и кроме того, генерируется много служебных функций, и скомпилированный код получается достаточно объемный.

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

Это Reified types, который тоже, кстати, есть в Dart. Что очень-очень классно в Kotlin, и на чем он всегда будет быстрее, чем Java?

Вы знаете, что когда вы используете generics, то на этапе компиляции происходит стирание типов (type erasure) и в рантайме вы уже не знаете, собственно, какой объект этого дженерика используется.

Магия. С Reified types вам не нужно использовать рефлекcию во многих местах, когда в Java вам было бы это нужно, потому что при inline методов именно с Reified остается знание о типе, и поэтому получается, что вы не используете рефлекцию и ваш код работает быстрее.

Они очень классные, они очень мне нравятся, но на момент выступления они входили только в альфа-версию, соответственно проводить с ними корректные сравнения возможности не было. И еще есть Coroutines.

FIELDS

Поэтому пойдем дальше, перейдем к тому, что мы можем сравнить с Java и том, на что вообще мы можем повлиять.

class Test
} class B (@JvmField var a: Int = 5,var b: Int = 6)

Как я сказал, у нас нет параметров у класса, у нас есть свойства.

У нас есть var, у нас есть val, у нас есть внешний класс, одно из свойств которого @JvmField, и мы будем смотреть, что, собственно, происходит с функцией work(): мы суммируем значение поля a и поля b собственного класса и значения поля a и поля b внешнего класса, который записан в неизменяемое поле c.

Все мы знаем, что это раз свойство, то будет вызван геттер этого класса для этого параметра. Вопрос заключается в том, что, собственно, будет вызвано в d = a + b.

L0 LINENUMBER 10 L0 ALOAD 0 GETFIELD kotlin/Test.a : I ALOAD 0 GETFIELD kotlin/Test.b : I IADD ISTORE 1

Но если мы посмотрим в байт-код, то увидим, что в реальности происходит обращение getfield. То есть это в байт-коде происходит не вызов InvokeVirtual функции, а напрямую обращение к полю. Нет того, что было обещнао нам изначально, что у нас все свойства, а не поля. Получается, что Kotlin нас обманывает, есть прямое обращение.

Что будет, если мы все-таки посмотрим, какой байт-код генерируется для другой строки: val e = c.a + c.b?

L1 LINENUMBER 11 L1 ALOAD 0 GETFIELD kotlin/Test.c : Lkotlin/B; GETFIELD kotlin/B.a : I ALOAD 0 GETFIELD kotlin/Test.c : Lkotlin/B; INVOKEVIRTUAL kotlin/B.getB ()I IADD ISTORE 2

Раньше, если вы обращались к неприватному свойству, то у вас всегда был вызов InvokeVirtual. Если это было приватное свойство, то к нему обращение шло через GetField. GetField намного быстрее, чем InvokeVirtual, в спецификации от Аndroid утверждается, что обращение напрямую к полю в 3–7 раз быстрее. Поэтому рекомендуется всегда обращаться к Field, а не через геттеры либо сеттеры. Сейчас, особенно в восьмой виртуальной машине ART, будут уже другие числа, но, если вы еще поддерживаете 4.1, это будет верно.

Поэтому получается, нам все-таки выгодно, чтобы был GetField, а не InvokeVirtual.

Тогда точно также в байт-коде будет вызов GetField, который в 3–7 раз быстрее. Сейчас, можно добиться GetField, если вы обращаетесь к свойству собственного класса, либо, если это публичное свойство, то необходимо поставить @JvmField.

Но, с другой стороны, если вы это делаете именно в UI-потоке, например, в методе ondraw обращаетесь к какому-то view, то это скажется на отрисовке каждого кадра, и можно сделать это чуть быстрее. Понятно, что здесь мы говорим в наносекундах и, с одной троны это очень-очень мало.

Если сложить все оптимизации, то в сумме это может что-то и дать.

STATIC!?

А что со статиками? Все мы знаем, что в Kotlin static — это companion object. Раньше вы наверняка добавляли какой-то тэг, например, public static, final static и т.д., если сконвертировать это в код на Kotlin, то вы получите companion object, в котором будет записано примерно следующее:

companion object { var k = 5 fun work2() : Int = 42 }

Это вообще static или нет? Как вы считаете данная запись идентична стандартному из Java объявлению static final?

Да, действительно, Kotlin заявляет, что вот это вот в Kotlin — static, что object говорит о том, что это static. В реальности это не static.

Если мы посмотрим на сгенерированный байт-код, то увидим следующее:

L2 LINENUMBER 21 L2 GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.getK ()I GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.work2 ()I IADD ISTORE 3

Генерируется Test.Companion singleton-объект, для которого создается instanсe, этот instanсe записывается в собственное поле. После этого обращение к чему-либо из companion object происходит через этот объект. Он берет getstatic, то есть статический instance этого класса и вызывает у него invokevirtual функцию getK, и точно то же само для функции work2. Таким образом мы получаем, что это не static.

Сейчас, конечно, на HotSpot оптимизированная виртуализация происходит очень круто, и это практически незаметно. Это имеет значение, по той причине, что на старых JVM invokestatic был примерно на 30 % быстрее, чем invokevirtual. Тем не менее, нужно это иметь в виду, тем более, что тут возникает одна лишняя аллокация, а лишняя локация на 4ST1 — это 700 наносекунд, тоже много.

Давайте посмотрим на Java-код, который получится, если обратно развернуть байт-код:

private static int k = 5;
public static final Test.Companion Companion =
new Test.Companion((DefaultConstructorMarker)null); public static final class Companion { public final int getK() { return Test.k;} public final void setK(int var1) { Test.k = var1; } public final int work2() { return 42; } private Companion() { } // $FF: synthetic method public Companion(DefaultConstructorMarker $constructor_marker) { this(); }
}

Создается статическое поле, static final реализация объекта Companion, создаются геттеры и сеттеры, причем, как вы можете увидеть, обращаясь к статическому полю внутри, появляется дополнительный статический метод. Все достаточно грустно.

Мы можем попробовать добавить @JvmField и @JvmStatic и посмотреть, что получится. Что же мы можем сделать, убедившись, что это не статика?

val i = k + work2() companion object { @JvmField var k = 5 JvmStatic fun work2() : Int = 42
}

Сразу скажу, что от @JvmStatic вы никак не уйдете, точно так же это буде объект, так как это companion object, будет лишняя аллокация этого объекта и будет лишний вызов.

private static int k = 5;
public static final Test.Companion Companion = new Test.Companion((DefaultConstructorMarker)null); public static final class Companion { @JvmStatic public final int work2() { return 42; } private Companion() {} // $FF: synthetic method public Companion(DefaultConstructorMarker $constructor_marker) { this(); }
}

Но вызов изменится только для k, потому что это будет @JvmField, оно будет браться напрямую как getstatic, геттеры и сеттеры уже не будет генерироваться. А для функции work2 ничего не изменится.

L2 LINENUMBER 21 L2 GETSTATIC kotlin/Test.k : I GETSTATIC kotlin/Test.Companion : Lkotlin/Test$Companion; INVOKEVIRTUAL kotlin/Test$Companion.work2 ()I IADD ISTORE 3

Второй вариант, как создать static предлагается в документации Kotlin, так сказано, что мы можем просто создать object, и это будет статический код.

object A { fun test() = 53
}

В реальности это все тоже не так.

L3 LINENUMBER 23 L3 GETSTATIC kotlin/A.INSTANCE : Lkotlin/A; INVOKEVIRTUAL kotlin/A.test ()I POP

Получается, что мы делаем вызов getstatic instance от singletone, который создается, и вызываем точно такие же виртуальные методы.

Когда мы просто пишем какую-то функцию вне класса, например, fun test2 будет действительно вызвана как статичная. Единственный вариант, как мы можем добиться именно invokestatic, это Higher-Order Functions.

fun test2() = 99 L4 LINENUMBER 24 L4 INVOKESTATIC kotlin/TestKt.test2 ()I POP

Причем, что самое интересное, что будет создан класс, объект, в данном случае это testKt, он сам cгенерирует объект у него сам сгенерирует функцию, которую положит в этот объект, и вот ее вызовет как invokestatic.

Многие этим недовольны, но есть и те, кто считает такую реализацию вполне нормальной. Почему так было сделано — непонятно. Art улучшается, сейчас это уже не настолько критично. Поскольку виртуальная машина, в т.ч. В восьмой версии Android, точно так же как на HotSpot, все заоптимизировано, но все же эти мелочи чуть-чуть влияют на общую производительность.

NULLABILITY

fun test(first: String, second: String?) : String { second ?: return first return "$first $second"
}

Это следующий интересный пример. Казалось бы, мы отметили, что second может быть nullable, и его надо проверить перед тем, как с ним что-то делать. В данном случае я ожидаю, что у нас есть один if. Когда этот код будет развернут в if second не равен нулю, то я думаю, что выполнение пойдет дальше и выведет только first.

На самом деле будет проверка. Как на самом деле это все развернется в java код?

@NotNull
public final String test(@NotNull String first,@Nullable String second) { Intrinsics.checkParameterIsNotNull(first, "first"); return second != null ? (first + " " + second) : first;
}

Мы получим Intrinsics изначально. Допустим, то, что я говорю, что вот этот вот

Но кроме этого, хотя мы даже зафиксировали, что первый параметр не может быть nullable, он все равно будет проверен через Intrinsics. If развернется в тернарный оператор.

И каждый раз, когда вы делаете параметр метода не nullable, он все равно его проверяет. Intrinsics — это внутренний класс в Kotlin, у которого есть некоторый набор параметров и проверок. Затем, что мы работаем в Interop Java, и может случиться так, что вы то ожидаете, что здесь не будет nullable, но с Java он откуда-нибудь возьмется. Зачем?

У вас все упадет, и вы не сможете понять, что, собственно, произошло. Если вы это проверите, это пойдет дальше по коду, и потом через 10-20 вызовов метода, вы сделаете что-то с параметром, который хоть и не может быть nullable, но почему то им оказался. И если он будет nullable, то будет exception. Чтобы не возникло такой ситуации, каждый раз, когда у вас происходит передача параметра null, у вас все равно будет его проверка.

Эта проверка тоже чего-то стоит, и если их таких будет много, то будет не очень хорошо.

Это очень-очень мало, и не стоит по этому поводу переживать, но это интересный фактор. Но на самом деле, если говорить о HotSpot, то 10 вызовов этих Intrinsics займет порядка четырех наносекунд.

PRIMITIVES

В Java есть такая вещь, как примитивы. В Kotlin, как все мы знаем, нет примитивов, мы всегда оперируем с объектами. В Java они используются для того, чтобы обеспечить более высокую производительность объектов на каких-либо незначительных вычислениях. Сложить два объекта намного дороже, чем сложить два примитива. Рассмотрим пример.

var a = 5 var b = 6 var bOption : Int? = 6

Есть три числа, для первых двух будет выведен not null тип, а про третье мы сами говорим, что он может быть nullable.

private int a = 5; private int b = 6; @Nullable private Integer bOption = Integer.valueOf(6);

Если посмотреть на байт-код и посмотреть, какой Java-код генерируется, то первые два числа not null, и поэтому они могут быть примитивами. Но примитив не может содержать в себе Null, это может делать только объект, поэтому для третьего числа будет сгенерирован объект.

AUTOBOXING

Когда вы работаете с примитивами, и выполняете операцию с примитивом и непримитивом, то либо надо будет один из них перевести в примитив, либо в объект.

Причем, если таких операций много, то вы теряете много. И, казалось бы, неудивительно, что если вы делаете операции с nullable и not nullable в Kotlin, то чуть-чуть теряете в производительности.

val a: String? = null var b = a?.isBlank() == true

Видите, где здесь будет Boxing/Unboxing? Я тоже не видел, пока не посмотрел на байт-код.

if (a != null && a.isBlank()) true else false

Собственно, я ожидал, что будет примерно такое сравнение: если строка не null и если она пустая, то установить true, а иначе — установить false. Вроде все просто, но в реальности генерируется следующий код:

String a = (String)null;
boolean b = Intrinsics.areEqual(a != null ? Boolean.valueOf(StringsKt.isBlank((CharSequence)a)) : null, Boolean.valueOf(true));

Давайте посмотрим внутрь. Берется переменная a, она кастится в CharSequence, после того, как ее закастили, на что тоже уже потратили сколько-то времени, вызывается другая проверка — StringsKt.isBlank — это как extension функция для CharSequence записана, поэтому она кастится и отправляется. Так как первое выражение может быть nullable, он берет его и делает Boxing, и оборачивает это все в Boolean.valueOf. Поэтому же примитив true тоже становится объектом, и только после этого уже происходит проверка и вызывается Intrinsics.areEqual.

На самом деле, таких вещей очень мало. Казалось бы, такая простая операция, а такой неожиданный результат. Поэтому я рекомендую вам как можно раньше уходить от непонятностей. Но когда у вас может быть nullable/not nullable, можно нагенерировать подобного достаточно много, причем такого, чего вы никогда бы не ожидали. как можно раньше приходить к иммутабельности значений и уходить от nullable, чтобы вы как можно быстрее, как можно чаще оперировали not null. Т.е.

LOOPS

Следующая интересная вещь.

Например, можно в цикле вызывать функцию work, где it будет какой-то элемент этого списка. Вы можете использовать обычный for, который есть в Java, но вы точно также можете использовать новый удобный АPI — сразу писать перебор элементов в list.

list.forEach { work(it * 2)
}

Будет сгенерирован итератор и будет банальный перебор по итератору. Это нормально, это много где рекомендуется. Но если мы посмотрим, какие советы дает нам Google, то узнаем, с точки зрения производительности конкретно для ArrayList перебор через for работает в 3 раза быстрее, чем через итератор. Во всех остальных случаях итератор будет работать идентично.

Поэтому если вы уверены, что у вас ArrayList, логично сделать другую вещь — написать свой foreach.

inline fun <reified T> List<T>.foreach(crossinline action: (T)
-> Unit): Unit { val size = size var i = 0 while (i < size) { action(get(i)) i++ }
} list.foreach { }

Это тоже будет API, но который будет генерировать чуть-чуть другой код. Здесь мы используем всю мощь, которую дает нам Kotlin: мы сделаем extension функцию, которая будет «инлайниться», которая будет типа reified, т.е. мы ничего не сотрем, и еще сделаем так, что передадим лямбду, для которой выполним crossinline. Поэтому все везде станет очень хорошо, даже идеально, счет работает очень быстро. В 3 раза быстрее, как и рекомендует нам спецификация Android от Google.

RANGES

Это же мы могли сделать с помощью Ranges.

inline fun <reified T> List<T>.foreach(crossinline action: (T)
-> Unit): Unit { val size = size for(i in 0..size) { work(i * 2) }
}

Предыдущий пример и этот с: Unit будут идентично отработаны в байт-коде. Но если вы попробуете сделать здесь либо −1, либо until добавить, либо другой шаг, то обратно будут итераторы. И кроме этого, будет аллокация для объекта, который будет генерировать ranges. Т.е. вы аллоцируете объект, в который записывается начальная точка. Каждую следующую итерацию будет вызван этот метод со следующим значением step. Про это стоит помнить.

INTRINSICS

Вернёмся-ка к Intrinsics, и рассмотрим еще один интересный пример:

class Test { fun concat(first: String, second: String) = "$first $second"
}

В этом случае Intrinsics вызывается два раза — и для second, и для first.

public final class Test { @NotNull public final String concat(@NotNull String first, @NotNull String second) { Intrinsics.checkParameterIsNotNull(first, "first"); Intrinsics.checkParameterIsNotNull(second, "second"); return first + " " + second; }
}

Их можно выключить, но их нельзя выключить в gradle. Если вы выделите, что у вас очень-очень важно вплоть до этих 4 наносекунд, то вы можете там их отключить. Вы можете сделать модуль Kotlin с UI, где вы точно уверены, что туда не может ничего попасть nullable, и передать напрямую Kotlin компилятору:

kotlinc -Xno-call-assertions -Xno-param-assertions Test.kt

Это вырубит Intrinsics, как проверяющий входные параметры, так и результат.

Но параметр — Xno-param-assertions — вырубает эти два Intrinsics, и все работает очень хорошо. На самом деле, я не видел ни разу, чтобы вторая часть была особо полезна.

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

REDEX

Многие считают, что геттеры и сеттеры, как написано в документации, инлайнятся в Proguard. Но я бы сказал, что в 99% случаев метод, который состоит из одной функции, не будет заинлайнен. В Android 8.0 это оптимизировали, и там уже инвайнится. Остается лишь ждать, когда мы все будем на нем.

В нем также используются оптимизации байт-кода, но точно так же он не инлайнит все, и точно также не инлайнит геттеры и сеттеры. Другой вариант, это использовать кроме Proguard, инструмент от Facebook, который называется Redex. Получается, что Jvm Fields на данный момент единственный вариант, как уйти от геттера и сеттера для простых свойств.

В частности, я создал примитивное приложение, где абсолютно не писал никакого кода, добавил для него Proguard, котрый вырезал все, что только можно было. Кроме этого, в Redex включены другие оптимизации. Мне кажется, это достаточно хорошо. После этого я провернул это приложение еще и через Redex и получил минус 7% к весу APK.

BENCHMARKS

Перейдем к бенчмаркам. Я взял достаточно интересное приложение, у которого много фреймов и много анимаций, чтобы было удобно его мерить. Это приложение написал Ярослав Мыткалык, а я замерил бэнчмарки на четырех разных телефонах. Собственно, я сделал dumpsys gfxinfo и тысячи раз собирал данные, которые после этого свел в итоговое значение. В моем github профиле github.com/smred вы сможете найти исходники и результаты.

Итак, на достаточно слабеньком устройстве Huawei.

Зеленый — максимальный, на разных расчетах всегда прыгал. Фиолетовый столбец показывает минимальный вариант одного кадра. Но, к сожалению, по графику результат бэнчмарка довольно трудно понять — все очень близко, поэтому посмотрим на время отрисовки одного кадра в миллисекундах.
Голубой столбец отражает медианное значение, которое было довольно стабильным, погрешность была порядка 0,04 миллисекунды.

После того, как произведены все оптимизации, отличия почти в рамках погрешности. Когда мы просто перешли на Kotlin, получили немножечко больше времени на кадр. Но если посмотреть среднее, то, конечно, результаты всегда чуть лучше было, чем в просто автосгенерированном котлиновском коде. Но почему-то получилось, что медиана в оптимизированном Kotlin даже больше, чем у Java. На четырех устройствах получилась примерно похожая схема.

Да, из-за некоторых особенностей и отличий, например, в абстракциях, есть немного дополнительной нагрузки, но если вы захотите, вы всегда сможете добиться практически идентичной скорости работы.
Получается, оптимизации, о которых я говорил, действительно помогали и приводили к тому, что Kotlin навскидку практически всегда работает так же как код на Java.

становилось даже лучше. Кстати, еще одна особенность: почему-то в этих бэнчмарках всегда для Kotlin минимальное время на отрисовку одного кадра уменьшалось, т.е. На удивление у какого-то китайского телефона с маленьким разрешением получается времени на отрисовку одного кадра уходило намного-намного меньше — практически в 2 раза меньше, чем у крутого Galaxy S6, с очень большим разрешением экрана.
В среднем же получался либо небольшой рост, либо точно такое же время.

Для него разница очень небольшая, всё в пределах 0,1 милисекунды. Это бэнчмарк на Google Pixel.

Для того, чтобы подвести итог, я бы хотел сказать, что

  • Быстродействие важно только на UI потоке или custom view.
  • Очень критично в onmeasure-onlayout-ondraw. Старайтесь избежать там всех autoboxing, not null параметров и т.д.
  • Практически всегда можно написать код на Kotlin, который будет работать с идентичной Java скоростью, а в некоторых местах даже может получиться быстрее.
  • Преждевременная оптимизация — зло.

Все то, что я сделал, могло затратить у вас очень много времени. Есть разработчики, которые считают, что некоторые современные средства, например, Kotlin, плохи с точки зрения производительности. Но у меня получилось представить доказательства того, что Kotlin никак на это не влияет и можно без проблем его использовать в продакшене.

Не тратьте время там, где могли бы его не тратить.

Хотя, и вся целиком программа будет крутой. Александр Смирнов входит в Программный комитет нашей brand new AppsConf, в том числе благодаря его работе секция Android будет такой сильной. Бронируйте билеты, и увидимся 8 и 9 октября на масштабнейшей конференции по моблиьной тематике.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть