Руководство по фоновой работе в Android. Часть 2: Loaders

https://proandroiddev.com/android-background-in-a-nutshell-part-ii-loaders-f763f70fdd15
  • Перевод
Это вторая из серии статей об инструментах и методах фоновой работы в Android. Ранее уже были рассмотрены AsyncTask, в следующих выпусках — ThreadPools с EventBus, RxJava 2 и корутины в Kotlin.



В предыдущем тексте мы упомянули, что у AsyncTasks есть несколько проблем. Давайте вспомним две из них:

  • AsyncTasks ничего не знают о жизненном цикле Activity. При неправильном обращении вы в лучшем случае получите утечку памяти, а в худшем — сбой.
  • AsyncTask не поддерживает сохранение состояния прогресса и повторное использование результатов загрузки.

Смысл первой проблемы вот в чем: чтобы обновить UI в методе onPostExecute, нам нужна ссылка на конкретный view или на всю Activity, к которой он относится. Наивный подход в том, чтобы хранить эту ссылку внутри самого AsyncTask:

public static LoadWeatherForecastTask extends AsyncTask<String, Void, WeatherForecast> {

    private Activity activity;

    LoadWeatherForecastTask(Activity activity) {
        this.activity = activity;
    }
}


Проблема в том, что как только пользователь поворачивает устройство, Activity уничтожается, и ссылка устаревает. Это приводит к утечке памяти. Почему? Вспомним, что наш метод doInBackground вызывается внутри Future, исполняемого на executor — статическом члене класса AsyncTask. Это делает наш объект AsyncTask, а также Activity, строго достижимыми(потому что статика является одним из корней GC), а следовательно, неподходящими для сборки мусора. Это в свою очередь означает, что несколько поворотов экрана могут вызвать OutOfMemoryError, потому что Activity занимает приличный объем памяти.

Исправить эту ошибку можно с помощью WeakReference:

public static LoadWeatherForecastTask extends AsyncTask<String, Void, WeatherForecast> {

  private WeakReference<Activity> activityRef;
  
  LoadWeatherForecastTask(Activity activity) {
    this.activityRef = new WeakReference<>(activity);
  }
}

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



Для того, чтобы исправить это, команда Android несколько лет назад предложила Loaders API («Загрузчики»). Посмотрим, как использовать это API. Нам нужно реализовать интерфейс Loader.Callbacks:

inner class WeatherForecastLoaderCallbacks : LoaderManager.LoaderCallbacks<WeatherForecast> {

   override fun onLoaderReset(loader: Loader<WeatherForecast>?) {

   }

   override fun onCreateLoader(id: Int, args: Bundle?): Loader<WeatherForecast> {
      return WeatherForecastLoader(applicationContext)
   }

   override fun onLoadFinished(loader: Loader<WeatherForecast>?, data: WeatherForecast?) {
      temperatureTextView.text = data!!.temp.toString();
   }
}

Как можно заметить, метод onLoadFinished очень похож на onPostExecute, который мы реализовывали в AsyncTask.

Нам нужно создать сам Loader:


class WeatherForecastLoader(context: Context) : AsyncTaskLoader<WeatherForecast>(context) {

   override fun loadInBackground(): WeatherForecast {
      try {
         Thread.sleep(5000)
      } catch (e: InterruptedException) {
         return WeatherForecast("", 0F, "")
      }

      return WeatherForecast("Saint-Petersburg", 20F, "Sunny")
   }
}


И вызвать initLoader() с id нашего Loader:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    ...
    val weatherForecastLoader = WeatherForecastLoaderCallbacks()
    loaderManager
      .initLoader(forecastLoaderId, Bundle(), weatherForecastLoader)
}

Пожалуйста, обратите внимание: WeatherForecastLoaderCallbacks — вложенный класс нашей Activity; LoaderManager хранит ссылку на этот объект Callbacks — а значит, и на саму Activity тоже

Немало кода, да? Но мы тут получаем важное преимущество. Во-первых, Loader оказывается переиспользован при повороте экрана (или других изменениях конфигурации). Если экран повернули, onLoadFinished будет вызван с передачей результата, загруженного нами ранее.

Другое преимущество в том, что у нас не происходит утечка памяти, хотя доступ к Activity у нас остается, позволяя обновлять интерфейс.

