Javanese Online

Hype-driven Android-development, или как инженерная специальность превращается в маркетинг

Я привык считать программирование инженерной специальностью. То есть такой, где нужно проектировать, рассчитывать, измерять, а выбор обосновывать составленными списками плюсов и минусов. Где лучший специалист — тот, кто пишет максимально простой для понимания и внесения изменений код; кто способен спроектировать надёжную и производительную систему, разработать уникальную функциональность, проработать все угловые случаи; сделать пользователя довольным. При таком подходе основной критерий при выборе инструментов — баланс проблем, которые он решает, против неудобств, которые приносит.

Разберём в деталях, что происходит вместо этого в экосистеме Android-разработки.

ConstraintLayout

Теперь в свежесозданном проекте сразу подключена эта библиотека, а корневым контейнером заготовки вёрстки стал ConstraintLayout вместо RelativeLayout. Constraint — отличный нишевый инструмент для решения очень узкого круга задач: позволяет плоско сверстать сложные отношения, расставив вью, например, по окружности, в вершинах звезды или на кончиках усов котёнка. Только это не значит, что он везде должен собой заменить RelativeLayout, FrameLayout и LinearLayout. Многим проектам он не нужен вовсе, потому что способен улучшить вёрстку одного редко посещаемого экрана, ухудшив остальные. Самое неприятное, что RelativeLayout теперь переехал во вкладку Legacy в палитре компонентов визуального редактора (очевидно, в Android SDK ничего нет ему на замену).

Преимущества

Недостатки

RxJava 2

Библиотека для «реактивщины» — так сейчас модно называть асинхронную обработку событий. Является одной из реализаций парадигмы ФРП (функциональное реактивное программирование) (на самом деле нет).

Решает те проблемы, которые зачастую можно решить с помощью java.util.stream и java.util.concurrent, от Executors до CompletableFuture.

Проблемы реализации

stream
    .filter(it -> it > 10)
    .map(it -> 10*it)
    .reduce((a, b) -> a + b)

мог бы выглядеть так, если бы все операторы были реализованы снаружи:

stream
    .compose(filter(it -> it > 10))
    .compose(map(it -> 10*it))
    .compose(reduce((a, b) -> a + b))

такой стиль склоняет к более модульному коду и слабому связыванию.

Проблемы применения

Future<A> aFuture = ioExecutor.submit(() -> {
    Thread.sleep(500); // типа, сходили в интернет
    return new A(/* типа, вернули данные */);
});
Future<B> bFuture = ioExecutor.submit(() -> {
    Thread.sleep(500);
    return new B(...);
});
ioExecutor.execute(() -> {
    doSomethingWith(aFuture.get(), bFuture.get());
});

Здесь get — блокирующее ожидание результата. В UI-потоке, конечно, так делать нельзя, ждать придётся на бэгкраунде:

/**
 * Executes {@param supplier} on the {@param worker},
 * passes returned value to {@param consumer} on the {@param target}.
 * Ignores error handling problem.
 */
public static <R> Future<?> executeAsync(
        final ExecutorService worker, final Callable<R> supplier,
        final Handler target, final Consumer<R> consumer
) {
    return worker.submit(() -> {
        final R value = supplier.call();
        target.post(() -> consumer.accept(value));
        return null; // conform Callable
    });
}

void sample() {
    Future<A> aFuture = ...;
    Future<B> bFuture = ...;
    executeAsync(
            ioExecutor, () -> combineSomehow(aFuture.get(), bFuture.get()),
            new Handler(Looper.getMainLooper()), result -> {
                someTextView.setText(result.textValue());
            }
    );
}

Более современный и похожий на pipeline путь — CompletableFuture, который появился в Java 1.8 (2014), но был бэкпортирован как минимум дважды: stefan-zobel/streamsupport, retrostreams/android-retrofuture, (заметка о лицензии).

supplyAsync(() -> new A(...), ioExecutor)
.thenCombineAsync(
        supplyAsync(() -> new B(...), ioExecutor),
        ::combineSomehow,
        ioExecutor
)
.thenAcceptAsync(
        result -> someTextView.setText(result.textValue()),
        uiExecutor
)

В качестве uiExecutor не удастся передать экземпляр Handler, подойдёт лямбда runnable -> handler.post(runnable) или вот такая штука.

public final class Debounce extends SimpleTextWatcher
        implements Runnable, View.OnAttachStateChangeListener {

    /**
     * Invokes {@param consumer} on {@param handler} passing text from {@param source}
     * at most one time in {@param delayMillis}
     * and only if {@param source} view is attached to window.
     */
    public static void debounced(
            Handler handler, TextView source, int delayMillis, Consumer<String> consumer
    ) {
        Debounce d = new Debounce(handler, source, delayMillis, consumer);
        source.addTextChangedListener(d);
        source.addOnAttachStateChangeListener(d);
    }

    private final Handler handler;
    private final TextView source;
    private final int delayMillis;
    private final Consumer<String> consumer;

    private Debounce(
            Handler handler, TextView source, int delayMillis, Consumer<String> consumer
    ) {
        this.handler = handler;
        this.source = source;
        this.delayMillis = delayMillis;
        this.consumer = consumer;
    }

    @Override public void afterTextChanged(@NotNull Editable s) {
        if (source.isAttachedToWindow()) {
            handler.removeCallbacks(this);
            handler.postDelayed(this, delayMillis);
        }
    }

    @Override public void run() {
        consumer.accept(source.getText().toString());
    }

    @Override public void onViewAttachedToWindow(View v) {
    }
    @Override public void onViewDetachedFromWindow(View v) {
        handler.removeCallbacks(this);
    }

}

В этом коде примечательно, что не нужно отписываться, — Debounce станет недостижимым вместе с иерархией вью. А при необходимости несложно добавить пару усовершенствований: отправку первого события сразу же при создании; отправку события при onAttachedToWindow, если предыдущая отправка была отменена из-за onViewDetachedFromWindow

Преимущества

Кстати, разработчикам библиотек стоит учитывать, что если они хотят поддержать «реактивность», нужно добавлять в зависимости не RxJava, а Reactive Streams, чтобы позволить клиенту выбирать реализацию самостоятельно. Ведь есть ещё как минимум Project Reactor.

DI-фреймворки

Из-за фундаментальных ошибок проектирования Android даже такая простая задача, как создание графа объектов и удовлетворение их зависимостей, превращается в кошмар. Что могут предложить нам DI-контейнеры и Service Locator'ы вроде Dagger 2, Toothpick, Koin, KoDeIn?

Решаемые проблемы

Привносимые проблемы

Вот и выходит, что забрать зависимости из static или Application — это более прямолинейно, менее многословно и не тормозит ни компиляцию, ни рантайм, а отличия в коде минимальные.

При выборе архитектурного решения можно задавать вопрос: как это будет работать в двух экземплярах? Например: смогут ли соседствовать два фрагмента, которые обмениваются данными с активити через синглтон? если они общаются через интерфейс, сможет ли активити отличить один от другого? если два фрагмента открыли один и тот же скоуп, не столкнутся ли их зависимости? можно ли добавить на экран два фрагмента одного класса, передав им разные реализации презентера/вьюмодели?

Если ответы — «нет», «вроде можно, но это не точно», «со скрипом» или «ой, ну сделаем, если надо будет», то выбранное решение ведёт к сильному связыванию и мешает повторному использованию кода.

MVP

Model-View-Presenter — порт неудачного server-side паттерна Model-View-Controller на client-side. Презентер отличается от контроллера тем, что не только передаёт данные во View, но и слушает события из View и реагирует на них.

Решаемые проблемы

Собственные проблемы

Moxy

Moxy — реализация MVP, которая сохраняет презентер между сменами конфигурации, а после пересоздания View восстанавливает его состояние, доставляя старые события заново.

Восстановление View работает только при смене конфигурации (что довольно бесполезно, ведь в Activity можно поставить configChanges="orientation|screenSize" и не терять вью, а в retain-фрагментах можно хранить состояние в полях, никто его не украдёт). В случае рестарта процесса никак не поможет. Даже наоборот: рекомендуется «очищать» состояние вью в onFirstViewAttach(), потому что после пересоздания процесса создастся новый презентер, а состояние View восстановится встроенными средствами Android.

То есть единственная задача, с которой справляется Moxy, — протаскивание багов мимо тестирования в релиз (ну, пока отдел тестирования не прочитает про Don't Keep Activities). Всё это — ценой следующих минусов:

AAC ViewModel

Не имеет никакого отношения к ViewModel из MVVM, правильное название — RetainObject или NonConfigurationInstance.

Плюсы:

Минусы:

Retrofit

Retrofit представляет удалённые HTTP-эндпоинты в виде Java-методов. В мире повсеместных контроллеров это очень уместная абстракция. Однако, имеются проблемы:

На пересечении Retrofit и ProGuard возникает ещё одна проблема: т. к. ProGuard не предоставляет возможности сохранить определённые аннотации, вырезав остальные, приходится писать -keepattributes *Annotation*, сохраняя все. Когда добавляется Kotlin, в конечное приложение попадает ощутимое количество мусора в виде @kotlin.Metadata. Коллега подсказывает, что R8 отлично справляется с аннотациями. Круто.

Coil

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

Начнём с того, у них на гитхабе написано «Coil adds ~2000 methods to your APK (for apps that already use OkHttp and Coroutines), which is comparable to Picasso and significantly less than Glide and Fresco.» Формулировки обтекаемые и, строго говоря, ложью не являются. Давайте посмотрим в цифрах, что значит «comparable» и «significantly less»:

Артефакты Объявленные методы Размер, КБ*
com.squareup.picasso:picasso:2.71828 +
jp.wasabeef:picasso-transformations:2.4.0
499+104
=603
62.6+18.2
=80.8
io.coil-kt:coil:1.1.1 1605 197.5
io.coil-kt:coil:1.1.1 +
io.coil-kt:coil-gif:1.1.1 +
io.coil-kt:coil-video:1.1.1
1725 220
com.github.bumptech.glide:glide:4.12.0 3390 372.9

* Размер субъективен, на него влияет другой код, лежащий в том же дексе.

Итого, «comparable to Picasso» переводится как «в два с половиной раза толще Пикассы». Это если не считать сюда корутины. А «significantly less than Glide» по факту означает разницу менее чем в два раза (опять же, не считая корутин — с ними Коил проиграет). Почему я вообще прикопался к размеру? Разве это так критично? Нет, потому что авторы посчитали важным об этом сообщить, а я увидел в этом манипуляцию.

Что мы имеем? Интерфейс идентичен Picasso и Glide. Есть переиспользование битмапов, как в Glide. Есть поддержка GIF и видео, как в Glide, только отдельными модулями (и это правильное решение). Coil можно воспринимать как «менее толстую версию глайда», хотя из-за корутин он также сойдёт за «более толстую версию глайда». В конечном счёте революции не произошло. В полку посредственных загружалок картинок прибыло.

XKCD: Standards

Clean architecture

Вероятно, здесь я буду критиковать не саму clean architecture Дядюшки Боба, а распространённую в Android-сообществе интерпретацию. В качестве примера разберём теоретический материал по основам архитектуры на ломаном русском.

Решаемые проблемы

Привносимые проблемы

В продолжение этой темы некоторые разделяют проект на модули. Но, опять же, вместо того чтобы отделять фичи (что бывает полезно), они отделяют слои. Смысл этого костыля в том, что kapt меньше тормозит, если Room, Moxy, Dagger находятся каждый в своём модуле.

Конференции

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

Трудоустройство

Казалось бы: пусть сходят с ума как хотят. Кому какое дело? Проблема в том, что мусорные библиотеки стали стандартом и насквозь пронизали индустрию. Хорошую работу со своевременной «белой» зарплатой и адекватным руководством найти непросто, а тут ещё и технологии, которые делят индустрию на несколько лагерей.

Я вижу такой (неутешительный) вывод: на рынке труда лучше себя чувствует тот разработчик, который владеет модными инструментами, может найти им применение там, где это не нужно, сумеет разобраться в «современном» коде. А инженерные вопросы стали своего рода «вопросами религии» и могут спровоцировать неиллюзорные «холивары». Я уже предвижу в комментариях религиозных фанатиков, адептов превеликого Dagger и святой RxJava, которые абсолютно необходимы в любом современном CRUD-приложении.

Вместо завершения

Комментарии к статье

{"type":"articleComments","id":"8adb9cd1-df2a-496b-98ea-4247c04686ca","comments":[{"id":"c7e64a17-3a27-4937-8cda-29c8a4e4fbf5","authorSrc":"GitHub","authorId":"Nik-Gleb","text":"Miha! :)\nБольшое тебе спасибо за такую содержательную статью, такой глубокий анализ.\nНа самом деле твои мысли - имеют схожее отражение у 25% наших коллег, и \nвозможно еще у 25% - это молчаливый глас совести. \nОчень рад что кто-то взялся за оформление этих позиций в тезисы и словесную форму.\nБолее того - статья получилась, по-настоящему техническая, беспристрастная, с анализом, \nплюсами и минусами. Приятно, что она в итоге ни к чему не клонит, не призывает, \nне снабжена приглашением на очередной доклад или репозиторий на гитхабе.\nАбсолютно согласен с общим посылом в целом и особенно с абзацем-заключением \nпро \"влияние на трудоустройство и рынок труда\". Всё прям точно сказано! \nЯ бы сказал - \"как с уст снял)\"","added":1554100924807,"answers":[]},{"id":"6d2c7c23-1d8d-4529-829b-b110a9f2ba02","authorSrc":"GitHub","authorId":"andrewgrow","text":"Отличная статья, спасибо! Вот прямо мои мысли оформил в текст. Сохранил в закладки, а то в спорах я быстро думать не умею и потому проигрываю обсуждения. А тут вот прямо готовый меч-кладенец ) ","added":1554193913732,"answers":[]},{"id":"d399eebc-e724-416a-85d5-31afcca9ae26","authorSrc":"GitHub","authorId":"Semper-Viventem","text":"Миша, спасибо за статью! Только про Rx хочу уточнить.\n\n> Решает лишь те проблемы, которые уже давно решены с помощью java.util.stream и java.util.concurrent, от Executors до CompletableFuture.\n\nТак реактивные стримы вроде CompletableFeature и появились только в Java 8 (2014 год), а первый коммит в RxJava был в 2012 (https://github.com/ReactiveX/RxJava/commit/697fd66aae9beed107e13f49a741455f1d9d8dd9), а это несколько раньше получается. Затем вышла вторая версия RxJava, которая является идейным продолжением, и исправляет некоторые проблемы, и предлагает новые решения для работы с реактивными потоками.\n","added":1554205646602,"answers":[{"id":"f0a68fac-6615-487b-b7eb-f8ba3d7f45da","authorSrc":"GitHub","authorId":"Miha-x64","text":"Думаю, что создатели `CompletableFuture` вдохновлялись именно идеями RxJava.\nНо сейчас уже нет большой разницы, что появилось раньше: просто есть разные инструменты и выбор между ними, а CF — один из примеров возможного выбора.\n\n(А мне обычно хватает `Executors`.)","added":1554207690287,"answers":[]}]},{"id":"127b77a7-7ae8-4ad1-8a00-2729b75f6003","authorSrc":"GitHub","authorId":"alaershov","text":"Было бы классно предложить альтернативы по каждому из перечисленных вами пунктов. Вы назвали популярные решения для некоторых задач. Да, решения не идеальные, но ничего ведь не происходит на ровном месте, в том числе и перечисленные вами библиотеки. Если вы не используете ни одно из них, и утверждаете, что это всё не нужно, и при этом нашли свой подход, который решает все задачи лучше, чем \"хайповые\" решения, то, может быть, поделитесь этим подходом с массами?","added":1554208053912,"answers":[{"id":"9bd3b450-12d7-4628-9234-55864171dbe9","authorSrc":"GitHub","authorId":"Miha-x64","text":"Часть проблем считаю совсем надуманными. Часть решаю «коробочными» инструментами. Ещё для части разрабатываю [библиотеку](http://github.com/Miha-x64/reactive-properties/), про которую буду [рассказывать на AppsConf](http://appsconf.ru/moscow/2019/abstracts/4652).","added":1554212932412,"answers":[{"id":"43f29f84-62ef-41d8-879c-54960ccfda52","authorSrc":"GitHub","authorId":"alaershov","text":"Круто, ждём доклада)","added":1554268273476,"answers":[]}]}]},{"id":"cbaa06ff-bd6e-442c-b473-47ec1a4e2c21","authorSrc":"GitHub","authorId":"Yukooo","text":"Автор, не путай людей, применения любого инструменты в работе требует ясного понимание, что это за инструмент и как с ним правильно обращаться.\nPS. Наберись опыта и поучаствуй в больших продуктовых проектах (от 10 человек).","added":1554218059313,"answers":[{"id":"69d90bb2-113c-428e-8808-ebeb95b65435","authorSrc":"GitHub","authorId":"Miha-x64","text":"Аргументов, конечно, не будет.","added":1554226187779,"answers":[]}]},{"id":"3a5fd027-93d6-40f3-a935-b6f83d961bd3","authorSrc":"GitHub","authorId":"techyourchance","text":"В деталях я не согласен по нескольким пунктам (особенно с тем что касается Dependency Injection), но в целом, конечно, все верно, и от этого очень грустно. Особенно огорчает ситуация с конфами (хотя мне кажется, что в русскоязычном сообществе проблема не так остра как везде) и трудоустройством.\nСпасибо за статью. ","added":1554263769543,"answers":[]},{"id":"ca0e15d7-542e-4af0-a093-a10d4cba9fc0","authorSrc":"GitHub","authorId":"ilyamodder","text":"TL;DR: все хайповые инструменты мне непонятны, а значит, не нужны. Что мне привычнее, то и надо использовать","added":1554981786869,"answers":[{"id":"4a599abf-36e4-4896-b8fb-aca1050e208e","authorSrc":"GitHub","authorId":"Miha-x64","text":"tl;dr в статью не вникал, аргументов не будет.","added":1555239707481,"answers":[]}]},{"id":"0e508c79-b4e8-40c9-90ef-9dc4b2d7fc8e","authorSrc":"GitHub","authorId":"syndarin","text":"Похоже на очередной плач Ярославны, если честно.\n\n1. ConstraintLayout - возражу с помощью всего лишь двух аргументов: Chains & Guidelines. Сделать интерфейс, элементы которого равномерно расположены по экрану, либо же занимают фиксированную его долю всегда было большой головной болью, которую эти фичи успешно решают. Тем паче, что костыли с LinearLayout & weights работают далеко не во всех случаях.\n\n- \"неудобство переиспользования/композирования отдельных частей вёрстки\" - что, include уже запретили? Если Вы не об этом, то тогда вообще не вполне понятно, как Вы привыкли её переиспользовать (копипаст не считается).\n\n2. RxJava:\n- \"невозможность передачи null-значений\" - строго говоря, воспринимая любой rx-овый тип как поток значений, не вполне понятно, зачем нужны отсутствующие? Если они таки нужны, то, имхо, это проблемы с дизайном Вашей архитектуры. Ну, в крайнем случае, используйте Optional, кто мешает? \n- \"обилие разных интерфейсов\" - обусловлено разным поведением, не находите?.\n- \"необходимость ручной отписки (плохо ложится на парадигму автоматического управления памятью)\" - за отсутствием memory leaks тоже приходится следить вручную, Вас это не напрягает?\n- \"возможность возникновения OnErrorNotImplementedException\" - простите, как разумный аргумент воспринимать не могу.\n\nИ да, StreamAPI не замена Rx. По крайней мере, если не использовать Rx исключительно для операций над коллекциями. Из часто используемых примеров - zip, combineLatest, withLatestFrom.\n\n3. DI-фрейморки\n\nВступление уже прекрасно: \"Из-за фундаментальных ошибок проектирования Android даже такая простая задача, как создание графа объектов и удовлетворение их зависимостей, превращается в кошмар.\"\n\nЭто действительно причина рассматривать DI-фреймворки как просто очередной хайповый инструмент?\n\n\n","added":1554982596216,"answers":[{"id":"8ab8cfde-015e-4de0-a844-11c1fc81106a","authorSrc":"GitHub","authorId":"Miha-x64","text":"> Сделать интерфейс, (...) всегда было большой головной болью, которую эти фичи успешно решают.\n\nК такому применению никаких претензий нет. Я же говорю о ситуации, когда констреинт используется вместо всего.\n\n> что, include уже запретили?\n\nЕсли не рассматривать вариант инклуда констреинта в констреинт, то все include-вёрстки должны содержать layout-параметры именно для констреинта. Тогда такие «вставки» гораздо меньше похожи на самостоятельные компоненты и сильно зависят от родительского контейнера.\n\n> Если они [null-значения] таки нужны, то, имхо, это проблемы с дизайном Вашей архитектуры.\n\nСогласен. Мне вообще приятно писать на языках без нуллов. [А кому-то это зачем-то нужно](https://stackoverflow.com/a/45371201/3050249)\n\n> в крайнем случае, используйте Optional, кто мешает?\n\nВ Java/Kotlin встроен бесплатный `Optional`. `null` называется.\n\n> (...) OnErrorNotImplementedException\" - простите, как разумный аргумент воспринимать не могу.\n\nСогласен, я плохо сформулировал свою идею. В JVM-мире нет подходящего решения проблемы, лучше удалю этот пункт.\n\n> И да, StreamAPI не замена Rx.\n\nДа, ничто не замена Rx, кроме Rx. Для разных применений я предложил разные замены.\n\n > Это действительно причина рассматривать DI-фреймворки как просто очередной хайповый инструмент?\n\nНет, это вступление, в котором я утверждаю, что проблема зависимостей в Android стоит действительно остро. А вот DI-фреймворки — неудачное её решение.","added":1555245167482,"answers":[]}]},{"id":"d2e05e88-ca21-4a20-9a68-da535cd07c3e","authorSrc":"GitHub","authorId":"KuVaUgU","text":"Почти со всем согласен. Вы работу не ищите?","added":1554982985611,"answers":[{"id":"22c75e8b-9698-4840-96c4-7f22c843f0ea","authorSrc":"GitHub","authorId":"Miha-x64","text":"Короткий ответ — всё сложно. Можно [обсудить в Телеграме](https://t.me/Harmonizr)","added":1555247541204,"answers":[]}]}]}

Javanese.Online в GitHub

Чаты и каналы в Telegram

RSS-лента