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

Это вторая из серии статей об инструментах и методах фоновой работы в Android. Ранее уже были рассмотрены AsyncTask, в следующих выпусках — ThreadPools с EventBus, RxJava 2 и корутины в Kotlin.

odm6alpw_8kk0z4qz4kzf3irrtw.jpeg

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

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


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

public static LoadWeatherForecastTask extends AsyncTask {

    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 {

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

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

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

inner class WeatherForecastLoaderCallbacks : LoaderManager.LoaderCallbacks {

   override fun onLoaderReset(loader: Loader?) {

   }

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

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

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

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


class WeatherForecastLoader(context: Context) : AsyncTaskLoader(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();
    }
    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  Loader initLoader(int id, Bundle args,       
                        LoaderManager.LoaderCallbacks callback) {
       ...

       LoaderInfo info = mLoaders.get(id);

       ...

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

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

    return (Loader)info.mLoader;
}



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

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

public class Activity {
    static final class NonConfigurationInstances {
       Object activity;
       HashMap children;
       FragmentManagerNonConfig fragments;
       ArrayMap 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, в программный комитет которой я вхожу, и могу обещать, что в этом году программа будет особенно насыщенна. На сайте конференции пока что опубликована только часть программы, потому что нам крайне тяжело выбрать лучшие — конкуренция остра как никогда. Уже можете изучить имеющиеся описания докладов, а скоро к ним добавятся новые!

© Habrahabr.ru