AsyncTask устарел, что теперь?

Автор: | 22.12.2019

За последнее десятилетие, AsyncTask стал одним из наиболее широко используемых решений для написания многопоточного кода в Android. Тем не менее, он заработал очень противоречивую репутацию. С одной стороны, AsyncTask поддерживался и до сих пор поддерживается многими приложениями Android. С другой стороны, большинство профессиональных разработчиков Android открыто не любят этот API.

В целом, я бы сказал, что отношение сообщества Android к AsyncTask можно охарактеризовать как «от любви до ненависти». Но теперь есть одна существенная новость: эра AsyncTask подходит к концу, как так коммит, который делает его устаревшим, попал в Android Open Source Project.

В этой статье я рассмотрю официальное заявление, мотивирующее устаревание AsyncTas, а также реальные причины причины того, почему он должен быть объявлен устаревшим. Как вы увидите, это будут разные причины. Кроме того, в конце этой статьи я поделюсь своими мыслями о будущем API для многопоточного выполнения.

Официальная причина устаревания AsyncTask

Официальная причина устаревания AsyncTask, а также мотивация этого решения, были представлены вместе с коммитом. Добавленный с этим коммитом абзац гласит:

AsyncTask был предназначен для обеспечения правильного и простого использования UI-потока. Тем не менее, наиболее распространенным вариантом использования стало внедрение с UI, и это могло приводить к утечкам контекста, пропущенным коллбэкам или крешам во время изменения конфигурации. Также  AsyncTask имеет противоречивое поведение на разных версиях платформы, проглатывает исключения, падающие в doInBackground(), и не предоставляет таких возможностей, какие предоставляет непосредственное использование Executor.

Хоть это и официальное заявление Google, есть несколько неточностей, на которые стоит обратить внимание.

Во-первых. AsyncTask никогда не предназначался для «обеспечения правильного и просто использования UI-потока». Он был предназначен для разгрузки длительных операций из UI-потока в фоновые потоки, а затем доставки результатов этих операций обратно в UI-поток.. Я знаю, я придираюсь здесь. Однако, по моему мнению, когда Google осуждает API, который они сами изобрели и продвигали в течение многих лет, было бы более уважительно к разработчикам, которые используют этот API по сей день и будут использовать его в течение многих лет, лучше разъяснить своим мысли по поводу устаревания, чтобы предотвратить дальнейшую путаницу.

Тем не менее, более интересная часть этого заявления заключается в следующем: «это могло приводить к утечкам контекста, пропущенным коллбекам или  крешам во время изменения конфигурации». Получается, Google в принципе говорить о том, что наиболее распространенный вариант использования AsyncTask автоматически приводит к очень серьёзным проблемам. Однако существует множество качественно сделанных приложений, использующих AsyncTask, которые работают безупречно. Даже некоторые классы внутри самого AOSP используют AsyncTask. Почему они не испытывают эти проблемы?

Чтобы ответить на этот вопрос, давайте подробно обсудим взаимосвязь между AsyncTask и утечками памяти.

AsyncTask и утечки памяти

AsyncTask ниже будет приводить к утечке объекта Fragment (или Activity) всегда:

@Override
public void onStart() {
  super.onStart();
  new AsyncTask<Void, Void, Void>() {
    @Override
    protected Void doInBackground(Void... voids) {
      int counter = 0;
      while (true) {
        Log.d("AsyncTask", "count: " + counter);
        counter ++;
      }
    }
  }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}

Если посмотреть на пример, то он подтверждает точку зрения Google: AsyncTask действительно вызывает утечку памяти. Мы должны использовать другой подход для написания многопоточного кода. Ну, давайте попробуем!

Этот же пример, переписанный с использование RxJava:

@Override
public void onStart() {
  super.onStart();
  Observable.fromCallable(() -> {
    int counter = 0;
    while (true) {
      Log.d("RxJava", "count: " + counter);
     counter ++;
    }
  }).subscribeOn(Schedulers.computation()).subscribe();
}

