Răsfoiți Sursa

погода (начало)

Евгений Колесников 4 ani în urmă
părinte
comite
a1cbdd226b
5 a modificat fișierele cu 510 adăugiri și 0 ștergeri
  1. 6 0
      api.http
  2. 373 0
      articles/weather.md
  3. BIN
      img/04031.png
  4. 2 0
      readme.md
  5. 129 0
      shpora/HttpHelper.kt

+ 6 - 0
api.http

@@ -0,0 +1,6 @@
+@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

+ 373 - 0
articles/weather.md

@@ -0,0 +1,373 @@
+# Проект погода (начало): геолокация, http(s)-запросы, разбор json, ImageView.
+
+На примере "Калькулятора" мы познакомились с интерфейсом *Android Studio*, более-менее разобрались со структурой проекта андроид.
+
+На примере проекта "Погода" мы научимся:
+
+* [определять текущую позицию](#геолокация)
+* [запрашивать данные о погоде](#получение-информации-о-погоде)
+* [разбирать полученные данные (json)](#разбор-json)
+* [отображать полученные данные (тут добавится новый визуальный элемент - **ImageView**)](#отображение-иконки)
+* выбор города из списка и получение погоды для выбранного города (во второй части)
+* получение погоды за несколько дней и вывод списка погодных данных (во второй части)
+
+Создайте новый проект
+
+## Геолокация
+
+Последнее время Google сильно закрутили гайки. Мало того, что есть список разрешений в манифесте, но для использования геолокации мы должны ещё явно запросить разрешение у пользователя.
+
+Итак, **первым делом** в манифест добавляем разрешения для геолокации (прямо в теге **manifest**):
+
+```
+<uses-permission 
+    android:name="android.permission.ACCESS_FINE_LOCATION" />
+
+<uses-permission 
+    android:name="android.permission.ACCESS_COARSE_LOCATION" />
+```
+
+**Затем** в классе главного окна объявляем переменные *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())
+    }
+}
+
+// этот метод будет вызван, когда клиент геолокации получит координаты
+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)
+
+>Позже я постараюсь завернуть весь этот ужас в отдельный класс.
+
+## Получение информации о погоде
+
+Для получения информации о погоде воспользуемся открытым АПИ [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:
+
+```
+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
+}
+```
+
+Для http-запросов в Андроиде есть встроенный клиент. Он сильно устарел и вообще монстрообразный, в реальной разработке обычно используют библиотеки типа **okhttp**. Но т.к. на демо-экзамене не будет доступа в интернет, то придется использовать то что есть.
+
+Я нашел и адаптировал для вас синглтон (объект), для запросов. Лежит он в [каталоге](../shpora/HttpHelper.kt) `shpora` этого репозитория (тут я текст не привожу, т.к. он ещё не до конца отлажен). Его же я положу и в публичный репозиторий админа на демо-экзамене.
+
+Итак, в методе *onGetCoordinates* вместо вывода координат на экран вставьте http-запрос:
+
+>Токен объявите переменной в начале класса
+>```kt
+>private val token = "d4c9eea0d00fb43230b479793d6aa78f"
+>```
+
+
+```kt
+val url = "https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${token}&lang=RU"
+HTTP.requestGET(url) {result, error ->
+    if(result != null) {
+        val json = JSONObject(result)
+        val wheather = json.getJSONArray("weather")
+        val icoName = wheather.getJSONObject(0).getString("icon")
+
+        runOnUiThread {
+            textView.text = json.getString("name")
+        }
+    }
+}
+```
+
+Что здесь происходит?
+
+**Во-первых**, андроид не разрешает запускать http-запросы из основного потока. Их нужно заворачивать в потоки или корутины. Я заворачитваю запрос в поток, т.к. для поддержки корутин нужно добавлять библиотеку, вам дополнительно с потоками разбираться не нужно. 
+
+```kt
+fun requestGET(
+    r_url: String, 
+    callback: (result: String?, error: String)->Unit
+){
+    Thread( Runnable {
+        var error = ""
+        var result: String? = null
+        try {
+            ...
+        }
+        catch (e: Exception){
+            error = e.message.toString()
+        }
+
+        callback.invoke(result, error)
+    }).start()
+}
+```
+
+Функция принимает на вход URL, с которого нужно получить данные и callback-функцию в виде лямбда-выражения, которая возвращает текст ответа типа **String?** (нуллабельная строка, т.е. при ошибке вернет **null**) и текст ошибки. 
+
+Но в коде главного окна вызов этой функции выглядит иначе - один параметр и какой-то блок кода за функцией
+
+```kt
+HTTP.requestGET(url) {result, error ->
+    //
+}
+```
+
+Это фича котлина. Если лямбда выражение объявлено последним параметром функции, то его можно вынести за скобки. В принципе более привычный аналог выглядит так
+
+```kt
+HTTP.requestGET(url, {result, error ->
+    //
+})
+```
+
+Но вы должны знать о такой фиче, т.к. она достаточно часто используется.
+
+После разбора принятых данных их обычно выводят на экран. Тут надо учитывать что наша функция обратного вызова всё ещё находится в потоке, а к визуальным элементам можно обращаться только из основного потока. Для работы с визуальными элементами заворачиваем кусок кода в конструкцию:
+
+```kt
+runOnUiThread {
+    textView.text = json.getString("name")
+}
+```
+
+Тут я вывел на экран название города (разбор JSON будет ниже)
+
+## Разбор JSON
+
+Мы получили результат в виде строки, теперь нужно преобразовать её в JSON-объект и достать нужные нам данные
+
+Для преобразования строки в JSON-объект используется конструктор JSONObject
+
+```kt
+val json = JSONObject(result)
+```
+
+Теперь в переменной 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(result)
+
+// получение массива
+val wheather = json.getJSONArray("weather")
+
+// извлечение строки из первого элемента массива  
+val icoName = wheather.getJSONObject(0).getString("icon")
+
+// извлечение числа из объекта
+val temp = json.getJSONObject("main").getDouble("temp")
+```
+
+## Отображение иконки
+
+В разметку главного окна добавьте элемент **ImageView**
+
+```xml
+<ImageView
+    android:id="@+id/ico"
+    android:layout_width="100dp"
+    android:layout_height="100dp"
+    app:layout_constraintStart_toStartOf="parent"
+    app:layout_constraintTop_toTopOf="parent"
+    />
+```
+
+Для программного отображения изображения нужно вызвать метод **setImageBitmap(Bitmap)**
+
+Но сам **Bitmap** нам ещё нужно получить из интернета.
+
+В JSON-ответе название иконки погоды находится в массиве погоды. Я его доставал в примере выше (переменная icoName)
+
+Урл иконки выглядит так
+
+```
+https://openweathermap.org/img/w/${icoName}.png
+```
+
+И в моей библиотеке есть отдельный метод для загрузки картинок
+
+```
+fun getImage(url: String, callback: (result: Bitmap?, error: String)->Unit)
+```
+
+Первый параметр URL изображения, второй лямбда выражение для функции обратного вызова, которое возвращает Bitmap или ошибку
+
+Вызов этого метода можно вставить в предыдущий callback
+
+```kt
+...
+runOnUiThread {
+    textView.text = json.getString("name")
+}
+
+HTTP.getImage("https://openweathermap.org/img/w/${icoName}.png") { bitmap, error ->
+    if (bitmap != null) {
+        var imageView = findViewById<ImageView>(R.id.ico)
+        runOnUiThread {
+            imageView.setImageBitmap(bitmap)
+        }
+    }
+}
+```
+
+# Задание
+
+Разобрать все перечисленные выше параметры погоды и вывести их на экран

BIN
img/04031.png


+ 2 - 0
readme.md

@@ -136,6 +136,8 @@ http://sergeyteplyakov.blogspot.com/2014/01/microsoft-fakes-state-verification.h
 
 3. [Стили и темы. Ресурсы. Фигуры. Обработчики событий.](./articles/themes.md)
 
+4. [Проект погода (начало): геолокация, http(s)-запросы, разбор json, ImageView.](./articles/weather.md)
+
 <!--
 
 https://office-menu.ru/uroki-sql Уроки SQL

+ 129 - 0
shpora/HttpHelper.kt

@@ -0,0 +1,129 @@
+package ru.yotc.myapplication
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import org.json.JSONObject
+import java.io.*
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLEncoder
+import javax.net.ssl.HttpsURLConnection
+
+/*
+Перед использованием не забудьте добавить в манифест
+
+разрешение
+<uses-permission android:name="android.permission.INTERNET" />
+
+И атрибут в тег application
+android:usesCleartextTraffic="true"
+*/
+
+object HTTP
+{
+    private const val GET : String = "GET"
+    private const val POST : String = "POST"
+    /*
+    @Throws(IOException::class)
+    fun requestPOST(r_url: String, postDataParams: JSONObject): String? {
+        val url = URL(r_url)
+        val conn: HttpURLConnection = if(r_url.startsWith("https:", true))
+            url.openConnection() as HttpsURLConnection
+        else
+            url.openConnection() as HttpURLConnection
+
+        conn.readTimeout = 3000
+        conn.connectTimeout = 3000
+        conn.requestMethod = POST
+        conn.doInput = true
+        conn.doOutput = true
+        val os: OutputStream = conn.outputStream
+        val writer = BufferedWriter(OutputStreamWriter(os, "UTF-8"))
+        writer.write(encodeParams(postDataParams))
+        writer.flush()
+        writer.close()
+        os.close()
+        val responseCode: Int = conn.responseCode // To Check for 200
+        if (responseCode == HttpsURLConnection.HTTP_OK) {
+            val `in` = BufferedReader(InputStreamReader(conn.inputStream))
+            val sb = StringBuffer("")
+            var line: String? = ""
+            while (`in`.readLine().also { line = it } != null) {
+                sb.append(line)
+                break
+            }
+            `in`.close()
+            return sb.toString()
+        }
+        return null
+    }
+    */
+
+    fun getImage(url: String, callback: (result: Bitmap?, error: String)->Unit){
+        Thread( Runnable {
+            var image: Bitmap? = null
+            var error = ""
+            try {
+                val `in` = URL(url).openStream()
+                image = BitmapFactory.decodeStream(`in`)
+            }
+            catch (e: Exception) {
+                error = e.message.toString()
+            }
+            callback.invoke(image, error)
+        }).start()
+    }
+
+    fun requestGET(r_url: String, callback: (result: String?, error: String)->Unit) {
+        Thread( Runnable {
+            var error = ""
+            var result: String? = null
+            try {
+                val obj = URL(r_url)
+
+                val con: HttpURLConnection = if(r_url.startsWith("https:", true))
+                    obj.openConnection() as HttpsURLConnection
+                else
+                    obj.openConnection() as HttpURLConnection
+
+                con.requestMethod = GET
+                val responseCode = con.responseCode
+
+                result = if (responseCode == HttpURLConnection.HTTP_OK) { // connection ok
+                    val `in` =
+                        BufferedReader(InputStreamReader(con.inputStream))
+                    var inputLine: String?
+                    val response = StringBuffer()
+                    while (`in`.readLine().also { inputLine = it } != null) {
+                        response.append(inputLine)
+                    }
+                    `in`.close()
+                    response.toString()
+                } else {
+                    null
+                }
+            }
+            catch (e: Exception){
+                error = e.message.toString()
+            }
+
+            callback.invoke(result, error)
+        }).start()
+    }
+
+    @Throws(IOException::class)
+    private fun encodeParams(params: JSONObject): String? {
+        val result = StringBuilder()
+        var first = true
+        val itr = params.keys()
+        while (itr.hasNext()) {
+            val key = itr.next()
+            val value = params[key]
+            if (first) first = false else result.append("&")
+            result.append(URLEncoder.encode(key, "UTF-8"))
+            result.append("=")
+            result.append(URLEncoder.encode(value.toString(), "UTF-8"))
+        }
+        return result.toString()
+    }
+}