Круто, у AsyncTask обоих этих преимуществ не было! Давайте теперь разберёмся, как всё это работает.



Тут-то и начинается главное веселье. Я думал, что LoaderManager хранится где-то внутри Application, но настоящая реализация оказалась куда более интересной.

LoaderManager создается при создании экземпляра Activity:

public class Activity {
    
    final FragmentController mFragments =       
           FragmentController.createController(new HostCallbacks());
    
    public LoaderManager getLoaderManager() {
        return mFragments.getLoaderManager();
    }
}


FragmentController.createController — это просто именованный конструктор для класса FragmentController. FragmentController делегирует создание LoaderManager в HostCallbacks (вложенному классу нашей Activity), реализация выглядит так:

LoaderManagerImpl getLoaderManagerImpl() {
    if (mLoaderManager != null) {
        return mLoaderManager;
    }
    mCheckedForLoaderManager = true;
    mLoaderManager = getLoaderManager("(root)", mLoadersStarted, true /*create*/);
    return mLoaderManager;

Как видите, LoaderManager для самой Activity инициализируется лениво; экземпляр LoaderManager не создается до тех пор, пока впервые не понадобится. Доступ к LoaderManager нашей Activity происходит по ключу ‘(root)’ в Map LoadersManagers. Доступ к этой Map реализован так:

LoaderManagerImpl getLoaderManager(String who, boolean started, boolean create) {
    if (mAllLoaderManagers == null) {
        mAllLoaderManagers = new ArrayMap<String, LoaderManager>();
    }
    LoaderManagerImpl lm = (LoaderManagerImpl)              mAllLoaderManagers.get(who);
    if (lm == null && create) {
        lm = new LoaderManagerImpl(who, this, started);
        mAllLoaderManagers.put(who, lm);
    } else if (started && lm != null && !lm.mStarted){
        lm.doStart();
    }
    return lm;
}

Однако это не последняя запись поля LoaderManager. Посмотрим на метод Activity#onCreate:

@MainThread
@CallSuper
protected void onCreate(@Nullable Bundle savedInstanceState) {
    ...
    if (mLastNonConfigurationInstances != null) {
        mFragments.restoreLoaderNonConfig(
              mLastNonConfigurationInstances.loaders);
    }
    ...
}

Метод restoreLoaderNonConfig в итоге просто обновляет host controller, который теперь является членом класса нового экземпляра Activity, созданной после изменения конфигурации.


При вызове метода initLoader() у LoaderManager уже есть вся информация о Loaders, которые были созданы в уничтоженной Activity. Так что он может опубликовать загруженный результат немедленно:

public abstract class LoaderManager {
    public <D> Loader<D> initLoader(int id, Bundle args,       
                        LoaderManager.LoaderCallbacks<D> callback) {
       ...

       LoaderInfo info = mLoaders.get(id);

       ...

        if (info == null) {
            info = createAndInstallLoader(id, args, 
                  (LoaderManager.LoaderCallbacks<Object>)callback);
        } else {
         // override old callbacks reference here to new one            
         info.mCallbacks =       
             (LoaderManager.LoaderCallbacks<Object>) callback;
        
    }

    if (info.mHaveData && mStarted) {
          // deliver the result we already have                
          info.callOnLoadFinished(info.mLoader, info.mData);
    }

    return (Loader<D>)info.mLoader;
}


Здорово, что мы разобрались с двумя вещами сразу: как мы избегаем утечек памяти (заменяя экземпляр LoaderCallback) и как доставляем результат в новую Activity!

Возможно, вас интересует, что ещё за зверь такой — mLastNonConfigurationInstances. Это экземпляр класса NonConfigurationInstances, определённый внутри класса Activity:

public class Activity {
    static final class NonConfigurationInstances {
       Object activity;
       HashMap<String, Object> children;
       FragmentManagerNonConfig fragments;
       ArrayMap<String, LoaderManager> loaders;
       VoiceInteractor voiceInteractor;
    }
    NonConfigurationInstances mLastNonConfigurationInstances;
}


Объект создаётся с помощью метода retainNonConfigurationInstance(), а затем к нему напрямую обращается Android OS. И он становится доступен для Activity в методе Activity#attach() (а это внутреннее API Activity):

final void attach(Context context, ActivityThread aThread,
        Instrumentation instr, IBinder token, int ident,
        Application application, Intent intent, ActivityInfo info,
        CharSequence title, Activity parent, String id,
        NonConfigurationInstances lastNonConfigurationInstances,
        Configuration config, String referrer, IVoiceInteractor voiceInteractor,
        Window window, ActivityConfigCallback activityConfigCallback) {
    attachBaseContext(context);
    ...
    mLastNonConfigurationInstances = lastNonConfigurationInstances;
    
    ...
}

Так что, к сожалению, главная магия остаётся внутри Android OS. Но поучиться на её примере нам никто не запретит!


Давайте подытожим, что мы обнаружили:

  1. У Loaders неочевидное API, но они позволяют сохранять результат загрузки при изменении конфигурации
  2. Loaders не вызывают утечки памяти: просто не делайте их вложенными классами Activity
  3. Loaders позволяют в фоновой работе переиспользовать AsyncTask, но можно и реализовать свой собственный Loader
  4. LoaderManager оказывается переиспользованным между уничтоженными и вновь созданными Activity благодаря сохранению в специальный объект

В следующей статье мы поговорим, как организовывать фоновую работу на Executors, и подмешаем туда немного EventBus. Stay tuned!
Минутка рекламы.
От автора текста:

Как вы заметили, это перевод моей англоязычной статьи. Если статья вам показалось ценной, обратите внимание — в апреле пройдёт конференция Mobius, в программный комитет которой я вхожу, и могу обещать, что в этом году программа будет особенно насыщенна. На сайте конференции пока что опубликована только часть программы, потому что нам крайне тяжело выбрать лучшие — конкуренция остра как никогда. Уже можете изучить имеющиеся описания докладов, а скоро к ним добавятся новые!
  • +23
  • 8,3k
  • 9
JUG.ru Group 281,56
Конференции для взрослых. Java, .NET, JS и др. 18+
Поделиться публикацией
Комментарии 9
  • 0
    Может, конечно, велосипед (Андроид и Java не основной мой стек), но я для изменения View из AsyncTask поступал по другому.
    Начнём с того, что я манипулировал фрагментами в одной активности.
    Есть синглтон (пусть) FragmentListenerRegister. Каждый фрагмент в обработчике onCreateView подписывает себя на оповещение в это синглтоне, соответственно в onDestroyView — отписывается.
    Есть несколько тасков, выполняющих определённую задачу и наследующих абстрактный общий наследник AsyncTask. В этом абстрактном классе-наследнике при выполнении onProgressUpdate и onPostExecute вызывается метод информирования фрагментов, подписанных в FragmentListenerRegister об окончании работы или изменении прогресса.
    Вся работа с моделью данных производится внутри этих тасков.
    Общий принцип такой:
    Создался фрагмент, подписался на события.
    Вызвали таск, он работает, как-то видоизменяет модель (если всё идёт хорошо). По окончании работы (или изменении прогресса) производится информирование фрагмента, который производит обновление представления.
    • +1
      Многие такие велосипеды писали, сейчас нужно смотреть в сторону ViewModel/LiveData и их интеграций (например, с Room).
      • 0
        а еще к этому всему очень неплохо ложится databinding
    • 0
      Спасибо за перевод, но скрывайте, пожалуйста, гифки под спойлер.
      • 0
        А почему три примера кода в описании загрузчиков приведены не на Java?
        • 0
          Так Kotlin же, короче и яснее.
          • 0
            У меня неожиданная смена синтаксиса вызвала когнитивный диссонанс: )
        • 0
          Вопрос, который тревожит меня давно: если приложение пишется только под вертикальную ориентацию, т.е. жестко в манифесте это указывается, так ли уже надо заморачиваться уничтожением Activity? Конечно, я пониманию, что Activity может быть уничтожена и просто из-за нехватки ресурсов системы и пересоздана заново, но для современного железа эта ситуация все больше становится маловероятной.
          Реальность такова, что 80% времени смартфоном пользуются в вертикальном положении.

          Конечно, нужно стремиться к совершенству, никто не спорит, интересует лишь список возможных проблем для приложений с вертикальной ориентацией.
          • +1
            Надо, Активити может будет пересоздана по многим другим причинам, например, split-screen или если пользователь просто сменил дату-время-язык на телефоне.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое