Javanese Online

Фундаментальные проблемы Android

Android — это плохой DI-контейнер

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

Все компоненты Android-приложений — активити, фрагменты, сервисы — по умолчанию создаются через no-arg конструктор (данные можно передать лишь посредством extras/arguments). Как только компонент создан, он оторван от внешнего мира и нежизнеспособен: уже позже вызываются setArguments, attachBaseContext/onAttach, onCreate и т. п.. Соответственно, такие объекты, подобно плохим синглтонам, вынуждены разрешать зависимости самостоятельно: лезть в Application или static и забирать нужные объекты. Более-менее исправлено у Activity и Service в SDK 28 и у бэкпорта Fragment.

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

Необходимость иметь no-arg-конструкторы становится феерической помехой повторному использованию кода: при добавлении новой функциональности она не делегируется объектам, которые передаются в конструктор, а реализуется прямо внутри фрагмента (активити, презентера, вьюмодели) в if-else/switch-case/when. Впоследствии такой класс становится типичным customizable object, подчиняющимся десяткам условий — а такой код крайне сложно поддерживать.

Ложку дёгтя добавляет жизненный цикл. Предположение, что Activity/Fragment — обычный Java-объект, оказывается немного неверно: после пересоздания ту же задачу выполняет уже другой экземпляр, которому достаётся Bundle с сохранённым состоянием предшественника. Люди, не знающие Java, часто неверно интерпретируют пересоздание как «GC вычистил мне все ссылки!»

Иерархии типов

Нужно постоянно наследовать классы. Если какая-нибудь библиотека, например, хочет предоставить свои подтипы фрагментов, приходится учитывать, что есть нативные фрагменты (android.app.Fragment), их старый бэкпорт (android.support.v4.app.Fragment) и новый бэкпорт (androidx.fragment.app.Fragment), а также несколько особых случаев: DialogFragment, BottomSheetDialogFragment и т. п..

У бэкпортированных и нативных фрагментов одинаковый интерфейс (множество публичных методов), но они не реализуют один interface, следовательно, для поддержки обеих иерархий классов нужно создавать два экземпляра одного кода, которые различаются только импортами. (Благодаря AndroidX эта проблема теперь есть и у тех, кто наследует андроидные View.)

Чтобы поселить фактический сервис (просто объект, представляющий определённую функциональность) в Bound Service и передать его в Activity, нужно унаследовать как минимум Service и Binder (а также реализовать ServiceConnection). Пример заворачивания объекта в сервис

Как работать с асинхронностью?

AsyncTask

Невероятно странный интерфейс: метод execute() принимает дженерик, но задачу можно исполнить лишь один раз, поэтому проще передать параметр в конструктор; второй дженерик определяет тип промежуточных данных — он обычно не используется, поэтому имеет смысл завести для редкого случая отдельный класс — скажем, ProgressAsyncTask; execute() и onPostExecute() принимают vararg, но в большинстве случаев передаётся ровно один объект; нельзя создавать и запускать AsyncTask из фона — onPreExecute() вызывается прямо из execute(), а в старых версиях статический инициализатор вызывает new Handler(без аргументов), тем самым привязываясь к текущему Looper (если он есть); ничего не знает о жизненном цикле. В Android 11 считается устаревшим с бредовым обоснованием.

Loader

Полны багов, API монструозен. Считаются устаревшими в Android 9.

ThreadPoolExecutor

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

Стороннее

Kotlinx.coroutines или монструозную RxJava несложно адаптировать для работы с Android (в частности, прерывать текущие задачи в onDestroyView или onDestroy), но их нужно приносить с собой, т. к. фреймворк их не содержит. Да и в большинстве случаев это из пушки по воробьям.

Работа с JSON

Я бы не стал использовать этот формат добровольно, но к этому принуждают многие публичные API.

HTTP

HttpURLConnection — это вообще издевательство. Конечно же, большинство использует OkHttp/Retrofit, пока Google пытается делать припарки библиотеке Volley.

Управление зависимостями

