Pull to refresh

Разработка производительных приложений

Reading time9 min
Views5.6K
Original author: Android Developers Guide

Производительные приложения



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

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


Интро

Всего два основных правила разработки производительного кода:

  • Не делай работу, которую делать не нужно
  • Не выделяй память, которую можно не выделять

Оптимизируйте с умом

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

Так же предполагается, что вы уже выбрали лучшие алгоритмы и структуры данных, и предусмотрели влияние ваших решений относительно API на производительность. Использование правильных структур данных и алгоритмов гораздо сильнее улучшает производительность, чем любой из данных советов, и тщательное обдумывание влияния API на производительность облегчит переход на более качественную реализацию в дальнейшем(что в первую очередь важно для кода библиотек, нежели кода приложений).

Одной из самых хитрых трудностей, которую вы повстречаете во время микрооптимизации приложения для Android, станет то, что ваше приложение должно с высокой вероятностью работать на множестве разных аппаратных платформ. Разные версии виртуальной машины на различных процессорах работают с разной скоростью. Вобщем, это даже не тот случай, когда можно просто сказать «Устройство X в n раз быстрее/медленнее устройства Y» и проэкстраполировать результаты на другие устройства. В частности, тестирование на эмуляторе практически ничего не говорит о производительности на любом устройстве. Существует также огромная разница между устройствами с и без JIT: «лучшй» код для устройства с JIT не всегда остается лучшем для устройства, его лишенного.

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

Избегайте создания ненужных объектов

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

Если вы выделяете объекты в цикле в пользовательском интерфейсе, вы принудительно вызываете периодическую сборку мусора, создавая маленькие «заикания», которые видны пользователю. Параллельный сборщик мусора, появившийся в Gingerbread, может помочь и в этом, но ненужной работы всегда стоит избегать.

Таким образом, следует избегать создания объектов, в которых нет необходимости. Вот пара примеров, которые могут в этом помочь:

  • Если есть метод, который возвращает строку и известно, что результат всегда будет добавлен в StringBuffer, измените реализацию таким образом, чтобы метод выполнял добавление напрямую вместо создания короткоживущего временного объекта.
  • При извлечении строки из набора входных данных, постарайтесь возвращать подстроку начальных данных вместо создания копии. Будет создан новый объект String, но массив символов для него и начальных данных будет общий. (Компромисс в том, что если используется только малая часть из начального ввода, то все равно оно будет хранится в памяти целиком, если следовать этому совету).


Возьмем кое-что более радикальное: деление многомерных массивов в один параллельный одномерный массив:

  • Массив int гораздо лучше, чем массив Integer. Но можно обобщить этот факт: два параллельных массива int так же гораздо эффективней массива объектов (int, int). Тоже самое касается любой комбинации примитивов.
  • Если нужно реализовать контейнер, который содержит в себе пары (Foo, Bar), вспомните, что два параллельных массива Foo[] и Bar[] вобщем гораздо лучше, чем один массив (Foo, Bar) объектов. (Исключение составляет случай, когда вы разрабатывает API; для этих случаев лучше держаться хорошего API в небольшой ущерб производительности. Но в собственном внутреннем коде стоит пытаться быть как можно эффективней.)


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

Мифы о производительности

Старые версии данного руководства содержат различные некорректные заявления. Мы обратимся к некоторым из них.

На устройствах без JIT вызов методов на объекте конкретного класса немного быстрее, чем вызов через интерфейс. (Таким образом дешевле вызывать методы HashMap вместо Map, даже если это тот же объект.) Быстрее не в 2 раза. Реальное число более близко к 6%. Более того, с JIT разницы незаметно вообще.

На устройствах без JIT кэширование обращений к полям класса на 20% быстрее повторящегося обращения непосредственно к полю. С JIT цена обращения к полю равна цене обращения по локальному адресу, так что эта оптимизация оказывается не нужна до тех пор, пока не кажется, что она делает код читабельней. (Что является правдой касательно final, static и static final полей).

Предпочитайте статическое виртуальному

Если нет нужды в обращении к полям объекта, то метод можно сделать статическим. Вызов станет на 15-20% быстрее. Это хорошо и потому, что можно по сигнатуре сказать, что метод не меняет состояния объекта.

Избегайте внутренних методов-акцессоров

В нативных языках, таких как С++, хорошей практикой считается использование геттеров(e.g. i = getCount()) взамен прямого доступа(i = mCount). Это замечательная привычка для С++, потому что компилятор обычно может выполнить инлайн-подстановку и, если нужно ограничить или отладить обращение к полю, то можно добавить нужный код в любое время.

Для Android же это плохая идея. Вызов виртуальных методов достаточно дорог — гораздо дороже, чем поиск полей объекта. Конечно, использование общих практик ООП и использование геттеров и сеттеров в интерфейсе является обоснованным, но внутри класса всегда следует обращаться к полям напрямую.

Без JIT прямой доступ до поля примерно в 3 раза быстрее, чем вызов обычного геттера. С JIT, где прямое обращение равно по скорости прямому обращению к локальному адресу, прямой доступ будет примерно в 7 раз быстрее вызова метода-аксессора. Это утверждение верно уже для Froyo, но в будущих релизах ситуация улучшится засчет того, что JIT инлайнит геттеры.

Используйте static final для констант

Рассмотрим следующее объявление в начале класса:

 static int intVal = 42;
 static String strVal = "Hello, world!";


Компилятор генерирует метод инициализации класса, с именем , который исполняется, когда класс используется в первый раз. Метод присваивает значение 42 переменной intVal и извлекает ссылку из таблицы неизменяемых строк класс-файла для strVal. Когда произойдет обращение к этим переменным, будет произведен поиск соответствующих полей класса.

Вот как мы можем изменить это поведение одним ключевым словом «final»:

 static final int intVal = 42;
 static final String strVal = "Hello, world!";


Больше не нужен метод , потому что константы записываются в статические инициализаторы полей в dex файле. Код, который обращается к intVal, использует целое значение 42 напрямую, а доступ к strVal вызовет недорогое обращение к строковой константе вместо поиска поля. (Отметим, что эта оптимизация работает только в отношении примитивов и строк, а не всех произвольных типов ссылок. Несмотря на это, объявления констант как static final следует использовать везде, где это возможно.)

Использование улучшенного синтаксиса цикла For

Цикл «for-each» может быть использован для коллекций, которые реализуют интерфейс Iterable и массивов. Для коллекций выделяется итератор в целях вызова методов hasNext() и next(). Для ArrayList классический цикл с счетчиком примерно в 3 раза быстрее(с или без JIT), но для прочих коллекций, «for-each» синтаксис будет эквивалентен явному использованию итератора.

Существует несколько альтернатив прохода по массиву:

    static class Foo {
        int mSplat;
    }
    Foo[] mArray = ...

    public void zero() {
        int sum = 0;
        for (int i = 0; i < mArray.length; ++i) {
            sum += mArray[i].mSplat;
        }
    }

    public void one() {
        int sum = 0;
        Foo[] localArray = mArray;
        int len = localArray.length;

        for (int i = 0; i < len; ++i) {
            sum += localArray[i].mSplat;
        }
    }

    public void two() {
        int sum = 0;
        for (Foo a : mArray) {
            sum += a.mSplat;
        }
    }


zero() — самый медленный метод, потому что JIT не может соптимизировать получение длины массива на каждом шаге итерации.

one() выполняется быстрее. Он вытягивает нужную информацию в локальные переменные, избегая поиска поля. Только array.length здесь улучшает производительность.

two() быстрее для устройств без JIT и неотличим от one() для устройств с JIT. Он использует расширенный синтаксис for, представленный в Java 1.5

В итоге: по умолчанию используйте for-each синтаксис, но думайте о ручном итерировании для критичных по производительности проходам по ArrayList. (См. также Effective Java, пункт 46.)

Используйте package-private доступ вместо private для приватных внутренних классов.

Рассмотрим следующее определение класса:

  public class Foo {
    private class Inner {
        void stuff() {
            Foo.this.doStuff(Foo.this.mValue);
        }
    }

    private int mValue;

    public void run() {
        Inner in = new Inner();
        mValue = 27;
        in.stuff();
    }

    private void doStuff(int value) {
        System.out.println("Значение равно " + value);
    }
}


Самое главное, что стоит отметить, это определение приватного внутреннего класса (Foo$Inner), который напрямую обращается к приватному методу и приватное поле во внешнем классе. Код правильный и выводит «Значение равно 27», как и ожидается.
Проблемой здесь является то, что виртуальная машина считает прямое обращение к приватным членам Foo из внутреннего класса недопустимым, потому что Foo и Foo$Inner являются разными классами, даже несмотря на то, что Java разрешает внутренним классам доступ до приватных членов внешних классов. Чтобы преодолеть этот разрыв, компилятор генерирует пару искуственых методов:

/*package*/ static int Foo.access$100(Foo foo) {
    return foo.mValue;
}
/*package*/ static void Foo.access$200(Foo foo, int value) {
    foo.doStuff(value);
}


Внутренний класс вызывает эти статические методы каждый раз, когда нуждается в доступе к mValue или вызывает doStuff() внешнего класса. Это значит, что код выше превращается в случай, когда происходит доступ до полей класса через методы-аксессоры. Мы уже обсуждали вопрос о медлительности таких методов перед прямым доступом до полей, так что выходит, что конкретная идиома языка выливается в «невидимое» ухудшение производительности.

Если критический к производительности кусок приложения использует похожий код, то можно избежать подобного поведения, объявляя поля и методы, к которым происходит доступ из внутреннего класса, package-private вместо private. К сожалению, это означает, что поля будут доступными из других классов пакета, так что этот прием нельзя использовать в публичном API.

Используйте числа с плавающей точкой с умом

Вкратце, вычисления с плавающей точкой примерно в 2 раза медленее целочисленных на устройствах Android. Это верно для G1(без JIT и FPU) и Nexus One с FPU и JIT. (Хотя конечно, абсолютная разница между двумя этими устройствами по скорости арифметических операций составляет порядка 10 раз).

В терминах скорости, нет никакой разницы между float и double на более современном железе. По памяти, double в 2 раза больше. Для настольных компьютеров, не беря в расчет память, следует предпочитать double вместо float.

Так же, некоторые чипы несут на борту интегрированное умножение целых, но не имеют интегрированного целочисленного деления. В таких случаях, целочисленное деление и операции по модулю выполняются на уровне ПО. Подумайте над этим, если вы пишете хэш-таблицу или выполняете множество математических операций.

Знайте и используйте библиотеки

В дополнение ко всем обычным причинам использовать код библиотек вместо написания своего собственного, держите в голове тот факт, что система может заменить библиотечный код на ассемблерные вставки, которые могут быть быстрее лучшего кода, произведенного JIT-компилятором для Java-эквивалента. Типичным примером может служить String.indexOf и прочие методы, которые Dalvik заменяет на внутренний код. Из-за этого же System.arraycopy примерно в 9(!) раз быстрее, чем реализованный вручную цикл на Nexus One с имеющиймся JIT. (См. также Effective Java, пункт 47.)

Используйте нативные методы с умом


Нативный код не обязательно более эффективен, чем Java. По одной причине: существует цена за переход Java -> нативный код, и JIT не может ничего сделать в этих границах. Если вы выделяете нативные ресурсы
(память в куче, файловые дескрипторы или что-либо еще), то сложность своевременного сбора этих ресурсов заметно возрастает. Так же приходится компилировать код для каждой архитектуры, на которой планируется его запускать(вместо того, чтобы полагаться в этом на JIT). Можно даже собрать несколько версий для одной и той же архитектуры: нативный код, собранный для ARM-процессора в G1 не может использовать все преимущества того же процессора, но в Nexus One, а код, собранный для Nexus One просто не запустится на G1.

Нативный код в основном полезен, если существует некоторая нативная база, которую хочется портировать на Android, а не для ускорения отдельных частей Java-приложения. (См. также Effective Java, пункт54.)

Напоследок


Одна последняя вещь: всегда измеряйте. Перед тем, как начать оптимизировать, убедитесь, что у вас проблема. Убедитесь, что можно аккуратно измерить существующую производительность, иначе вы не сможете измерить преимущство, полученное от альтернативных решений.

Каждое заявление, сделанное здесь, подкреплено тестом. Исходные коды могут быть найдены на code.google.com, в проекте «dalvik».

Эти тесты написаны с помощью фреймворка микроизмерений Caliper. Микроизмерения трудно выполнить правильно, так что Caliper помогает проделать тяжелую работу за вас, и даже определяет некоторые случаи, когда вы измеряете не то, что пытаетесь измерить(например, потому, что виртуальная машина соптимизировала весь ваш код напрочь). Мы настоятельно рекомендуем использовать Caliper для производства собственных микроизмерений.

Можно также воспользоваться Traceview для профилирования, но важно понимать, что сейчас он выключает JIT, что ведет к большому времени выполнения, которое JIT может отыграть. Особенно важно после внесения изменений, предложенных Traceview, убедиться, что полученный код на самом деле исполняется быстрее, будучи запущенным без Traceview.
Tags:
Hubs:
+65
Comments9

Articles