Смена ориентации, жизненный цикл activity, сохранение данных Содержание Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная)
# Проект погода (начало): геолокация, http(s)-запросы, разбор json, ImageView. На примере проекта *Калькулятор* мы познакомились с интерфейсом **Android Studio**, более-менее разобрались со структурой проекта андроид. На примере проекта "Погода" мы научимся: * [определять текущую позицию](#геолокация) * [запрашивать данные о погоде](#получение-информации-о-погоде) * [разбирать полученные данные (json)](#разбор-json) * [отображать полученные данные (тут добавится новый визуальный элемент - **ImageView**)](#отображение-иконки) * выбор города из списка и получение погоды для выбранного города (во второй части) * получение погоды за несколько дней и вывод списка погодных данных (во второй части) **Создайте новый проект** >Если ругается на несовместимость версии, то попробуйте откатить версии зависимостей: > >``` >dependencies { > implementation 'androidx.core:core-ktx:1.3.2' > implementation 'androidx.appcompat:appcompat:1.2.0' > implementation 'com.google.android.material:material:1.3.0' > implementation 'androidx.constraintlayout:constraintlayout:2.0.4' > implementation 'com.google.android.gms:play-services-location:17.0.0' >} >``` ## Геолокация Последнее время Google сильно закрутили гайки. Мало того, что есть список разрешений в манифесте, но для использования геолокации мы должны ещё явно запросить разрешение у пользователя. Итак, **первым делом** в манифест добавляем разрешения для геолокации (прямо в теге **manifest**): ``` ``` **Затем** в классе главного окна объявляем переменные *fusedLocationClient* и *mLocationRequest*: ```kt class MainActivity : AppCompatActivity() { // не в каком-то методе, а прямо в классе private lateinit var fusedLocationClient: FusedLocationProviderClient private lateinit var mLocationRequest: LocationRequest ``` В конструкторе главного окна инициализируем *fusedLocationClient* и проверяем разрешение на геолокацию (метод *checkPermission*, реализация будет ниже): ```kt fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) checkPermission() ``` Реализуем метод *checkPermission*: ```kt private fun checkPermission(){ if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { // нет разрешений - запрашиваем val permissions = arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ActivityCompat.requestPermissions(this, permissions, 0) } else { // есть разрешения - запускаем периодический опрос геолокации mLocationRequest = LocationRequest() mLocationRequest.interval = 10000 mLocationRequest.fastestInterval = 1000 mLocationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY fusedLocationClient.requestLocationUpdates(mLocationRequest, mLocationCallback, Looper.myLooper()) } } // этот метод будет вызван, когда пользователь разрешит геолокацию // в нём мы снова вызываем метод checkPermission override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { when (requestCode) { 1 -> { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if ((ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION) === PackageManager.PERMISSION_GRANTED)) { checkPermission() } } else { Toast.makeText(this, "Permission Denied", Toast.LENGTH_SHORT).show() } return } } } // этот метод будет вызван, когда клиент геолокации получит координаты private var mLocationCallback: LocationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { if (locationResult.locations.isNotEmpty()) { val locIndex = locationResult.locations.size - 1 val lon = locationResult.locations[locIndex].longitude val lat = locationResult.locations[locIndex].latitude onGetCoordinates(lat, lon) } } } ``` И, наконец, реализуем метод получения координат: ```kt fun onGetCoordinates(lat: Double, lon: Double){ // первым делом останавливаем опрос fusedLocationClient.removeLocationUpdates(mLocationCallback) // пока просто выводим координаты на экран Toast.makeText(this, "${lat}, ${lon}", Toast.LENGTH_LONG).show() } ``` При первом запуске приложение запросит у нас разрешение на доступ к геолокации ![](../img/04031.png) >Позже я постараюсь завернуть весь этот ужас в отдельный класс. ## Получение информации о погоде [Раздел из ПМ 05.01 про HTTP](api_php.md) Для получения информации о погоде воспользуемся открытым АПИ [openweathermap](https://openweathermap.org/api) В АПИ есть несколько вариантов: текущая погода, почасовая, на несколько дней, на месяц... Но для бесплатного использования подходят не все. Для начала получим данные о [текущей](https://openweathermap.org/current) погоде по координатам (By geographic coordinates) Формат запроса: ``` api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={API key} ``` Координаты мы уже получили в предыдущем разделе, **API key** можно получить зарегистрировавшись на сайте, но можно воспользоваться моим (увидите в коде) Дополнительно можно указать: * **mode** - формат ответа (**json** или **xml**, **json** установлен по-умолчанию, поэтому этот параметр не трогаем) * **units** - единицы измерения, нам нужны метрические, поэтому этот паарметр используем * **lang** - Язык ответа. По умолчанию английский, поэтому тоже используем В итоге получается такой URL: ```kt val url = "https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${token}&lang=ru" ``` Проверить правильность запроса и посмотреть на результат можно запустив этот URL в **Postman**-е (он будет на демо-экзамене). Но лично мне больше нравится плагин **REST Client** для **VSCode** Можно описать переменные и запрос в отдельном файле (например, `api.http`) и выполнять запросы прямо из **VSCode** ``` @lat=56.638372 @lon=47.892991 @token=d4c9eea0d00fb43230b479793d6aa78f ### Запрос текущей погоды GET https://api.openweathermap.org/data/2.5/weather?lat={{lat}}&lon={{lon}}&units=metric&appid={{token}}&lang=ru ``` В ответ на этот запрос должно прийти что-то подобное: ```json { "coord": { "lon": 47.893, "lat": 56.6384 }, "weather": [ { "id": 802, "main": "Clouds", "description": "переменная облачность", "icon": "03n" } ], "base": "stations", "main": { "temp": -6.58, "feels_like": -10.63, "temp_min": -6.58, "temp_max": -6.58, "pressure": 1030, "humidity": 65, "sea_level": 1030, "grnd_level": 1018 }, "visibility": 10000, "wind": { "speed": 2.36, "deg": 236, "gust": 6.21 }, "clouds": { "all": 33 }, "dt": 1636565376, "sys": { "type": 1, "id": 9042, "country": "RU", "sunrise": 1636517829, "sunset": 1636548458 }, "timezone": 10800, "id": 466806, "name": "Йошкар-Ола", "cod": 200 } ``` В **Android**-е есть встроенные функции работы с **http**-запросами, но стандартный код для сетевых запросов сложен, излишен и в реальном мире почти не используется. Используются библиотеки. Самые популярные: [OkHttp](https://square.github.io/okhttp/) и Retrofit. Рассмотрим работу к **OkHttp** >[Примеры синхронных и асинхронных запросов на котлине](https://square.github.io/okhttp/recipes/) Перед использованием не забудьте добавить в манифест разрешение на работу с интернетом ``` ``` И, если на сайте нет сертификата, атрибут в тег **application**: ``` android:usesCleartextTraffic="true" ``` Ещё в зависимости проекта нужно добавить билиотеку (в файл `build.gradle(:app)` в раздел *dependencies*): ``` implementation 'com.squareup.okhttp3:okhttp:4.10.0' ``` >Токен объявите константой в свойствах класса >```kt >private val appid = "d4c9eea0d00fb43230b479793d6aa78f" >``` Итак, в методе *onGetCoordinates* вместо вывода координат на экран вставьте http-запрос: >Неизвестные методы **Android Studio** показывает красным цветом. Чтобы добавить пакет, в котором описан такой метод, нужно поместить курсор на этот метод и, либо через контекстное меню, либо нажатием **Alt+Enter** добавить пакет в импортируемые (если вариантов импорта несколько, то смотрите по контексту - в нашем случае в названии пакета должно быть что-то про **okhttp**) В примерах из [OkHttp](https://square.github.io/okhttp/) нет обработки исключительных ситуаций, я написал метод, который принимает на вход строку **url** или готовый **Request** и возвращает callback с ответом или исключением (если связи физически нет): ```kt // в классе объявите свойство client private val client = OkHttpClient() fun httpGet( url: Any, // тип Any - тут может быть строка для GET запроса, или готовый Request callback: (response: Response?, error: Exception?)->Unit ) { var request: Request = when (url) { is String -> Request.Builder() .url(url) .build() is Request -> url as Request else -> { callback.invoke(null, Exception("Не верный тип параметра \"url\"")) return } } client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { callback.invoke(null, Exception(e.message!!)) } override fun onResponse(call: Call, response: Response) { response.use { callback.invoke(response, null) } } }) } ``` Как выглядит вызов этого метода: ```kt fun onGetCoordinates(lat: Double, lon: Double){ ... httpGet("https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${appid}&lang=ru&units=metric") {response, error -> try { // если в запросе получено исключение, то "выбрасываем" его if (error != null) throw error // если ответ получен, но код не 200, то тоже "выбрасываем" исключение if (!response!!.isSuccessful) throw Exception(response.message) // начинаем обработку ответа val json = JSONObject(response.body!!.string()) // обращение к UI должно быть в контексте UiThread runOnUiThread { textView.text = json.getString("name") } } catch (e: Exception) { // любую ошибку показываем на экране runOnUiThread { AlertDialog.Builder(this) .setTitle("Ошибка") .setMessage(e.message) .setPositiveButton("OK", null) .create() .show() } } } } ``` Что здесь происходит? **Во-первых**, андроид не разрешает запускать http(s)-запросы из основного потока. Их нужно запускать асинхронно. В **OkHttp** это делает метод **enqueue**. В параметрах метода указывается *callback*-функция в виде лямбда-выражения, в котором должны быть реализованы методы для успешного и неуспешного запросов (тут нужно учитывать, что успех или не успех относятся к транспортному уровню, смог **OkHttp** получить ответ сервера или нет). После разбора принятых данных их обычно выводят на экран. Тут надо учитывать что функция обратного вызова всё ещё находится в потоке, а к визуальным элементам можно обращаться только из основного потока. Для работы с визуальными элементами заворачиваем кусок кода в конструкцию: ```kt runOnUiThread { textView.text = json.getString("name") } ``` Тут я вывел на экран только название города (разбор JSON будет ниже) ## Разбор JSON Мы получили результат в виде JSON-строки, теперь нужно преобразовать её в JSON-объект и достать нужные нам данные Для преобразования строки в JSON-объект используется конструктор JSONObject ```kt val json = JSONObject(someJsonString) ``` Теперь в переменной json у нас экземпляр JSON-объекта Для получения данных из JSON-объекта есть get методы * **getJSONArray** - получить массив * **getJSONObject** - получение объекта (по индексу из массива или по имени из объекта) * **getString** - получить строку Также есть **getInt**, **getDouble**..., доступные методы будут видны в контекстном меню. Из полученного объекта (листинг был выше) нам нужны (эти данные вы будете выводить на экран по итогам этой лекции): * название иконки погоды: **weather[0].icon** * описание погоды: **weather[0].description** * температура: **main.temp** * влажность: **main.humidity** * скорость (**wind.speed**) и направление (**wind.deg**) ветра * название населенного пункта: **name** Приведу несколько примеров ```kt val json = JSONObject(response.body!!.string()) // получение массива val wheather = json.getJSONArray("weather") // извлечение строки из первого элемента массива val icoName = wheather.getJSONObject(0).getString("icon") // извлечение числа из объекта val temp = json.getJSONObject("main").getDouble("temp") ``` ## Отображение иконки В разметку окна добавьте элемент **ImageView** ```xml ``` Для программного отображения изображения нужно вызвать метод **setImageBitmap(Bitmap)** Но сам **Bitmap** нам ещё нужно получить из интернета. В JSON-ответе название иконки погоды находится в массиве погоды. Я его доставал в примере выше (переменная icoName) Урл иконки выглядит так (описано в АПИ): ```kt val icoUrl = "https://openweathermap.org/img/w/${icoName}.png" ``` Для загрузки картинок воспользуемся тем же методом **httpGet**: >Вызов этого метода нужно вставить в предыдущий callback ```kt ... 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() } } } ``` --- ## Задание Разобрать все перечисленные выше параметры погоды и вывести их на экран
Смена ориентации, жизненный цикл activity, сохранение данных Содержание Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная)