Я привык считать программирование инженерной специальностью. То есть такой, где нужно проектировать, рассчитывать, измерять, а выбор обосновывать составленными списками плюсов и минусов. Где лучший специалист — тот, кто пишет максимально простой для понимания и внесения изменений код; кто способен спроектировать надёжную и производительную систему, разработать уникальную функциональность, проработать все угловые случаи; сделать пользователя довольным. При таком подходе основной критерий при выборе инструментов — баланс проблем, которые он решает, против неудобств, которые приносит.
Разберём в деталях, что происходит вместо этого в экосистеме Android-разработки.
ConstraintLayout
Теперь в свежесозданном проекте сразу подключена эта библиотека, а корневым контейнером заготовки вёрстки стал ConstraintLayout вместо RelativeLayout. Constraint — отличный нишевый инструмент для решения очень узкого круга задач: позволяет плоско сверстать сложные отношения, расставив вью, например, по окружности, в вершинах звезды или на кончиках усов котёнка. Только это не значит, что он везде должен собой заменить RelativeLayout, FrameLayout и LinearLayout. Многим проектам он не нужен вовсе, потому что способен улучшить вёрстку одного редко посещаемого экрана, ухудшив остальные. Самое неприятное, что RelativeLayout теперь переехал во вкладку Legacy в палитре компонентов визуального редактора (очевидно, в Android SDK ничего нет ему на замену).
Преимущества
- в некоторых случаях уменьшает вложенность вёрстки, тем самым ускоряя onMeasure и сокращая количество потребляемой памяти
- поддерживает редактирование мышью в превью
- плоскую вёрстку удобнее анимировать
- нет проблем с границами теней (elevation)
- MotionLayout
Недостатки
- непредсказуемая производительность, ставящая под вопрос целесообразность всей затеи
- констреинты, направляющие, а также хелперы — группа и барьер — полноценные вьюхи с большим retained size и толстым конструктором, что снова ставит под вопрос целесообразность «плоской» вёрстки
- Placeholder — очень удобный и мощный инструмент, если бы не его бесполезность вне констреинта
- неудобство переиспользования/композирования отдельных частей вёрстки
- сложность чтения вёрстки без IDE/Preview — всё сваливается в неиерархическую кучу
- трудночитаемые diff'ы в контроле версий, особенно после редактирования мышью
- как и любая библиотека, увеличивает размер APK; как и любые классы, требует загрузки в оперативную память, верификации, JIT-компиляции — то есть нельзя просто «бесплатно» заменить все Relative на Constraint
RxJava 2
Библиотека для «реактивщины» — так сейчас модно называть асинхронную обработку событий. Является одной из реализаций парадигмы ФРП (функциональное реактивное программирование) (на самом деле нет).
Решает те проблемы, которые зачастую можно решить с помощью java.util.stream и java.util.concurrent, от Executors до CompletableFuture.
Проблемы реализации
- невозможность передачи null-значений. Сам факт наличия null в Java — это ошибка, но исправить её уже не суждено. В JDK, Android и многих популярных библиотеках полно нуллов, так что практические решения обязаны смириться и поддержать это. Для симуляции нуллов можно использовать Maybe, но оно может заворачивать не только значение, но и асинхронные вычисления, так что использование blockingGet рисковано
- обилие разных интерфейсов для похожих задач: Single != Observable, Completable != Single<Void>
- горы операторов для обработки ошибок вместо использования result type
- необходимость ручной отписки (плохо ложится на парадигму автоматического управления памятью)
- очень много «операторов» (т. е. методов, продублированных внутри Observable, Single, Flowable) — значительно больше, чем, например, в перегруженных Collection и Stream из JDK. SRP нарушен дичайшим образом
- все «операторы» — это member methods, что никак не поддаётся расширению. Вот этот hello world
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))
такой стиль склоняет к более модульному коду и слабому связыванию.
- объём: занимает более 10 тысяч методов, более 1 МБ DEX
- отдельные «операторы» subscribeOn и observeOn выглядят не особо удачно: обычно автор задачи знает, где она должна выполняться (пример из Java Core: CompletableFuture.supplyAsync(supplier, executor)), а потребитель — куда приносить результат (completableFuture.thenAcceptAsync(consumer, executor)); сложно представить, как будет вести себя observable.subscribeOn(sch1).subscribeOn(sch2).observeOn(sch3).observeOn(sch4) — а значит, нужно запретить это
Проблемы применения
- с обработкой коллекций в Java отлично справляется Solid — минималистичный порт стримов, в Kotlin — stdlib
- для одноразовых асинхронных операций в Java с незапамятных времён есть ExecutorService и Future.
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) или вот такая штука.
- когда дело касается обработки потока данных, всё зависит от того, откуда берётся этот поток, какие там данные и что с ними нужно сделать. Например, debounce текста из поля ввода тривиально реализовывается с помощью Handler:
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
- для обработки коллекций подходит «коробочный» инструмент из Java 1.8 — Streams. Они значительно отличаются от RxJava, но пересечение функциональности достаточно большое. Для Android имеется бэкпорт — Stream Support
Преимущества
- очень развитая экосистема — RxBindings, поддержка в Retrofit, GreenDAO, Room, Realm
Кстати, разработчикам библиотек стоит учитывать, что если они хотят поддержать «реактивность», нужно добавлять в зависимости не RxJava, а Reactive Streams, чтобы позволить клиенту выбирать реализацию самостоятельно. Ведь есть ещё как минимум Project Reactor.
DI-фреймворки
Из-за фундаментальных ошибок проектирования Android даже такая простая задача, как создание графа объектов и удовлетворение их зависимостей, превращается в кошмар. Что могут предложить нам DI-контейнеры и Service Locator'ы вроде Dagger 2, Toothpick, Koin, KoDeIn?
Решаемые проблемы
- собственно, передача необходимых объектов от Application или static к компонентам приложения
Привносимые проблемы
- создаёт свой метаязык (зачастую из аннотаций): знания языка программирования и платформы для поддержки такого кода недостаточно
- унося контроль из классов, также изымает его из рук разработчиков — зависимости контролируются опосредованно
- обычно тормозит компиляцию кодогенерацией или рантайм — рефлексией
- зачастую не даёт compile-time гарантий существования зависимостей
- провоцирует сильное связывание (делает проблематичным инжект разных реализаций одного интерфейса, тем самым нарушает DIP) и делает инверсию контроля бессмысленной
- скоупы — это глобальное изменяемое состояние, а их закрытие сродни ручному управлению памятью
- превращает навигацию по коду в кошмар даже в IDE
Вот и выходит, что забрать зависимости из static или Application — это более прямолинейно, менее многословно и не тормозит ни компиляцию, ни рантайм, а отличия в коде минимальные.
При выборе архитектурного решения можно задавать вопрос: как это будет работать в двух экземплярах? Например: смогут ли соседствовать два фрагмента, которые обмениваются данными с активити через синглтон? если они общаются через интерфейс, сможет ли активити отличить один от другого? если два фрагмента открыли один и тот же скоуп, не столкнутся ли их зависимости? можно ли добавить на экран два фрагмента одного класса, передав им разные реализации презентера/вьюмодели?
Если ответы — «нет», «вроде можно, но это не точно», «со скрипом» или «ой, ну сделаем, если надо будет», то выбранное решение ведёт к сильному связыванию и мешает повторному использованию кода.
MVP
Model-View-Presenter — порт неудачного server-side паттерна Model-View-Controller на client-side. Презентер отличается от контроллера тем, что не только передаёт данные во View, но и слушает события из View и реагирует на них.
Решаемые проблемы
- помогает хоть как-то отделить логику от UI и избежать совесем уж монолитного кода
- тестируемость
Собственные проблемы
- императивность: принимает события и отдаёт команды, что приводит к сложным, трудновоспроизводимым состояниям
- состояние живёт в презентере и во вью — сложно его отделить и сохранить
- бессмысленность тестирования: при тестировании легко доказать, что презентер в определённых обстоятельствах вызывает нужные методы вью, но нет никакой гарантии, что вью обрабатывает их правильно — в каком состоянии оно будет после showProgress(); showData(list)? ProgressBar+List или только List?
Moxy
Moxy — реализация MVP, которая сохраняет презентер между сменами конфигурации, а после пересоздания View восстанавливает его состояние, доставляя старые события заново.
Восстановление View работает только при смене конфигурации (что довольно бесполезно, ведь в Activity можно поставить configChanges="orientation|screenSize" и не терять вью, а в retain-фрагментах можно хранить состояние в полях, никто его не украдёт). В случае рестарта процесса никак не поможет. Даже наоборот: рекомендуется «очищать» состояние вью в onFirstViewAttach(), потому что после пересоздания процесса создастся новый презентер, а состояние View восстановится встроенными средствами Android.
То есть единственная задача, с которой справляется Moxy, — протаскивание багов мимо тестирования в релиз (ну, пока отдел тестирования не прочитает про Don't Keep Activities). Всё это — ценой следующих минусов:
- Расход памяти при использовании AddToEndStrategy необратимо растёт.
- Медленная компиляция (apt/kapt), довольно большие объёмы сгенерированного кода.
- Замусоривание результатов поиска классов и символов в IDEA/AS.
AAC ViewModel
Не имеет никакого отношения к ViewModel из MVVM, правильное название — RetainObject или NonConfigurationInstance.
Плюсы:
- Это самое простое, что можно использовать после депрекейта nonConfigurationInstance.
- Есть коллбэк onCleared(), что очень актуально при сломанном Fragment#isRemoving.
Минусы:
Retrofit
Retrofit представляет удалённые HTTP-эндпоинты в виде Java-методов. В мире повсеместных контроллеров это очень уместная абстракция. Однако, имеются проблемы:
- метаязык из аннотаций, проверяемых в рантайме, вида @POST("smth/{wtf}") Call<T> smth(@Path("wtf") String path) вместо явного new Endpoint(Method.POST, "smth/{wtf}", new PathParameter("wtf")) с проверками на типах
- CallAdapter.Factory и Converter.Factory регистрируются в билдере, а «используются» в интерфейсе. До запуска приложения нет простого способа узнать, присутствуют ли все необходимые для работы конвертеры; есть ли неиспользуемые конвертеры. На последний вопрос также не в состоянии ответить ProGuard, т. к. поддерживает reflection в очень ограниченном виде.
На пересечении 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 можно воспринимать как «менее толстую версию глайда», хотя из-за корутин он также сойдёт за «более толстую версию глайда». В конечном счёте революции не произошло. В полку посредственных загружалок картинок прибыло.
Clean architecture
Вероятно, здесь я буду критиковать не саму clean architecture Дядюшки Боба, а распространённую в Android-сообществе интерпретацию. В качестве примера разберём теоретический материал по основам архитектуры на ломаном русском.
Решаемые проблемы
- много слоёв проще редактировать большой командой — меньше шансов получить merge conflict
- интеракторы и презентеры легко покрываются тестами (правда, баги всё равно обычно возникают в других местах)
Привносимые проблемы
- провоцирует так называемый пахлава-код — код, состоящий из большого количества несамостоятельных слоёв, ни один из которых ни за что конкретное не отвечает
- чтобы отредактировать одну фичу, нужно затронуть несколько слоёв — отличный подход с точки зрения удержания рабочего места
- большинство интерфейсов пишутся, потому что надо
- классы предлагается группировать по слоям, то есть в одном пакете находятся классы, решающие разные продуктовые задачи (фичи)
В продолжение этой темы некоторые разделяют проект на модули. Но, опять же, вместо того чтобы отделять фичи (что бывает полезно), они отделяют слои. Смысл этого костыля в том, что kapt меньше тормозит, если Room, Moxy, Dagger находятся каждый в своём модуле.
Конференции
Множество докладов заключается в пересказе документации очередной модной библиотеки или изложении очередного модного подхода. Главный вопрос — какая при этом решается проблема — остаётся без ответа.
Трудоустройство
Казалось бы: пусть сходят с ума как хотят. Кому какое дело? Проблема в том, что мусорные библиотеки стали стандартом и насквозь пронизали индустрию. Хорошую работу со своевременной «белой» зарплатой и адекватным руководством найти непросто, а тут ещё и технологии, которые делят индустрию на несколько лагерей.
Я вижу такой (неутешительный) вывод: на рынке труда лучше себя чувствует тот разработчик, который владеет модными инструментами, может найти им применение там, где это не нужно, сумеет разобраться в «современном» коде. А инженерные вопросы стали своего рода «вопросами религии» и могут спровоцировать неиллюзорные «холивары». Я уже предвижу в комментариях религиозных фанатиков, адептов превеликого Dagger и святой RxJava, которые абсолютно необходимы в любом современном CRUD-приложении.