Forecasting the Weather
The Weather
sample module in the Sampler
and SamplerJ
projects shows a weather forecast for New York City. It gets the forecast from the US National Weather Service, which offers a Web service for retrieving forecasts. The forecast elements that the app uses includes the time, projected temperature, and an icon representing the expected weather (e.g., sunny, partly cloudy, mostly cloudy, rain, snow, fog, alien invasion, kaiju attack, or zombie infestation).
(OK, perhaps not those last three)
The icon is supplied in the form of a URL from which we can download the icon.
To get the weather forecast itself, we will use Retrofit. To populate ImageView
widgets in a RecyclerView
with the weather icons, we will use Glide.
The Dependencies
Glide is simple. Most of the time, you can just use the com.github.bumptech.glide:glide
library:
implementation 'com.github.bumptech.glide:glide:4.12.0'
Retrofit is a bit more complicated. You will usually need at least two libraries:
-
com.squareup.retrofit2:retrofit
, which is Retrofit itself - A “converter” library that teaches Retrofit how to parse the sort of content that your Web service returns (JSON, XML, etc.)
The US National Weather Service Web service API supports returning JSON. There are a few JSON converter options for Retrofit, each of which use a different JSON parser. If you are using a JSON parser elsewhere in your app. you can try to use the corresponding Retrofit converter library — that way, you do not wind up bundling two JSON parsing libraries in your app. The three most popular JSON parsers for Android are Gson, Jackson, and Moshi, and there are Retrofit converters for each of those. Moshi happens to be written by Square, the same team that created Retrofit itself, so this sample app uses Moshi:
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
The Response Classes
The converters that Retrofit supports are designed to take a raw response — such as a JSON string — and convert them into instances of classes that are designed to reflect the response structure. The US National Weather Service has some documentation about their Web service API, and that information can be used to create classes that match the JSON that they will serve.
Usually, your classes can skip portions of the response that you do not care about. Moshi or other JSON parsers will skip anything that appears in the JSON but does not have a corresponding spot in your Java or Kotlin classes.
The JSON that we get back from the Web service looks like this (with the list of forecast periods truncated to save space in the book):
{
"@context": [
"https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld",
{
"wx": "https://api.weather.gov/ontology#",
"geo": "https://www.opengis.net/ont/geosparql#",
"unit": "https://codes.wmo.int/common/unit/",
"@vocab": "https://api.weather.gov/ontology#"
}
],
"type": "Feature",
"geometry": {
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"coordinates": [
-74.0130202,
40.714515800000001
]
},
{
"type": "Polygon",
"coordinates": [
[
[
-74.025095199999996,
40.727052399999998
],
[
-74.0295579,
40.705361699999997
],
[
-74.000948300000005,
40.701977499999998
],
[
-73.996479800000003,
40.723667800000001
],
[
-74.025095199999996,
40.727052399999998
]
]
]
}
]
},
"properties": {
"updated": "2019-06-05T19:48:39+00:00",
"units": "us",
"forecastGenerator": "BaselineForecastGenerator",
"generatedAt": "2019-06-05T22:38:08+00:00",
"updateTime": "2019-06-05T19:48:39+00:00",
"validTimes": "2019-06-05T13:00:00+00:00/P8D",
"elevation": {
"value": 2.1335999999999999,
"unitCode": "unit:m"
},
"periods": [
{
"number": 1,
"name": "Tonight",
"startTime": "2019-06-05T18:00:00-04:00",
"endTime": "2019-06-06T06:00:00-04:00",
"isDaytime": false,
"temperature": 67,
"temperatureUnit": "F",
"temperatureTrend": null,
"windSpeed": "10 to 14 mph",
"windDirection": "SW",
"icon": "https://api.weather.gov/icons/land/night/tsra,80?size=medium",
"shortForecast": "Showers And Thunderstorms",
"detailedForecast": "Showers and thunderstorms. Cloudy, with a low around 67. Southwest wind 10 to 14 mph, with gusts as high as 24 mph. Chance of precipitation is 80%. New rainfall amounts between a quarter and half of an inch possible."
},
{
"number": 2,
"name": "Thursday",
"startTime": "2019-06-06T06:00:00-04:00",
"endTime": "2019-06-06T18:00:00-04:00",
"isDaytime": true,
"temperature": 83,
"temperatureUnit": "F",
"temperatureTrend": null,
"windSpeed": "10 mph",
"windDirection": "W",
"icon": "https://api.weather.gov/icons/land/day/rain_showers,50/rain_showers,20?size=medium",
"shortForecast": "Chance Rain Showers",
"detailedForecast": "A chance of rain showers before 3pm. Partly sunny, with a high near 83. West wind around 10 mph. Chance of precipitation is 50%. New rainfall amounts between a tenth and quarter of an inch possible."
},
{
"number": 3,
"name": "Thursday Night",
"startTime": "2019-06-06T18:00:00-04:00",
"endTime": "2019-06-07T06:00:00-04:00",
"isDaytime": false,
"temperature": 66,
"temperatureUnit": "F",
"temperatureTrend": null,
"windSpeed": "7 to 10 mph",
"windDirection": "N",
"icon": "https://api.weather.gov/icons/land/night/sct?size=medium",
"shortForecast": "Partly Cloudy",
"detailedForecast": "Partly cloudy, with a low around 66. North wind 7 to 10 mph."
}
]
}
}
For the purposes of this app, we only need a small subset of this information. We need the periods
list of objects from the properties
property of the root JSON object. And, for each period, we need the name
, temperature
, temperatureUnit
, and icon
properties. A full-featured US weather app could use a lot more of the properties, but we will keep this simple.
So, we have these classes to model that response in Java:
package com.commonsware.jetpack.weather;
import java.util.List;
public class WeatherResponse {
public final Properties properties=null;
public static class Properties {
public final List<Forecast> periods=null;
}
}
package com.commonsware.jetpack.weather;
public class Forecast {
final String name;
final int temperature;
final String temperatureUnit;
final String icon;
public Forecast(String name, int temperature,
String temperatureUnit, String icon) {
this.name = name;
this.temperature = temperature;
this.temperatureUnit = temperatureUnit;
this.icon = icon;
}
}
…and Kotlin:
package com.commonsware.jetpack.weather
class WeatherResponse {
val properties: Properties? = null
class Properties {
val periods: List<Forecast>? = null
}
}
package com.commonsware.jetpack.weather
data class Forecast(
val name: String,
val temperature: Int,
val temperatureUnit: String,
val icon: String
)
The overall Web service response is a WeatherResponse
, which holds a Properties
object, which in turn host a List
of Forecast
objects.
Moshi does not care about the package structure, so long as it can create instances of the class and fill in the fields or properties. Here, we have a mix of top-level classes (WeatherResponse
, Forecast
) and nested classes (Properties
) — you can organize your classes as you see fit.
The Retrofit API Declaration
The next step for using Retrofit is to declare an interface
that describes the API that we are invoking on the Web service and maps it to functions that we want to be able to call from our Java/Kotlin code.
In our case, we are only invoking a single Web service URL, where we tell it a location for a forecast, and we get back the corresponding forecast data. So, our interface
has only a getForecast()
function:
package com.commonsware.jetpack.weather;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Headers;
import retrofit2.http.Path;
public interface NWSInterface {
@Headers("Accept: application/geo+json")
@GET("/gridpoints/{office}/{gridX},{gridY}/forecast")
Call<WeatherResponse> getForecast(@Path("office") String office,
@Path("gridX") int gridX,
@Path("gridY") int gridY);
}
package com.commonsware.jetpack.weather
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Path
interface NWSInterface {
@Headers("Accept: application/geo+json")
@GET("/gridpoints/{office}/{gridX},{gridY}/forecast")
suspend fun getForecast(
@Path("office") office: String,
@Path("gridX") gridX: Int,
@Path("gridY") gridY: Int
): WeatherResponse
}
Every Retrofit interface function will have at least one annotation, one that indicates the HTTP operation to perform and a relative path on which to perform it. In our case, that is @GET("/gridpoints/{office}/{gridX},{gridY}/forecast")
, saying that we want to perform an HTTP GET
operation on… something.
The @GET
annotation takes a relative path, where portions of that path can come from parameters to the annotated function. Our three parameters — office
, gridX
, and gridY
— are themselves annotated with @Path
. @Path
says “this function parameter can be used to help assemble the relative path”. The name given as a parameter to @Path
can be used in the relative path, wrapped in braces. Retrofit will then replace that brace-wrapped name with the actual runtime value of the parameter when we call it. So, "/gridpoints/{office}/{gridX},{gridY}/forecast"
will turn into something like "/gridpoints/OKX/32,34/forecast"
, if we call getForecast("OKX", 32, 34)
.
The US National Weather Service Web service API divides its forecasts into gridded areas served by offices. We are supplying the ID of an office (office
) plus the X/Y coordinates of a particular grid cell (gridX
and gridY
). We will receive a forecast for that particular cell of that specific office. There is a separate Web service URL that gives us the office and cell for a given latitude and longitude. An app that provided weather information for an arbitrary location — such as one that is retrieved from LocationManager
and the GPS hardware on the device — would use that URL to get the office and grid, then use the URL associated with getForecast()
to get the weather. Here, we will supply the office and cell from values hard-coded in our activity, to help simplify the example.
Retrofit has a variety of additional annotations that you can add as needed. In our case, we have a @Headers
annotation to add an HTTP header to our Web service call, indicating that we want a GeoJSON-encoded response.
The Repository
We have a WeatherRepository
that is responsible for working with Retrofit and obtaining our weather forecast. As with some of the previous samples, the Java and Kotlin implementations diverge a bit, as Kotlin uses coroutines, while Java returns a LiveData
instead.
Java
Our Java implementation of WeatherRepository
looks like this:
package com.commonsware.jetpack.weather;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.moshi.MoshiConverterFactory;
class WeatherRepository {
private static volatile WeatherRepository INSTANCE;
private final NWSInterface api;
synchronized static WeatherRepository get() {
if (INSTANCE == null) {
INSTANCE = new WeatherRepository();
}
return INSTANCE;
}
private WeatherRepository() {
Retrofit retrofit=
new Retrofit.Builder()
.baseUrl("https://api.weather.gov")
.addConverterFactory(MoshiConverterFactory.create())
.build();
api = retrofit.create(NWSInterface.class);
}
LiveData<WeatherResult> load(String office, int gridX, int gridY) {
final MutableLiveData<WeatherResult> result = new MutableLiveData<>();
result.setValue(new WeatherResult(true, null, null));
api.getForecast(office, gridX, gridY).enqueue(
new Callback<WeatherResponse>() {
@Override
public void onResponse(Call<WeatherResponse> call,
Response<WeatherResponse> response) {
result.postValue(new WeatherResult(false, response.body().properties.periods, null));
}
@Override
public void onFailure(Call<WeatherResponse> call, Throwable t) {
result.postValue(new WeatherResult(false, null, t));
}
});
return result;
}
}
In the constructor, we get an instance of our NWSInterface
from Retrofit. Specifically, we:
- Create a
Retrofit.Builder
object - Provide the base URL to it (
https://api.weather.gov
), which will combine with the paths on the individual interface functions to assemble the entire URL to use - Teach the
Builder
that it can use Moshi for converting JSON into objects, viaaddConverterFactory(MoshiConverterFactory.create())
-
build()
the resultingRetrofit
object - Call
create()
on theRetrofit
object, to cause Retrofit to give us an instance of some generated class that implements ourNWSInterface
Retrofit can handle background threading for us. Our Java implementation of NWSInterface
returns our WeatherResponse
wrapped in a Call
object. The two main methods on Call
are execute()
(to perform the Web service request synchronously) and enqueue()
(to perform the Web service request asynchronously, on a Retrofit-supplied background thread). load()
on WeatherRepository
uses enqueue()
, so Retrofit will handle our threading for us. We need to then supply a Callback
to receive either our WeatherResponse
or a Throwable
if there is some problem (e.g., the Web service is down for maintenance). In our case, we wrap those results in a WeatherResult
and update a MutableLiveData
with that result. WeatherResult
encapsulates an isLoading
flag, along with our WeatherResponse
and Throwable
from the callback:
package com.commonsware.jetpack.weather;
import java.util.List;
public class WeatherResult {
final boolean isLoading;
final List<Forecast> forecasts;
final Throwable error;
WeatherResult(boolean isLoading, List<Forecast> forecasts, Throwable error) {
this.isLoading = isLoading;
this.forecasts = forecasts;
this.error = error;
}
}
Hence, if we call load()
on our WeatherRepository
singleton, we will get a LiveData
that we can observe, where it will give us our WeatherResult
as we progress from the loading state to either the success or failure states.
Kotlin
The Kotlin code is far simpler, because Retrofit (as of 2.6.0) has built-in support for coroutines. So, our Kotlin NWSInterface
returns a WeatherResponse
directly (without the Call
wrapper), and its getForecast()
is marked with the suspend
keyword. So, our WeatherRepository
can just have its own suspend
function that delegates to Retrofit and converts the WeatherResponse
or caught exception into WeatherResult
objects:
package com.commonsware.jetpack.weather
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
object WeatherRepository {
private val api = Retrofit.Builder()
.baseUrl("https://api.weather.gov")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(NWSInterface::class.java)
suspend fun load(office: String, gridX: Int, gridY: Int) = try {
val response = api.getForecast(office, gridX, gridY)
WeatherResult.Content(response.properties?.periods ?: listOf())
} catch (t: Throwable) {
WeatherResult.Error(t)
}
}
In our case, then, we only have Content
and Error
states — load()
returns the end result of the API call, so it has no opportunity to return a Loading
state:
package com.commonsware.jetpack.weather
sealed class WeatherResult {
data class Content(val forecasts: List<Forecast>) : WeatherResult()
data class Error(val throwable: Throwable) : WeatherResult()
}
The Motor and the View States
The Forecast
objects in our WeatherResult
have an integer temperature
value plus a temperatureUnit
string (e.g., F
for Fahrenheit, C
for Celsius). For display purposes, it would be nice to convert those into a single string, one that we can data bind into a TextView
.
So, our MainMotor
implementations will have a LiveData
of MainViewState
objects. MainViewState
, in turn, will have a List
of RowState
objects, where we have a single property for the visual representation of the temperature. MainMotor
will get the WeatherResult
from the Web service and convert it into a MainViewState
object as they come in.
Java
MainViewState
and RowState
look a lot like WeatherResult
and Forecast
, just with the single temperature field:
package com.commonsware.jetpack.weather;
import java.util.List;
class MainViewState {
final boolean isLoading;
final List<RowState> forecasts;
final Throwable error;
MainViewState(boolean isLoading, List<RowState> forecasts, Throwable error) {
this.isLoading = isLoading;
this.forecasts = forecasts;
this.error = error;
}
}
package com.commonsware.jetpack.weather;
public class RowState {
public final String name;
public final String temp;
public final String icon;
RowState(String name, String temp, String icon) {
this.name = name;
this.temp = temp;
this.icon = icon;
}
}
As with some of the previous examples, MainMotor
uses MediatorLiveData
, to fold one or more load()
calls into a single results
field that contains our LiveData
of MainViewState
objects:
package com.commonsware.jetpack.weather;
import android.app.Application;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
public class MainMotor extends AndroidViewModel {
final private WeatherRepository repo = WeatherRepository.get();
final MediatorLiveData<MainViewState> results = new MediatorLiveData<>();
private LiveData<WeatherResult> lastResult;
public MainMotor(@NonNull Application application) {
super(application);
}
void load(String office, int gridX, int gridY) {
if (lastResult != null) {
results.removeSource(lastResult);
}
lastResult = repo.load(office, gridX, gridY);
results.addSource(lastResult, weather -> {
ArrayList<RowState> rows = new ArrayList<>();
if (weather.forecasts != null) {
for (Forecast forecast : weather.forecasts) {
String temp =
getApplication().getString(R.string.temp, forecast.temperature,
forecast.temperatureUnit);
rows.add(new RowState(forecast.name, temp, forecast.icon));
}
}
results.postValue(
new MainViewState(weather.isLoading, rows, weather.error));
});
}
}
To create the temperature string, we use a string resource that contains placeholders for the number and unit:
<string name="temp">%d%s</string>
Then, we use getString()
on a Context
to retrieve that string resource and fill in those placeholders with our desired values.
Kotlin
The Kotlin implementation is similar, just using the MutableLiveData
and viewModelScope
approach that we saw in previous examples:
package com.commonsware.jetpack.weather
sealed class MainViewState {
object Loading : MainViewState()
data class Content(val forecasts: List<RowState>) : MainViewState()
data class Error(val throwable: Throwable) : MainViewState()
}
package com.commonsware.jetpack.weather
data class RowState(
val name: String,
val temp: String,
val icon: String
)
package com.commonsware.jetpack.weather
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainMotor(application: Application) : AndroidViewModel(application) {
private val _results = MutableLiveData<MainViewState>()
val results: LiveData<MainViewState> = _results
fun load(office: String, gridX: Int, gridY: Int) {
_results.value = MainViewState.Loading
viewModelScope.launch(Dispatchers.Main) {
val result = WeatherRepository.load(office, gridX, gridY)
_results.value = when (result) {
is WeatherResult.Content -> {
val rows = result.forecasts.map { forecast ->
val temp = getApplication<Application>()
.getString(
R.string.temp,
forecast.temperature,
forecast.temperatureUnit
)
RowState(forecast.name, temp, forecast.icon)
}
MainViewState.Content(rows)
}
is WeatherResult.Error -> MainViewState.Error(result.throwable)
}
}
}
}
The Image Loading
Eventually, our List
of RowState
objects makes it over to a WeatherAdapter
. This is a ListAdapter
that we use to fill in a RecyclerView
that will show the list of forecasts.
We use data binding for the rows, where our row.xml
layout has binding expressions to populate its widgets from a RowState
:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="state"
type="com.commonsware.jetpack.weather.RowState" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:paddingTop="8dp">
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{state.name}"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/temp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/icon"
tools:text="Tonight" />
<ImageView
android:id="@+id/icon"
android:layout_width="0dp"
android:layout_height="64dp"
android:contentDescription="@string/icon"
app:imageUrl="@{state.icon}"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/temp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
android:text="@{state.temp}"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="@id/icon"
app:layout_constraintEnd_toStartOf="@+id/icon"
app:layout_constraintTop_toTopOf="@id/icon"
tools:text="72F" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
In particular, our ImageView
for the weather icon uses app:imageUrl="@{state.icon}"
to pull the icon
property out of the RowState
and apply it to the ImageView
. ImageView
does not have an app:imageUrl
attribute, though — we are using a BindingAdapter
that in turn uses Glide to load the image:
package com.commonsware.jetpack.weather;
import android.widget.ImageView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.model.GlideUrl;
import java.util.HashMap;
import java.util.Map;
import androidx.databinding.BindingAdapter;
public class BindingAdapters {
private static final Map<String, String> HEADERS = new HashMap<>();
static {
HEADERS.put("User-Agent", "me");
}
@BindingAdapter("imageUrl")
public static void loadImage(ImageView view, String url) {
if (url != null) {
Glide.with(view.getContext())
.load(new GlideUrl(url, () -> HEADERS))
.into(view);
}
}
}
package com.commonsware.jetpack.weather
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
private val HEADERS = mapOf("User-Agent" to "me")
@BindingAdapter("imageUrl")
fun ImageView.loadImage(url: String?) {
url?.let {
Glide.with(context)
.load(GlideUrl(it) { HEADERS })
.into(this)
}
}
For simple cases like this one, Glide has a really simple API:
- Call
Glide.with()
to get aRequestManager
object, passing in aContext
- Call
load()
on theRequestManager
to ask it to load a URL — this returns aRequestBuilder
- Call
into()
on theRequestBuilder
to tell it to show the resulting image in the suppliedImageView
And that’s it. Glide will handle doing the network I/O and populating the ImageView
with the resulting image, using a background thread for the I/O work. It also handles recycling — if we call into()
with an ImageView
that already has an outstanding request, Glide will cancel the old request for us automatically.
Ordinarily, you can call load()
with just the URL. Unfortunately, the US National Weather Service does not like the default Glide user agent header, so we have to call another version of load()
that takes a GlideUrl
parameter. A GlideUrl
wraps up the details of an HTTPS request, including a custom set of headers to blend into the request.
The Results
Our MainActivity
starts all of this off by calling load()
on the MainMotor
:
motor.load("OKX", 32, 34)
Here, OKX
, 32
, and 34
were determined by manually invoking another Web service URL, providing the latitude and longitude for One World Trade Center in lower Manhattan.
MainActivity
observes the LiveData
from the motor, and for successful Web service calls forwards the List
of RowState
objects to the WeatherAdapter
that it created and attached to a RecyclerView
.
If you run the app, you should see an upcoming forecast for New York City:
Here, we see that there will be a 20% chance of rain today, then clear skies for the next few days, with no signs of kaiju.
(then again, kaiju can be sneaky…)
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.