Вес приложений усугубляется полнейшим отсутствием пакетного менеджера. В некоторых дистрибутивах Linux есть apt-get, apt или aptitude, на Mac OS можно установить brew. В Java-мире есть системы сборки Gradle, Maven и множество других. Все вышеперечисленные умеют скачивать пакеты, их зависимости, зависимости их зависимостей и т. д.. В Android нет ничего подобного — Gradle скачивает всё на многострадальный компьютер разработчика, ProGuard/R8 давится десятками библиотек, dx/D8 конвертирует их в Android-совместимый байт-код, программист загружает в маркет, пользователь — из маркета, рантайм верифицирует, загружает, компилирует все эти классы, собирает о них статистическую информацию для наиболее оптимального выполнения (profile-driven compilation). Всё это происходит снова и снова, даже если множество приложений используют одни и те же зависимости одинаковых версий, в том числе для таких крупных библиотек как AppCompat/Support/AndroidX/Desing/Material, Google Mobile Services, RxJava, ExoPlayer, Realm, FFMpeg. Последние две содержат нативный (машинный) код, что заставляет разработчиков собирать по несколько APK для разных архитектур (ABI splits) либо App Bundle (отдай гуглу свою подпись, это же так безопасно!).

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

См. также: Graviton Browser.

Context

Контекст — это god object. View, например, нужен не контекст, а тема и ресурсы. А registerReceiver мог бы быть методом не Context, а Application (для локального броадкаста) и, допустим, AndroidSystem (выдуманный класс) для броадкаста по всей системе.

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

non-configuration instance

Retain-фрагменты (deprecated) переживают смену конфигурации. Слышали что-нибудь о retain-фрагментах без View? Это костыль, который позволяет хранить объекты в таком фрагменте. В Activity есть похожий механизм — non-configuration instance (custom non-configuration instance у AppCompatActivity). Отсюда вопрос: почему нельзя сделать Activity живучей, как retain-фрагмент, и почему у фрагмента, наоборот, нет non-configuration instance?

Ресурсы

У Android собственный механизм для доступа к ресурсам. Стандартные для Java ресурсы из classpath работают, но медленно и расточительно. Та же проблема затрагивает механизм ServiceLoader, т. к. META-INF/services — тоже ресурсы classpath.

XML — основной и официальный способ описания ресурсов — от вёрстки, меню, переводов и графики до конфигураций, примитивов и строк. Он хорошо подходит для случая, когда ОС/лончеру/шторке нужно достать ресурсы, не пробуждая приложение, но мешает всяческим попыткам написать лаконично, гибко и без дублирования. Присутствует (неудавшаяся) попытка исправить XML статической типизацией.

Каждый ресурс формально находится в определённом пакете (package) — изолированном пространстве имён. На практике таковых всего два — android и пакет текущего приложения; Android Gradle Plugin сливает ресурсы изо всех AAR-библиотек в пакет приложения, провоцируя конфликты имён, а также применяет страшные костыли для перегенерации R.class.

AttributeSet — это интерфейс, но реализовывать его бесполезно: obtainStyledAttributes кастит его к XmlBlock.Parser — а это package-private класс. По сути, AttributeSet играет роль маркер-интерфейса, что есть антипаттерн.

Сама абстракция Drawable кажется мне очень удачной, а разнообразие коробочных реализаций радует глаз. Но атрибуты темы не работают на четвёрках, названия XML-тегов (selector, shape) отличаются от имён классов (StateListDrawable, GradientDrawable), возможность использовать в XML свои классы доступна аж с SDK 24, а Drawable paddings влияют на View paddings по-разному, в зависимости от версии Android.

Кстати, Drawable парсит содержимое своего тега в произвольной форме: <shape ...><corners ... /></shape>. LayoutInflater же этого не позволяет: внутри вьюгруппы могут находиться только вью, а родитель парсит только LayoutParams из тегов своих детей. Вследствие этого образовалось несколько недоразумений:

В векторных картинках можно задавать контурам цвета из темы (например, ?colorPrimary). Но темы нельзя создавать из кода! (Выход: берём androidx.core.graphics.PathParser голыми руками и рисуем векторные контуры, как вздумается!)

Нет никакой гарантии, что указанный ресурс вообще существует. Он может иметь значение только для определённых квалификаторов или не иметь его вовсе (<item name="..." type="..." />).

shrinkResources удаляет неиспользуемые ресурсы, но как-то неуверенно; оставшиеся ресурсы не переименовываются.

Remote views нельзя создать из кода, только XML. Что вносит множество ограничений.

LayoutParams можно создать только из одного XML-тэга. <include> с layout_gravity, в котором находится <ViewStub> с layout_margin, который содержит вью с layout_width и layout_height — нельзя, получайте непереиспользуемый код.

В XML-вёрстку нельзя передавать параметры, будь это хоть LayoutInflater#inflate, хоть <include> или <ViewStub> (а вот в любом шаблонизаторе такая возможность есть).

