Продолжение Лекции 1 Курса по архитектуре андроид-приложений. Часть 1 здесь.
- Запрет смены ориентации
- Самостоятельная обработка
- Сохранение состояния в Bundle
- Retain Fragments
- Лоадеры
- Практическое задание
- Ссылки и полезные ресурсы
Обработка смены конфигурации
Общеизвестно, что Activity пересоздается при каждом изменений конфигурации (например, при смене ориентации или языка). Пересоздание означает уничтожение Activity и запуск ее заново. Уничтожение в свою очередь подразумевает то, что все поля, которые вы хранили в Activity, будут уничтожены. Что это означает на практике? Это означает то, что, если вы при создании Activity получаете информацию с сервера, сохраняете ее в какое-то поле в Activity и отображаете пользователю, то при пересоздании Activity вы потеряете всю информацию, запрос начнет выполняться заново со всеми возможными последствиями:
- Пользователь не ожидает того, что ему снова покажется процесс загрузки, хотя он вроде бы ничего не делал, только повернул экран, к примеру.
- Вы можете не получить данные, например, в случае ошибки сервера. Это будет еще более странным поведением для пользователя, так как данные исчезли после поворота.
Так как же с этим бороться? И почему вообще возникла такая проблема при таком невинном действии?
Ответить на вопрос о том, почему такая проблема возникла, достаточно сложно. Очевидно, что было необходимо уничтожать все данные при изменении конфигурации, чтобы не показать пользователю некорректное состояние. Но наверняка был путь, который позволил бы уменьшить количество таких проблем, но, вероятно, он был слишком непростым для первых версий Android, а сейчас необходимо поддерживать обратную совместимость.[wpanchor id=»1″]
Раз сделать с такой ситуацией ничего нельзя, то приходится привыкать и к таким условиям. В самом деле, есть немало способов, как корректно сохранить и восстановить данные при пересоздании Activity. Рассмотрим их.
Запрет смены ориентации
Разумеется, наиболее частой причиной пересоздания Activity по причине изменения конфигурации является смена ориентации. Поэтому достаточно многие разработчики, не желая иметь дела со всеми проблемами, связанными с обработкой жизненного цикла, жестко фиксируют ориентацию и дальше не думают об этой проблеме. Такая фиксация достигается за счет добавления флага в манифесте:
<activity android:name=".WeatherActivity" android:screenOrientation="portrait"/>
[wpanchor id=»2″]Конечно, такой подход многое упрощает, но все же он не всегда приемлем. В принципе, существует немало приложений, которым достаточно только портретной ориентации, но это скорее исключение, чем правило. Часто пользователи работают в альбомной ориентации (особенно на планшетах) и заставлять их менять ее ради вашего приложения не очень хорошо. И ко всему прочему нужно понимать, что фиксированная ориентация не избавляет вас от проблем с пересозданием Activity, так как для этого есть и другие причины, а не только смена ориентации. Поэтому такое решение не может считаться идеальным.
Самостоятельная обработка
Кроме того, можно не запретить изменение какой-то конфигурации (например, ориентации), а обрабатывать его самому. Для этого нужно указать флаг в манифесте с соответствующим значением:
<activity android:name=".WeatherActivity" android:configChanges="orientation|keyboardHidden|screenSize"/> В этом случае при изменении какой-то конфигурации система уведомит Activity о том, что произошло такое изменение и нужно его обработать. Для этого служит метод onConfigurationChanged в классе Activity: @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); // handle new configuration }
[wpanchor id=»3″]В каких-то случаях эта обработка не требуется. Но нужно понимать, что такая обработка также чревата последствиями, так как система не применяет альтернативные ресурсы автоматически (а это уже относится не только к языковым ресурсам, но и к привычным layout-land, к примеру). Поэтому это достаточно редкий вариант, но его нужно также иметь в виду.
Сохранение состояния в Bundle
Android предоставляет нам способ сохранения состояния с последующим его восстановлением при пересоздании Activity. Здесь стоит обратить внимание на параметр savedInstanceState, который передается в методе onCreate в Activity. Этот экземпляр класса Bundle передается не просто так, он может хранить в себе различные поля, которые в него запишут. При первом запуске Activity этот параметр всегда будет равен null. При пересоздании Activity он уже не будет равен null, поэтому можно отследить, происходит ли первый запуск Activity или же это вызов после пересоздания, что весьма удобно. И теперь главное – вы можете сохранить в Bundle свои поля в методе onSaveInstanceState в классе Activity примерно следующим образом:
@Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putSerializable(WEATHER_KEY, mCity); }
И этот объект класса Bundle, в который вы сохранили какие-то значения, после пересоздания попадет в качестве параметра в метод onCreate, и уже оттуда вы сможете извлечь все данные. При таком подходе код для обработки смены состояния экрана выглядит следующим образом:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_weather); if (savedInstanceState == null) { loadWeather(); } else { mCity = (City) savedInstanceState.getSerializable(WEATHER_KEY); showWeather(); } }
То есть мы проверяем, если Activity запускается в первый раз (словосочетание “в первый раз” здесь не очень подходит, поскольку Activity может запускаться несколько раз, но здесь понимается запуск не после пересоздания), то мы начинаем загружать информацию о погоде. Если же Activity пересоздается, то мы сохраняем информацию о погоде в методе onSaveInstanceState, а восстанавливаем в методе onCreate.
Тут нужно заметить важный факт – не всегда погода будет загружена до того, как Activity будет пересоздана. Поэтому код выше надо слегка модифицировать:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_weather); if (savedInstanceState == null || !savedInstanceState.containsKey(WEATHER_KEY)) { loadWeather(); } else { mCity = (City) savedInstanceState.getSerializable(WEATHER_KEY); showWeather(); } } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mCity != null) { outState.putSerializable(WEATHER_KEY, mCity); } }
[wpanchor id=»4″]Возможно, что этот способ не настолько удобен, но он хорошо работает, когда вы должны сохранить небольшие данные на каком-то одном экране. Но при этом нужно учитывать, что таким образом нельзя сохранять большие объекты или огромные объемы данных, так как их сериализация и восстановление занимает много времени, и из-за этого приложение будет работать медленно.
Retain Fragments
Еще одним очень популярным и очень эффективным способом обработки смены конфигурации являются Retain Fragments. По сути это обычные фрагменты, для которых был вызван метод setRetainInstance:
public class WeatherFragment extends Fragment { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } }
Вызов этого метода меняет жизненный цикл фрагмента, а именно, он убирает из него вызовы onCreate и onDestroy при пересоздании Activity. Теперь при пересоздании Activity этот фрагмент не будет уничтожен, и все его поля сохранят свои значения. Но при этом остальные методы из жизненного цикла Fragment будут вызваны, так что не возникнет проблем с заменой ресурсов в зависимости от конфигурации. Поэтому нам только нужно добавить этот фрагмент при первом старте Activity и выполнять все запросы в нем, так как ему безразличны пересоздания Activity:
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_weather_retain); if (savedInstanceState == null) { WeatherFragment fragment = new WeatherFragment(); getSupportFragmentManager().beginTransaction() .replace(R.id.container, fragment) .commit(); } }
Во фрагменте в методе onViewCreated мы проверяем, если данные уже загрузились, то отображаем их, иначе начинаем загрузку данных и показываем процесс загрузки:
@Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); if (mCity == null) { loadWeather(); } else { showWeather(); } }
Кажется, что такой подход идеален. И в принципе, да, у него есть серьезные достоинства, по сравнению с предыдущими подходами:
- Можно сохранять даже большие и сложные объекты
- Не нужно сохранять данные вручную
Но при этом нужно понимать и ограничения такого подхода:
- Во-первых, нужно быть крайне аккуратными с сохранением в таком фрагменте любых ссылок на Activity / Context. При пересоздании Activity она уничтожается, но, если ваш фрагмент будет держать ссылку на эту Activity, то сборщик мусора не сможет утилизировать ее, и вы можете получить утечки памяти. А это в свою очередь накладывает определенные ограничения – вы уже не можете сохранять ссылку на Activity в методе onCreate такого фрагмента.[wpanchor id=»5″]
- Такой подход хорошо помогает против проблемы поворотов, но, к сожалению, он точно также привязан к текущему видимому экрану пользователя. И это значит, что, если пользователь решит закрыть приложение во время выполнения какого-то запроса, фрагмент будет уничтожен, и мы также потеряем нужные данные.
Поэтому и работа с retain фрагментами требует аккуратности и имеет свои минусы.
Лоадеры
И последним компонентом, который мы рассмотрим, будут лоадеры. Несмотря на принципиальные различия в сути, с точки зрения проблемы обработки смены конфигурации, этот компонент очень похож на предыдущий: он точно также без потери данных переживает пересоздание Activity, он точно также управляется специальным классом (LoaderManager), как и фрагменты (FragmentManager).
Лоадеры будут использоваться и дальше в ходе курса, поэтому сейчас мы остановимся на них чуть подробнее.
Даже если смотреть по названию, лоадеры должны быть предназначены для загрузки чего-либо. А обычно мы загружаем данные – из базы или с сервера. Поэтому решение использовать лоадеры для задачи обеспечения клиент-серверного взаимодействия выглядит логично. Так что же такое лоадеры и как их использовать?
Лоадер – это компонент Android, который через класс LoaderManager связан с жизненным циклом Activity и Fragment. Это позволяет использовать их без опасения, что данные будут утрачены при закрытии приложения или результат вернется не в тот коллбэк. Разберем простейший пример (который хоть и простейший, но требует немало кода, это один из недостатков лоадеров). Создаем класс лоадера (для простоты он не будет грузить данные с сервера, а лишь имитировать загрузку):
public class StubLoader extends AsyncTaskLoader<Integer> { public StubLoader(Context context) { super(context); } @Override protected void onStartLoading() { super.onStartLoading(); forceLoad(); } @Override public Integer loadInBackground() { // emulate long-running operation SystemClock.sleep(2000); return 5; } }
Класс лоадера очень похож на класс AsyncTask-а (впрочем, не зря же мы наследуемся от AsyncTaskLoader). Понятно, что в методе loadInBackground мы должны загрузить данные, а вот для чего нужен метод onStartLoading (и другие методы) мы разберем позже. А пока перейдем к использованию. В отличие от AsyncTask-а лоадер не нужно запускать вручную, это делается неявным образом через класс LoaderManager. У этого класса есть два метода с одинаковой сигнатурой:
public abstract <D> Loader<D> initLoader(int id, Bundle args, LoaderManager.LoaderCallbacks<D> callback); public abstract <D> Loader<D> restartLoader(int id, Bundle args, LoaderManager.LoaderCallbacks<D> callback); Про различие этих методов будет рассказано сразу после того, как мы рассмотрим их применение. Какие есть параметры у этих методов? Во-первых, вы можете запускать несколько лоадеров в одной Activity / Fragment, и вам нужно будет различать их. Для этого служит параметр id. Вторым параметром идет Bundle, с помощью которого вы можете передать аргументы для создания лоадера. И последний параметр является основным – это Callback для создания лоадера и получения результата его работы: public interface LoaderCallbacks<D> { public Loader<D> onCreateLoader(int id, Bundle args); public void onLoadFinished(Loader<D> loader, D data); public void onLoaderReset(Loader<D> loader); }
В методе onCreateLoader вы должны вернуть нужный лоадер в зависимости от переданного id и используя аргументы в Bundle. В методе onLoadFinished в параметре D вам придет результат работы лоадера. В методе onLoaderReset вы должны очистить все данные, которые связаны с этим лоадером.
Тогда давайте создадим экземпляр LoaderCallbacks для нашего лоадера:
private class StubLoaderCallbacks implements LoaderManager.LoaderCallbacks<Integer> { @Override public Loader<Integer> onCreateLoader(int id, Bundle args) { if (id == R.id.stub_loader_id) { return new StubLoader(WeatherActivity.this); } return null; } @Override public void onLoadFinished(Loader<Integer> loader, Integer data) { if (loader.getId() == R.id.stub_loader_id) { Toast.makeText(WeatherActivity.this, R.string.load_finished, Toast.LENGTH_SHORT).show(); } } @Override public void onLoaderReset(Loader<Integer> loader) { // Do nothing } } И теперь запустим лоадер, наконец-то подобравшись к сути: @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_weather); getSupportLoaderManager().initLoader(R.id.stub_loader_id, Bundle.EMPTY, new StubLoaderCallbacks()); }
Через 2 секунды после запуска Activity покажется тоаст. В принципе, ничего другого мы и не ждали. Но теперь главное – повернем устройство. Теперь по логике работа лоадера должна начаться заново и через 2 секунды покажется тоаст. Однако при этом мы видим, что тоаст показался мгновенно!
Вся магия заключается в методе initLoader и в классе LoaderManager. И теперь настала пора объяснить, как эта магия работает. Если лоадер еще не был создан (он не хранится в LoaderManager), то метод initLoader создает его и начинает его работу. Однако, если лоадер уже был создан (при первом запуске активити), то при повторном вызове initLoader, LoaderManager не пересоздаст лоадер и не будет его перезапускать. Вместо этого он заменит экземпляр LoaderCallbacks на новый переданный (что замечательно, ведь старый экземпляр был уничтожен вместе со старой Activity) и, если данные уже загрузились, передаст их в onLoadFInished. И при всем этом нам не нужно заниматься ручной проверкой на null для Bundle в onCreate. Если у нас на экране есть один запрос, то мы вполне можем при старте вызывать initLoader и не беспокоиться о пересоздании, что дает хороший уровень абстракции при обработке смены конфигурации.
Логичным будет вопрос о том, а что если мы все же хотим перезапустить выполнение лоадера (например, мы хотим обновить данные, а не получить предыдущий результат)? Для этого есть метод restartLoader, который всегда перезапускает выполнение лоадера. В остальном его работа абсолютно аналогична вызову initLoader.
Давайте теперь посмотрим, как это можно применить для решения нашей реальной задачи и реального запроса. Создадим лоадер, который будет загружать информацию о погоде:
public class WeatherLoader extends AsyncTaskLoader<City> { public WeatherLoader(Context context) { super(context); } @Override protected void onStartLoading() { super.onStartLoading(); forceLoad(); } @Override public City loadInBackground() { String city = getContext().getString(R.string.default_city); try { return ApiFactory.getWeatherService().getWeather(city).execute().body(); } catch (IOException e) { return null; } } }
И вызовем его в Activity следующим образом:
private void loadWeather(boolean restart) { mWeatherLayout.setVisibility(View.INVISIBLE); mErrorLayout.setVisibility(View.GONE); mLoadingView.showLoadingIndicator(); LoaderManager.LoaderCallbacks<City> callbacks = new WeatherCallbacks(); if (restart) { getSupportLoaderManager().restartLoader(R.id.weather_loader_id, Bundle.EMPTY, callbacks); } else { getSupportLoaderManager().initLoader(R.id.weather_loader_id, Bundle.EMPTY, callbacks); } }
Обратите внимание на передаваемый флаг, который определяет, какой метод вызывать, initLoader или restartLoader. Даже на таком простом экране нам требуется вызов restartLoader, например, для обработки ошибки, когда пользователь хочет выполнить запрос повторно. Аналогичная ситуация возникает и при обновлении данных (например, Callback от SwipeRefreshLayout).
Нужно опять заметить важный плюс от использования лоадеров – мы нигде специально не пишем код для обработки пересоздания, что очень удобно.
Как уже говорилось выше, также очень важно понимать внутреннее устройство класса Loader. Если раньше особо не было другого пути, как изучать лоадеры, то сейчас ситуация изменилась. Сегодня технологии разработки под Android выросли очень серьезно, появилось множество библиотек для обеспечения асинхронности и работы с сетью (именно поэтому типичный путь разработчика сейчас выглядит следующим образом: Thread, AsyncTask, RxJava, минуя лоадеры). Но знать такой мощный компонент и уметь его использовать – это необходимо. Поэтому мы разберем и то, как лоадер устроен внутри.
Пока что мы наследовались всегда от класса AsyncTaskLoader, который обеспечивал работу в фоне. Но все же изначальным классом является именно класс Loader. При этом примечательно, что класс Loader не предоставляет никаких средств для обеспечения работы в фоне. И это не просто так. Лоадер в первую очередь предназначен для того, чтобы быть связанным с жизненным циклом Activity / Fragment. Для обеспечения же работы в фоне нужно либо использовать класс AsyncTaskLoader, либо использовать другие средства обеспечения многопоточности (например, Call из Retrofit, RxJava). И за такое решение нужно сказать большое спасибо разработчикам из Google. Ведь они позволили нам использовать свои средства для обеспечения многопоточности (иначе у нас, к примеру, не было бы возможности использовать RxJava в связке с лоадерами, чем мы займемся далее), при этом сохранив мощь лоадеров.
В классе Loader определено 3 основных метода, которые нужно переопределить для корректного написания своего лоадера. Это следующие методы:
protected void onStartLoading() { } protected void onForceLoad() { } protected void onStopLoading() { }
Метод onStartLoading вызывается в случае, когда нужно загрузить данные и вернуть их в Callback. На самом деле этот метод вызывается как результат вызова метода startLoading. Обычно метод startLoading вызывается классом LoaderManager, и нам нет нужды использовать его самостоятельно.
Аналогично метод onStopLoading служит для уведомления о том, что нужно остановить загрузку данных (запрос к серверу, к примеру), но при этом не нужно очищать данные.
Методы onStartLoading и onStopLoading вызываются соответственно при вызове методов onStart и onStop в Activity, но при этом они не вызываются в ситуации, когда Activity пересоздается, а только при сворачивании / разворачивании. И это очень хорошо, ведь мы должны останавливать загрузку данных, когда пользователь не находится на экране, чтобы не тратить заряд батареи.
Так что же, это означает, что при каждом сворачивании / разворачивании экрана процесс загрузки будет начинаться заново? Вовсе нет, но для этого нам придется немного поработать самим. Поля в лоадере не уничтожаются, а значит, мы можем проверить, завершился ли уже запрос, и, если да, то мы можем вернуть полученные данные.
Поэтому написание собственного лоадера обычно выглядит следующим образом. Во-первых, определяются поля лоадера, а это обычно результат загрузки, который мы хотим получить, и объект для выполнения запроса к серверу:
public class RetrofitWeatherLoader extends Loader<City> { private final Call<City> mCall; @Nullable private City mCity; public RetrofitWeatherLoader(Context context) { super(context); String city = context.getString(R.string.default_city); mCall = ApiFactory.getWeatherService().getWeather(city); } }
После этого переопределяется метод onStartLoading, в котором мы проверяем, завершился ли запрос (доступны ли сохраненные данные). Если данные есть, то мы сразу возвращаем их, иначе начинаем загружать все заново:
@Override protected void onStartLoading() { super.onStartLoading(); if (mCity != null) { deliverResult(mCity); } else { forceLoad(); } }
Метод deliverResult возвращает данные в LoaderCallbacks. А метод forceLoad инициирует вызов метода onForceLoad, который мы упомянули, но обсудить не успели. По сути, этот метод служит только для удобства и логического разделения между методами жизненного цикла и методами для загрузки данных. В методе onForceLoad вы должны загрузить данные асинхронно и вернуть результат с помощью метода deliverResult.
@Override protected void onForceLoad() { super.onForceLoad(); mCall.enqueue(new Callback<City>() { @Override public void onResponse(Call<City> call, Response<City> response) { mCity = response.body(); deliverResult(mCity); } @Override public void onFailure(Call<City> call, Throwable t) { deliverResult(null); } }); }
И остался последний метод – onStopLoading, в котором мы должны остановить загрузку данных. Благо, с Retrofit это очень просто:
@Override protected void onStopLoading() { mCall.cancel(); super.onStopLoading(); }
И это все – мы полностью изменили способ загрузки данных, но при этом не нужно ничего менять в UI-классах – потрясающий уровень абстракции!
Здесь можно закончить рассмотрение лоадеров, этого достаточно для дальнейшего изучения. Больше примеров и варианты работы с базой данных и обработкой ошибок можно найти в статье.
[wpanchor id=»6″]Несмотря на все удобства, которые мы рассмотрели, и у класса Loader есть свои слабые места, и они почти всегда аналогичны проблемам с Retain Fragment. Например, при полном закрытии приложения в момент загрузки данных, вы также можете получить рассинхронизированное состояние. При этом отличие заключается в том, что лоадер специально предназначен для загрузки данных и предоставляет больший уровень абстракции, но при этом он требует больше кода.
Практическое задание
- Скачайте Проект LoaderWeather. Описание задачи — в файле ru.gdgkazan.simpleweather.screen.weatherlist.WeatherListActivity
- Нужно загрузить погоду во всех городах при старте приложения
- Сделать это наиболее быстрым способом (не каждый город последовательно)
- Добавить возможность обновления через SwipeRefreshLayout
- Реализовать обработку пересоздания Activity
Вопросы по уроку вы можете задать в комментариях.
Пример практического задания
Ссылки и полезные ресурсы
- Приложения из репозитория:
- SimpleWeather – демонстрация различных способов обработки смены конфигурации.
- LoaderWeather – использование лоадеров для загрузки данных и практическое задание.
- История развития системы Android по версиям.
- Книга “Clean Code” от Роберта Мартина.
- Clean Architecture от Роберта Мартина.
- Документация по обработке смены конфигурации.
- Хороший ответ про retain фрагменты.
- Документация по лоадерам.
- Статья про использование лоадеров для загрузки данных.
Тоже не получается собрать проект, даже после замены в файлах build.gradle, build.gradle (Project LoaderWeather) и versions.gradle.
Ошибка:
Cannot determine classpath for resource ‘java/sql/SQLException.class’ from location ‘jrt:/java.sql/java/sql/SQLException.class’.
The project uses Gradle 2.14.1 which is incompatible with IDEA running on Java 10 or newer.
See details at https://github.com/gradle/gradle/issues/8431
Possible solution:
— Upgrade Gradle wrapper to 4.8.1 version and re-import the project
После замены build.gradle рекомендованной выше возникла ошибка в файле WeatherApp.java
не видно библиотеку import ru.arturvasilov.sqlite.core.SQLite;
ЦТА?!
Error:(1, 0) Plugin with id ‘com.android.application’ not found. Скачал проект, попытался поменять gradle файлы, как показано в комментариях. Вот эта ошибка возникает. Android Studio 3
С выходом новой Android Studio и Java8 никак не могу собрать и запустить проает LoaderWeather. Подскажите, какие изменения нужно внести в build.gradle?
замените build.gradle на
apply plugin: 'com.android.application'
android {
compileSdkVersion 26
defaultConfig {
applicationId "ru.gdgkazan.simpleweather"
minSdkVersion 17
targetSdkVersion 26
versionCode 1
versionName "1.0"
buildConfigField "String", "API_ENDPOINT", '"http://api.openweathermap.org/"'
buildConfigField "String", "API_KEY", '"bc0ffae33833bd4d0214451ff2c0d4be"'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
apply from: '../versions.gradle'
dependencies {
implementation "com.android.support:support-v4:$supportVersion"
implementation "com.android.support:support-v13:$supportVersion"
implementation "com.android.support:appcompat-v7:$supportVersion"
implementation "com.android.support:design:$supportVersion"
implementation "com.android.support:cardview-v7:$supportVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "io.reactivex:rxandroid:$rxandroidVersion"
implementation "io.reactivex:rxjava:$rxjavaVersion"
implementation "com.jakewharton:butterknife:$butterKnifeVersion"
annotationProcessor "com.jakewharton:butterknife-compiler:$butterKnifeVersion"
implementation "com.github.orhanobut:hawk:$hawkVersion"
}
замените versions.gradle на
ext {
supportVersion = '26.1.0'
okhttpVersion = '3.4.1'
retrofitVersion = '2.1.0'
rxandroidVersion = '1.2.1'
rxjavaVersion = '1.1.9'
butterKnifeVersion = '8.8.1'
hawkVersion = '1.23'
junitVersion = '4.12'
mockitoVersion = '2.0.111-beta'
runnerVersion = '0.5'
espressoVersion = '2.2.2'
}
а также замените build.gradle (Project LoaderWeather) на:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.0'
}
configurations.classpath.exclude group: 'com.android.tools.external.lombok'
}
allprojects {
repositories {
jcenter()
maven { url "https://jitpack.io" }
maven { url 'https://maven.google.com' }
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}