Java 8 в Android со Stream API и лямбдами

от
Android    retrolambda, stream api, java 8, library

Как вы знаете, 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 всё становится проще, если использовать лямбды:
  1. mButton.setOnClickListener(v -> v.setBackgroundColor(0xFF00FF00));
При сборке, Retrolambda заменит байткод, соответствующий вызову лямбда-выражения, на вызов анонимного класса, что будет равносильно:
  1. mButton.setOnClickListener(new View.OnClickListener() {
  2.     @Override
  3.     public void onClick(View v) {
  4.         v.setBackgroundColor(0xFF00FF00);
  5.     }
  6. });
Так зачем писать больше?

   Надеюсь, к этому моменту я заинтересовал вас в использовании Java 8 в Android. Теперь поговорим о том, как всё это дело настроить для работы.

Существует плагин для Maven и для Gradle.
Если для сборки вы используете Maven, добавьте в pom.xml следующий код:
  1. <plugin>
  2.     <groupId>net.orfjackal.retrolambda</groupId>
  3.     <artifactId>retrolambda-maven-plugin</artifactId>
  4.     <version>1.8.1</version>
  5.     <executions>
  6.         <execution>
  7.             <goals>
  8.                 <goal>process-main</goal>
  9.                 <goal>process-test</goal>
  10.             </goals>
  11.         </execution>
  12.     </executions>
  13. </plugin>
Дополнительную информацию можно найти здесь

Для Gradle всё так же просто — в build.gradle добавьте:
  1. buildscript {
  2.   repositories {
  3.      mavenCentral()
  4.   }
  5.   dependencies {
  6.      classpath 'me.tatarka:gradle-retrolambda:2.5.0'
  7.   }
  8. }
  9.  
  10. repositories {
  11.   mavenCentral()
  12. }
  13.  
  14. apply plugin: 'me.tatarka.retrolambda'

Для Android Studio нужно также указать версию Java. Всё в том же build.gradle укажите:
  1. android {
  2.   compileOptions {
  3.     sourceCompatibility JavaVersion.VERSION_1_8
  4.     targetCompatibility JavaVersion.VERSION_1_8
  5.   }
  6. }

Подробное описание приведено на странице проекта плагина 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 красных перпендикулярных прямых линий, вывести информацию о каждой, отсортировать и сохранить их в списке.
Как бы мы это делали:
  1. List<Lines> red7lines = new ArrayList<>();
  2. for (Line line : allLines) {    
  3.     if (line.getColor() == RED) {
  4.         System.out.println(line);
  5.         red7lines.add(line);
  6.     }
  7.     if (red7lines.size() >= 7) break;
  8. }
  9. Collections.sort(red7lines);

А вот как это делается с лямбдами и Lightweight-Stream-API:
  1. List<Lines> red7lines = Stream.of(allLines)
  2.         .filter(line -> line.getColor() == RED)
  3.         .limit(7)
  4.         .peek(System.out::println)
  5.         .sorted()
  6.         .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.

Несколько примеров самых важных функций:
  1. Stream.ofRange(0, 10).map(p -> p * p)
Возводит в квадрат каждое число от 0 до 9 включительно.

  1. Stream.ofRange(0, 10).map(p -> String.format("%d ^ 2 = %d", p, p * p))
Преобразует числа в строки вида: "1 ^ 2 = 1", "2 ^ 2 = 4", "3 ^ 2 = 9", ..., "9 ^ 2 = 81"

  1. Stream.ofRange(2, 10).flatMap(
  2.     i -> Stream.ofRange(2, 10).map(
  3.         j -> String.format("%d*%d=%d ", i, j, (i*j))
  4.     )
  5. ).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 в первый.

  1. Stream.ofRange(0, 100).reduce(0, (i,j) -> i + j)
Суммирует все числа от 0 до 100. Вызов будет происходить так:
0,0 -> 0
  0,1 -> 1
   1,2 -> 3
    3,3 -> 6
     6,4 -> 10
      10,5 -> 15

Точно так же, можно сделать умножение:
  1. Stream.of(4,6,8,10).reduce(10, (i,j) -> i * j)
10,4 -> 40
  40,6 -> 240
   240,8 -> 1920
    1920,10 -> 19200

Если в reduce первый аргумент не указывать, то за начальный аргумент принимается первый элемент в стриме (при этом функция не применяется)
  1. Stream.of("a","b","c").reduce((i,j) -> String.format("%s..%s", i, j));
??,"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. Будь собой, все остальные роли уже разобраны. Оскар Уальд.

Сначала прочитаем все строки в файле.
  1. final List<String> lines = new ArrayList<>();
  2. try (final InputStream is = context.getAssets().open("words.txt");
  3.      final InputStreamReader isr = new InputStreamReader(is, "UTF-8");
  4.      final BufferedReader reader = new BufferedReader(isr)) {
  5.     String line;
  6.     while ( (line = reader.readLine()) != null ) {
  7.         lines.add(line);
  8.     }
  9. } catch (IOException e) {
  10.     Log.e("Java 8 Example", "Utils.readWords", e);
  11. }
Здесь мы используем try-with-resources, который в обычном случае разрешено использовать только для minSdkVersion >= 19.
Далее, преобразуем список строк в список классов Word.
  1. return Stream.of(lines)
  2.         .map(str -> str.split("\t"))
  3.         .filter(arr -> arr.length == 2)
  4.         .map(arr -> new Word(arr[0], arr[1]))
  5.         .collect(Collectors.toList(new Word[0]));
Вот как будет выглядеть преобразование на примере одной строки:
str = "bedrock основа" -> str.split("\t")
  arr = {"bedrock", "основа"} -> отбираем элементы у которых длина массива = 2
   arr = {"bedrock", "основа"} -> new Word(arr[0], arr[1])
     собираем всё в список

Затем полученный список возвращается и заполняется адаптер:
  1. mAdapter = new WordAdapter(this, Utils.readWords(this));

Есть три кнопки: Distinct, Sort, Info.
Distinct убирает дубликаты в списке.
  1. findViewById(R.id.distinct).setOnClickListener(v -> {
  2.     Stream.of(mAdapter.getCurrentList())
  3.             .distinct()
  4.             .collect(Utils.collectAdapter(mAdapter));
  5. });
Sort, понятно, сортирует.
  1. findViewById(R.id.sort).setOnClickListener(v -> {
  2.     Stream.of(mAdapter.getCurrentList())
  3.             .sorted()
  4.             .collect(Utils.collectAdapter(mAdapter));
  5. });
Info показывает информацию о количестве элементов всего в адаптере и в ListView.
  1. findViewById(R.id.info).setOnClickListener(v -> {
  2.     long all = mAdapter.getWords().size();
  3.     long list = mListView.getCount();
  4.     String text = String.format("%d items all\n%d items in list", all, list);
  5.     Toast.makeText(getApplicationContext(), text, Toast.LENGTH_SHORT).show();
  6. });

Основные действия происходят по нажатию кнопки Go
  1. findViewById(R.id.go).setOnClickListener(v -> {
  2.     final int index = mActionSpinner.getSelectedItemPosition();
  3.     if (index != Spinner.INVALID_POSITION) {
  4.         action(actions[index]);
  5.     }
  6. });
У нас будут команды "filter 1", "add index", "group" и т.д. На основе этих команд мы будем выполнять нужные операции. Здесь удобно использовать switch для строк:
  1. switch (action) {
  2.     case "filter 1": /* ... */ break;
  3.     case "filter 2+":/* ... */ break;
  4.     case "filter %N words": /* ... */ break;
  5.     case "add index": /* ... */ break;
  6. }

Теперь об основных операциях.
Если нам нужно выбрать только те фразы, которые состоят из одного слова, то разбиваем строку по пробелу и проверяем полученный размер массива:
  1. stream.filter(p -> p.getWord().split(" ").length == 1)

Отобрать 2 и более строк:
  1. stream.filter(p -> p.getWord().split(" ").length >= 2)
stream_api_android_1.png

Отобразить длину строки перевода:
  1. stream.map(p -> p.setWord( String.valueOf(p.getTranslate().length()) ))
  stream_api_android_4.png

Вывести, содержится ли подстрока в заданном наборе:
  1. stream.map(p -> print(p.getWord().contains("ok") ? "yes" : "no"))
stream_api_android_3.png

Добавить порядковый номер каждому элементу:
  1. Stream.ofRange(0, mAdapter.getCount())
  2.     .map(i -> String.format("%d. %s", i+1, mAdapter.getItem(i).getWord()))
  3.     .map(str -> new Word(str, ""));
Здесь мы создаём стрим от 0 до [кол-во элементов], затем добавляем это (число+1) к заданному элементу.
stream_api_android_5.png
Выглядит так:
  1. 0 -> "1. " + getItem(0)
  2.  1 -> "2. " + getItem(1)
  3.   2 -> "3. " + getItem(2)

Сгруппировать по первому символу:
  1. Stream.ofRange('a', 'z'+1)
  2.     .map(i -> String.valueOf((char) i.shortValue()))
  3.     .flatMap(s -> Stream.of(mAdapter.getWords())
  4.             .filter(w -> w.getWord().startsWith(s))
  5.             .limit(5))
  6.     .map(w -> new Word(String.valueOf(w.getWord().charAt(0)), w.getWord()));
stream_api_android_7.png
Создаём стрим от кода символа 'a' до 'z' включительно, переводим символ в строку, применяем flatMap к 5 фразам, начинающимся с заданной буквы, выводим [буквы, фраза]
'a' -> "a" -> [фразы -> фразы, начинающиеся на "a" -> 5 фраз, начинающихся на "a"]
'b' -> "b" -> [фразы -> фразы, начинающиеся на "b" -> 5 фраз, начинающихся на "b"]
и т.д.


   Без лямбд и Stream API, пришлось бы писать много вложенных циклов, сравнений и присвоений, а так, получается очень компактно.
stream_api_android_2.png stream_api_android_6.png

Исходный код проекта: GitHub или Java8StreamExample.zip
Приложение: Java8StreamExample.apk
Библиотека Lightweight-Stream-API: GitHub | версия для Java ME
Retrolambda: https://github.com/orfjackal/retrolambda
  • +9
  • views 25157