Этот код также будет приводить к утечкам фрагмента или активности.

Тогда может быть новая блестящая особенность Kotlin под названием  Corointines нам поможет? Вот как я смог достичь той же функциональности, используя Corountines:

override fun onStart() {
  super.onStart()
  CoroutineScope(Dispatchers.Main).launch {
    withContext(Dispatchers.Default) {
      var counter = 0
      while (true) {
        Log.d("Coroutines", "count: $counter")
        counter++
      }
    }
  }
}

К сожалению, это приводит к точно такой же утечке памяти.

Похоже, что эта фича создаёт утечку фрагмента или активности, независимо от выбора фреймворка для многопоточности. Фактически, это привело бы к утечке, даже если бы я использовал голый класс Thread:

@Override
public void onStart() {
  super.onStart();
  new Thread(() -> {
    int counter = 0;
    while (true) {
      Log.d("Thread", "count: " + counter);
      counter++;
    }
  }).start();
}

Получается, что это не проблема конкретно AsyncTask, но проблема логики написания. Чтобы вернуться к этому вопросу, давайте изменим пример, который использует AsyncTask для устранения утечки памяти:

private AsyncTask mAsyncTask;
 
@Override
public void onStart() {
    super.onStart();
    mAsyncTask = new AsyncTask<Void, Void, Void>() {
        @Override
        protected Void doInBackground(Void... voids) {
            int counter = 0;
            while (!isCancelled()) {
                Log.d("AsyncTask", "count: " + counter);
                counter ++;
            }
            return null;
        }
    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
 
@Override
public void onStop() {
    super.onStop();
    mAsyncTask.cancel(true);
}

В этом случае я использую «ужасный» AsyncTask, но  никаких утечек нет. Магия!

На самом деле это, конечно, не магия. Это всего лишь размышления на тему того, как можно написать безопасный и правильный многопоточный код, используя AsyncTask, точно так же, как вы можете этого сделать с любым другим фреймворком. Не существует особо связи между AsyncTask и утечками памяти. Следовательно, распространенное мнение о том, что AsyncTask автоматически приводит к утечкам памяти, а также сообщение об устаревании в AOSP, просто неверно.

Возможно, вам будет интересно: если эта идея о том, что AsyncTask приводит к утечкам памяти, неверна, почему она так широко распространена среди разработчиков Android?

Что ж, в Android Studio есть встроенное правило Lint, которое предупреждает вас и рекомендует делать статические AsyncTask, чтобы избежать утечек памяти. Это предупреждение также неверно, но разработчики, которые используют AsyncTask в своём проекте, получают это предупреждение и, поскольку оно исходит от Google, принимают его за чистую монету.

По моему мнению, вышеупомянутое предупреждение Lint является причиной того, что миф, который связывает AsyncTask с утечками памяти, настолько распространен — он навязывается самим Google.

Как избежать утечек памяти в многопоточном коде

На данный момент мы установили, что нет причинно-следственной связи между использованием AsyncTask и утечками памяти. Кроме того, вы видели, что утечки памяти могут происходить с любым многопоточным фреймворком. Следовательно, вы вероятно думаете о том, как избежать утечек памяти в ваших приложениях.

Я не буду подробно отвечать на этот вопрос, потому что я не хочу отклоняться от темы, но я также не хочу оставлять вас с пустыми руками. Поэтому позвольте мне перечислить концепции, которые вам нужно понять, чтобы избегать писать многопоточный код на Java и Kotlin без утечек памяти:

  • Сборщик мусора
  • Корневые точки
  • Жизненный цикл потока для корректной сборки мусора
  • Неяные ссылки из внутренних классов к родительским объектам

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

Была ли причина делать AsyncTask устаревшим

Поскольку AsyncTask автоматически не приводит к утечкам памяти, похоже, что Google отказался от неё по ошибке без какой-либо причины. Ну, это не совсем так.

За последние годы AsyncTask уже «устарел» для самих разработчиков Android. Многие из них открыто выступают против использования этого API в приложениях. Лично мне жаль разработчиков, которые поддерживают кодовые базы, активно использующие AsyncTask. По прошествии стольких лет стал очевидно, что AsyncTask — очень проблемный API. Таким образом, он является устаревшим с логической точки зрения. Если вы спросите меня, Google должен был сделать его устаревшим ещё много лет назад.

Поэтому, хотя Google всё ещё путаются в работе своих собственных творений, само заявление об устаревании очень уместно и приветствуется. По крайней мере, это позволит новым разработчикам Android знать, что им не нужно тратить время на изучение этого API, и они не будут использовать его в своих приложениях.

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

Поэтому позвольте мне перечислить то, что я лично вижу как недостатки данного API.

Проблема 1: Делает многопоточность более сложной

Одним из главных презентующих пунктов AsyncTask было обещание, что вам не нужно иметь дело с классом Thread и другими примитивами многопоточности самостоятельно. Это должно было упростить многопоточность, особенно для новых разработчиков Android. Звучит отлично, правда? Однако на практике эта «простота» имела последствия.

Javadoc для класса AsyncTask использует слово «поток» 16 раз. Кроме того, этот Javadoc содержит набор специфических для AsyncTask огранический и условий. Другими словами, если вы хотите использовать AsyncTask, вам нужно понимать принцип работы потоков, а также вы должны понимать многие нюансы самого API. Это нельзя назвать «простым» даже с большой натяжкой.

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

Поэтому, по моему мнению, просто невозможно упростить многопоточность, и амбиции AsyncTask были обречены на провал с самого начала.

Проблема 2: Плохая документация

Не секрет, что документация по Android не является оптимальной (стараюсь быть вежливым). С годами ситуация улучшилась, но даже сегодня я бы не назвал её приличной. На мой взгляд, неудачная документация была ведущим фактором для определения проблемной истории AsyncTask. Если бы API был просто избыточным, сложным и полным нюансов многопоточным фреймворком, как и есть на самом деле, но с хорошей документацией, она могла бы остаться частью экосистемы. В конце концов, в Android нет недостатка в уродливых API, к которым привыкли разработчики. Но документация AsyncTask была ужасной и усугубляла все остальные его недостатки.

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

В дополнение к этому, документация не содержит каких-либо объяснений основных понятий, связанных с многопоточностью (те, которые я перечислил ранее, и другие). Фактически, я думаю, что нигде в официальной документации это не сделано. Нет даже ссылки на JLS Chapter 17: Threads and Locks, куда можно было бы отправить разработчиков, которые действительно хотят разобраться в понятии «многопоточность».

Кстати, на мой взгляд, вышеупомянутое правило Lint в Android Studio, распространяющее миф об утечках памяти, также является частью документации. Поэтому дело не только в том, что документации недостаточно, она ещё и содержала неправильную информацию.

Проблема 3: Чрезмерная сложность

AsyncTask имеет 3 универсальных аргумента. ТРИ! Если я не ошибаюсь, я никогда не видел ни одного другого класса, который требовал бы столько дженериков.

Я до сих пор помню своё первое знакомство с AsyncTask. К тому времени я уже немного знал о потоках Java и не мог понять, почему многопоточность в Android так сложна. Три универсальных аргумента было очень трудно понять, и я очень нервничал. Кроме того, Поскольку методы AsyncTask вызываются в разных потоках, мне приходилось постоянно напоминать себе об этом, а затем проверять, правильно ли я понял, читая документацию.

Сегодня, когда я знаю гораздо больше о многопоточности и UI-потоке Android, я, вероятно, смогу разобрать эту информацию. Тем не менее, этот уровень понимания пришёл ко мне намного позже в моей карьере, после того, как полностью отказался от AsyncTask.

И со всеми этими сложностями вы всё равно должны вызывать execute() только из UI-потока!

Проблема 4: Злоупотребление наследованием

Философия AyncTask основана на наследовании: всякий раз, когда вам нужно выполнить задачу в фоновом режиме, вы наследуете AsyncTask.

В сочетании с плохой документацией философия наследования подтолкнула разработчиков к написанию огромных классов. которые объединяли многопоточность, основную и UI-логику вместе наиболее неэффективным и сложным в обслуживании образом. В конце концов, почему бы и нет? Это возможности, которые предоставляет AsyncTask.

Правило «Всегда стоит предпочитать композицию наследованию», из Effective Java, если его придерживаться, будет иметь реальное значение в случае с AsyncTask. Интересно, что Джошуа Блох, автор Effective Java, работал в Google и работал над Android на относительно ранней стадии.

Проблема 5: Надежность

Проще говоря, по умолчанию THREAD_POOL_EXECUTOR, который поддерживает AsyncTask, неправильно настроен и ненадёжен. Google подправлял свою конфигурацию по крайней мере дважды за эти годы (этот коммит и тот), но он всё равно крашит даже официальное приложение «Настройки».

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

Проблема 6: Заблуждение о параллелизме

Этот момент связан с плохой документацией, но я думаю, что он заслуживает отдельного внимания. Javadoc для метода executeOnExecutor() гласит:

Разрешение множественного выполнения нескольких задач из пула потоков это обычно не то, что нужно, поскольку порядок их работы не определён. […] Такие изменения лучше всего выполнять последовательно; чтобы гарантировать, что работа будет сериализована независимо от версии платформы, вы можете использовать эту функцию с SERIAL_EXECUTOR.

Ну, это просто неправильно. Разрешить одновременное выполнение нескольких задач — это именно то. что вам нужно в большинстве случае при разгрузке работы из UI-потока.

Например, допустим, вы отправили запрос по сети, и по какой-то причине время ожидания истекло. Время ожидания по умолчанию в OkHttp составляет 10 секунд. Если вы используете SERIAL_EXECUTOR, который в любой момент времени выполняет только одну задачу, вы просто остановите всю фоновую работу в своём приложении на 10 секунд. А если вам нужно посылать два запроса с одинаковым тайм-аутом? Ну, 20 секунд без фоновой обработки. Сейчас тайм-аут сетевых запросов не является каким-то исключением, аналогично ему есть и другие случаи использования: запросы к базе данных. обработка изображений, вычисления, IPC и т.д.

Да, как указано в документации, порядок операций, выгружаемых, например, в пул потоков, не определён, поскольку они выполняются одновременно. Тем не менее, это не проблема. На самом деле, это в значительной степени определение параллелизма.

Поэтому, на мой взгляд, это утверждение в официальных документациях указывает на очень серьёзное заблуждение относительно параллелизма среди авторов AsyncTask. Я просто не вижу другого объяснения такой вводящей в заблуждение информации, которая содержится в официальной документации.

Будущее AsyncTask

Надеюсь, я убедил вас, что отказ от AsyncTask — хороший шаг со стороны Google. Однако для проектов, которые используют AsyncTask сегодня, это не очень хорошая новость. Если вы работаете над таким проектом, должны ли вы провести рефакторинг своего кода сейчас?

Прежде всего, я не думаю, что вам нужно активно удалять AsyncTask из вашего кода. Устаревание этого API не означает, что он перестанет работать. На самом деле, я не удивлюсь, если он будет использоваться до тех пор, когда жив Android. Слишком много приложений, включая собственные приложения Google, используют этот API. И даже если он будет удалён, скажем, лет через 5, вы сможете скопировать его код и вставить в свой проект, изменив импорты.

Основное влияние устаревание API окажет на новых разработчиков Android. Им будет ясно, что им не нужно тратить время на изучение AsyncTask и использование его в новых приложениях.

Будущее многопоточности в Android

Устаревание AsyncTask оставляет некоторую пустоту, которую должен заполнить какой-то другой многопоточный фреймворк. Что это будет?  Позвольте мне поделиться с вами моим личным мнением на эту тему.

Если вы только начинаете своё путешествие по Android и используете Java, я рекомендую комбинацию голого класса Thread и UI Handler. Многие разработчики Android будут возражать, но я сам некоторое время использовал этот подход, и он работал намного лучше, чем AsyncTask.

Если у вас уже есть небольшой опыт, вы можете заменить ручную реализацию потоков на ExecutorService. Для меня самой большой проблемой с использованием класса Thread было то, что я постоянно забывал запускать потоки, а затем приходилось тратить время на отладку этих глупых ошибок. Это раздражает. ExecutorService решил эту проблему.

Теперь я лично предочитаю использовать библиотеку ThreadPoster для мультипоточности в Java. Это очень лёгкая абстракция по сравнению с ExecutorService и Handler. Эта библиотека делает многопоточность более понятной и упрощает тестирование.

Если вы используете Kotlin, то приведённые выше рекомендации остаются в силе, но есть ещё одно соображение, которое необходимо учитывать.

Похоже, что фреймворк Corountines станет официальным примитивом Kotlin для параллелизма. Другими словами, не смотря на то, что Kotlin в Android использует потоки под капотом, Corountines будет минимальным уровнем абстракции в документации и учебных пособиях по языку.

Лично для меня. Corountines чувствуется очень сложным и сырым на данным момент, но я всегда стараюсь выбирать инструменты на основе своих прогнозов для экосистемы через 2 года. По этим критериям я бы выбрал Corountines для нового проекта на Kotlin. Поэтому я рекомендую всем разработчикам, которые используют Kotlin, использовать этот фреймворк.

Самое главное, независимо от того, какой подход вы выберете, потратьте время на изучение основ многопоточности. Как вы видели в этой статье, правильность вашего кода определяется не фреймворками, а вашим пониманием основополагающих принципов.

Выводы

По моему мнению, устаревание AsyncTask давно назрело, и это сделало многопоточность в экосистеме Android чище. Этот API имел много проблем и вызывал много ошибок на протяжении многих лет.

К сожалению, официальное заявление об устаревании содержит неверную информацию и может привести в замешательство разработчиков, которые используют AsyncTask или будут наследовать базы его кода в будущем. Надеемся, что эта статья прояснила некоторые моменты, касающиеся AsyncTask, а также дала вам пищу для размышлений о параллелизме в Android в целом.

Для проектов, которые используют AsyncTask сегодня, это устаревание проблематично, но не требует каких-либо немедленных действий. AsyncTask не будет удален из Android в ближайшее время.

AsyncTask устарел, что теперь?: 3 комментария

  1. Данила

    Это именно та статья что мне нужна.
    Пишу свое первое приложение на Android игру Судоку. (в перерывах еще два очень простеньких написал)
    Мне необходимо было как-то отображать прогресс бар во время генерации головоломки, полез по интернету, попалась статья про AsyncTask. Смотрю: 4 метода, 3 из них для работы с UI и 1 для работы в фоне, все понятно. Написал, запустил — крашится. Несколько дней у меня ушло чтоб это заработало.
    Коли он у меня заработал, я решил сделать процедурную генерацию элементов интерфейса, т.е. после создания каждой кнопки, вызывался publishProgress в котором она выводилась на экран — на это ушло еще несколько дней, не меньше чем в прошлый раз.
    И вот теперь, эта зараза, AsyncTask, после закрытия и повторного открытия Activity, не запускает doInBackground. (кэш сохраняется наверно).
    Вот я брожу по сайта ищу совет, пример или документацию, и похоже совет: «Брось ты это дело, используй что-то по-нормальней»).

  2. Sergei

    А что на счет работы с сетями? У меня классический стек — AsyncTask + спринговый RestTemplate + Jackson
    Работает более-менее
    На что в таком случае переходить?

    1. Владимир

      Здравствуйте! Основные альтернативы сейчас это использование Executor вместе с Handler, либо стандартные Thread. Есть различные реализации, например здесь и здесь, возможно что-то из этого сможет подойти.

Добавить комментарий для Владимир Отменить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *