Недавно мы полностью переработали приложение Pyrus для Android. Первая версия приложения работала аж под Android 2.2. Отказавшись от поддержки Android ниже 4.1, мы смогли выплатить накопленный технический долг и заметно упростили исходный код. Да, мы потеряли часть пользователей (менее 1%), но зато мы сэкономили время разработчиков на исправление редких багов. Мы сможем инвестировать его в развитие функционала для всех текущих и новых пользователей. В долгосрочной перспективе это гораздо важнее.
Здесь мы делимся опытом, который может быть полезен тем, кто подумывает начать разработку для платформы Android.
В Android привлекают хорошие средства разработки, проверенный временем язык (Java) с привычным синтаксисом, огромная аудитория пользователей. Однако создавать удобные приложения на Android сложно: несмотря на развитый API часто бывает, что готового компонента с подходящим поведением нет. Приходится либо отказываться от задумок UX-дизайнера, либо соглашаться на зависимость от сторонних библиотек, либо писать самому. Не все архитектурные решения в Android оказались удачными и это также увеличивает объем кода без явной пользы.
Интерфейс в Android построен на Activity. Это контейнер, который обычно занимает целиком экран устройства, в нем живут остальные виджеты и он получает события о взаимодействии с пользователем.
Когда пользователь активирует приложение, в Activity приходит событие onResume. Нажал кнопку Home — Activity исчезло, но перед этим ему пришло событие onPause. Пока все логично. Что происходит когда пользователь поворачивает экран устройства на 90 градусов? Ни за что не догадаетесь: ОС убивает Activity и создает заново! При удалении Activity удаляются все компоненты, которые она содержит. Это означает, что приходится писать специальный код, который сохраняет состояние виджетов внутри Activity (позицию скролла, выделенный кусок текста, состояние чекбоксов) и восстанавливает их после воссоздания родителя. Android делает часть этой работы за вас, но не всё.
Сложно понять, что сподвигло архитекторов на подобное решение. Позже кто-то решил, что выглядит как-то некрасиво, и добавил таки параметр, который подавляет поведение системы по умолчанию. Теперь вместо пересоздания Activity ОС может вызывать специальный метод onConfigurationChanged. Это “решение” похоже на грязный хак и только усугубило проблему, ведь в документации Android сказано “эта техника должна рассматриваться как крайняя мера и не рекомендуется для большинства приложений”.
Изначально ОС Android была создана для телефонов. С появлением планшетов команда Android справедливо решила, что те элементы интерфейса, которые раньше из-за маленьких размеров экрана телефона были разнесены по разным Activity, теперь могут одновременно сосуществовать на большом экране планшета. (Пример: список писем и электронное письмо на телефоне были в двух разных Activity, а на планшете — делят вместе один экран.) Да вот беда: несколько Activity не могут одновременно взаимодействовать с пользователем by design. Поэтому нужен другой механизм повторного использования компонентов, и выход был найден: появились фрагменты (Fragment).
Согласно определению, Fragment представляет поведение или часть пользовательского интерфейса в Activity. Все ясно. Однако в инструкции по использованию фрагментов находим раздел “Добавление фрагмента, не имеющего пользовательского интерфейса”. ЧТОА? Оказывается, у фрагмента может принципиально не быть своего окна для отрисовки!
А что же происходит с фрагментами при поворотах экрана? Разработчики Android подумали об этом — при пересоздании Activity все ее фрагменты также автоматически пересоздаются. Однако, при внимательном рассмотрении у фрагмента есть метод “setRetainInstance”, если вы его вызовете, то именно этот фрагмент не будет удаляться и восстанавливаться при поворотах.
Стройной и непротиворечивой эту концепцию не назовешь.
Поток обработки событий нельзя блокировать долгими операциями, поэтому обмен данными с диском и сетью должны делаться асинхронно в других потоках. Android предоставляет аж целых четыре механизма асинхронной работы: AsyncTask, Service, IntentService, Thread. Разработчикам предлагается самим разобраться, какой им лучше использовать. Выбор нетривиален: например, если приложение запустило AsyncTask, а пользователь во время его выполнения повернул экран, то по окончании работы AsyncTask не имеет возможности найти новую (созданную после поворота) Activity. И ни в одном из четырех механизмов не реализована простая логика: если пользователь запустил второй асинхронный процесс, не дождавшись результатов первого, а затем первый все-таки закончился раньше, то уместно игнорировать и не отражать в UI его результаты.
Становится понятным появление многочисленных библиотек — шин данных для организации асинхронной работы внутри Android-приложения.
Если у Android заканчивается свободная оперативная память, ОС может завершить любой процесс. Когда в будущем пользователь активирует приложение, система постарается восстановить его состояние. По идее, это должно быть незаметно для разработчика (если он реализовал во всех компонентах метод onSaveInstanceState): система сама пересоздает стек Activity в том виде, как он был во время принудительной остановки. Однако, если инициализация требует времени (например, загрузка кэшей с диска или из сети), то правильно будет показать пользователю индикатор ожидания. Получается, при создании Activity все равно нужно отслеживать “холодный старт” и проводить инициализацию вручную. Не говоря уже о том, что при перезапуске не восстанавливаются никакие Thread и AsyncTask.
Насколько вероятно столкнуться с нехваткой памяти на практике? Обычно такая ситуация возникает при обработке изображений высокого разрешения. Например, картинка 2048x1536 занимает 12Mb в памяти объекта Bitmap. Объем доступной приложению памяти зависит от конкретной модели устройства и иногда бывает совсем небольшим (64Mb или 128Mb). Поскольку garbage collector во всех версиях Android до 8.0 не был сжимающим (compacting), то даже если у вас есть 100Mb свободной памяти, но она разбита на блоки по 10Mb, то попытка выделить память под такую картинку приводила к аварийной остановке приложения.
Однажды наш пользователь заметил, что при прокрутке длинных списков приложение “лагало” — каждые 2-3 секунды анимация останавливалась на короткие паузы (200-300 мс), и затем продолжалась. Анализ показал, что в приложении подозрительно часто запускается сборка мусора. Оказалось, что стандартный класс HashMap (который мы используем для получения объекта Java по его ID) в нашем случае неэффективен: нужно создавать объект-обертку на каждый ключ, являющийся целым числом (int). Поэтому количество аллокаций памяти увеличивается без какой-либо пользы. Решением было перейти на специальный контейнер SparseArray (есть только в Android, отсутствует в стандартной платформе Java), что сразу снизило давление на сборщик мусора.
При чем тут лаги в анимации, спросите вы? Дело в том, что сборщик мусора в Android останавливал все нити во время своей работы, в том числе основную нить, занимающуюся отрисовкой. В версиях Android начиная c 5.0 используется другая виртуальная машина (ART вместо Dalvik) и другой алгоритм сборки мусора, делающий паузы в анимации реже и короче. (Про сравнение времени сборки мусора можно почитать здесь.)
Если вы хотите вставить в ваше приложение предпросмотр документов, вынуждены разочаровать: в Android нет встроенного компонента, который умеет показывать файлы Word, Excel, Powerpoint. Не говоря уже об архивах ZIP или документах PDF. Выход? Заставлять пользователя устанавливать сторонние приложения для просмотра каждого типа файла или использовать компонент WebView (фактически браузер). В обоих вариантах при тапе пользователя по файлу будет запускаться внешнее приложение и некоторые сценарии просто невозможно реализовать, например, ленту изображений: нет простого способа встроить WebView в стандартный ViewPager.
Некоторые исследования утверждают, что трудоемкость разработки под Android сравнительно высока. Наш опыт показывает, что грамотному разработчику легко привыкнуть к особенностям этой платформы. И хотя сегодня Android — самая популярная в мире мобильная ОС, через 5-10 лет ситуация может радикально поменяться.
Последние годы компания Google секретно разрабатывает новую ОC Fuchsia с принципиально иной (по сравнению с современными ОС) моделью безопасности. В качестве основного языка программирования в ней, вероятно, будет использоваться Dart, а в качестве основного способа создания приложений — фреймворк Flutter. Ходят слухи, что Fuchsia может заменить Android и ChromeOS на всех устройствах. Если Google пойдет на это, компания скорее всего предоставит нативную поддержку написанных под Android приложений (как это делала Microsoft при переходе с DOS на Windows). Поэтому пока можно не беспокоиться и продолжать накапливать экспертизу в Android. А тем, кто хочет заглянуть в будущее, скачать Flutter и поиграться с ним можно здесь.
Здесь мы делимся опытом, который может быть полезен тем, кто подумывает начать разработку для платформы Android.
В Android привлекают хорошие средства разработки, проверенный временем язык (Java) с привычным синтаксисом, огромная аудитория пользователей. Однако создавать удобные приложения на Android сложно: несмотря на развитый API часто бывает, что готового компонента с подходящим поведением нет. Приходится либо отказываться от задумок UX-дизайнера, либо соглашаться на зависимость от сторонних библиотек, либо писать самому. Не все архитектурные решения в Android оказались удачными и это также увеличивает объем кода без явной пользы.
Умирающие и восстающие из пепла Activity
Интерфейс в Android построен на Activity. Это контейнер, который обычно занимает целиком экран устройства, в нем живут остальные виджеты и он получает события о взаимодействии с пользователем.
Когда пользователь активирует приложение, в Activity приходит событие onResume. Нажал кнопку Home — Activity исчезло, но перед этим ему пришло событие onPause. Пока все логично. Что происходит когда пользователь поворачивает экран устройства на 90 градусов? Ни за что не догадаетесь: ОС убивает Activity и создает заново! При удалении Activity удаляются все компоненты, которые она содержит. Это означает, что приходится писать специальный код, который сохраняет состояние виджетов внутри Activity (позицию скролла, выделенный кусок текста, состояние чекбоксов) и восстанавливает их после воссоздания родителя. Android делает часть этой работы за вас, но не всё.
Сложно понять, что сподвигло архитекторов на подобное решение. Позже кто-то решил, что выглядит как-то некрасиво, и добавил таки параметр, который подавляет поведение системы по умолчанию. Теперь вместо пересоздания Activity ОС может вызывать специальный метод onConfigurationChanged. Это “решение” похоже на грязный хак и только усугубило проблему, ведь в документации Android сказано “эта техника должна рассматриваться как крайняя мера и не рекомендуется для большинства приложений”.
Фрагментированные фрагменты
Изначально ОС Android была создана для телефонов. С появлением планшетов команда Android справедливо решила, что те элементы интерфейса, которые раньше из-за маленьких размеров экрана телефона были разнесены по разным Activity, теперь могут одновременно сосуществовать на большом экране планшета. (Пример: список писем и электронное письмо на телефоне были в двух разных Activity, а на планшете — делят вместе один экран.) Да вот беда: несколько Activity не могут одновременно взаимодействовать с пользователем by design. Поэтому нужен другой механизм повторного использования компонентов, и выход был найден: появились фрагменты (Fragment).
Согласно определению, Fragment представляет поведение или часть пользовательского интерфейса в Activity. Все ясно. Однако в инструкции по использованию фрагментов находим раздел “Добавление фрагмента, не имеющего пользовательского интерфейса”. ЧТОА? Оказывается, у фрагмента может принципиально не быть своего окна для отрисовки!
А что же происходит с фрагментами при поворотах экрана? Разработчики Android подумали об этом — при пересоздании Activity все ее фрагменты также автоматически пересоздаются. Однако, при внимательном рассмотрении у фрагмента есть метод “setRetainInstance”, если вы его вызовете, то именно этот фрагмент не будет удаляться и восстанавливаться при поворотах.
Стройной и непротиворечивой эту концепцию не назовешь.
Асинхронная работа
Поток обработки событий нельзя блокировать долгими операциями, поэтому обмен данными с диском и сетью должны делаться асинхронно в других потоках. Android предоставляет аж целых четыре механизма асинхронной работы: AsyncTask, Service, IntentService, Thread. Разработчикам предлагается самим разобраться, какой им лучше использовать. Выбор нетривиален: например, если приложение запустило AsyncTask, а пользователь во время его выполнения повернул экран, то по окончании работы AsyncTask не имеет возможности найти новую (созданную после поворота) Activity. И ни в одном из четырех механизмов не реализована простая логика: если пользователь запустил второй асинхронный процесс, не дождавшись результатов первого, а затем первый все-таки закончился раньше, то уместно игнорировать и не отражать в UI его результаты.
Становится понятным появление многочисленных библиотек — шин данных для организации асинхронной работы внутри Android-приложения.
Перезапуск приложения после OOM
Если у Android заканчивается свободная оперативная память, ОС может завершить любой процесс. Когда в будущем пользователь активирует приложение, система постарается восстановить его состояние. По идее, это должно быть незаметно для разработчика (если он реализовал во всех компонентах метод onSaveInstanceState): система сама пересоздает стек Activity в том виде, как он был во время принудительной остановки. Однако, если инициализация требует времени (например, загрузка кэшей с диска или из сети), то правильно будет показать пользователю индикатор ожидания. Получается, при создании Activity все равно нужно отслеживать “холодный старт” и проводить инициализацию вручную. Не говоря уже о том, что при перезапуске не восстанавливаются никакие Thread и AsyncTask.
Насколько вероятно столкнуться с нехваткой памяти на практике? Обычно такая ситуация возникает при обработке изображений высокого разрешения. Например, картинка 2048x1536 занимает 12Mb в памяти объекта Bitmap. Объем доступной приложению памяти зависит от конкретной модели устройства и иногда бывает совсем небольшим (64Mb или 128Mb). Поскольку garbage collector во всех версиях Android до 8.0 не был сжимающим (compacting), то даже если у вас есть 100Mb свободной памяти, но она разбита на блоки по 10Mb, то попытка выделить память под такую картинку приводила к аварийной остановке приложения.
Вечная сборка мусора
Однажды наш пользователь заметил, что при прокрутке длинных списков приложение “лагало” — каждые 2-3 секунды анимация останавливалась на короткие паузы (200-300 мс), и затем продолжалась. Анализ показал, что в приложении подозрительно часто запускается сборка мусора. Оказалось, что стандартный класс HashMap (который мы используем для получения объекта Java по его ID) в нашем случае неэффективен: нужно создавать объект-обертку на каждый ключ, являющийся целым числом (int). Поэтому количество аллокаций памяти увеличивается без какой-либо пользы. Решением было перейти на специальный контейнер SparseArray (есть только в Android, отсутствует в стандартной платформе Java), что сразу снизило давление на сборщик мусора.
При чем тут лаги в анимации, спросите вы? Дело в том, что сборщик мусора в Android останавливал все нити во время своей работы, в том числе основную нить, занимающуюся отрисовкой. В версиях Android начиная c 5.0 используется другая виртуальная машина (ART вместо Dalvik) и другой алгоритм сборки мусора, делающий паузы в анимации реже и короче. (Про сравнение времени сборки мусора можно почитать здесь.)
Просмотр документов
Если вы хотите вставить в ваше приложение предпросмотр документов, вынуждены разочаровать: в Android нет встроенного компонента, который умеет показывать файлы Word, Excel, Powerpoint. Не говоря уже об архивах ZIP или документах PDF. Выход? Заставлять пользователя устанавливать сторонние приложения для просмотра каждого типа файла или использовать компонент WebView (фактически браузер). В обоих вариантах при тапе пользователя по файлу будет запускаться внешнее приложение и некоторые сценарии просто невозможно реализовать, например, ленту изображений: нет простого способа встроить WebView в стандартный ViewPager.
Что дальше
Некоторые исследования утверждают, что трудоемкость разработки под Android сравнительно высока. Наш опыт показывает, что грамотному разработчику легко привыкнуть к особенностям этой платформы. И хотя сегодня Android — самая популярная в мире мобильная ОС, через 5-10 лет ситуация может радикально поменяться.
Последние годы компания Google секретно разрабатывает новую ОC Fuchsia с принципиально иной (по сравнению с современными ОС) моделью безопасности. В качестве основного языка программирования в ней, вероятно, будет использоваться Dart, а в качестве основного способа создания приложений — фреймворк Flutter. Ходят слухи, что Fuchsia может заменить Android и ChromeOS на всех устройствах. Если Google пойдет на это, компания скорее всего предоставит нативную поддержку написанных под Android приложений (как это делала Microsoft при переходе с DOS на Windows). Поэтому пока можно не беспокоиться и продолжать накапливать экспертизу в Android. А тем, кто хочет заглянуть в будущее, скачать Flutter и поиграться с ним можно здесь.