Практика по лекции 1 Курса по архитектуре андроид-приложений

Это практическое занятие по первой лекции курса по архитектуре андроид приложений.

  • Чтобы выполнить практическое задание, нужно скачать проект LoaderWeather по ссылке.
  • Разархивируйте проект и откройте его в среде разработки Android Studio.
  • Описание практического задания находится в классе WeatherListActivity
  • Нужно загрузить погоду во всех городах при старте приложения
  • Сделать это наиболее быстрым способом
  • Добавить возможность обновления через SwipeRefreshLayout.
  • Реализовать обработку пересоздания Activity.

Как видим, здесь у нас есть уже готовый проект, который много чего умеет делать, нам только нужно его слегка модифицировать, добавить функциональность.

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

Практика по лекции 1 Курса по архитектуре андроид-приложений

Наша задача – реализовать отображение погоды на первом экране, прямо в списке напротив каждого города.

Для загрузки погоды мы будем использовать лоадер, аналогично как это реализовано в классе WeatherActivity. Только мы будем использовать не RetrofitWeatherLoader,  а WeatherLoader, который здесь приготовлен заранее.

Если мы откроем разметку  экрана WeatherListActivity, то увидим, что используется старый добрый RecyclerView. А в разметке пункта списка уже есть поле для отображения температуры.

В пакете weatherlist видим 2 класса – CitiesAdapter и CityHolder.  В адаптере реализован метод changeDataSet, который принимает список объектов класса City, хранящих полученную информацию о погоде, обновляет список городов и вызывает метод notifyDataSetChanged(), который сигнализирует, что данные обновились и нужно обновить список.

В классе CityHolder в методе bind формируется строка для поля температуры через проверку условия, что информация о погоде присутствует в объекте City.

Приступим к реализации. В классе WeatherListActivity создадим приватный внутренний класс WeatherCallbacks, в реализующий интерфейс LoaderCallbacks абстрактного класса LoaderManager, с параметром City.

private class WeatherCallbacks implements LoaderManager.LoaderCallbacks<City> {

    private City city;

    private String cityName;

    public WeatherCallbacks(City city, String cityName) {
        this.city = city;
        this.cityName = cityName;
    }

    @Override
    public Loader onCreateLoader(int id, Bundle args) {
        
    }

    @Override
    public void onLoadFinished(Loader<City> loader, City city) {

        
    }

    @Override
    public void onLoaderReset(Loader<City> loader) {

    }

}

Нам нужно реализовать три метода. Вспоминаем материал лекции – в методе onCreateLoader вы должны вернуть нужный лоадер в зависимости от переданного id и используя аргументы в Bundle. В методе onLoadFinished в параметре City вам придет результат работы лоадера. В методе onLoaderReset вы должны очистить все данные, которые связаны с этим лоадером.

Нам понадобится переменная класса City и строковая переменная cityName. Будем их инициализировать в конструкторе.

Создадим два списочных массива:

private List<City> loadedCities = new ArrayList<>();
private List<City> cities;

Первый – для городов с загруженной информацией о погоде. Второй будем инициализировать в onCreate через метод getInitialCities(), который получает список городов из ресурсов.

 @NonNull
private List<City> getInitialCities() {
    List<City> cities = new ArrayList<>();
    String[] initialCities = getResources().getStringArray(R.array.initial_cities);
    for (String city : initialCities) {
        cities.add(new City(city));
    }
    return cities;
}

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

@Override
public Loader onCreateLoader(int id, Bundle args) {
    if (id <= cities.size()) {
        return new WeatherLoader(WeatherListActivity.this, cityName);
    }
    return null;
}

Далее в основном классе создаем метод loadWeather с такими параметрами: restart для определения, возвращать кешированные данные или загружать новые; объект класса City; строковый параметр cityName и параметр id лоадера.

private void loadWeather(boolean restart, City city, String cityName, Integer id) {

    mLoadingView.showLoadingIndicator();
    LoaderManager.LoaderCallbacks<City> callbacks = new WeatherCallbacks(city, cityName);


    if (restart) {
        getSupportLoaderManager().restartLoader(id, Bundle.EMPTY, callbacks);


    } else {
        getSupportLoaderManager().initLoader(id, Bundle.EMPTY, callbacks);

    }
}

Здесь будем отображать индикатор загрузки,  и вызывать WeatherCallbacks.

В зависимости от переданного флага определяем, какой метод вызывать, initLoader или restartLoader. Вызов restartLoader требуется, например, для обработки ошибки, когда нужно выполнить запрос повторно, и при обновлении данных (например, Callback от SwipeRefreshLayout).

В противном случае вызывается метод initLoader. Вспоминаем лекцию. Если лоадер еще не был создан, то метод initLoader создает и стартует его. Однако, если лоадер уже был создан, то при повторном вызове initLoader, LoaderManager не пересоздаст лоадер и не будет его перезапускать. Вместо этого он заменит экземпляр LoaderCallbacks на новый переданный, и если данные уже загрузились, передаст их в onLoadFInished. И при всем этом нам не нужно заниматься ручной проверкой на null для Bundle в onCreate. Мы просто можем вызывать initLoader и не беспокоиться о пересоздании, решая таким образом проблему обработки смены конфигурации.

Теперь напишем метод load(), который принимает список городов и флаг restart для метода loadWeather(), который мы будем здесь вызывать.

private void load(List<City> cities, boolean restart) {

    for (int i = 0; i < cities.size(); i++) {

        String cityName = cities.get(i).getName();
        loadWeather(restart, cities.get(i), cityName, i+1);
    }
}

Мы в цикле для каждого города из списка вызываем метод loaderWeather(), которому отдаем флаг restart, объект класса City, строковую переменную cityName и идентификатор для лоадера. Здесь i увеличиваем на 1, поскольку id не может быть 0. Метод load() вызовем в onCreate() с передачей ему списочного массива городов и значения флага false, поскольку лоадеры будут создаваться в первый раз.

load(cities, false);

Теперь реализуем метод showWeather(), в который будем передавать результат запроса от сервера.

private void showWeather(@Nullable City city) {


    if (city == null || city.getMain() == null || city.getWeather() == null
            || city.getWind() == null) {

        showError();

        return;
    }


    loadedCities.add(city);


    if (loadedCities.size() >= cities.size()) {

        mLoadingView.hideLoadingIndicator();

        sortAllcities(loadedCities);

        mAdapter.changeDataSet(loadedCities);
        loadedCities.clear();
    }


}

Здесь мы проверяем поля полученного объекта на null, и если хоть одно из них пустое, показываем пользователю ошибку.

Затем формируем списочный массив для адаптера. Как только массив городов заполнится, мы прячем индикатор загрузки и передаем массив методу changeDataSet() адаптера списка и очищаем массив от элементов.

Есть небольшая проблема, которая заключается в том, что мы получаем данные от сервера в случайном порядке. Чтобы города в списке не отображались вперемешку, добавим метод сортировки массива, который использует метод sort() класса Collections Ему мы передаем массив и компаратор, который и сортирует массив в методе compare() по полю Name каждого элемента.

private List<City> sortAllcities(List<City> cities) {
    Collections.sort(cities, new Comparator<City>() {
        @Override
        public int compare(City o1, City o2) {
            return o1.getName().compareTo(o2.getName());
        }
    });
    return cities;
}

Также здесь реализован метод отображения ошибки с помощью снекбара:

private void showError() {
    mLoadingView.hideLoadingIndicator();
    Snackbar snackbar = Snackbar.make(mRecyclerView, R.string.error, Snackbar.LENGTH_LONG)
            .setAction(R.string.retry, v -> load(cities, true));
    snackbar.setDuration(4000);
    snackbar.show();
}

По нажатию на кнопку действия вызываем метод load() с параметрами для повторной загрузки данных.

Теперь пропишем вызов метода showWeather() в методе onLoadFinished() нашего коллбека.

Нам осталось реализовать обновление списка с помощью SwipeRefreshLayout. Для начала добавим его в разметку макета activity_weather_list:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <android.support.design.widget.AppBarLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/colorPrimary"
            android:minHeight="?attr/actionBarSize"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:layout_scrollFlags="scroll|enterAlways"/>

    </android.support.design.widget.AppBarLayout>

    <android.support.v4.widget.SwipeRefreshLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/swipeContainer"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:overScrollMode="never"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    <include layout="@layout/empty"/>
    </android.support.v4.widget.SwipeRefreshLayout>

    <TextView
        android:id="@+id/error_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:fontFamily="sans-serif-light"
        android:gravity="center"
        android:text="@string/weather_error"
        android:textColor="@color/gray_880b1f35"
        android:textSize="@dimen/text.18"
        android:visibility="gone"/>

</android.support.design.widget.CoordinatorLayout>

Не забудьте добавить идентификатор.

Теперь объявим компонент в классе WeatherListActivity и заодно свяжем его с разметкой при помощи аннотации @BindView (такую возможность нам предоставляет библиотека butterknife, кто не в курсе):

@BindView(R.id.swipe_container)
SwipeRefreshLayout mSwipeRefreshLayout;

Затем инициализируем компонент в методе onCreate(), присвоим ему слушатель и определим цветовую схему:

mSwipeRefreshLayout.setOnRefreshListener(this);

mSwipeRefreshLayout.setColorSchemeColors(
         Color.RED, Color.GREEN, Color.BLUE, Color.CYAN);

Слушателем будет активити, поэтому нужно реализовать интерфейс SwipeRefreshLayout.OnRefreshListener. Также нам нужно переопределить метод этого интерфейса onRefresh().

В методе onRefresh() будем вызывать метод load():

@Override
public void onRefresh() {

    load(cities, true);

    new Handler().postDelayed(new Runnable() {
        @Override
        public void run() {
            // Отменяем анимацию обновления
            mSwipeRefreshLayout.setRefreshing(false);

        }
    }, 4000);
}

В этот раз методу load() передаем true, поскольку нам нужно, чтобы данные загрузились заново. Далее мы создаем новый поток через Handler, чтобы анимация крутилась в фоне.

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

Практика по лекции 1 Курса по архитектуре андроид-приложений

Весь код класса WeatherListActivity:

package ru.gdgkazan.simpleweather.screen.weatherlist;

import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.widget.Snackbar;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.view.View;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

import butterknife.BindView;
import butterknife.ButterKnife;
import ru.gdgkazan.simpleweather.R;
import ru.gdgkazan.simpleweather.model.City;
import ru.gdgkazan.simpleweather.screen.general.LoadingDialog;
import ru.gdgkazan.simpleweather.screen.general.LoadingView;
import ru.gdgkazan.simpleweather.screen.general.SimpleDividerItemDecoration;
import ru.gdgkazan.simpleweather.screen.weather.WeatherActivity;
import ru.gdgkazan.simpleweather.screen.weather.WeatherLoader;

import static android.R.attr.data;


public class WeatherListActivity extends AppCompatActivity implements CitiesAdapter.OnItemClick, SwipeRefreshLayout.OnRefreshListener {

    @BindView(R.id.toolbar)
    Toolbar mToolbar;

    @BindView(R.id.recyclerView)
    RecyclerView mRecyclerView;

    @BindView(R.id.empty)
    View mEmptyView;

    private CitiesAdapter mAdapter;

    private LoadingView mLoadingView;

    private List<City> loadedCity = new ArrayList<>();
    private List<City> cities;

