Предыдущая лекция |   | Следующая лекция :----------------:|:----------:|:----------------: [Intent (намерение)](./intents.md) | [Содержание](../readme.md#практика-разработка-мобильных-приложений) | [Фрагменты](./fragments.md) # Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная). Разбор XML. ## Содержание * [SplashScreen](#splashscreen) * [Выбор города](#выбор-города) * [Выделение лямбда-выражения в отдельную переменную](#выделение-лямбда-выражения-в-отдельную-переменную) * [Получение и разбор массива данных. Вывод списка на экран.](#получение-и-разбор-массива-данных-Вывод-списка-на-экран) * [Доработка SplashScrin-а](#Доработка-SplashScreen-а) * [Вывод сообщений](#Вывод-сообщений) * [Пример GET-запроса с обработкой ошибок](#пример-с-обработкой-ошибок) * [Разбор XML](#Разбор-XML) ## SplashScreen Если приложение долго грузится (запрос геолокации или "тяжёлых" данных из сети), то принято при запуске показывать заставку (SplashScreen). Есть два варианта: 1. Рисуют дополнительную **activity** с заставкой и запускают её первой. 2. Прямо на основной **activity** рисуют **ImageView** поверх всех элементов и скрывают её, когда необходимость в ней исчезает. Рассмотрим второй вариант (при условии что у нас **ConstraintLayout**): Я в качестве заставки буду показывать фон: * первым элементом кладем **ImageView** с картинкой, задав ему режим растягивания на весь экран `android:scaleType="fitXY"` и Z-индекс `android:elevation="999dp"` ```xml ``` * в коде главного окна в момент, когда нужно убрать заставку меняем Z-индекс заставки ```kt val splash = findViewById(R.id.splash) splash.elevation = 0F ``` >чтобы картинка нам не мешалась при разработке, мы можем в дизайнере оставить Z-индекс **0**, а в конструкторе окна задать **999F** Тут надо помнить, что в вёрстке указываются **dp**, а в коде **float** ## Выбор города >В принципе список можно вывести прямо в главном окне (на форму кинуть **ListView** и с помощью атрибута *elevation* поместить его поверх всех элементов), но в таком случае не будет реализован выход по кнопке *Назад*, что может сказаться на юзабилити. Поэтому, будем реализовывать "классический" вариант со списком в отдельном **activity**. Заодно научимся запускать дополнительные **activity** и получать от них ответ. 1. Создаем новую форму (**Activity**) с именем *CityListActivity* ![Создаем новую форму](../img/as032.png) 2. На главное окно добавляем кнопу перехода на экран выбора города и обработчик для нее: ```kt startActivityForResult( Intent(this, CityListActivity::class.java), 1) ``` Здесь *startActivityForResult* - метод запуска нужной **activity**. **Интент (Intent)** – это объект для обмена между *activity*, который абстрактно представляет собой **намерение** выполнить какое-либо действие. В основном интенты используются для запуска *activity*. Как только интент отправляется, его получает система Android, и считывает информацию в нем. Для открытия второго экрана, нам нужно создать и отправить объект **Intent** с указанием *activity*, которое нужно открыть. Затем вызвать метод *startActivity()* с передачей объекта **Intent**, который отправит это сообщение фреймворку Android, который откроет это активити. 3. На форму *CityListActivity* кидаем вертикальный **LinearLayout**, в него **TextView** (для заголовка "Выберите город") и **ListView**. **ListView** присваиваем id *cityList* ![](../img/as035.png) 4. В классе *CityListActivity* * создаем массив городов ```kt private var cityNames = arrayOf( "Moscow", "Yoshkar-Ola", "Kazan" ) ``` * в конструкторе получаем указатель на **ListView** ```kt cityListView = findViewById(R.id.cityList) ``` * задаем для списка **ArrayAdapter**. **ArrayAdapter** связывает массив данных с шаблоном элемента списка. ```kt cityListView.adapter = ArrayAdapter( this, R.layout.city_list_item, cityNames ) ``` Android Studio покажет ошибку, что не знает что такое ``city_list_item`` - в контекстном меню добавляем реализацию: ![Создание Layout для элемента списка](../img/as036.png) **Внимание!** RootElement поменять на TextView ![Создание Layout для элемента списка](../img/as037.png) Созданный шаблон можно настроить (установить высоту, добавить границы...) * задаем обработчик клика по элементу списка ```kt cityListView.setOnItemClickListener { parent, view, position, id -> // получаем название города val cityName = cityNames[position] // запоминаем выбранное название города в параметрах val newIntent = Intent() newIntent.putExtra("cityName", cityName) setResult(RESULT_OK, newIntent) // заверщаем текущий activity finish(); } ``` **Передача информации между активити** На первом экране при возврате должно отображаться название выбранного города (и данные о погоде). Для этого первому *activity* требуется выбранное название города. Мы можем передать эту информацию в интенте, используя метод *putExtra()*. *Activity* может получить интент, который запустил его (или вернулся как результат вызова другого *activity*), и извлечь данные из этого интента. 5. В классе главного окна для получения результата выбора реализуем метод *onActivityResult* ```kt @SuppressLint("MissingSuperCall") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (data == null) { return } val name = data.getStringExtra("cityName") // тут запускаем http-запрос по имени города httpGet( "https://api.openweathermap.org/data/2.5/weather?q=${name}&appid=${appid}&lang=ru&units=metric", callback) } ``` >Метод *onActivityResult* гугл объявил устаревшим (deprecated), и в IDE он помечается как ошибка - надо в контекстном меню "More action..." выбрать "Supress: add...". Перед методом будет добавлена аннотация `@SuppressLint("MissingSuperCall")`. >OpenWeather поддерживает [*геокодирование*](https://openweathermap.org/current), т.е. может найти координаты (и, соответственно, погоду по ним) по названию города: >``` >https://api.openweathermap.org/data/2.5/weather?q={city name}&appid={API key} >``` >где, *city_name* название города ## Выделение лямбда-выражения в отдельную переменную Для обработки результатов мы результаты запроса обрабатывли в месте получения. Но теперь тот же код будет использоваться для получения погоды по городу. Поэтому имеет смысл обработку результата оформить отдельной лямбда-функцией: ```kt // weatherCallback - свойство класса, объявляется в теле класса private val weatherCallback: (response: Response?, error: Exception?)->Unit = { response, error -> try { // если в запросе получено исключение, то "выбрасываем" его if (error != null) throw error // если ответ получен, но код не 200, то тоже "выбрасываем" исключение if (!response!!.isSuccessful) throw Exception(response.message) // начинаем обработку ответа val json = JSONObject(response.body!!.string()) val wheather = json.getJSONArray("weather") val icoName = wheather.getJSONObject(0).getString("icon") // обращение к UI должно быть в контексте UiThread runOnUiThread { textView.text = json.getString("name") } httpGet("https://openweathermap.org/img/w/${icoName}.png") {response, error -> try { // если в запросе получено исключение, то "выбрасываем" его if (error != null) throw error // если ответ получен, но код не 200, то тоже "выбрасываем" исключение if (!response!!.isSuccessful) throw Exception(response.message) runOnUiThread { ico.setImageBitmap( BitmapFactory .decodeStream( response.body!!.byteStream() ) ) } } catch (e: Exception) { // любую ошибку показываем на экране runOnUiThread { AlertDialog.Builder(this) .setTitle("Ошибка") .setMessage(e.message) .setPositiveButton("OK", null) .create() .show() } } } } catch (e: Exception) { // любую ошибку показываем на экране runOnUiThread { AlertDialog.Builder(this) .setTitle("Ошибка") .setMessage(e.message) .setPositiveButton("OK", null) .create() .show() } } } } ... // при запросе погоды используем переменную, объявленную выше httpGet( "https://api.openweathermap.org/data/2.5/weather?lat=56.638372&lon=47.892991&appid=${appid}&lang=ru&units=metric", weatherCallback) ``` ## Получение и разбор массива данных. Вывод списка на экран. Для начала определимся со структурой формы: Всё окно разбито по вертикали на три блока (указаны стрелками на рисунке ниже) * первый блок - детальная информация о выбранной погоде * второй блок - горизонтальный список кратких данных о погоде * третий блок - панель с кнопками (пока у нас там только "Поиск города", но может ещё что-то придумаем) ![](../img/04032.png) ### Класс погода Для хранения массива полученных данных нам нужно описать структуру элемента списка. Для этого в котлине есть **data class** - класс, который содержит только свойства. Выглядит он примерно так (каждый класс жедательно заворачивать в отдельный файл) ```kt data class Weather ( val dt: Int, val mainTemp: Double, val mainHumidity: Int, val weatherIcon: String, val weatherDescription: String, val windSpeed: Double, val windDeg: Int, val dtTxt: String ) ``` ### Заполнение массива данных о погоде С моим бесплатным аккаунтом на **openweathermap** кроме текущих данных можно запросить только список за 5 дней: ```txt ### Запрос погоды за 5 дней https://openweathermap.org/forecast5 GET https://api.openweathermap.org/data/2.5/forecast?lat={{lat}}&lon={{lon}}&appid={{token}}&lang=ru&units=metric ``` 1. Объявим в классе главного окна массив данных о погоде ```kt private val weatherList = ArrayList() ``` 2. Получаем и заполняем массив ```kt val url = "https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${token}&lang=ru&units=metric" httpGet(url) {response, error -> try { // если в запросе получено исключение, то "выбрасываем" его if (error != null) throw error // если ответ получен, но код не 200, то тоже "выбрасываем" исключение if (!response!!.isSuccessful) throw Exception(response.message) weatherList.clear() val json = JSONObject(response.body!!.string()) val list = json.getJSONArray("list") // перебираем json массив for(i in 0 until list.length()){ val item = list.getJSONObject(i) val weather = item.getJSONArray("weather").getJSONObject(0) // добавляем в список новый элемент weatherList.add( Weather( item.getInt("dt"), item.getJSONObject("main").getDouble("temp"), item.getJSONObject("main").getInt("humidity"), weather.getString("icon"), weather.getString("description"), item.getJSONObject("wind").getDouble("speed"), item.getJSONObject("wind").getInt("deg"), item.getString("dt_txt") ) ) } runOnUiThread { // уведомляем визуальный элемент, что данные изменились dailyInfoRecyclerView.adapter?.notifyDataSetChanged() } } catch (e: Exception) { // любую ошибку показываем на экране runOnUiThread { AlertDialog.Builder(this) .setTitle("Ошибка") .setMessage(e.message) .setPositiveButton("OK", null) .create() .show() } } } ``` ### Вывод списка (RecyclerView) **RecyclerView** - рекомендованный способ вывода списков (с его помощью можно было теоретически заполнить и список городов, но он для этого слишком монструозный). Его особенность в том, что визуальные элементы списка существуют только при отображении на экране. При выходе за экран визуальный элемент удаляется, перед появлением создается заново. Таким образом экономится память. Итак, на форме у нас уже лежит элемент **RecyclerView** (№2) В класс главного окна добавим переменную для связи с элементом **RecyclerView** ```kt private lateinit var dailyInfoRecyclerView: RecyclerView ``` Затем в конструкторе её инициализируем и назначаем *layoutManager* и *adapter* ```kt dailyInfoRecyclerView = findViewById(R.id.dailyInfoRecyclerView) // назначаем менеджер разметки dailyInfoRecyclerView.layoutManager = LinearLayoutManager( this, RecyclerView.HORIZONTAL, false) // создаем адаптер val weatherAdapter = WeatherAdapter( weatherList, this) // при клике на элемент списка показать подробную информацию (сделайте сами) weatherAdapter.setItemClickListener { weather -> Log.d("KEILOG", "Click on Weather item") } dailyInfoRecyclerView.adapter = weatherAdapter ``` Класс **WeatherAdapter** мы должны написать сами. Я положил его в [шпаргалки](../shpora/WeatherAdapter.kt). Разметка для элемента списка (не полная) ```xml ``` ### Перфекционизм Информация в **RecyclerView** может затеряться на ярком фоне. Чтобы этого не происходило можно либо задать для **RecyclerView** фон с заливкой каким-то цветом, либо, что-бы не полностью скрывать наш красивый фон, сделать этот фон полупрозрачным. Для задания полупрозрачности элемента используется тег **alpha**, но с ним тоже не всё хорошо - полупрозрачным становится и всё содержимое элемента. Есть другой вариант: задать в качестве фона **drawable** ресурс, которому заливку указать с альфа-каналом: ```xml ``` ## Доработка SplashScreen-а По разным причинам данные мы можем и не получить. Чтобы наша заставка вечно не висела на экране приделаем таймер. * В конструктор добавляем таймер с обратным отсчётом, в параметрах которого указываем **макcимальное время ожидания** и **интервал** между тиками. На каждый тик срабатвает метод *onTick*, в котором мы можем принудительно закрыть таймер. Если за **макcимальное время ожидания** таймер не закроют в *onTick*, то сработает метод *onFinish* и таймер завершит свою работу. >На уровне класса нужно объявить переменные `counter=0` и `ready=false` ```kt object : CountDownTimer(5000,1000){ override fun onTick(millisUntilFinished: Long) { // заставляем пялиться на нашу заставку как минимум 3 секунды counter++ if(counter>3 && ready){ // данные получены - скрываем заставку splash.elevation = 0F this.cancel() } } override fun onFinish(){ splash.elevation = 0F } }.start() ``` Не забудьте при получении данных установить `ready=true`. Хотя ничего страшного не произойдёт, если забудете - просто будут смотреть на заставку чуть подольше. ## Разбор XML Маловероятно, мо может встретиться XML-формат в данных. Разберёмся с ним на примере погоды. В URL добавьте параметр `&mode=xml`. Придёт примерно такой ответ: ```xml Йошкар-Ола RU 10800 0 ... ``` Для работы с XML в андроиде есть класс **XmlPullParser**. Разберём с его помощью ответ сервера. **Во-первых**, создадим сам объект парсера: ```kt val factory = XmlPullParserFactory.newInstance() factory.isNamespaceAware = true val parser = factory.newPullParser() parser.setInput(StringReader(result)) ... ``` А дальше идёт тупой перебор тегов в цикле: ```kt while (parser.eventType != XmlPullParser.END_DOCUMENT) { when (parser.eventType) { XmlPullParser.START_TAG -> ... XmlPullParser.END_TAG -> ... } parser.next() } ``` Т.е. пока не достигнем конца документа смотрим что у нас в текущем элементе: * START_DOCUMENT – начало документа * START_TAG – начало тега * TEXT – содержимое элемента * END_TAG – конец тега * END_DOCUMENT – конец документа Мы из всего этого многообразия будем использовать только начало и конец тега. По началу тега мы смотрим в каком теге находимся и, при необходимости, считываем текст или атрибут в локальную переменную. А по концу тега создаём объект **Weather** из накопленных данных и записываем его в массив. Пример (как обычно не полный, но достаточный для понимания): >В переменных *topTag* и *subTag* я запоминаю текущую позицию в иерархии XML. В этом документе они в принципе не нужны, но для примера проверок сделал. ```kt var cityName = "" var topTag = "" var subTag = "" var dt_txt = "" var description: String = "" var icon: String = "" while (parser.eventType != XmlPullParser.END_DOCUMENT) { when (parser.eventType) { XmlPullParser.START_TAG -> { when (parser.name) { // в качестве начальных тегов нам интересны "location" и "forecast" "location", "forecast" -> topTag = parser.name "name" -> { // внутри "location" в теге "name" читаем название города if(topTag=="location") cityName = parser.nextText() } "time" -> { if (topTag=="forecast") { // внутри "forecast" нам интересно содержимое "time" subTag = parser.name // и сразу считываем СТРОКОВУЮ дату dt_txt = parser.getAttributeValue(null, "from").toString() } } "symbol" -> { if(subTag=="time"){ description = parser.getAttributeValue(null, "name").toString() icon = parser.getAttributeValue(null, "var").toString() } } // тут мне как обычно лень стало расписывать остальные теги } } XmlPullParser.END_TAG -> { when (parser.name) { "time" -> { subTag = "" // по закрытию тега "time" пишем погоду в массив weatherList.add( Weather( 0, 0.0, 0, icon, description, 0.0, 0, dt_txt ) ) } } } } parser.next() } ``` ## Разбор даты Форматы дат более менее [стандартизованы](https://developer.android.com/reference/java/time/format/DateTimeFormatter), но если под рукой нет интернета, то можно разобрать любую дату по [шаблону](https://developer.android.com/reference/java/time/format/DateTimeFormatter#patterns Литерал | Значение :------:|--------- y | Год (2021, 21) M | Номер месяца (1..12) d | День месяца (1..31) H | Часы m | Минуты s | Секунды '' | Любой литерал ```kt // разбор простой MySQL даты, тут практически нет подводных камней tempDate = LocalDateTime.parse( "2021-11-16 10:12:13", DateTimeFormatter.ofPattern("y-M-d H:m:s") ) // разбор ISO даты, нужно экранировать символ "Т" и обрезать лишнюю часть строки tempDate = LocalDateTime.parse( "2021-11-16T10:12:13Z".substring(0,19), DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") ) ``` --- ## Задание * вывести в элементы списка остальную информацию о погоде * при обновлени списка и при клике на элемент списка выводить в верхнюю часть детальную информацию о погоде * вставить алерты на все нештатные ситуации (не получены координаты, нет ответа от сервера...) * завернуть всю работу с интернетом и разбором данных в исключительные ситуации (try..catch..) с выводом алертов ## Это интересно https://habr.com/ru/company/true_engineering/blog/267497/ Предыдущая лекция |   | Следующая лекция :----------------:|:----------:|:----------------: [Intent (намерение)](./intents.md) | [Содержание](../readme.md#практика-разработка-мобильных-приложений) | [Фрагменты](./fragments.md)