Java 8 в Android со Stream API и лямбдами
от aNNiMON
Как вы знаете, Android основан на Java 6. Google не спешит добавлять поддержку новых версий Java, поэтому приходится мечтать даже об использовании try-with-resources, multi-catch из Java 7, не говоря уже о Lambda Expressions и Stream API.
Но если чего-то сильно захотеть, то никакие преграды не остановят, ведь так? Если мы не можем запустить код из Java 8 в Android-приложениях, то почему бы не преобразовать его в Java 6 совместимый? Именно это и делает проект Retrolambda финского разработчика Esko Luontola.
Retrolambda
На странице проекта есть описание и короткое видео, демонстрирующее, как работает преобразование.
Вкратце, Retrolambda анализирует байт-код скомпилированных классов, ищет вызовы лямбда-выражений, ссылок на методы и прочие нововведения и заменяет их на те аналоги, которые уже присутствуют в указанной версии Java. То есть, лямбды заменятся анонимными классами, multi-catch (если мы компилируем для Java 6) заменится на несколько одинарных вызовов catch и т.д.
Это даёт нам массу безграничных возможностей. Теперь мы можем писать меньше кода, а на этапе компиляции он автоматически адаптируется под наши нужды.
Пример:
В Android очень долго вешать обработчики на кнопки или другие элементы. Настолько этот процесс утомителен, что разработчики стараются упростить это использованием аннотаций или автоматическим генерированием кода. В Java 8 всё становится проще, если использовать лямбды:
При сборке, Retrolambda заменит байткод, соответствующий вызову лямбда-выражения, на вызов анонимного класса, что будет равносильно:
Так зачем писать больше?
Надеюсь, к этому моменту я заинтересовал вас в использовании Java 8 в Android. Теперь поговорим о том, как всё это дело настроить для работы.
Существует плагин для Maven и для Gradle.
Если для сборки вы используете Maven, добавьте в pom.xml следующий код:
Дополнительную информацию можно найти здесь
Для Gradle всё так же просто — в build.gradle добавьте:
Для Android Studio нужно также указать версию Java. Всё в том же build.gradle укажите:
Подробное описание приведено на странице проекта плагина Gradle.
После этого можно приступать к использованию всех вкусностей Java 8 (и Java 7) в своих приложениях. Поддерживаются:
- (Java 8) лямбда-выражения: i -> i * i;
- (Java 8) ссылки на методы: Integer::compare;
- (Java 7) try-with-resources: try (InputStream is = ...) { .. };
- (Java 7) multi-catch: try { .. } catch (NullPointerException | IOException ex) { .. };
- (Java 7) switch для строк: switch (str) { case "add": ... case "remove": ... }
Автор обещает в скором времени добавить поддержку статических методов и методов по умолчанию в интерфейсах.
ВАЖНО: Retrolambda изменяет только языковые возможности, стандартные классы из Java 8 использовать не получится.
Stream API
Ввиду того, что с Java 8 API в Android работать нельзя, но очень хочется использовать Stream API и другие полезные классы, почему бы нам не переписать всё нужное под Java 6?
В библиотеке Lightweight-Stream-API как раз это и сделано. Мы имеем:
- (Java 8) легковесный вариант Stream API (реализованный на итераторах, без параллельной обработки);
- (Java 8) функциональный интерфейс (интерфейсы Predicate, Function, Consumer и прочие);
- (Java 8) класс Optional;
- (Java 7) класс Objects.
Библиотека занимает порядка 30 килобайт, есть версии для Java ME, так что при желании, можно использовать в любой версии Java. Скачать можно на этой странице.
Пример:
Допустим есть список линий, а нам нужно отобрать из него 7 красных перпендикулярных прямых линий, вывести информацию о каждой, отсортировать и сохранить их в списке.
Как бы мы это делали:
А вот как это делается с лямбдами и Lightweight-Stream-API:
Операции над Stream:
- filter(Predicate p) - фильтрует данные по заданному критерию p;
- map(Function f) - применяет функцию к каждому элементу списка. Например, map(p -> p + 1) - увеличивает значение каждого элемента на 1, map(p -> Integer.toHexString(p)) - преобразовывает числа в строку в HEX;
- flatMap(Function f) - как и map, применяет функцию, но работает с другим Stream, упаковывая его в один (без примера не обойтись, будет ниже);
- distinct() - отбирает дублирующиеся значения;
- sorted() / sorted(Comparator c) - сортирует данные;
- peek(Consumer c) - для каждого элемента вызывает заданный обработчик. Пригодится для вывода элементов перед помещением в список, как было показано выше;
- limit(long max) - ограничивает набор элементов;
- skip(long n) - пропускает заданное количество элементов;
- forEach(Consumer c) - работает, как и peek, но после forEach больше нельзя обращаться к Stream - то есть это окончательная операция;
- reduce(identity, BiFunction f) / reduce(BiFunction f) - применяет функцию с двумя аргументами для получения результата (пример ниже);
- collect(Supplier s, BiConsumer acc) / collect(Collector c) - собирает элементы в заданный контейнер. Можно применять как для сбора элементов в список/множество, сбора символов в строку, так и для подсчёта среднего значения в числовых элементах;
- min(Comparator c) / max(Comparator c) - находит минимальное/максимальное значение;
- count() - возвращает количество элементов;
- anyMatch(Predicate p) - возвращает true, если хотя бы один элемент соответствует критерию;
- allMatch(Predicate p) - возвращает true, если все элементы соответствуют критерию;
- noneMatch(Predicate p) - возвращает true, если все элементы НЕ соответствуют критерию;
- findFirst() - возвращает первый элемент в Stream.
Несколько примеров самых важных функций:
Возводит в квадрат каждое число от 0 до 9 включительно.
Преобразует числа в строки вида: "1 ^ 2 = 1", "2 ^ 2 = 4", "3 ^ 2 = 9", ..., "9 ^ 2 = 81"
Выводит таблицу умножения:
2*2=4, 2*3=6, ...
3*2=6, 3*3=9, ...
...
9*2=18, 9*3=27, ...
Иными словами, у нас были числа {2,3,4,5,6,7,8,9}, а мы получили {2[2],2[3],...,2[9],3[1],3[2],...,3[9],...,9[8],9[9]} — вот это и есть flatMap, он "упаковывает" второй Stream в первый.
Суммирует все числа от 0 до 100. Вызов будет происходить так:
0,0 -> 0
0,1 -> 1
1,2 -> 3
3,3 -> 6
6,4 -> 10
10,5 -> 15
Точно так же, можно сделать умножение:
10,4 -> 40
40,6 -> 240
240,8 -> 1920
1920,10 -> 19200
Если в reduce первый аргумент не указывать, то за начальный аргумент принимается первый элемент в стриме (при этом функция не применяется)
??,"a" -> "a" (функция не применяется)
"a","b" -> "a..b"
"a..b","c" -> "a..b..c"
Пример для Android
Для демонстрации возможностей Retrolambda и Lightweight-Stream-API создадим небольшое приложение. Из файла будет считываться набор слов [фраза - перевод] и выводиться в список. Далее с этим списком мы будем производить различные операции с использованием Stream API.
После создания проекта в Android Studio, кидаем Lightweight-Stream-API.jar в папку app/libs, открываем app/build.gradle и дописываем необходимые зависимости. Пример файла build.gradle
Данные будем грузить из файла words.txt в папке assets. Фразы отделены друг от друга символом табуляции. Пример содержимого файла:
baffled сбитый с толку
bedrock основа
beet свёкла
brittle хрупкий
burn out выгореть, сгореть дотла
bushes кусты
be yourself; everyone else is already taken. oscar wilde. Будь собой, все остальные роли уже разобраны. Оскар Уальд.
Сначала прочитаем все строки в файле.
Здесь мы используем try-with-resources, который в обычном случае разрешено использовать только для minSdkVersion >= 19.
Далее, преобразуем список строк в список классов Word.
Вот как будет выглядеть преобразование на примере одной строки:
str = "bedrock основа" -> str.split("\t")
arr = {"bedrock", "основа"} -> отбираем элементы у которых длина массива = 2
arr = {"bedrock", "основа"} -> new Word(arr[0], arr[1])
собираем всё в список
Затем полученный список возвращается и заполняется адаптер:
Есть три кнопки: Distinct, Sort, Info.
Distinct убирает дубликаты в списке.
Sort, понятно, сортирует.
Info показывает информацию о количестве элементов всего в адаптере и в ListView.
Основные действия происходят по нажатию кнопки Go
У нас будут команды "filter 1", "add index", "group" и т.д. На основе этих команд мы будем выполнять нужные операции. Здесь удобно использовать switch для строк:
Теперь об основных операциях.
▌Если нам нужно выбрать только те фразы, которые состоят из одного слова, то разбиваем строку по пробелу и проверяем полученный размер массива:
▌Отобрать 2 и более строк:
▌Отобразить длину строки перевода:
▌Вывести, содержится ли подстрока в заданном наборе:
▌Добавить порядковый номер каждому элементу:
Здесь мы создаём стрим от 0 до [кол-во элементов], затем добавляем это (число+1) к заданному элементу.
Выглядит так:
▌Сгруппировать по первому символу:
Создаём стрим от кода символа 'a' до 'z' включительно, переводим символ в строку, применяем flatMap к 5 фразам, начинающимся с заданной буквы, выводим [буквы, фраза]
'a' -> "a" -> [фразы -> фразы, начинающиеся на "a" -> 5 фраз, начинающихся на "a"]
'b' -> "b" -> [фразы -> фразы, начинающиеся на "b" -> 5 фраз, начинающихся на "b"]
и т.д.
Без лямбд и Stream API, пришлось бы писать много вложенных циклов, сравнений и присвоений, а так, получается очень компактно.
Исходный код проекта: GitHub или Java8StreamExample.zip
Приложение: Java8StreamExample.apk
Библиотека Lightweight-Stream-API: GitHub | версия для Java ME
Retrolambda: https://github.com/orfjackal/retrolambda
Но если чего-то сильно захотеть, то никакие преграды не остановят, ведь так? Если мы не можем запустить код из Java 8 в Android-приложениях, то почему бы не преобразовать его в Java 6 совместимый? Именно это и делает проект Retrolambda финского разработчика Esko Luontola.
Retrolambda
На странице проекта есть описание и короткое видео, демонстрирующее, как работает преобразование.
Вкратце, Retrolambda анализирует байт-код скомпилированных классов, ищет вызовы лямбда-выражений, ссылок на методы и прочие нововведения и заменяет их на те аналоги, которые уже присутствуют в указанной версии Java. То есть, лямбды заменятся анонимными классами, multi-catch (если мы компилируем для Java 6) заменится на несколько одинарных вызовов catch и т.д.
Это даёт нам массу безграничных возможностей. Теперь мы можем писать меньше кода, а на этапе компиляции он автоматически адаптируется под наши нужды.
Пример:
В Android очень долго вешать обработчики на кнопки или другие элементы. Настолько этот процесс утомителен, что разработчики стараются упростить это использованием аннотаций или автоматическим генерированием кода. В Java 8 всё становится проще, если использовать лямбды:
- mButton.setOnClickListener(v -> v.setBackgroundColor(0xFF00FF00));
- mButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- v.setBackgroundColor(0xFF00FF00);
- }
- });
Надеюсь, к этому моменту я заинтересовал вас в использовании Java 8 в Android. Теперь поговорим о том, как всё это дело настроить для работы.
Существует плагин для Maven и для Gradle.
Если для сборки вы используете Maven, добавьте в pom.xml следующий код:
- <plugin>
- <groupId>net.orfjackal.retrolambda</groupId>
- <artifactId>retrolambda-maven-plugin</artifactId>
- <version>1.8.1</version>
- <executions>
- <execution>
- <goals>
- <goal>process-main</goal>
- <goal>process-test</goal>
- </goals>
- </execution>
- </executions>
- </plugin>
Для Gradle всё так же просто — в build.gradle добавьте:
- buildscript {
- repositories {
- mavenCentral()
- }
- dependencies {
- classpath 'me.tatarka:gradle-retrolambda:2.5.0'
- }
- }
- repositories {
- mavenCentral()
- }
- apply plugin: 'me.tatarka.retrolambda'
Для Android Studio нужно также указать версию Java. Всё в том же build.gradle укажите:
- android {
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- }
Подробное описание приведено на странице проекта плагина Gradle.
После этого можно приступать к использованию всех вкусностей Java 8 (и Java 7) в своих приложениях. Поддерживаются:
- (Java 8) лямбда-выражения: i -> i * i;
- (Java 8) ссылки на методы: Integer::compare;
- (Java 7) try-with-resources: try (InputStream is = ...) { .. };
- (Java 7) multi-catch: try { .. } catch (NullPointerException | IOException ex) { .. };
- (Java 7) switch для строк: switch (str) { case "add": ... case "remove": ... }
Автор обещает в скором времени добавить поддержку статических методов и методов по умолчанию в интерфейсах.
ВАЖНО: Retrolambda изменяет только языковые возможности, стандартные классы из Java 8 использовать не получится.
Stream API
Ввиду того, что с Java 8 API в Android работать нельзя, но очень хочется использовать Stream API и другие полезные классы, почему бы нам не переписать всё нужное под Java 6?
В библиотеке Lightweight-Stream-API как раз это и сделано. Мы имеем:
- (Java 8) легковесный вариант Stream API (реализованный на итераторах, без параллельной обработки);
- (Java 8) функциональный интерфейс (интерфейсы Predicate, Function, Consumer и прочие);
- (Java 8) класс Optional;
- (Java 7) класс Objects.
Библиотека занимает порядка 30 килобайт, есть версии для Java ME, так что при желании, можно использовать в любой версии Java. Скачать можно на этой странице.
Пример:
Допустим есть список линий, а нам нужно отобрать из него 7 красных перпендикулярных прямых линий, вывести информацию о каждой, отсортировать и сохранить их в списке.
Как бы мы это делали:
- List<Lines> red7lines = new ArrayList<>();
- for (Line line : allLines) {
- if (line.getColor() == RED) {
- System.out.println(line);
- red7lines.add(line);
- }
- if (red7lines.size() >= 7) break;
- }
- Collections.sort(red7lines);
А вот как это делается с лямбдами и Lightweight-Stream-API:
- List<Lines> red7lines = Stream.of(allLines)
- .filter(line -> line.getColor() == RED)
- .limit(7)
- .peek(System.out::println)
- .sorted()
- .collect(Collectors.toList());
Операции над Stream:
- filter(Predicate p) - фильтрует данные по заданному критерию p;
- map(Function f) - применяет функцию к каждому элементу списка. Например, map(p -> p + 1) - увеличивает значение каждого элемента на 1, map(p -> Integer.toHexString(p)) - преобразовывает числа в строку в HEX;
- flatMap(Function f) - как и map, применяет функцию, но работает с другим Stream, упаковывая его в один (без примера не обойтись, будет ниже);
- distinct() - отбирает дублирующиеся значения;
- sorted() / sorted(Comparator c) - сортирует данные;
- peek(Consumer c) - для каждого элемента вызывает заданный обработчик. Пригодится для вывода элементов перед помещением в список, как было показано выше;
- limit(long max) - ограничивает набор элементов;
- skip(long n) - пропускает заданное количество элементов;
- forEach(Consumer c) - работает, как и peek, но после forEach больше нельзя обращаться к Stream - то есть это окончательная операция;
- reduce(identity, BiFunction f) / reduce(BiFunction f) - применяет функцию с двумя аргументами для получения результата (пример ниже);
- collect(Supplier s, BiConsumer acc) / collect(Collector c) - собирает элементы в заданный контейнер. Можно применять как для сбора элементов в список/множество, сбора символов в строку, так и для подсчёта среднего значения в числовых элементах;
- min(Comparator c) / max(Comparator c) - находит минимальное/максимальное значение;
- count() - возвращает количество элементов;
- anyMatch(Predicate p) - возвращает true, если хотя бы один элемент соответствует критерию;
- allMatch(Predicate p) - возвращает true, если все элементы соответствуют критерию;
- noneMatch(Predicate p) - возвращает true, если все элементы НЕ соответствуют критерию;
- findFirst() - возвращает первый элемент в Stream.
Несколько примеров самых важных функций:
- Stream.ofRange(0, 10).map(p -> p * p)
- Stream.ofRange(0, 10).map(p -> String.format("%d ^ 2 = %d", p, p * p))
- Stream.ofRange(2, 10).flatMap(
- i -> Stream.ofRange(2, 10).map(
- j -> String.format("%d*%d=%d ", i, j, (i*j))
- )
- ).forEach(System.out::println);
2*2=4, 2*3=6, ...
3*2=6, 3*3=9, ...
...
9*2=18, 9*3=27, ...
Иными словами, у нас были числа {2,3,4,5,6,7,8,9}, а мы получили {2[2],2[3],...,2[9],3[1],3[2],...,3[9],...,9[8],9[9]} — вот это и есть flatMap, он "упаковывает" второй Stream в первый.
- Stream.ofRange(0, 100).reduce(0, (i,j) -> i + j)
0,0 -> 0
0,1 -> 1
1,2 -> 3
3,3 -> 6
6,4 -> 10
10,5 -> 15
Точно так же, можно сделать умножение:
- Stream.of(4,6,8,10).reduce(10, (i,j) -> i * j)
40,6 -> 240
240,8 -> 1920
1920,10 -> 19200
Если в reduce первый аргумент не указывать, то за начальный аргумент принимается первый элемент в стриме (при этом функция не применяется)
- Stream.of("a","b","c").reduce((i,j) -> String.format("%s..%s", i, j));
"a","b" -> "a..b"
"a..b","c" -> "a..b..c"
Пример для Android
Для демонстрации возможностей Retrolambda и Lightweight-Stream-API создадим небольшое приложение. Из файла будет считываться набор слов [фраза - перевод] и выводиться в список. Далее с этим списком мы будем производить различные операции с использованием Stream API.
После создания проекта в Android Studio, кидаем Lightweight-Stream-API.jar в папку app/libs, открываем app/build.gradle и дописываем необходимые зависимости. Пример файла build.gradle
Данные будем грузить из файла words.txt в папке assets. Фразы отделены друг от друга символом табуляции. Пример содержимого файла:
baffled сбитый с толку
bedrock основа
beet свёкла
brittle хрупкий
burn out выгореть, сгореть дотла
bushes кусты
be yourself; everyone else is already taken. oscar wilde. Будь собой, все остальные роли уже разобраны. Оскар Уальд.
Сначала прочитаем все строки в файле.
- final List<String> lines = new ArrayList<>();
- try (final InputStream is = context.getAssets().open("words.txt");
- final InputStreamReader isr = new InputStreamReader(is, "UTF-8");
- final BufferedReader reader = new BufferedReader(isr)) {
- String line;
- while ( (line = reader.readLine()) != null ) {
- lines.add(line);
- }
- } catch (IOException e) {
- Log.e("Java 8 Example", "Utils.readWords", e);
- }
Далее, преобразуем список строк в список классов Word.
- return Stream.of(lines)
- .map(str -> str.split("\t"))
- .filter(arr -> arr.length == 2)
- .map(arr -> new Word(arr[0], arr[1]))
- .collect(Collectors.toList(new Word[0]));
str = "bedrock основа" -> str.split("\t")
arr = {"bedrock", "основа"} -> отбираем элементы у которых длина массива = 2
arr = {"bedrock", "основа"} -> new Word(arr[0], arr[1])
собираем всё в список
Затем полученный список возвращается и заполняется адаптер:
- mAdapter = new WordAdapter(this, Utils.readWords(this));
Есть три кнопки: Distinct, Sort, Info.
Distinct убирает дубликаты в списке.
- findViewById(R.id.distinct).setOnClickListener(v -> {
- Stream.of(mAdapter.getCurrentList())
- .distinct()
- .collect(Utils.collectAdapter(mAdapter));
- });
- findViewById(R.id.sort).setOnClickListener(v -> {
- Stream.of(mAdapter.getCurrentList())
- .sorted()
- .collect(Utils.collectAdapter(mAdapter));
- });
- findViewById(R.id.info).setOnClickListener(v -> {
- long all = mAdapter.getWords().size();
- long list = mListView.getCount();
- String text = String.format("%d items all\n%d items in list", all, list);
- Toast.makeText(getApplicationContext(), text, Toast.LENGTH_SHORT).show();
- });
Основные действия происходят по нажатию кнопки Go
- findViewById(R.id.go).setOnClickListener(v -> {
- final int index = mActionSpinner.getSelectedItemPosition();
- if (index != Spinner.INVALID_POSITION) {
- action(actions[index]);
- }
- });
- switch (action) {
- case "filter 1": /* ... */ break;
- case "filter 2+":/* ... */ break;
- case "filter %N words": /* ... */ break;
- case "add index": /* ... */ break;
- }
Теперь об основных операциях.
▌Если нам нужно выбрать только те фразы, которые состоят из одного слова, то разбиваем строку по пробелу и проверяем полученный размер массива:
- stream.filter(p -> p.getWord().split(" ").length == 1)
▌Отобрать 2 и более строк:
- stream.filter(p -> p.getWord().split(" ").length >= 2)
▌Отобразить длину строки перевода:
- stream.map(p -> p.setWord( String.valueOf(p.getTranslate().length()) ))
▌Вывести, содержится ли подстрока в заданном наборе:
- stream.map(p -> print(p.getWord().contains("ok") ? "yes" : "no"))
▌Добавить порядковый номер каждому элементу:
- Stream.ofRange(0, mAdapter.getCount())
- .map(i -> String.format("%d. %s", i+1, mAdapter.getItem(i).getWord()))
- .map(str -> new Word(str, ""));
Выглядит так:
- 0 -> "1. " + getItem(0)
- 1 -> "2. " + getItem(1)
- 2 -> "3. " + getItem(2)
▌Сгруппировать по первому символу:
- Stream.ofRange('a', 'z'+1)
- .map(i -> String.valueOf((char) i.shortValue()))
- .flatMap(s -> Stream.of(mAdapter.getWords())
- .filter(w -> w.getWord().startsWith(s))
- .limit(5))
- .map(w -> new Word(String.valueOf(w.getWord().charAt(0)), w.getWord()));
Создаём стрим от кода символа 'a' до 'z' включительно, переводим символ в строку, применяем flatMap к 5 фразам, начинающимся с заданной буквы, выводим [буквы, фраза]
'a' -> "a" -> [фразы -> фразы, начинающиеся на "a" -> 5 фраз, начинающихся на "a"]
'b' -> "b" -> [фразы -> фразы, начинающиеся на "b" -> 5 фраз, начинающихся на "b"]
и т.д.
Без лямбд и Stream API, пришлось бы писать много вложенных циклов, сравнений и присвоений, а так, получается очень компактно.
Исходный код проекта: GitHub или Java8StreamExample.zip
Приложение: Java8StreamExample.apk
Библиотека Lightweight-Stream-API: GitHub | версия для Java ME
Retrolambda: https://github.com/orfjackal/retrolambda