    @BindView(R.id.swipeContainer)
    SwipeRefreshLayout mSwipeRefreshLayout;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_weather_list);
        ButterKnife.bind(this);
        setSupportActionBar(mToolbar);

        mRecyclerView.setLayoutManager(new LinearLayoutManager(this));
        mRecyclerView.addItemDecoration(new SimpleDividerItemDecoration(this, false));
        mAdapter = new CitiesAdapter(getInitialCities(), this);
        mRecyclerView.setAdapter(mAdapter);
        mLoadingView = LoadingDialog.view(getSupportFragmentManager());

        mSwipeRefreshLayout.setOnRefreshListener(this);
        mSwipeRefreshLayout.setColorSchemeColors(Color.RED, Color.GREEN, Color.BLUE, Color.CYAN);

        cities = getInitialCities();

        load(cities, false);

        /**
         * TODO : task
         *
         * 1) Load all cities forecast using one or multiple loaders
         * 2) Try to run these requests as most parallel as possible
         * or better do as less requests as possible
         * 3) Show loading indicator during loading process
         * 4) Allow to update forecasts with SwipeRefreshLayout
         * 5) Handle configuration changes
         *
         * Note that for the start point you only have cities names, not ids,
         * so you can't load multiple cities in one request.
         *
         * But you should think how to manage this case. I suggest you to start from reading docs mindfully.
         */
    }

    @Override
    public void onRefresh() {

        load(cities, true);

        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                mSwipeRefreshLayout.setRefreshing(false);
            }
        }, 4000);

    }

    private class WeatherCallbacks implements LoaderManager.LoaderCallbacks<City> {

        private City city;
        private String cityName;

        public WeatherCallbacks(City city, String cityName) {
            this.city = city;
            this.cityName = cityName;
        }

        @Override
        public Loader<City> onCreateLoader(int id, Bundle args) {

            if (id <= cities.size()){
                return new WeatherLoader(WeatherListActivity.this, cityName);
            }
            return null;
        }

        @Override
        public void onLoadFinished(Loader<City> loader, City city) {
            showWeather(city);

        }

        @Override
        public void onLoaderReset(Loader<City> loader) {

        }
    }

    private void loadWeather(boolean restart, City city, String cityName, Integer id){

        mLoadingView.showLoadingIndicator();

        LoaderManager.LoaderCallbacks<City> callbacks = new WeatherCallbacks(city, cityName);

        if (restart){
            getSupportLoaderManager().restartLoader(id, Bundle.EMPTY, callbacks);
        } else {
            getSupportLoaderManager().initLoader(id, Bundle.EMPTY, callbacks);
        }
    }

    private void load(List<City> cities, boolean restart){
        for (int i = 0; i < cities.size(); i++){
            String cityName = cities.get(i).getName();
            loadWeather(restart, cities.get(i), cityName, i+1);
        }
    }

    @Override
    public void onItemClick(@NonNull City city) {
        startActivity(WeatherActivity.makeIntent(this, city.getName()));
    }

    @NonNull
    private List<City> getInitialCities() {
        List<City> cities = new ArrayList<>();
        String[] initialCities = getResources().getStringArray(R.array.initial_cities);
        for (String city : initialCities) {
            cities.add(new City(city));
        }
        return cities;
    }

    private List<City> sortAllCities(List<City> cities){
        Collections.sort(cities, new Comparator<City>() {
            @Override
            public int compare(City t0, City t1) {
                return t0.getName().compareTo(t1.getName());
            }
        });
        return cities;
    }

    private void showError(){
        mLoadingView.hideLoadingIndicator();
        Snackbar snackbar = Snackbar.make(mRecyclerView, "Error loading weather", Snackbar.LENGTH_LONG)
                .setAction("Retry", v -> load(cities, true));
        snackbar.setDuration(4000);
        snackbar.show();
    }

    private void showWeather(@Nullable City city){
        if (city == null || city.getMain() == null || city.getWeather() == null || city.getWind() == null){
            showError();
            return;
        }
        loadedCity.add(city);
        if (loadedCity.size() >= cities.size()){
            mLoadingView.hideLoadingIndicator();
            sortAllCities(loadedCity);
            mAdapter.changeDataSet(loadedCity);
            loadedCity.clear();
        }
    }
}

Скачать проект полностью можно по ссылке

Коментарі: 6
  1. Непонятно, как отобразилась температура в конце статьи. Ведь нигде нету ни слова о том, как заполнить то самое поле с температурой. Если делать все по статье, то в конце мы не получим такого результата как написано в конце. У нас будет постоянно выскакивать ошибка из-за того, что какие-либо из данных не получены.

    1. Примечание. По поводу загрузки данных, был неверный параметр у метода showWeather(), поэтому не работало.

    2. kadruk@gmail.com
      kadruk@gmail.com

      А какой параметр тогда верый, скажите пожалуйста?

  2. В разметке ошибка, стоит “android:id=”@+id/swipeContainer”, а в BindView “@BindView(R.id.swipe_container)”

  3. art-sov
    art-sov

    При создании объекта new WeatherLoader(WeatherListActivity.this, cityName) в методе public Loader onCreateLoader(int id, Bundle args) студия выдает ошибку “Incompatible type. Required: android.content.Loader. Found: ru.gdgkazan.simpleweather.screen.weather.WeatherLoader

    Подскажите, пожалуйста, что это?
    Спасибо

    1. Виталий Непочатов
      admin (автор)

      проверьте импорты, сверьтесь с исходниками

Додати коментар