В XML-вёрстке нельзя поставить точку останова (breakpoint), что делает отладку ошибок вроде Binary XML file line #0: Error inflating class ... чрезвычайно увлекательной. LayoutInflater работает рекурсивно, поэтому многие стектрейсы не вмещаются и обрезаются: 86 more...

findViewById — нечто абсолютно противоположное лаконичности и типобезопасности. Количество различных костылей, с этим связанных, превышает все мыслимые пределы. Нет механизма, который помешал бы использовать вью разных типов в вариантах одной вёрстки для разных конфигураций и получать трудноуловимые рантаймовые падения. Можно написать свой класс View, у которого будут type-параметры (дженерики) — тогда каст в findViewById станет unchecked. Так как сам каст находится внутри findViewById, компилятор никак об этом не предупредит.

Есть альтернативный вариант записи вьюшек в XML: <view class="com.example.SomeClass$MyView">. Незаменимо, когда вью — вложенный класс, но слишком непривычно, поэтому на практике не используется. Стоит ли упоминать, что name обрабатывается отдельным, специально обученным кодом, и никакие @ссылки и ?атрибуты не поддерживаются?

Для ресурсов генерируются провязки в Java-код — R.java. Но для вариантов <enum> этого не сделали — это обычные инты, для которых соответствующие enumы или @IntDefы можно написать руками.

Идеологически TypedArray — это массив TypedValue. Но функциональность у них разная: TypedArray предоставляет человеческий интерфейс из методов getText, getBoolean, getInt, getFloat, getColor, ..., а TypedValue ничего из этого не умеет. Зато у TypedValue есть поле changingConfigurations, а вот TypedArray#getChangingConfigurations возвращает одно общее значение для всех элементов (за O(n)) и доступно только для 21+. В итоге приходится использовать TypedArray даже для единственного атрибута и прибегать к помощи TypedValue, даже когда имеется «человеческий» TypedArray.

changingConfigurations у классов ColorStateList, Theme, TypedArray, Drawable, TypedValue расскажут, при смене каких конфигураций ресурс инвалидируется. Казалось бы, чтобы обновлять ресурсы вовремя, нужно придержать айдишники всех ресурсов, которые могут инвалидироваться в течение жизни данного компонента (т. е. (component.configChanges & resource.configChanges) != 0) и переопределить onConfigurationChanged данного Activity или View, где и можно перезагрузить протухшие ресурсы. Но метод Activity#getChangingConfigurations предназначен для другого и возвращает осмысленное значение, только когда активити уничтожается, а у вью вообще нет подобного метода. Чтобы узнать, какие конфигурации обрабатывает текущая активити, нужно спросить ActivityInfo у PackageManager. Хотя нужный объект ActivityInfo уже есть у Activity. В приватном поле.

По умолчанию все ресурсы библиотеки считаются публичными. Как только появляются ресурсы, явно отмеченные, как публичные, все остальные становятся приватными. Android Lint отмечает использование «приватных» ресурсов как warning. Не хватает возможностей:

Есть Resources#getString(@StringRes int) для получения строки, Resources#getString(@StringRes int id, Object... formatArgs) для строки с подстановками, Resources#getText(@StringRes int id) для текста с форматированием, но нет метода для обработки пересечения последних двух случаев: Resources#getText(int, Object... formatArgs). Хотя это максимально универсальный вариант, который перекрывает функциональность всех трёх существующих методов.

В строковых ресурсах используется собственный механизм экранирования. С \@ и \? всё понятно, а вот вместо &nbsp; придётся писать \u00A0. Зачем нужно экранировать одинарные кавычки — для меня загадка. Ещё большей загадкой остаётся вопрос, что же мешало явно отделить @ и ? от литералов: либо <string name="alias" ref="@..." />, либо <string name="literal">литерал<string>. Всё равно смешивать ссылки и литералы нельзя: <string name="close_window">@string/close форточку</string>.

В итоге <strings> терпимо использовать для переводов (если написать свой getText(int, Object... formatArgs)), а растровые и векторные картинки нормально чувствуют себя в drawable-*dpi. Всё остальное настолько убого, негибко и многословно, что оказывается довольно бесполезным, т. к. напрашивается на перенос в код.

Parcel

Писать собственную сериализацию — это всегда весело и задорно. Parcel очень напоминает DataInput и DataOutput из JDK, а Parcelable — это подобие Externalizable, но реализациями этих интерфейсов они не являются, что заставляет писать платформозависимый код.

