In the last decade, the internet has seen a surge of new ways to improve the transfer speeds and availability for people worldwide. Most of the applications developed and maintained today connect to a web service one way or another. This chapter will focus on how Flutter handles network communications with asynchronous programming, code generators, and the http
and dio
packages.
Because tasks involving network communication are inherently long-running ones, Dart heavily relies on the Future
class and async
functions to deal with them. We've discussed these features in a chapter 5.
When communicating over a network, different objects are sent between applications written usually in different programming languages running on different operating systems. To make sure that the receiver can understand the transmitted message, the communication protocol must specify the method and format of the communication.
When sending an object over the network, the object first needs to be converted to a stream of bytes (also called serialization) conforming to the specified format, while the receiver converts it back (called de-serialization). There are several different format definitions, some of the most notable being:
- JSON: A lightweight, human-readable text format, focused around key-value pairs and array data types. It was derived from JavaScript, but today, most programming languages include support for it. In Dart, we can use the
jsonEncode()
andjsonDecode()
functions (found in theconvert
library included with the SDK) to convert between Dart types and their JSONString
representations. - XML: A markup language that is both human- and machine-readable. While designed with simplicity and generality in mind, XML is often criticized as overly verbose, complex, and redundant. In Dart, the
xml
package can be used to parse, query, and transform XMLString
s and files. - Protobuf: A compact binary format. Communication messages are defined in the protobuf interface definition language, and code generators are used to generate serialization and de-serialization logic for many programming languages, including Dart.
Due to the popularity of it, we will be focusing on JSON serialization in this chapter. As mentioned before, Dart has built-in support for JSON with the jsonEncode()
and jsonDecode()
functions. However, these functions can only convert the following types:
int
double
String
bool
null
List
Map
withString
keys
The jsonDecode()
function will always return one of these types. jsonEncode()
takes an optional callback function which will be called whenever a value with an unsupported type is passed to the function as a parameter. If a callback function is not specified, the input object will be handled as a dynamic
type value and the toJson()
function will be called on it. The toJson()
function should be present in such types and it has to convert the object to a supported representation.
While Dart supports reflection, Flutter (due to aggressive dead code elimination) disables it. Because of this, we cannot write a generalized de-serialization function with type parameters. Instead, we usually declare a named constructor with the name fromJson()
.
import 'dart:convert';
class MyClass{
int age;
MyClass(this.age);
}
class MyJSONClass{
int age;
MyJSONClass(this.age);
Map<String, int> toJson() => {
"age" : age
};
MyJSONClass.fromJson(Map<String, dynamic> json) : this(json["age"]);
}
void main() {
var testObject = MyClass(5);
var testJsonObject = MyJSONClass(5);
//print(jsonEncode(testObject)); //ERROR: Converting object to an encodable object failed
print(jsonEncode(testObject, toEncodable: (obj){
if (obj is MyClass){
return {
"age" : obj.age
};
}
return null;
}));
print(jsonEncode(testJsonObject));
var deserialized = MyJSONClass.fromJson(jsonDecode('{"age" : 6 }'));
print(deserialized.age);
}
While it is possible to write every required serialization logic manually, Flutter supports the usage of code generators. The build_runner
package can be added in the pubspec.yaml
file as an external dependency to enable generators (in the dev_dependencies
section). Other libraries can depend on this package to generate their files. These generated files usually have the name of the original file, with .g.dart
appended to it. The generated files can be included in the original file with the part
keyword.
To run build_runner
, we must open a terminal and run flutter pub run build_runner {build/watch}
. build
will run the generator and exit, while watch
will continuously monitor file changes and run the code generator as needed. If there are conflicting generated files in the target folder, the --delete-conflicting-outputs
option must be passed to the command to clear things up.
To generate the serialization logic, we will use the json_serializable
package (which also has to be placed in the dev_dependencies
section). The library uses annotations to find and configure classes, which can be found in the json_annotation
package. We must annotate our classes with @JsonSerializable()
to mark these classes as JSON serializable. Additionally, flags can be set in the build.yaml
file for the whole project, in the @JsonSerializable()
for classes, or with the @JsonKey()
annotation for a single field, which changes the default serialization logic. These can be found in the description of the package.
The following example demonstrates the usage of json_serializable
:
json_class.dart
:
import 'package:json_annotation/json_annotation.dart';
part 'json_class.g.dart';
@JsonSerializable()
class Person{
final int age;
final String name;
final Person? mother;
final Person? father;
Person(this.age, this.name, {this.mother, this.father});
dynamic toJson() => _$PersonToJson(this);
factory Person.fromJson(Map<String, dynamic> obj) => _$PersonFromJson(obj);
}
After running flutter pub run build_runner build
, the generated json_class.g.dart
:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'json_class.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Person _$PersonFromJson(Map<String, dynamic> json) {
return Person(
json['age'] as int,
json['name'] as String,
mother: json['mother'] == null
? null
: Person.fromJson(json['mother'] as Map<String, dynamic>),
father: json['father'] == null
? null
: Person.fromJson(json['father'] as Map<String, dynamic>),
);
}
Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
'age': instance.age,
'name': instance.name,
'mother': instance.mother,
'father': instance.father,
};
We can see how json_serializable
generates two private functions for serialization and de-serialization respectively. The $
does not have any special meaning in Dart. Code generators usually use this character to differentiate the generated code. The part
and part of
keywords are used so that our file can see the private functions declared in the generated code.
While the
toJson()
andfromJson()
function only delegate to the corresponding generated private functions, currently there is no way to avoid these boilerplate function declarations. WhilefromJson()
could be moved to a generated extensions class in theory, thetoJson()
function must be declared inside the class forjsonEncode()
to work correctly. Remember that functions declared inside an extension class work like static functions: the type of the variable must be known to the compiler before the extension function can be called.
built_value
and freezed
are another recommended libraries. While they contain serialization logic, their main purpose is to create immutable value classes, somewhat similar to Kotlin's data classes and Java's AutoValue library. Because of this, more boilerplate is needed for JSON serialization.
While Dart has in-built support for TCP and UDP connections through the dart:io
package (not available when targeting the web), the official http
package provides a set of high-level functions that make it easy to consume HTTP resources. In the following example applications, we will use the public OpenWeather service to query the weather for some cities.
To use the library, we typically call the top-level functions corresponding to an HTTP verb (such as GET, POST, PUT, etc.).
These functions take a Uri
, which can be created either by the Uri.http()
or Uri.https()
constructors. These have the following parameters:
authority
: The base URL of the service.unencodedPath
: The relative path of the resource.queryParameters
: The optional query parameters of the request. While the type of this variable isMap<String, dynamic>
, the type of the value objects must be eitherString
orIterable<String>
, otherwise an exception is thrown.
The result of a request will be of type Response
, from which we can read the String
representation of the HTTP response. We must first call jsonDecode()
on the result before calling the appropriate fromJson()
function.
Take a look into the finished flutter_http
project to see how the http
package was used to make an HTTP request. Some notes regarding the solution:
- We separated the application models from the network models. The OpenWeather specific models can be found inside the
ow_json_models.dart
file, while the request is implemented inow_service.dart
. The repository is responsible for encapsulating the network connection and transforming the service-specific network model to the application logic model. This way, we could easily extend the application to include other services or even implement caching logic in the repository layer. We will discuss the recommended application architecture in a later chapter. - We use the
RefreshIndicator
widget to add the pull-to-refresh functionality to our application. To use this, we must add thephysics: const AlwaysScrollableScrollPhysics()
parameter to ourListView
to allow the over scrolling needed for the indicator to appear. TheonRefresh()
callback is called when refresh is needed. The refresh indicator is shown until theFuture
object returned from the callback is completed. If we want to show the indicator manually, we would have to use aGlobalKey
on the widget and call theshow()
method on the correspondingState
object. - The
FutureBuilder
widget subscribes to theFuture
object and calls thebuilder
callback whenever the state of theFuture
changes. Remember that thebuild()
function may be called every frame, so we must not create theFuture
object in this method. Instead, we use aStatefulWidget
to store the currentFuture
object. This is similar to how we can use theFutureProvider.value()
constructor from theprovider
package. However, if we were to use aFutureProvider
, we would have to create theFuture
object in thecreate()
callback (remember the difference between the default and thevalue
constructor from theprovider
package). - To avoid blocking the UI [isolate]https://api.dart.dev/stable/2.12.0/dart-isolate/Isolate-class.html) (a Dart execution environment), we use the
compute()
function to spawn a newIsolate
and parse the JSON there. We must pass a top-level or static function to thecompute()
function, which will be the entry point of the newIsolate
, and must take one argument, which comes from the other parameter of thecompute()
function. Note that due to the web platform's limitation, this code will run on the main isolate instead when targeting the web.
While the http
library is excellent for simple network communication, it's missing some advanced features. Third-party libraries (that depend on http
) have been created to add these missing features. Currently, there are two popular networking libraries out there: chopper
and dio
.
The chopper
library is an HTTP client generator inspired by the Retrofit
library on Android. We can define the service as an abstract class, where every function call corresponds to a network request, and we can use annotations to specify how the request should be made. A code generator is then used to generate the implementation for the network requests.
On the other hand, dio
can be thought of as a more advanced version of the http
library. With dio
, the typical usage is that we define a Dio
object, on which we can call the corresponding HTTP
request, similarly to http
. It supports the global configuration of most of the parameters, request cancellation, cookie management, and interceptors, to name a few features.
Interceptors are objects that can interact with network requests. We can add multiple interceptors, which form an interceptor chain. Whenever we start a new request, the request is passed to the first interceptor in the chain. The interceptor can decide what to do with the request, but it will typically modify some value (such as adding a token to the headers) and pass the modified request to the next interceptor.
An interceptor has three functions that are called at different stages of the request:
onRequest()
: Before a request is initiated. Receives aRequestOptions
object.onResponse()
: After the request finished successfully. Receives aResponse
object.onError()
: After the request finished unsuccessfully. Receives aDioError
object.
Every callback function also receives a <Request/Response/Error>InterceptorHandler
, which can be used to either pass the received parameter to the next interceptor with the next()
function call, or finish it with either the resolve()
or reject()
function call.
In contrast to
OkHttp
's interceptor chain, the order of the calls to the interceptors always matches the order they were added, independent of whether theonRequest()
oronResponse()/onError()
are called.
The interceptors can be blocked by calling the lock()
function on the corresponding interceptor lock. This will stop any request or response from entering the interceptor until the lock is unlocked. The lock()
and unlock()
function can also be found in the Dio
class, which corresponds to the requestLock()
. This can be useful if we want to block every network request until a token is retrieved from the server.
If we want to create our interceptor, we can either extend the base Interceptor
class or use the InterceptorsWrapper
class to pass the three callback functions as constructor parameters. The dio
library contains a built-in LogInterceptor()
class, which prints out every request and response to the console.
Other useful utilities can be found in external libraries:
dio_cookie_manager
: Adds automatic cookie support to our network layer. The cookies can either be stored in memory or on persistent storage.dio_http_cache
: Includes a caching layer for our HTTP requests.
Take a look into the flutter_dio
project to see how dio
can be used in the weather application. To extend the original application's functionality, we've also added a snackbar that is shown if an error occurs during the network request. The user can also press a button to retry the request. Some notes regarding the implementation:
- In
dio
andchopper
, the base URL can be any string, and the relative path will be appended to this. In our example, the base URL now also contains the relative path of the API (/data/2.5/
). - Notice how both of the first interceptor's functions are called before the second interceptor's corresponding functions.
- With the help of interceptors, we can add parameters needed for every request (such as the API key) in the interceptor.
- In the current project, the snackbar is created inside the service code. This is done to illustrate how we can retry a failed request. Still, in a typical application, we recommend using a callback function to separate the application logic from the UI code.
In this chapter, we have discussed how we can add network communication to our application. Due to the absence of reflection in Flutter, we must depend on code generators for serialization and de-serialization of network models. This is implemented in the json_serializable
package. We have also seen how we can make HTTP requests with the http
and dio
packages and mentioned chopper
as an alternative.