10 Serialization With JSON¶
In this chapter, you’ll learn how to serialize JSON data into model classes. A model class represents data for a particular object. An example is a recipe model class, which usually has a title, an ingredient list and steps to cook it.
You’ll continue with the previous project, which is the starter project for this chapter, and you’ll add a class that models a recipe and its properties. Then you’ll integrate that class into the existing project.
By the end of the chapter, you’ll know:
- How to serialize JSON into model classes.
- How to use Dart tools to automate the generation of model classes from JSON.
What is JSON?¶
JSON, which stands for JavaScript Object Notation, is an open-standard format used on the web and in mobile clients. It’s the most widely used format for Representational State Transfer (REST)-based APIs that servers provide (https://en.wikipedia.org/wiki/Representational_state_transfer). If you talk to a server that has a REST API, it will most likely return data in a JSON format. An example of a JSON response looks something like this:
{
"recipe": {
"uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_b79327d05b8e5b838ad6cfd9576b30b6",
"label": "Chicken Vesuvio"
}
}
That is an example recipe response that contains two fields inside a recipe object.
While it’s possible to treat the JSON as just a long string and try to parse out the data, it’s much easier to use a package that already knows how to do that. Flutter has a built-in package for decoding JSON, but in this chapter, you’ll use the json_serializable and json_annotationpackages to help make the process easier.
Flutter’s built-in dart:convert package contains methods like json.decode
and json.encode
, which converts a JSON string to a Map<String, dynamic>
and back. While this is a step ahead of manually parsing JSON, you’d still have to write extra code that takes that map and puts the values into a new class.
The json_serializable package comes in handy because it can generate model classes for you according to the annotations you provide via json_annotation. Before taking a look at automated serialization, you’ll see in the next section what manual serialization entails.
Writing the code yourself¶
So how do you go about writing code to serialize JSON yourself? Typical model classes have toJson()
and fromJson()
methods.
In the next section you learn how to use automated serialization. For now, you don’t need to type this into your project, but need to understand the methods to convert the JSON above to a model class.
First, you’d create a Recipe
model class:
class Recipe {
final String uri;
final String label;
Recipe({this.uri, this.label});
}
Then you’d add a toJson()
factory method and a fromJson()
method:
factory Recipe.fromJson(Map<String, dynamic> json) {
return Recipe(json['uri'] as String, json['label'] as String);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{ 'uri': uri, 'label': label}
}
In fromJson()
, you grab data from the JSON map variable named json
and convert it to arguments you pass to the Recipe
constructor. In toJson()
, you construct a map using the JSON field names.
While it doesn’t take much effort to do that by hand for two fields, what if you had multiple model classes, each with, say, five fields, or more? What if you renamed one of the fields? Would you remember to rename all of the occurrences of that field?
The more model classes you have, the more complicated it becomes to maintain the code behind them. Fear not, that’s where automated code generation comes to the rescue.
Automating JSON serialization¶
You’ll use two packages in this chapter: json_annotation and json_serializable from Google.
You use the first to add annotations to model classes so that json_serializable can generate helper classes to convert JSON from a string to a model and back.
To do that, you mark a class with the @JsonSerializable()
annotation so the builder package can generate code for you. Each field in the class should either have the same name as the field in the JSON string or use the @JsonKey()
annotation to give it a different name.
Most builder packages work by importing what’s called a .part file. That will be a file that is generated for you. All you need to do is create a few factory methods which will call the generated code.
Adding the necessary dependencies¶
Continue with your current project or open the starter project in the projects folder. Add the following package to pubspec.yaml in the Flutter dependencies
section underneath and aligned with shared_preferences
:
json_annotation: ^4.6.0
In the dev_dependencies
section, after the flutter_test
section, replace # TODO: Add new dev_dependencies
with:
build_runner: ^2.2.0
json_serializable: ^6.3.1
Make sure these are all indented correctly. The result should look like this:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.5
cached_network_image: ^3.2.2
flutter_slidable: ^2.0.0
platform: ^3.1.0
flutter_svg: ^1.1.4
shared_preferences: ^2.0.15
json_annotation: ^4.6.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.2.0
json_serializable: ^6.3.1
flutter_lints: ^2.0.1
build_runner
is a package that all code generators require in order to build .part file classes.
Finally, press the Pub get button you should see at the top of the file. You’re now ready to generate model classes.
Generating classes from JSON¶
The JSON that you’re trying to serialize looks something like:
{
"q": "pasta",
"from": 0,
"to": 10,
"more": true,
"count": 33060,
"hits": [
{
"recipe": {
"uri": "http://www.edamam.com/ontologies/edamam.owl#recipe_09b4dbdf0c7244c462a4d2622d88958e",
"label": "Pasta Frittata Recipe",
"image": "https://www.edamam.com/web-img/5a5/5a5220b7a65c911a1480502ed0532b5c.jpg",
"source": "Food Republic",
"url": "http://www.foodrepublic.com/2012/01/21/pasta-frittata-recipe",
}
]
}
- The
q
field is the query. In this instance, you’re querying about pasta. from
is the starting index.to
is the ending index.more
is a boolean that tells you whether there are more items to retrieve.- while
count
is the total number of items you could receive. - The
hits
array is the actual list of recipes.
In this chapter, you’ll use the label
and image
fields of the recipe item. Your next step is to generate the classes that model that data.
Creating model classes¶
Start by creating a new directory named network in the lib folder. Inside this folder, create a new file named recipe_model.dart. Then add the needed imports:
import 'package:json_annotation/json_annotation.dart';
part 'recipe_model.g.dart';
The json_annotation
library lets you mark classes as serializable. The file recipe_model.g.dartdoesn’t exist yet; you’ll generate it in a later step.
Next, add a class named APIRecipeQuery
with a @JsonSerializable()
annotation:
@JsonSerializable()
class APIRecipeQuery {
// TODO: Add APIRecipeQuery.fromJson
}
// TODO: Add @JsonSerializable() class APIHits
// TODO: Add @JsonSerializable() class APIRecipe
// TODO: Add @JsonSerializable() class APIIngredients
That marks the APIRecipeQuery
class as serializable so the json_serializable package can generate the .g.dart file.
Command-Click on JsonSerializable
and you’ll see its definition:
...
/// Creates a new [JsonSerializable] instance.
const JsonSerializable({
@Deprecated('Has no effect') bool? nullable,
this.anyMap,
this.checked,
this.createFactory,
this.createToJson,
this.disallowUnrecognizedKeys,
this.explicitToJson,
this.fieldRename,
this.ignoreUnannotated,
this.includeIfNull,
this.genericArgumentFactories,
});
...
For example, you can make the class nullable
and add extra checks for validating JSON properly. Close the json_serialization.dart source file after reviewing it.
Converting to and from JSON¶
Now, you need to add JSON conversion methods within the APIRecipeQuery
class. Return to recipe_model.dart and replace // TODO: Add APIRecipeQuery.fromJson
with:
factory APIRecipeQuery.fromJson(Map<String, dynamic> json) =>
_$APIRecipeQueryFromJson(json);
Map<String, dynamic> toJson() => _$APIRecipeQueryToJson(this);
// TODO: Add fields here
// TODO: Add APIRecipeQuery constructor
Note that the methods on the right of the arrow operator don’t exist yet, so ignore any red squiggles. You’ll create them later by running the build_runner command.
Note also that the first call is a factory
method. That’s because you need a class-level method when you’re creating the instance, while you use the other method on an object that already exists.
Now, add the following fields right after the conversion methods by replacing // TODO: Add fields here
with the following:
@JsonKey(name: 'q')
String query;
int from;
int to;
bool more;
int count;
List<APIHits> hits;
The @JsonKey
annotation states that you represent the query
field in JSON with the string q
. The rest of the fields look in JSON just like their names here. You’ll define APIHits
in a couple of steps.
Next, replace // TODO: Add APIRecipeQuery constructor
with this constructor, again ignoring the red squiggles:
APIRecipeQuery({
required this.query,
required this.from,
required this.to,
required this.more,
required this.count,
required this.hits,
});
The required
annotation says that these fields are mandatory when creating a new instance.
Then, find // TODO: Add @JsonSerializable() class APIHits
and replace it with a new class named APIHits
, continuing to ignore the red squiggles:
// 1
@JsonSerializable()
class APIHits {
// 2
APIRecipe recipe;
// 3
APIHits({
required this.recipe,
});
// 4
factory APIHits.fromJson(Map<String, dynamic> json) =>
_$APIHitsFromJson(json);
Map<String, dynamic> toJson() => _$APIHitsToJson(this);
}
Here’s what this code does:
- Marks the class serializable.
- Defines a field of class
APIRecipe
, which you’ll create soon. - Defines a constructor that accepts a
recipe
parameter. - Adds the methods for JSON serialization.
Add the APIRecipe
class definition by replacing // TODO: Add @JsonSerializable() class APIRecipe
with:
@JsonSerializable()
class APIRecipe {
// 1
String label;
String image;
String url;
// 2
List<APIIngredients> ingredients;
double calories;
double totalWeight;
double totalTime;
APIRecipe({
required this.label,
required this.image,
required this.url,
required this.ingredients,
required this.calories,
required this.totalWeight,
required this.totalTime,
});
// 3
factory APIRecipe.fromJson(Map<String, dynamic> json) =>
_$APIRecipeFromJson(json);
Map<String, dynamic> toJson() => _$APIRecipeToJson(this);
}
// TODO: Add global Helper Functions
Here you:
- Define the fields for a recipe.
label
is the text shown andimage
is the URL of the image to show. - State that each recipe has a list of ingredients.
- Create the factory methods for serializing JSON.
Now replace the TODO: Add global Helper Functions
with:
// 4
String getCalories(double? calories) {
if (calories == null) {
return '0 KCAL';
}
return '${calories.floor()} KCAL';
}
// 5
String getWeight(double? weight) {
if (weight == null) {
return '0g';
}
return '${weight.floor()}g';
}
- Add a helper method to turn a calorie into a string.
- Add another helper method to turn the weight into a string.
Finally, replace // TODO: Add @JsonSerializable() class APIIngredients
with:
@JsonSerializable()
class APIIngredients {
// 6
@JsonKey(name: 'text')
String name;
double weight;
APIIngredients({
required this.name,
required this.weight,
});
// 7
factory APIIngredients.fromJson(Map<String, dynamic> json) =>
_$APIIngredientsFromJson(json);
Map<String, dynamic> toJson() => _$APIIngredientsToJson(this);
}
Here you:
- State that the
name
field of this class maps to the JSON field namedtext
. - Create the methods to serialize JSON.
For your next step, you’ll create the .part file.
Generating the .part file¶
Open the terminal in Android Studio by clicking on the Terminal
panel in the lower left, or by selecting View ▸ Tool Windows ▸ Terminal, and type:
flutter pub run build_runner build
The expected output will look something like this:
[INFO] Generating build script...
...
[INFO] Creating build script snapshot......
...
[INFO] Running build...
...
[INFO] Succeeded after ...
Note: If you have problems running the command, make sure that you’ve installed Flutter on your computer and you have a path set up to point to it. See Flutter installation documentation for more details, https://docs.flutter.dev/get-started/install.
This command creates recipe_model.g.dart in the network folder. If you don’t see the file, right-click on the network folder and choose Reload from disk.
Note: If you still don’t see it, restart Android Studio so it recognizes the presence of the new generated file when it starts up.
If you want the program to run every time you make a change to your file, you can use the watch
command, like this:
flutter pub run build_runner watch
The command will continue to run and watch for changes to files. To stop the process you can press Ctrl-C. Now, open recipe_model.g.dart. Here is the first generated method:
// 1
APIRecipeQuery _$APIRecipeQueryFromJson(Map<String, dynamic> json) =>
APIRecipeQuery(
// 2
query: json['q'] as String,
// 3
from: json['from'] as int,
to: json['to'] as int,
more: json['more'] as bool,
count: json['count'] as int,
// 4
hits: (json['hits'] as List<dynamic>)
.map((e) => APIHits.fromJson(e as Map<String, dynamic>))
.toList(),
);
Notice that it takes a map of String
to dynamic
, which is typical of JSON data in Flutter. The key is the string and the value will be either a primitive, a list or another map. The method:
- Returns a new
APIRecipeQuery
class. - Maps the
q
key to aquery
field. - Maps the
from
integer to thefrom
field, and maps the other fields. - Maps each element of the
hits
list to an instance of theAPIHits
class.
You could have written this code yourself, but it can get a bit tedious and is error-prone. Having a tool generate the code for you saves a lot of time and effort. Look through the rest of the file to see how the generated code converts the JSON data to all the other model classes.
Hot restart the app to make sure it still compiles and works as before. You won’t see any changes in the UI, but the code is now set up to parse recipe data.
Testing the generated JSON code¶
Now that you can parse model objects from JSON, you’ll read one of the JSON files included in the starter project and show one card to make sure you can use the generated code.
Open ui/recipes/recipe_list.dart and add the following imports at the top:
import 'dart:convert';
import '../../network/recipe_model.dart';
import 'package:flutter/services.dart';
import '../recipe_card.dart';
import 'recipe_details.dart';
In _RecipeListState
, replace // TODO: Add _currentRecipes1
with:
APIRecipeQuery? _currentRecipes1;
Next, replace // TODO: Add loadRecipes
with:
Future loadRecipes() async {
// 1
final jsonString = await rootBundle.loadString('assets/recipes1.json');
setState(() {
// 2
_currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
});
}
This method:
- Loads recipes1.json from the assets directory.
rootBundle
is the top-level property that holds references to all the items in the asset folder. This loads the file as a string. - Uses the built-in
jsonDecode()
method to convert the string to a map, then usesfromJson()
, which was generated for you, to make an instance of anAPIRecipeQuery
.
Now, replace // TODO: Call loadRecipes()
with:
loadRecipes();
At the bottom, replace // TODO: Add _buildRecipeCard
with:
Widget _buildRecipeCard(
BuildContext topLevelContext, List<APIHits> hits, int index) {
// 1
final recipe = hits[index].recipe;
return GestureDetector(
onTap: () {
Navigator.push(topLevelContext, MaterialPageRoute(
builder: (context) {
return const RecipeDetails();
},
));
},
// 2
child: recipeStringCard(recipe.image, recipe.label),
);
}
This method:
- Finds the recipe at the given index.
- Calls
recipeStringCard()
, which shows a nice card below the search field.
Now, locate // TODO: Replace method
and substitute the existing _buildRecipeLoader()
with the following:
Widget _buildRecipeLoader(BuildContext context) {
// 1
if (_currentRecipes1 == null || _currentRecipes1?.hits == null) {
return Container();
}
// Show a loading indicator while waiting for the recipes
return Flexible(
child: ListView.builder(
itemCount: 1,
itemBuilder: (BuildContext context, int index) {
return Center(
child: _buildRecipeCard(context, _currentRecipes1!.hits, 0));
},
),
);
}
This code now:
- Checks to see if the list of recipes is
null
. - If not, calls
_buildRecipeCard()
using the first item in the list.
Perform a hot restart (not hot reload) and the app will show a Chicken Vesuvio sample card:
Now that the data model classes work as expected, you’re ready to load recipes from the web. Fasten your seat belt. :]
Key points¶
- JSON is an open-standard format used on the web and in mobile clients, especially with REST APIs.
- In mobile apps, JSON code is usually parsed into the model objects that your app will work with.
- You can write JSON parsing code yourself, but it’s usually easier to let a JSON package generate the parsing code for you.
- json_annotation and json_serializable are packages that will let you generate the parsing code.
Where to go from here?¶
In this chapter, you’ve learned how to create models that you can parse from JSON and then use when you fetch JSON data from the network. If you want to learn more about json_serializable, go to https://pub.dev/packages/json_serializable.
In the next chapter, you build on what you’ve done so far and learn about getting data from the internet.