Parcelable.Creator нужно держать в статическом поле с именем CREATOR, чтобы фреймворк мог найти его рефлекшеном. При этом, не найдя CREATOR, фреймворк пойдёт искать его у суперклассов, откладывая возникновение ошибок десериализации на потом.

Parcel нельзя долгосрочно хранить, потому что формат может быть изменён при обновлении ОС. Но тогда такой сериализации прямой путь в мусорку, потому что почти всегда объкеты в каком-то виде пересылаются или хранятся, а поддерживать более одного формата сериализации — затея сомнительная.

Асинхронные события и фрагменты

У фрагментов может случиться state loss. Очевидно, выполнение транзакций с фрагментами после сохранения состояния — это ошибка. Но фреймворк никак не поможет её найти — в асинхронных стектрейсах вообще не будет вашего кода.

Даже если транзакция выполняется в ответ на действие пользователя (например, непосредственно в OnClickListener), Activity вполне может быть на паузе в этот момент. Банальное нажатие кнопки «назад» может привести к падению, приложению при этом вообще не обязательно использовать фрагменты.

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

Обратная совместимость

В javadoc не проставлены @since. (На сайте они откуда-то всё же берутся.)

APK, собранные с использованием gradle plugin 3 и build-tools 26, ломают лончер и файловый менеджер Cyanogenmod.

В AppCompat 26 сломали target у фрагментов в тех случаях, которые не описаны (и никогда не были описаны) в документации как недопустимые. Почему-то FragmentManager оказался не в силах пробежать по родительским менеджерам и найти общего менеджера двух произвольных фрагментов.

В AndroidX таргеты устарели, им на смену пришёл новый механизм. Давайте сравнивать:

Возможность \ механизм setTargetFragment(…, RC) setFragmentResultListener(RK, …)
Различать, от кого пришёл результат onActivityResult(getTargetRC(), …) а как хочешь
Фрагменты из разных менеджеров уже нельзя нельзя
Консистентность с Activity Result болимень отнюдь
Область видимости requestCode/Key вызывающий фрагмент менеджер
Момент доставки когда попало STARTED

Итого: изменения ради изменений, стало ещё более неудобно, а requestKey могут теперь конфликтовать с оными у соседних фрагментов. А ведь всё, чего не хватало — метода setResult(), который перебросит результат, когда вызывающий фрагмент оттает.

Слабые контракты

Intent может содержать Action (строка), Uri, Extras (Bundle, т. е. Map<String, ?>). Для популярных extras есть заранее заготовленные ключи, например, Intent.EXTRA_EMAIL. Но нет типов. Так, можно попытаться открыть почтовый клиент, передав в качестве темы письма картинку, или открыть браузер, не передавая адреса веб-страницы. Чтение интента в видимой всей системе активити — поле непаханное для крэшей. Некоторые популярные приложения валились у меня на глазах при попытке поделиться картинкой. И дело не только в кривизне рук разработчиков, но и в изначальной проблемности такого механизма.

Кроме как в Extras, Bundle используется ещё и в аргументах фрагмента (Fragment.setArguments). Как всегда, нет гарантий, что по нужному ключу передан объект нужного типа.

Положим, у вас есть фрагмент, который принимает какой-то объект (выдумаем, например, ParcelUser) через аргументы. И вы осознаёте, что не нужно передавать объект целиком, достаточно передать идентификатор пользователя (ParcelUuid). Счастливого рефакторинга!

Профилирование

Взамен Android Monitor сделали полурабочий Android Profiler. Вот тред о том, как снова нормально смотреть хип-дампы. Но советы из него больше не работают.

Взамен Android Device Monitor не сделали ничего. Благо, его не удалили по-настоящему, а просто убрали из меню в IDE.

Единственный профайлер, способный предоставить полезную информацию, — это стороннее решение. Method tracing бесполезен, т. к. выбрасывает скомпилированный (и даже интринсифицированный!) код и использует интерпретатор. Systrace помогает найти медленные места, но очень приблизительно, т. к. фреймворковые методы инструментировать нельзя.

Баги

Ясное дело, баги есть в любом софте. Только здесь серьёзные преграды для разработки почему-то не считаются проблемой.

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

{"type":"articleComments","id":"f33da80f-db37-4111-93ab-30a62b1bcddd","comments":[]}

Javanese.Online в GitHub

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

RSS-лента